diff --git a/packages/lambda-tiler/src/routes/tile.style.json.ts b/packages/lambda-tiler/src/routes/tile.style.json.ts index ce8c8008c..d8afc7b10 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -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++) { @@ -82,6 +79,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.`); @@ -91,6 +95,22 @@ function setStyleTerrain(style: StyleJson, terrain: string, tileMatrix: TileMatr }; } +async function setStyleLabels(req: LambdaHttpRequest, style: StyleJson): Promise { + 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, tileMatrix: TileMatrixSet, @@ -98,17 +118,16 @@ async function ensureTerrain( style: StyleJson, ): Promise { 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( @@ -116,7 +135,7 @@ export async function tileSetToStyle( tileSet: ConfigTileSetRaster, tileMatrix: TileMatrixSet, apiKey: string, - terrain?: string, + cfg: StyleConfig, ): Promise { const [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp']; if (tileFormat == null) return new LambdaHttpResponse(400, 'Invalid image format'); @@ -144,9 +163,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(); @@ -164,7 +184,7 @@ export async function tileSetOutputToStyle( tileSet: ConfigTileSetRaster, tileMatrix: TileMatrixSet, apiKey: string, - terrain?: string, + cfg: StyleConfig, ): Promise { const configLocation = ConfigLoader.extract(req); const query = toQueryString({ config: configLocation, api: apiKey }); @@ -227,9 +247,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()); @@ -250,6 +271,7 @@ export async function styleJsonGet(req: LambdaHttpRequest): Promise): Promise 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.button.innerHTML = `more`; + + 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'; + } + }; +} diff --git a/packages/landing/src/components/map.switcher.tsx b/packages/landing/src/components/map.switcher.tsx index f25943247..2fa39f050 100644 --- a/packages/landing/src/components/map.switcher.tsx +++ b/packages/landing/src/components/map.switcher.tsx @@ -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({ @@ -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); } diff --git a/packages/landing/src/components/map.tsx b/packages/landing/src/components/map.tsx index c7b4e4b68..67b722d67 100644 --- a/packages/landing/src/components/map.tsx +++ b/packages/landing/src/components/map.tsx @@ -1,17 +1,15 @@ import { DefaultExaggeration } from '@basemaps/config/build/config/vector.style.js'; import { GoogleTms, LocationUrl } from '@basemaps/geo'; -import maplibre, { RasterLayerSpecification } from 'maplibre-gl'; +import maplibre from 'maplibre-gl'; import { Component, ReactNode } from 'react'; import { MapAttribution } from '../attribution.js'; import { Config } from '../config.js'; import { getTileGrid, locationTransform } from '../tile.matrix.js'; -import { MapOptionType, WindowUrl } from '../url.js'; import { Debug } from './debug.js'; +import { MapLabelControl } from './map.label.js'; import { MapSwitcher } from './map.switcher.js'; -const LayerFadeTime = 750; - /** * Map loading in maplibre is weird, the on('load') event is different to 'loaded' * this function waits until the map.loaded() function is true before being run. @@ -138,7 +136,7 @@ export class Basemaps extends Component { - if (Config.map.visibleLayers == null) Config.map.visibleLayers = newLayers; - if (newLayers !== Config.map.visibleLayers) { - Config.map.visibleLayers = newLayers; - const newStyleId = `${Config.map.styleId}` + `before=${Config.map.filter.date.before?.slice(0, 4)}`; - if (this.map.getSource(newStyleId) == null) { - this.map.addSource(newStyleId, { - type: 'raster', - tiles: [ - WindowUrl.toTileUrl({ - urlType: MapOptionType.TileRaster, - tileMatrix: Config.map.tileMatrix, - layerId: Config.map.layerId, - config: Config.map.config, - date: Config.map.filter.date, - }), - ], - tileSize: 256, - }); - this.map.addLayer({ - id: newStyleId, - type: 'raster', - source: newStyleId, - paint: { 'raster-opacity': 0 }, - }); - this.map.moveLayer(newStyleId); // Move to front - this.map.setPaintProperty(newStyleId, 'raster-opacity-transition', { duration: LayerFadeTime }); - this.map.setPaintProperty(newStyleId, 'raster-opacity', 1); - } - } - }; - - removeOldLayers = (): void => { - const filteredLayers = this.map - ?.getStyle() - .layers.filter((layer) => layer.id.startsWith(Config.map.styleId)) as RasterLayerSpecification[]; - if (filteredLayers == null) return; - // The last item in the array is the top layer, we pop that to ensure it isn't removed - filteredLayers.pop(); - for (const layer of filteredLayers) { - this.map.setPaintProperty(layer.id, 'raster-opacity-transition', { duration: LayerFadeTime }); - this.map.setPaintProperty(layer.id, 'raster-opacity', 0); - setTimeout(() => { - this.map.removeLayer(layer.id); - this.map.removeSource(layer.source); - }, LayerFadeTime); - } - }; - override componentDidMount(): void { // Force the URL to be read before loading the map Config.map.updateFromUrl(); @@ -206,7 +155,7 @@ export class Basemaps extends Component { this._events.push( Config.map.on('location', this.updateLocation), Config.map.on('tileMatrix', this.updateStyle), Config.map.on('layer', this.updateStyle), + Config.map.on('labels', this.updateStyle), Config.map.on('bounds', this.updateBounds), - // TODO: Disable updateVisibleLayers for now before we need implement date range slider - // Config.map.on('visibleLayers', this.updateVisibleLayers), ); this.map.on('terrain', this.updateTerrainFromEvent); diff --git a/packages/landing/src/config.map.ts b/packages/landing/src/config.map.ts index 4136201d2..a01f91004 100644 --- a/packages/landing/src/config.map.ts +++ b/packages/landing/src/config.map.ts @@ -40,6 +40,8 @@ export interface MapConfigEvents { * [ LayerId, Style?, Pipeline? ] */ layer: [string, string | null, string | null, string | null]; + /** should labels be shown */ + labels: [boolean]; bounds: [LngLatBoundsLike]; filter: [Filter]; change: []; @@ -55,6 +57,8 @@ export class MapConfig extends Emitter { visibleLayers: string | null = null; filter: Filter = { date: { before: undefined } }; terrain: string | null = null; + /** Should labels be added to the current layer */ + labels: boolean = false; pipeline: string | null = null; imageFormat: ImageFormat | null = null; @@ -124,6 +128,7 @@ export class MapConfig extends Emitter { const config = urlParams.get('c') ?? urlParams.get('config'); const layerId = urlParams.get('i') ?? style ?? 'aerial'; const terrain = urlParams.get('t') ?? urlParams.get('terrain'); + const labels = Boolean(urlParams.get('labels')); const projectionParam = (urlParams.get('p') ?? urlParams.get('tileMatrix') ?? GoogleTms.identifier).toLowerCase(); let tileMatrix = TileMatrixSets.All.find((f) => f.identifier.toLowerCase() === projectionParam); @@ -140,6 +145,7 @@ export class MapConfig extends Emitter { this.style = style ?? null; this.layerId = layerId.startsWith('im_') ? layerId.slice(3) : layerId; this.tileMatrix = tileMatrix; + this.labels = labels; if (this.layerId === 'topographic' && this.style == null) this.style = 'topographic'; this.emit('tileMatrix', this.tileMatrix); @@ -156,6 +162,7 @@ export class MapConfig extends Emitter { if (opts.config) urlParams.append('config', ensureBase58(opts.config)); // We don't need to set terrain parameter for debug, as we got debug.terrain parameter to replace if (opts.terrain && !opts.isDebug) urlParams.append('terrain', opts.terrain); + if (opts.labels) urlParams.append('labels', 'true'); ConfigDebug.toUrl(opts.debug, urlParams); return urlParams.toString(); @@ -230,6 +237,14 @@ export class MapConfig extends Emitter { this.emit('change'); } + setLabels(labels: boolean): void { + if (this.labels === labels) return; + this.labels = labels; + window.history.pushState(null, '', `?${MapConfig.toUrl(Config.map)}`); + this.emit('labels', this.labels); + this.emit('change'); + } + setLayerId( layer: string, style: string | null = null, diff --git a/packages/landing/src/tile.matrix.ts b/packages/landing/src/tile.matrix.ts index 4ab58f839..0998a67d8 100644 --- a/packages/landing/src/tile.matrix.ts +++ b/packages/landing/src/tile.matrix.ts @@ -2,9 +2,16 @@ import { GoogleTms, Nztm2000QuadTms, Nztm2000Tms, Projection, TileMatrixSet } fr import { StyleSpecification } from 'maplibre-gl'; import { Config } from './config.js'; -import { FilterDate } from './config.map.js'; import { MapLocation, MapOptionType, WindowUrl } from './url.js'; +export interface TileGridStyle { + layerId: string; + style?: string | null; + config?: string | null; + terrain?: string | null; + labels?: boolean | null; +} + export class TileGrid { tileMatrix: TileMatrixSet; extraZoomLevels: number; @@ -13,21 +20,15 @@ export class TileGrid { this.extraZoomLevels = extraZoomLevels; } - getStyle( - layerId: string, - style?: string | null, - config = Config.map.config, - date?: FilterDate, - terrain = Config.map.terrain, - ): StyleSpecification | string { + getStyle(cfg: TileGridStyle): StyleSpecification | string { return WindowUrl.toTileUrl({ urlType: MapOptionType.Style, tileMatrix: this.tileMatrix, - layerId, - style, - config, - date, - terrain, + layerId: cfg.layerId, + style: cfg.style, + config: cfg.config ?? Config.map.config, + terrain: cfg.terrain ?? Config.map.terrain, + labels: cfg.labels ?? Config.map.labels, }); } } diff --git a/packages/landing/src/url.ts b/packages/landing/src/url.ts index acb0c7f65..97882d431 100644 --- a/packages/landing/src/url.ts +++ b/packages/landing/src/url.ts @@ -35,6 +35,7 @@ export interface TileUrlParams { date?: FilterDate; imageFormat?: string | null; terrain?: string | null; + labels?: boolean | null; } export function ensureBase58(s: null): null; @@ -76,6 +77,7 @@ export const WindowUrl = { if (params.pipeline != null) queryParams.set('pipeline', params.pipeline); if (params.date?.before != null) queryParams.set('date[before]', params.date.before); if (params.terrain != null && params.urlType === MapOptionType.Style) queryParams.set('terrain', params.terrain); + if (params.labels && params.urlType === MapOptionType.Style) queryParams.set('labels', String(params.labels)); const imageFormat = params.imageFormat ?? WindowUrl.ImageFormat; if (params.urlType === MapOptionType.Style) { diff --git a/packages/landing/static/index.css b/packages/landing/static/index.css index e489ee988..c2c048108 100644 --- a/packages/landing/static/index.css +++ b/packages/landing/static/index.css @@ -221,4 +221,26 @@ have higher specifity than the default styles of the react-select component /** Attempt to align the hamburger menu to the map controls */ .LuiHeaderV2-menu-icon { padding-right: 8px; +} + + +/** Label control **/ +.maplibregl-ctrl-group .maplibregl-ctrl-labels { + color: #333; + display: flex; + justify-content: center; + align-items: center; +} + +.maplibregl-ctrl-group .maplibregl-ctrl-labels-enabled { + color: #33b5e5; +} + +.maplibregl-ctrl-labels i { + font-size: 20px; +} + + +.display-none { + display: none!important; } \ No newline at end of file