diff --git a/packages/lambda-tiler/src/index.ts b/packages/lambda-tiler/src/index.ts index dee4fab09..475628915 100644 --- a/packages/lambda-tiler/src/index.ts +++ b/packages/lambda-tiler/src/index.ts @@ -90,6 +90,7 @@ handler.router.get('/v1/fonts/:fontStack/:range.pbf', fontGet); // StyleJSON handler.router.get('/v1/styles/:styleName.json', styleJsonGet); + /** @deprecated 2022-07-22 all styles should be being served from /v1/styles/:styleName.json */ handler.router.get('/v1/tiles/:tileSet/:tileMatrix/style/:styleName.json', styleJsonGet); diff --git a/packages/lambda-tiler/src/routes/__tests__/tile.style.json.test.ts b/packages/lambda-tiler/src/routes/__tests__/tile.style.json.test.ts index a35f37b99..312d6d293 100644 --- a/packages/lambda-tiler/src/routes/__tests__/tile.style.json.test.ts +++ b/packages/lambda-tiler/src/routes/__tests__/tile.style.json.test.ts @@ -195,32 +195,16 @@ describe('/v1/styles', () => { assert.equal(res.header('content-type'), 'application/json'); assert.equal(res.header('cache-control'), 'no-store'); - const body = Buffer.from(res.body ?? '', 'base64').toString(); - fakeStyle.sources['basemaps_vector'] = { - type: 'vector', - url: `${host}/vector?api=${Api.key}`, - }; - fakeStyle.sources['basemaps_raster'] = { - type: 'raster', - tiles: [`${host}/raster?api=${Api.key}`], - }; - fakeStyle.sources['basemaps_raster_encode'] = { - type: 'raster', - tiles: [`${host}/raster/{z}/{x}/{y}.webp?api=${Api.key}`], - }; - - fakeStyle.sources['basemaps_terrain'] = { - type: 'raster-dem', - tiles: [`${host}/elevation/{z}/{x}/{y}.png?pipeline=terrain-rgb&api=${Api.key}`], - }; + const targetStyle = JSON.parse(Buffer.from(res.body ?? '', 'base64').toString()) as StyleJson; - fakeStyle.sprite = `${host}/sprite`; - fakeStyle.glyphs = `${host}/glyphs`; - - assert.deepEqual(JSON.parse(body), fakeStyle); + assert.deepEqual(targetStyle.layers, fakeStyle.layers); + assert.deepEqual(Object.keys(targetStyle.sources), Object.keys(fakeStyle.sources)); + assert.equal(targetStyle.glyphs, `${host}${fakeStyle.glyphs}`); + assert.equal(targetStyle.sprite, `${host}${fakeStyle.sprite}`); }); it('should serve style json with excluded layers', async () => { + console.log(fakeRecord.style.layers.map((m) => m.id)); config.put(fakeRecord); const request = mockUrlRequest( '/v1/tiles/topographic/Google/style/topographic.json', @@ -233,25 +217,18 @@ describe('/v1/styles', () => { assert.equal(res.header('content-type'), 'application/json'); assert.equal(res.header('cache-control'), 'no-store'); - const body = Buffer.from(res.body ?? '', 'base64').toString(); - fakeStyle.sources['basemaps_vector'] = { - type: 'vector', - url: `${host}/vector?api=${Api.key}`, - }; - fakeStyle.sources['basemaps_raster'] = { - type: 'raster', - tiles: [`${host}/raster?api=${Api.key}`], - }; - fakeStyle.sources['basemaps_raster_encode'] = { - type: 'raster', - tiles: [`${host}/raster/{z}/{x}/{y}.webp?api=${Api.key}`], - }; - - fakeStyle.sprite = `${host}/sprite`; - fakeStyle.glyphs = `${host}/glyphs`; - fakeStyle.layers = [fakeStyle.layers[2]]; + const targetStyle = JSON.parse(Buffer.from(res.body ?? '', 'base64').toString()) as StyleJson; + assert.deepEqual( + targetStyle.layers.map((m) => m.id), + ['Background3'], + ); + // original record should be preserved + assert.deepEqual( + fakeRecord.style.layers.map((m) => m.id), + ['Background1', 'Background2', 'Background3'], + ); - assert.deepEqual(JSON.parse(body), fakeStyle); + assert.deepEqual(Object.keys(targetStyle.sources), Object.keys(fakeStyle.sources)); }); it('should create raster styles', async () => { @@ -371,6 +348,7 @@ describe('/v1/styles', () => { }, id: 'Background1', type: 'background', + source: 'basemaps_vector', minzoom: 0, }, ], @@ -399,6 +377,63 @@ describe('/v1/styles', () => { ]); }); + describe('style.merge', () => { + it('should merge terrain for all style config', async () => { + const request = mockUrlRequest('/v1/styles/aerial,topolite.json', '', Api.header); + config.put(fakeVectorRecord); + config.put(fakeAerialRecord); + + 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 aerialSource = body.sources['basemaps_raster'] as unknown as SourceRaster; + + assert.equal(aerialSource.type, 'raster'); + assert.equal(body.layers[0].source, 'basemaps_raster'); + assert.equal(body.layers[1].source, 'basemaps_vector'); + }); + + it('should fail merge terrain for duplicate layers', async () => { + const request = mockUrlRequest('/v1/styles/topolite,topolite.json', '', Api.header); + config.put(fakeVectorRecord); + + const res = await handler.router.handle(request); + + assert.equal(res.status, 400, res.statusDescription); + assert.equal(res.statusDescription.includes('duplicate layerIds'), true); + }); + + it('should fail merge terrain for large requests', async () => { + const layers = 'topolite,'.repeat(10).slice(0, -1); + const request = mockUrlRequest(`/v1/styles/${layers}.json`, '', Api.header); + config.put(fakeVectorRecord); + + const res = await handler.router.handle(request); + assert.equal(res.status, 400, res.statusDescription); + assert.equal(res.statusDescription.includes('Too many styles'), true); + }); + + it('should skip merging empty style names', async () => { + const layers = ','.repeat(10); + const request = mockUrlRequest(`/v1/styles/${layers}topolite.json`, '', Api.header); + config.put(fakeVectorRecord); + + const res = await handler.router.handle(request); + assert.equal(res.status, 200, res.statusDescription); + console.log(request.url); + }); + + it('should fail merge if one layer is missing', async () => { + const request = mockUrlRequest('/v1/styles/topolite,missing-layer.json', '', Api.header); + config.put(fakeVectorRecord); + + const res = await handler.router.handle(request); + + assert.equal(res.status, 404, res.statusDescription); + }); + }); + const fakeAerialStyleConfig = { id: 'test', name: 'test', @@ -420,8 +455,9 @@ describe('/v1/styles', () => { paint: { 'background-color': 'rgba(206, 229, 242, 1)', }, - id: 'Background1', - type: 'background', + id: 'aerial', + source: 'basemaps_raster', + type: 'raster', minzoom: 0, }, ], diff --git a/packages/lambda-tiler/src/routes/tile.style.json.ts b/packages/lambda-tiler/src/routes/tile.style.json.ts index 3ed3dc0ec..438445d1e 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -298,9 +298,51 @@ export interface StyleGet { }; } +/** + * Join styles together + * + * Returns a new style json with sources and layers merged together + * + * @throws if there are any duplicate layerIds between the styles + * @param styles styles to merge together, the first style in the array will be used as the base style and urls will be merged into this style + * @returns + */ +function mergeStyles(styles: StyleJson[]): StyleJson { + const target = structuredClone(styles[0]); + if (styles.length === 1) return target; + + const layerId = new Map(); + for (const l of target.layers) layerId.set(l.id, target.id); + + for (const st of styles.slice(1)) { + for (const newLayers of st.layers) { + if (layerId.has(newLayers.id)) { + const prev = layerId.get(newLayers.id); + throw new LambdaHttpResponse( + 400, + `Cannot merge styles with duplicate layerIds! styles: "${prev}" "${st.id}" layer: ${newLayers.id}`, + ); + } + layerId.set(newLayers.id, st.id); + } + + if (target.glyphs == null) target.glyphs = st.glyphs; + if (target.sprite == null) target.sprite = st.sprite; + if (target.sky == null) target.sky = st.sky; + + Object.assign(target.sources, st.sources); + target.layers.push(...st.layers); + target.name = target.name + '_' + st.name; + target.id = target.id + '_' + st.id; + } + + return target; +} + export async function styleJsonGet(req: LambdaHttpRequest): Promise { const apiKey = Validate.apiKey(req); - const styleName = req.params.styleName; + const styleNames = req.params.styleName.split(',').filter((f) => f.trim().length > 0); + if (styleNames.length > 9) throw new LambdaHttpResponse(400, 'Too many styles requested, max 10'); const tileMatrix = TileMatrixSets.find(req.query.get('tileMatrix') ?? GoogleTms.identifier); if (tileMatrix == null) return new LambdaHttpResponse(400, 'Invalid tile matrix'); @@ -311,23 +353,32 @@ export async function styleJsonGet(req: LambdaHttpRequest): Promise 0) req.set('excludedLayers', [...excluded]); /** - * Configuration options used for the landing page: - * "terrain" - force add a terrain layer - * "labels" - merge the labels style with the current style + * Force add a terrain layer * - * TODO: (2024-08) this is not a very scalable way of configuring styles, it would be good to provide a styleJSON merge + * @deprecated 2026-02: use "/aerial,terrain-v2.json" */ const terrain = req.query.get('terrain') ?? undefined; + /** + * Merge the labels style with the current style + * + * @deprecated 2026-02: use "/aerial,labels-v2.json" + */ const labels = Boolean(req.query.get('labels') ?? false); - req.set('styleConfig', { terrain, labels }); + req.set('styleConfig', { terrain, labels, styles: styleNames }); // Get style Config from db const config = await ConfigLoader.load(req); - const styleConfig = await config.Style.get(styleName); - const styleSource = - styleConfig?.style ?? (await generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey)); - const targetStyle = structuredClone(styleSource); + const styles = await Promise.all( + styleNames.map(async (styleName) => { + const styleConfig = await config.Style.get(styleName); + if (styleConfig?.style != null) return styleConfig.style; + return generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey); + }), + ); + + const targetStyle = mergeStyles(styles); + // Ensure elevation for style json config // TODO: We should remove this after adding terrain source into style configs. PR-916 await ensureTerrain(req, tileMatrix, apiKey, targetStyle);