Skip to content

Commit 5b207de

Browse files
feat(server): add redirect route to pre-zoomed tileset BM-1076 (#3354)
### Motivation As an Imagery Data Maintainer, I want a link for each tileset that auto-zooms to its bounding box. This is so I can keep _Basemaps_ URL links short, clean looking, and zoomed to the right spot. ### Modifications 1. Added a `/v1/link/:tileSet` route to the `basemaps/lambda-tiler` package. 2. Implemented a function to capture and process requests to `v1/link/:tileSet`. - On success, returns a `302` response that redirects the client to a _Basemaps_ URL that is already zoomed to the extent of the tileset's imagery. - On failure, returns a `4xx` response explaining why the function terminated. #### Usage | Enters from... | Status | Redirects to... | |-|-|-| | `/v1/link/ashburton-2023-0.1m` | 302 Found | `/@-43.9157018,171.7712402,z12?i=ashburton-2023-0.1m` | ### Verification Depending on the size of the user's viewport, there are situations where the _pre-zooming_ estimation may or may not suffice. See each of the following examples for details: #### Ashburton 0.1m (2023) | From | To | |-|-| | `/link/ashburton-2023-0.1m` | `/@-43.9157018,171.7712402,z12?i=ashburton-2023-0.1m` | || ![img_1] | The tileset is over-zoomed by a slight amount. But, it's not noticeable. #### Christchurch 0.05m (2021) | From | To | |-|-| | `/link/christchurch-urban-2021-0.05m` | `@-43.5286378,172.6309204,z12?i=christchurch-urban-2021-0.05m` | || ![img_2] | The tileset is under-zoomed quite substantially. It seems as though the bounding box itself is much larger than the imagery. The user will have to zoom in themselves. #### Otago 0.1m (2018) | From | To | |-|-| | `/link/otago-urban-2018-0 1m` | `/@-45.2516883,169.6289062,z10?i=otago-urban-2018-0.1m` | || ![img_3] | Regions of the tileset are cut off from the viewport. The user will have to zoom out themselves. [img_1]: https://github.com/user-attachments/assets/1ae960b6-e4d6-4f78-8512-1e45edd4dc41 [img_2]: https://github.com/user-attachments/assets/e51c74e0-4ab2-4255-930b-75bc53d5adcf [img_3]: https://github.com/user-attachments/assets/9e4835bc-296e-4f9a-aad0-3629adf7cc74
1 parent c156391 commit 5b207de

File tree

3 files changed

+173
-0
lines changed

3 files changed

+173
-0
lines changed

packages/lambda-tiler/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { configImageryGet, configTileSetGet } from './routes/config.js';
66
import { fontGet, fontList } from './routes/fonts.js';
77
import { healthGet } from './routes/health.js';
88
import { imageryGet } from './routes/imagery.js';
9+
import { linkGet } from './routes/link.js';
910
import { pingGet } from './routes/ping.js';
1011
import { previewIndexGet } from './routes/preview.index.js';
1112
import { tilePreviewGet } from './routes/preview.js';
@@ -102,6 +103,9 @@ handler.router.get('/v1/preview/:tileSet/:tileMatrix/:z/:lon/:lat/:outputType',
102103
handler.router.get('/v1/@:location', previewIndexGet);
103104
handler.router.get('/@:location', previewIndexGet);
104105

106+
// Link
107+
handler.router.get('/v1/link/:tileSet', linkGet);
108+
105109
// Attribution
106110
handler.router.get('/v1/tiles/:tileSet/:tileMatrix/attribution.json', tileAttributionGet);
107111
handler.router.get('/v1/attribution/:tileSet/:tileMatrix/summary.json', tileAttributionGet);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { strictEqual } from 'node:assert';
2+
import { afterEach, describe, it } from 'node:test';
3+
4+
import { ConfigProviderMemory } from '@basemaps/config';
5+
import { Epsg } from '@basemaps/geo';
6+
7+
import { FakeData, Imagery3857 } from '../../__tests__/config.data.js';
8+
import { mockRequest } from '../../__tests__/xyz.util.js';
9+
import { handler } from '../../index.js';
10+
import { ConfigLoader } from '../../util/config.loader.js';
11+
12+
describe('/v1/link/:tileSet', () => {
13+
const FakeTileSetName = 'tileset';
14+
const config = new ConfigProviderMemory();
15+
16+
afterEach(() => {
17+
config.objects.clear();
18+
});
19+
20+
/**
21+
* 3xx status responses
22+
*/
23+
24+
// tileset found, is raster type, has one layer, has '3857' entry, imagery found > 302 response
25+
it('success: redirect to pre-zoomed imagery', async (t) => {
26+
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));
27+
28+
config.put(FakeData.tileSetRaster(FakeTileSetName));
29+
config.put(Imagery3857);
30+
31+
const req = mockRequest(`/v1/link/${FakeTileSetName}`);
32+
const res = await handler.router.handle(req);
33+
34+
strictEqual(res.status, 302);
35+
strictEqual(res.statusDescription, 'Redirect to pre-zoomed imagery');
36+
});
37+
38+
/**
39+
* 4xx status responses
40+
*/
41+
42+
// tileset not found > 404 response
43+
it('failure: tileset not found', async (t) => {
44+
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));
45+
46+
const req = mockRequest(`/v1/link/${FakeTileSetName}`);
47+
const res = await handler.router.handle(req);
48+
49+
strictEqual(res.status, 404);
50+
strictEqual(res.statusDescription, 'Tileset not found');
51+
});
52+
53+
// tileset found, not raster type > 400 response
54+
it('failure: tileset must be raster type', async (t) => {
55+
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));
56+
57+
config.put(FakeData.tileSetVector(FakeTileSetName));
58+
59+
const req = mockRequest(`/v1/link/${FakeTileSetName}`);
60+
const res = await handler.router.handle(req);
61+
62+
strictEqual(res.status, 400);
63+
strictEqual(res.statusDescription, 'Tileset must be raster type');
64+
});
65+
66+
// tileset found, is raster type, has more than one layer > 400 response
67+
it('failure: too many layers', async (t) => {
68+
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));
69+
70+
const tileSet = FakeData.tileSetRaster(FakeTileSetName);
71+
72+
// add another layer
73+
tileSet.layers.push(tileSet.layers[0]);
74+
75+
config.put(tileSet);
76+
77+
const req = mockRequest(`/v1/link/${FakeTileSetName}`);
78+
const res = await handler.router.handle(req);
79+
80+
strictEqual(res.status, 400);
81+
strictEqual(res.statusDescription, 'Too many layers');
82+
});
83+
84+
// tileset found, is raster type, has one layer, no '3857' entry > 400 response
85+
it("failure: no imagery for '3857' projection", async (t) => {
86+
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));
87+
88+
const tileSet = FakeData.tileSetRaster(FakeTileSetName);
89+
90+
// delete '3857' entry
91+
delete tileSet.layers[0][Epsg.Google.code];
92+
93+
config.put(tileSet);
94+
95+
const req = mockRequest(`/v1/link/${FakeTileSetName}`);
96+
const res = await handler.router.handle(req);
97+
98+
strictEqual(res.status, 400);
99+
strictEqual(res.statusDescription, "No imagery for '3857' projection");
100+
});
101+
102+
// tileset found, is raster type, has one layer, has '3857' entry, imagery not found > 400 response
103+
it('failure: imagery not found', async (t) => {
104+
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));
105+
106+
config.put(FakeData.tileSetRaster(FakeTileSetName));
107+
108+
const req = mockRequest(`/v1/link/${FakeTileSetName}`);
109+
const res = await handler.router.handle(req);
110+
111+
strictEqual(res.status, 400);
112+
strictEqual(res.statusDescription, 'Imagery not found');
113+
});
114+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { TileSetType } from '@basemaps/config';
2+
import { Epsg } from '@basemaps/geo';
3+
import { getPreviewUrl } from '@basemaps/shared';
4+
import { LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
5+
6+
import { ConfigLoader } from '../util/config.loader.js';
7+
8+
export interface LinkGet {
9+
Params: {
10+
tileSet: string;
11+
};
12+
}
13+
14+
/**
15+
* Redirect the client to a Basemaps URL that is already zoomed to the extent of the tileset's imagery.
16+
*
17+
* /v1/link/:tileSet
18+
*
19+
* @example
20+
* '/v1/link/ashburton-2023-0.1m'
21+
*
22+
* @returns on success, 302 redirect response. on failure, 4xx status code response.
23+
*/
24+
export async function linkGet(req: LambdaHttpRequest<LinkGet>): Promise<LambdaHttpResponse> {
25+
const config = await ConfigLoader.load(req);
26+
27+
// get tileset
28+
29+
req.timer.start('tileset:load');
30+
const tileSet = await config.TileSet.get(req.params.tileSet);
31+
req.timer.end('tileset:load');
32+
33+
if (tileSet == null) return new LambdaHttpResponse(404, 'Tileset not found');
34+
35+
if (tileSet.type !== TileSetType.Raster) return new LambdaHttpResponse(400, 'Tileset must be raster type');
36+
37+
// TODO: add support for 'aerial' and 'elevation' multi-layer tilesets
38+
if (tileSet.layers.length !== 1) return new LambdaHttpResponse(400, 'Too many layers');
39+
40+
// get imagery
41+
42+
const imageryId = tileSet.layers[0][Epsg.Google.code];
43+
if (imageryId === undefined) return new LambdaHttpResponse(400, "No imagery for '3857' projection");
44+
45+
const imagery = await config.Imagery.get(imageryId);
46+
if (imagery == null) return new LambdaHttpResponse(400, 'Imagery not found');
47+
48+
// do redirect
49+
50+
const url = getPreviewUrl({ imagery });
51+
52+
return new LambdaHttpResponse(302, 'Redirect to pre-zoomed imagery', {
53+
location: `/${url.slug}?i=${url.name}`,
54+
});
55+
}

0 commit comments

Comments
 (0)