Skip to content

Commit

Permalink
feat(server): add redirect route to pre-zoomed tileset BM-1076 (#3354)
Browse files Browse the repository at this point in the history
### 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
  • Loading branch information
tawera-manaena authored Oct 7, 2024
1 parent c156391 commit 5b207de
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/lambda-tiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { configImageryGet, configTileSetGet } from './routes/config.js';
import { fontGet, fontList } from './routes/fonts.js';
import { healthGet } from './routes/health.js';
import { imageryGet } from './routes/imagery.js';
import { linkGet } from './routes/link.js';
import { pingGet } from './routes/ping.js';
import { previewIndexGet } from './routes/preview.index.js';
import { tilePreviewGet } from './routes/preview.js';
Expand Down Expand Up @@ -102,6 +103,9 @@ handler.router.get('/v1/preview/:tileSet/:tileMatrix/:z/:lon/:lat/:outputType',
handler.router.get('/v1/@:location', previewIndexGet);
handler.router.get('/@:location', previewIndexGet);

// Link
handler.router.get('/v1/link/:tileSet', linkGet);

// Attribution
handler.router.get('/v1/tiles/:tileSet/:tileMatrix/attribution.json', tileAttributionGet);
handler.router.get('/v1/attribution/:tileSet/:tileMatrix/summary.json', tileAttributionGet);
Expand Down
114 changes: 114 additions & 0 deletions packages/lambda-tiler/src/routes/__tests__/link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { strictEqual } from 'node:assert';
import { afterEach, describe, it } from 'node:test';

import { ConfigProviderMemory } from '@basemaps/config';
import { Epsg } from '@basemaps/geo';

import { FakeData, Imagery3857 } from '../../__tests__/config.data.js';
import { mockRequest } from '../../__tests__/xyz.util.js';
import { handler } from '../../index.js';
import { ConfigLoader } from '../../util/config.loader.js';

describe('/v1/link/:tileSet', () => {
const FakeTileSetName = 'tileset';
const config = new ConfigProviderMemory();

afterEach(() => {
config.objects.clear();
});

/**
* 3xx status responses
*/

// tileset found, is raster type, has one layer, has '3857' entry, imagery found > 302 response
it('success: redirect to pre-zoomed imagery', async (t) => {
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));

config.put(FakeData.tileSetRaster(FakeTileSetName));
config.put(Imagery3857);

const req = mockRequest(`/v1/link/${FakeTileSetName}`);
const res = await handler.router.handle(req);

strictEqual(res.status, 302);
strictEqual(res.statusDescription, 'Redirect to pre-zoomed imagery');
});

/**
* 4xx status responses
*/

// tileset not found > 404 response
it('failure: tileset not found', async (t) => {
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));

const req = mockRequest(`/v1/link/${FakeTileSetName}`);
const res = await handler.router.handle(req);

strictEqual(res.status, 404);
strictEqual(res.statusDescription, 'Tileset not found');
});

// tileset found, not raster type > 400 response
it('failure: tileset must be raster type', async (t) => {
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));

config.put(FakeData.tileSetVector(FakeTileSetName));

const req = mockRequest(`/v1/link/${FakeTileSetName}`);
const res = await handler.router.handle(req);

strictEqual(res.status, 400);
strictEqual(res.statusDescription, 'Tileset must be raster type');
});

// tileset found, is raster type, has more than one layer > 400 response
it('failure: too many layers', async (t) => {
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));

const tileSet = FakeData.tileSetRaster(FakeTileSetName);

// add another layer
tileSet.layers.push(tileSet.layers[0]);

config.put(tileSet);

const req = mockRequest(`/v1/link/${FakeTileSetName}`);
const res = await handler.router.handle(req);

strictEqual(res.status, 400);
strictEqual(res.statusDescription, 'Too many layers');
});

// tileset found, is raster type, has one layer, no '3857' entry > 400 response
it("failure: no imagery for '3857' projection", async (t) => {
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));

const tileSet = FakeData.tileSetRaster(FakeTileSetName);

// delete '3857' entry
delete tileSet.layers[0][Epsg.Google.code];

config.put(tileSet);

const req = mockRequest(`/v1/link/${FakeTileSetName}`);
const res = await handler.router.handle(req);

strictEqual(res.status, 400);
strictEqual(res.statusDescription, "No imagery for '3857' projection");
});

// tileset found, is raster type, has one layer, has '3857' entry, imagery not found > 400 response
it('failure: imagery not found', async (t) => {
t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config));

config.put(FakeData.tileSetRaster(FakeTileSetName));

const req = mockRequest(`/v1/link/${FakeTileSetName}`);
const res = await handler.router.handle(req);

strictEqual(res.status, 400);
strictEqual(res.statusDescription, 'Imagery not found');
});
});
55 changes: 55 additions & 0 deletions packages/lambda-tiler/src/routes/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { TileSetType } from '@basemaps/config';
import { Epsg } from '@basemaps/geo';
import { getPreviewUrl } from '@basemaps/shared';
import { LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';

import { ConfigLoader } from '../util/config.loader.js';

export interface LinkGet {
Params: {
tileSet: string;
};
}

/**
* Redirect the client to a Basemaps URL that is already zoomed to the extent of the tileset's imagery.
*
* /v1/link/:tileSet
*
* @example
* '/v1/link/ashburton-2023-0.1m'
*
* @returns on success, 302 redirect response. on failure, 4xx status code response.
*/
export async function linkGet(req: LambdaHttpRequest<LinkGet>): Promise<LambdaHttpResponse> {
const config = await ConfigLoader.load(req);

// get tileset

req.timer.start('tileset:load');
const tileSet = await config.TileSet.get(req.params.tileSet);
req.timer.end('tileset:load');

if (tileSet == null) return new LambdaHttpResponse(404, 'Tileset not found');

if (tileSet.type !== TileSetType.Raster) return new LambdaHttpResponse(400, 'Tileset must be raster type');

// TODO: add support for 'aerial' and 'elevation' multi-layer tilesets
if (tileSet.layers.length !== 1) return new LambdaHttpResponse(400, 'Too many layers');

// get imagery

const imageryId = tileSet.layers[0][Epsg.Google.code];
if (imageryId === undefined) return new LambdaHttpResponse(400, "No imagery for '3857' projection");

const imagery = await config.Imagery.get(imageryId);
if (imagery == null) return new LambdaHttpResponse(400, 'Imagery not found');

// do redirect

const url = getPreviewUrl({ imagery });

return new LambdaHttpResponse(302, 'Redirect to pre-zoomed imagery', {
location: `/${url.slug}?i=${url.name}`,
});
}

0 comments on commit 5b207de

Please sign in to comment.