-
Notifications
You must be signed in to change notification settings - Fork 17
/
tile.style.json.ts
328 lines (286 loc) · 11.8 KB
/
tile.style.json.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
import {
BasemapsConfigProvider,
ConfigId,
ConfigPrefix,
ConfigTileSetRaster,
Layer,
Sources,
StyleJson,
TileSetType,
} from '@basemaps/config';
import { DefaultExaggeration } from '@basemaps/config/build/config/vector.style.js';
import { GoogleTms, Nztm2000QuadTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo';
import { Env, toQueryString } from '@basemaps/shared';
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
import { URL } from 'url';
import { ConfigLoader } from '../util/config.loader.js';
import { Etag } from '../util/etag.js';
import { convertStyleToNztmStyle } from '../util/nztm.style.js';
import { NotFound, NotModified } from '../util/response.js';
import { Validate } from '../util/validate.js';
import { createTileSetAttribution } from './attribution.js';
/**
* Convert relative URL into a full hostname URL, converting {tileMatrix} into the provided tileMatrix
*
* Will also add query parameters of apiKey and configuration if provided
*
* @example
* ```typescript
* convertRelativeUrl("/v1/tiles/aerial/{tileMatrix}/{z}/{x}/{y}.webp", NZTM2000Quad)
* "https://basemaps.linz.govt.nz/v1/tiles/aerial/NZTM2000Quad/{z}/{x}/{y}.webp?api=c..."
* ```
*
* @param url possible url to update
* @param apiKey ApiKey to append with ?api= if required
* @param tileMatrix replace {tileMatrix} with the tile matrix
*
* @returns Updated URL or empty string if url is empty
*/
export function convertRelativeUrl(
url?: string,
tileMatrix?: TileMatrixSet,
apiKey?: string,
config?: string | null,
): string {
if (url == null) return '';
if (tileMatrix) url = url.replace('{tileMatrix}', tileMatrix.identifier);
const host = Env.get(Env.PublicUrlBase) ?? '';
if (!url.startsWith('/')) return url; // Not relative ignore
const fullUrl = new URL(url, host);
if (apiKey) fullUrl.searchParams.set('api', apiKey);
if (config) fullUrl.searchParams.set('config', config);
return fullUrl.toString().replace(/%7B/g, '{').replace(/%7D/g, '}');
}
/**
* Update the style JSON to have absolute urls to the current host and API Keys where required
*
* @param style style to update
* @param tileMatrix convert the tile matrix to the target tile matrix
* @param apiKey api key to inject
* @param config optional configuration url to use
* @param layers replace the layers in the style json
* @returns new style JSON
*/
export function setStyleUrls(style: StyleJson, tileMatrix: TileMatrixSet, apiKey: string, config: string | null): void {
for (const [key, value] of Object.entries(style.sources ?? {})) {
if (value.type === 'vector') {
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++) {
value.tiles[i] = convertRelativeUrl(value.tiles[i], tileMatrix, apiKey, config);
}
}
style.sources[key] = value;
}
if (style.glyphs) style.glyphs = convertRelativeUrl(style.glyphs, undefined, undefined, config);
if (style.sprite) style.sprite = convertRelativeUrl(style.sprite, undefined, undefined, config);
}
export interface StyleConfig {
/** Name of the terrain layer */
terrain?: string | null;
/** Combine layer with the labels layer */
labels: boolean;
}
/**
* Turn on the terrain setting in the style json
*/
export 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} does not exists in the style source.`);
style.terrain = {
source,
exaggeration: DefaultExaggeration[tileMatrix.identifier] ?? DefaultExaggeration[GoogleTms.identifier],
};
}
/**
* Merge the "labels" layer into the style json
*/
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;
}
const layerId = new Set<string>();
for (const l of style.layers) layerId.add(l.id);
for (const newLayers of labels.style.layers) {
if (layerId.has(newLayers.id)) {
throw new LambdaHttpResponse(400, 'Cannot merge styles with duplicate layerIds: ' + newLayers.id);
}
}
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);
}
/**
* Ensure that a "LINZ-Terrain" layer is force added into the output styleJSON source
*/
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('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, // TODO: this should be configurable based on the elevation layer
tiles: [convertRelativeUrl(`/v1/tiles/elevation/${tileMatrix.identifier}/{z}/{x}/{y}.png${elevationQuery}`)],
};
}
/**
* Generate a StyleJSON from a tileset
* @returns
*/
export async function tileSetToStyle(
req: LambdaHttpRequest<StyleGet>,
config: BasemapsConfigProvider,
tileSet: ConfigTileSetRaster,
tileMatrix: TileMatrixSet,
apiKey: string,
): Promise<StyleJson> {
// If the style has outputs defined it has a different process for generating the stylejson
if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey);
const [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp'];
if (tileFormat == null) throw new LambdaHttpResponse(400, 'Invalid image format');
const pipeline = Validate.pipeline(tileSet, tileFormat, req.query.get('pipeline'));
const pipelineName = pipeline?.name === 'rgba' ? undefined : pipeline?.name;
const configLocation = ConfigLoader.extract(req);
const query = toQueryString({ config: configLocation, api: apiKey, pipeline: pipelineName });
const tileUrl =
(Env.get(Env.PublicUrlBase) ?? '') +
`/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${tileFormat}${query}`;
const attribution = await createTileSetAttribution(config, tileSet, tileMatrix.projection);
const styleId = `basemaps-${tileSet.name}`;
return {
id: ConfigId.prefix(ConfigPrefix.Style, tileSet.name),
name: tileSet.name,
version: 8,
sources: {
[styleId]: {
type: 'raster',
tiles: [tileUrl],
tileSize: 256,
attribution,
},
},
layers: [{ id: styleId, type: 'raster', source: styleId }],
};
}
/**
* generate a style from a tile set which has a output
*/
export function tileSetOutputToStyle(
req: LambdaHttpRequest<StyleGet>,
tileSet: ConfigTileSetRaster,
tileMatrix: TileMatrixSet,
apiKey: string,
): StyleJson {
if (tileSet.outputs == null) throw new LambdaHttpResponse(400, 'TileSet does not have any outputs to generate');
const configLocation = ConfigLoader.extract(req);
const styleId = `basemaps-${tileSet.name}`;
const sources: Sources = {};
const layers: Layer[] = [];
for (const output of tileSet.outputs) {
const format = output.format?.[0] ?? 'webp';
const urlBase = Env.get(Env.PublicUrlBase) ?? '';
const query = toQueryString({ config: configLocation, api: apiKey, pipeline: output.name });
const tileUrl = `${urlBase}/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${format}${query}`;
if (output.name === 'terrain-rgb') {
// Add both raster source and dem raster source for terrain-rgb output
sources[`${styleId}-${output.name}`] = { type: 'raster', tiles: [tileUrl], tileSize: 256 };
sources[`${styleId}-${output.name}-dem`] = { type: 'raster-dem', tiles: [tileUrl], tileSize: 256 };
} else {
// Add raster source other outputs
sources[`${styleId}-${output.name}`] = { type: 'raster', tiles: [tileUrl], tileSize: 256 };
}
}
// Add first raster source as default layer
for (const source of Object.keys(sources)) {
if (sources[source].type === 'raster') {
layers.push({ id: styleId, type: 'raster', source });
break;
}
}
return {
id: ConfigId.prefix(ConfigPrefix.Style, tileSet.name),
name: tileSet.name,
version: 8,
sources,
layers,
};
}
async function generateStyleFromTileSet(
req: LambdaHttpRequest<StyleGet>,
config: BasemapsConfigProvider,
tileSetName: string,
tileMatrix: TileMatrixSet,
apiKey: string,
): Promise<StyleJson> {
const tileSet = await config.TileSet.get(tileSetName);
if (tileSet == null) throw NotFound();
if (tileSet.type !== TileSetType.Raster) {
throw new LambdaHttpResponse(400, 'Only raster tile sets can generate style JSON');
}
if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey);
return tileSetToStyle(req, config, tileSet, tileMatrix, apiKey);
}
export interface StyleGet {
Params: {
styleName: string;
};
}
export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<LambdaHttpResponse> {
const apiKey = Validate.apiKey(req);
const styleName = req.params.styleName;
const tileMatrix = TileMatrixSets.find(req.query.get('tileMatrix') ?? GoogleTms.identifier);
if (tileMatrix == null) return new LambdaHttpResponse(400, 'Invalid tile matrix');
// Remove layers from the output style json
const excludeLayers = req.query.getAll('exclude');
const excluded = new Set(excludeLayers.map((l) => l.toLowerCase()));
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
*
* TODO: (2024-08) this is not a very scalable way of configuring styles, it would be good to provide a styleJSON merge
*/
const terrain = req.query.get('terrain') ?? undefined;
const labels = Boolean(req.query.get('labels') ?? false);
req.set('styleConfig', { terrain, labels });
// 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);
// 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);
// Add terrain in style
if (terrain) setStyleTerrain(targetStyle, terrain, tileMatrix);
if (labels) await setStyleLabels(req, targetStyle);
// convert sources to full URLS and convert style between projections
setStyleUrls(targetStyle, tileMatrix, apiKey, ConfigLoader.extract(req));
if (tileMatrix.identifier === Nztm2000QuadTms.identifier) convertStyleToNztmStyle(targetStyle, false);
// filter out any excluded layers
if (excluded.size > 0) targetStyle.layers = targetStyle.layers.filter((f) => !excluded.has(f.id.toLowerCase()));
const data = Buffer.from(JSON.stringify(targetStyle));
const cacheKey = Etag.key(data);
if (Etag.isNotModified(req, cacheKey)) return NotModified();
const response = new LambdaHttpResponse(200, 'ok');
response.header(HttpHeader.ETag, cacheKey);
response.header(HttpHeader.CacheControl, 'no-store');
response.buffer(data, 'application/json');
req.set('bytes', data.byteLength);
return response;
}