Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/lambda-tiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
120 changes: 78 additions & 42 deletions packages/lambda-tiler/src/routes/__tests__/tile.style.json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -371,6 +348,7 @@ describe('/v1/styles', () => {
},
id: 'Background1',
type: 'background',
source: 'basemaps_vector',
minzoom: 0,
},
],
Expand Down Expand Up @@ -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',
Expand All @@ -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,
},
],
Expand Down
71 changes: 61 additions & 10 deletions packages/lambda-tiler/src/routes/tile.style.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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<StyleGet>): Promise<LambdaHttpResponse> {
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');
Expand All @@ -311,23 +353,32 @@ export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<La
if (excluded.size > 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);
Expand Down
Loading