Skip to content

Commit

Permalink
feat(landing): show labels on landing page (#3330)
Browse files Browse the repository at this point in the history
### Motivation

For additional context in aerial imagery its helpful to add vector
layers such as placename and roads

### Modifications

Allow landing page to request a combined aerial and label style json
Add button to enable/disable showing of labels on aerial layer


![image](https://github.com/user-attachments/assets/29f62d82-8191-4ffe-be23-650110eaf26f)


![image](https://github.com/user-attachments/assets/4a1308de-7ea3-4600-ab21-5566e4f7c954)


### Verification

Testing locally, but would be nice to test more in dev
  • Loading branch information
blacha authored Aug 26, 2024
1 parent 445da7f commit b9fe33f
Show file tree
Hide file tree
Showing 12 changed files with 200 additions and 105 deletions.
1 change: 1 addition & 0 deletions packages/_infra/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export class EdgeStack extends cdk.Stack {
'style',
'pipeline',
'terrain',
'labels',
// Deprecated single character query params for style and projection
's',
'p',
Expand Down
6 changes: 3 additions & 3 deletions packages/lambda-tiler/src/__tests__/config.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ export const TileSetAerial: ConfigTileSetRaster = {
export const TileSetVector: ConfigTileSetVector = {
id: 'ts_topographic',
type: TileSetType.Vector,
name: 'topotgrpahic',
description: 'topotgrpahic__description',
title: 'topotgrpahic Imagery',
name: 'topographic',
description: 'topographic__description',
title: 'topographic Imagery',
category: 'Basemap',
layers: [
{
Expand Down
7 changes: 0 additions & 7 deletions packages/lambda-tiler/src/__tests__/tile.style.json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test';
import { StyleJson } from '@basemaps/config';
import { GoogleTms, Nztm2000QuadTms } from '@basemaps/geo';
import { Env } from '@basemaps/shared';
import { LambdaHttpResponse } from '@linzjs/lambda';

import { convertRelativeUrl, convertStyleJson } from '../routes/tile.style.json.js';

Expand Down Expand Up @@ -153,12 +152,6 @@ describe('TileStyleJson', () => {
});
});

it('should thrown error for NZTM2000Quad with vector source', () => {
const converted = (): StyleJson => convertStyleJson(baseStyleJson, Nztm2000QuadTms, 'abc123', null);

assert.throws(converted, LambdaHttpResponse);
});

it('should convert relative glyphs and sprites', () => {
const apiKey = '0x9f9f';
const converted = convertStyleJson(baseStyleJson, GoogleTms, apiKey, null);
Expand Down
20 changes: 20 additions & 0 deletions packages/lambda-tiler/src/routes/__tests__/tile.style.json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,24 @@ describe('/v1/styles', () => {
assert.deepEqual(terrain.source, 'LINZ-Terrain');
assert.deepEqual(terrain.exaggeration, 1.2);
});

it('should set labels via parameter', async () => {
config.put(TileSetAerial);
config.put(TileSetElevation);

const fakeStyle = { id: 'st_labels', name: 'labels', style: fakeVectorStyleConfig };
config.put(fakeStyle);

const request = mockUrlRequest('/v1/styles/aerial.json', `?terrain=LINZ-Terrain&labels=true`, Api.header);
const res = await handler.router.handle(request);
assert.equal(res.status, 200, res.statusDescription);

const body = JSON.parse(Buffer.from(res.body, 'base64').toString()) as StyleJson;
const terrain = body.terrain as unknown as Terrain;
assert.deepEqual(terrain.source, 'LINZ-Terrain');
assert.deepEqual(terrain.exaggeration, 1.2);

assert.equal(body.sources['basemaps_vector']?.type, 'vector');
assert.equal(body.layers.length, 2);
});
});
70 changes: 48 additions & 22 deletions packages/lambda-tiler/src/routes/tile.style.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,6 @@ export function convertStyleJson(
const sources = JSON.parse(JSON.stringify(style.sources)) as Sources;
for (const [key, value] of Object.entries(sources)) {
if (value.type === 'vector') {
if (tileMatrix !== GoogleTms) {
throw new LambdaHttpResponse(400, `TileMatrix is not supported for the vector source ${value.url}.`);
}
value.url = convertRelativeUrl(value.url, tileMatrix, apiKey, config);
} else if ((value.type === 'raster' || value.type === 'raster-dem') && Array.isArray(value.tiles)) {
for (let i = 0; i < value.tiles.length; i++) {
Expand All @@ -72,6 +69,7 @@ export function convertStyleJson(
if (style.glyphs) styleJson.glyphs = convertRelativeUrl(style.glyphs, undefined, undefined, config);
if (style.sprite) styleJson.sprite = convertRelativeUrl(style.sprite, undefined, undefined, config);
if (style.sky) styleJson.sky = style.sky;
if (style.terrain) styleJson.terrain = style.terrain;

return styleJson;
}
Expand All @@ -82,6 +80,13 @@ export interface StyleGet {
};
}

export interface StyleConfig {
/** Name of the terrain layer */
terrain?: string | null;
/** Combine layer with the labels layer */
labels: boolean;
}

function setStyleTerrain(style: StyleJson, terrain: string, tileMatrix: TileMatrixSet): void {
const source = Object.keys(style.sources).find((s) => s === terrain);
if (source == null) throw new LambdaHttpResponse(400, `Terrain: ${terrain} is not exists in the style source.`);
Expand All @@ -91,32 +96,48 @@ function setStyleTerrain(style: StyleJson, terrain: string, tileMatrix: TileMatr
};
}

async function setStyleLabels(req: LambdaHttpRequest<StyleGet>, style: StyleJson): Promise<void> {
const config = await ConfigLoader.load(req);
const labels = await config.Style.get('labels');

if (labels == null) {
req.log.warn('LabelsStyle:Missing');
return;
}

if (style.glyphs == null) style.glyphs = labels.style.glyphs;
if (style.sprite == null) style.sprite = labels.style.sprite;
if (style.sky == null) style.sky = labels.style.sky;

Object.assign(style.sources, labels.style.sources);
style.layers = style.layers.concat(labels.style.layers);
}

async function ensureTerrain(
req: LambdaHttpRequest<StyleGet>,
tileMatrix: TileMatrixSet,
apiKey: string,
style: StyleJson,
): Promise<void> {
const config = await ConfigLoader.load(req);
const terrain = await config.TileSet.get('ts_elevation');
if (terrain) {
const configLocation = ConfigLoader.extract(req);
const elevationQuery = toQueryString({ config: configLocation, api: apiKey, pipeline: 'terrain-rgb' });
style.sources['LINZ-Terrain'] = {
type: 'raster-dem',
tileSize: 256,
maxzoom: 18,
tiles: [convertRelativeUrl(`/v1/tiles/elevation/${tileMatrix.identifier}/{z}/{x}/{y}.png${elevationQuery}`)],
};
}
const terrain = await config.TileSet.get('elevation');
if (terrain == null) return;
const configLocation = ConfigLoader.extract(req);
const elevationQuery = toQueryString({ config: configLocation, api: apiKey, pipeline: 'terrain-rgb' });
style.sources['LINZ-Terrain'] = {
type: 'raster-dem',
tileSize: 256,
maxzoom: 18,
tiles: [convertRelativeUrl(`/v1/tiles/elevation/${tileMatrix.identifier}/{z}/{x}/{y}.png${elevationQuery}`)],
};
}

export async function tileSetToStyle(
req: LambdaHttpRequest<StyleGet>,
tileSet: ConfigTileSetRaster,
tileMatrix: TileMatrixSet,
apiKey: string,
terrain?: string,
cfg: StyleConfig,
): Promise<LambdaHttpResponse> {
const [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp'];
if (tileFormat == null) return new LambdaHttpResponse(400, 'Invalid image format');
Expand Down Expand Up @@ -144,9 +165,10 @@ export async function tileSetToStyle(
await ensureTerrain(req, tileMatrix, apiKey, style);

// Add terrain in style
if (terrain) setStyleTerrain(style, terrain, tileMatrix);
if (cfg.terrain) setStyleTerrain(style, cfg.terrain, tileMatrix);
if (cfg.labels) await setStyleLabels(req, style);

const data = Buffer.from(JSON.stringify(style));
const data = Buffer.from(JSON.stringify(convertStyleJson(style, tileMatrix, apiKey, configLocation)));

const cacheKey = Etag.key(data);
if (Etag.isNotModified(req, cacheKey)) return NotModified();
Expand All @@ -164,7 +186,7 @@ export async function tileSetOutputToStyle(
tileSet: ConfigTileSetRaster,
tileMatrix: TileMatrixSet,
apiKey: string,
terrain?: string,
cfg: StyleConfig,
): Promise<LambdaHttpResponse> {
const configLocation = ConfigLoader.extract(req);
const query = toQueryString({ config: configLocation, api: apiKey });
Expand Down Expand Up @@ -227,9 +249,10 @@ export async function tileSetOutputToStyle(
await ensureTerrain(req, tileMatrix, apiKey, style);

// Add terrain in style
if (terrain) setStyleTerrain(style, terrain, tileMatrix);
if (cfg.terrain) setStyleTerrain(style, cfg.terrain, tileMatrix);
if (cfg.labels) await setStyleLabels(req, style);

const data = Buffer.from(JSON.stringify(style));
const data = Buffer.from(JSON.stringify(convertStyleJson(style, tileMatrix, apiKey, configLocation)));

const cacheKey = Etag.key(data);
if (Etag.isNotModified(req, cacheKey)) return Promise.resolve(NotModified());
Expand All @@ -250,18 +273,20 @@ export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<La
const tileMatrix = TileMatrixSets.find(req.query.get('tileMatrix') ?? GoogleTms.identifier);
if (tileMatrix == null) return new LambdaHttpResponse(400, 'Invalid tile matrix');
const terrain = req.query.get('terrain') ?? undefined;
const labels = Boolean(req.query.get('labels') ?? false);

// Get style Config from db
const config = await ConfigLoader.load(req);
const dbId = config.Style.id(styleName);
const styleConfig = await config.Style.get(dbId);

if (styleConfig == null) {
// Were we given a tileset name instead, generated
const tileSet = await config.TileSet.get(config.TileSet.id(styleName));
if (tileSet == null) return NotFound();
if (tileSet.type !== TileSetType.Raster) return NotFound();
if (tileSet.outputs) return await tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey, terrain);
else return await tileSetToStyle(req, tileSet, tileMatrix, apiKey, terrain);
if (tileSet.outputs) return await tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey, { terrain, labels });
else return await tileSetToStyle(req, tileSet, tileMatrix, apiKey, { terrain, labels });
}

// Prepare sources and add linz source
Expand All @@ -279,6 +304,7 @@ export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<La

// Add terrain in style
if (terrain) setStyleTerrain(style, terrain, tileMatrix);
if (labels) await setStyleLabels(req, style);

const data = Buffer.from(JSON.stringify(style));

Expand Down
67 changes: 67 additions & 0 deletions packages/landing/src/components/map.label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { IControl } from 'maplibre-gl';

import { Config } from '../config.js';

const LabelsDisabledLayers = new Set(['topographic', 'topolite']);

export class MapLabelControl implements IControl {
map?: maplibregl.Map;
container?: HTMLDivElement;
button?: HTMLButtonElement;
buttonIcon?: HTMLElement;
events: (() => boolean)[] = [];

onAdd(map: maplibregl.Map): HTMLDivElement {
this.map = map;
this.container = document.createElement('div');
this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group';

this.button = document.createElement('button');
this.button.className = 'maplibregl-ctrl-labels';
this.button.type = 'button';
this.button.addEventListener('click', this.toggleLabels);

this.buttonIcon = document.createElement('i');
this.buttonIcon.className = 'material-icons-round';
this.buttonIcon.innerText = 'more';
this.button.appendChild(this.buttonIcon);
this.container.appendChild(this.button);

this.events.push(Config.map.on('labels', this.updateLabelIcon));
this.events.push(Config.map.on('layer', this.updateLabelIcon));

this.updateLabelIcon();
return this.container;
}

onRemove(): void {
this.container?.parentNode?.removeChild(this.container);
for (const evt of this.events) evt();
this.events = [];
this.map = undefined;
}

toggleLabels = (): void => {
Config.map.setLabels(!Config.map.labels);
};

updateLabelIcon = (): void => {
if (this.button == null) return;
this.button.classList.remove('maplibregl-ctrl-labels-enabled');

// Topographic style disables the button
if (Config.map.style && LabelsDisabledLayers.has(Config.map.style)) {
this.button.classList.add('display-none');
this.button.title = 'Topographic style does not support layers';
return;
}
this.button.classList.remove('display-none');

if (Config.map.labels) {
this.button.classList.add('maplibregl-ctrl-labels-enabled');
this.button.title = 'Hide Labels';
} else {
this.button.title = 'Show Labels';
}
};
}
4 changes: 2 additions & 2 deletions packages/landing/src/components/map.switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class MapSwitcher extends Component {
const target = this.getStyleType();
this.currentStyle = `${target.layerId}::${target.style}`;

const style = tileGrid.getStyle(target.layerId, target.style);
const style = tileGrid.getStyle({ layerId: target.layerId, style: target.style });
const location = cfg.transformedLocation;

this.map = new maplibre.Map({
Expand Down Expand Up @@ -71,7 +71,7 @@ export class MapSwitcher extends Component {
const styleId = `${target.layerId}::${target.style}`;
if (this.currentStyle !== styleId) {
const tileGrid = getTileGrid(Config.map.tileMatrix.identifier);
const style = tileGrid.getStyle(target.layerId, target.style);
const style = tileGrid.getStyle({ layerId: target.layerId, style: target.style });
this.currentStyle = styleId;
this.map.setStyle(style);
}
Expand Down
Loading

0 comments on commit b9fe33f

Please sign in to comment.