map-zero 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/docs/api.md +66 -0
- package/docs/architecture.md +87 -0
- package/docs/cartography.md +77 -0
- package/docs/cesium.md +107 -0
- package/docs/openlayers.md +98 -0
- package/docs/styles.md +103 -0
- package/package.json +51 -0
- package/packages/cesium/package.json +13 -0
- package/packages/cesium/src/index.js +405 -0
- package/packages/ol/package.json +14 -0
- package/packages/ol/src/index.js +1705 -0
- package/packages/ol/src/labels.js +977 -0
- package/src/3dtiles/b3dm.js +38 -0
- package/src/3dtiles/clipper-surfaces.js +317 -0
- package/src/3dtiles/export.js +768 -0
- package/src/3dtiles/extrude.js +301 -0
- package/src/3dtiles/flat.js +531 -0
- package/src/3dtiles/glb.js +178 -0
- package/src/3dtiles/gpkg-buildings.js +240 -0
- package/src/3dtiles/gpkg-features.js +157 -0
- package/src/3dtiles/tileset.js +75 -0
- package/src/build.js +134 -0
- package/src/cli.js +656 -0
- package/src/export-pmtiles.js +962 -0
- package/src/geometry-read.js +50 -0
- package/src/gpkg-read.js +460 -0
- package/src/gpkg.js +567 -0
- package/src/html.js +593 -0
- package/src/layers.js +357 -0
- package/src/manifest.js +29 -0
- package/src/mvt.js +2593 -0
- package/src/ol.js +5 -0
- package/src/osm.js +2110 -0
- package/src/pmtiles-worker.js +70 -0
- package/src/pmtiles.js +260 -0
- package/src/server.js +720 -0
- package/src/style-command.js +78 -0
- package/src/style-filters.js +76 -0
- package/src/style-presets.js +93 -0
- package/src/style-themes.js +235 -0
- package/src/style.js +13 -0
- package/src/tile-cache.js +59 -0
- package/src/utils.js +222 -0
- package/styles/presets/light.json +4655 -0
- package/styles/presets/monochrome.json +4655 -0
- package/styles/presets/neon-dark-3d.json +90 -0
- package/styles/presets/neon-dark.json +4690 -0
- package/styles/presets/tactical.json +4690 -0
- package/styles/themes/neon-dark.theme.json +20 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
|
|
7
|
+
import { openGeoPackageReader } from './gpkg-read.js';
|
|
8
|
+
import { createCesiumViewerHtml, createViewerHtml } from './html.js';
|
|
9
|
+
import { encodeMvtTileSetWithStats, encodeMvtTileWithStats } from './mvt.js';
|
|
10
|
+
import { createHiddenFilters } from './style-filters.js';
|
|
11
|
+
import { TileCache } from './tile-cache.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* buffer: Buffer,
|
|
16
|
+
* featureCount: number,
|
|
17
|
+
* originalFeatureCount: number,
|
|
18
|
+
* encodedFeatureCount: number,
|
|
19
|
+
* droppedFeatureCount: number,
|
|
20
|
+
* bbox: [number, number, number, number],
|
|
21
|
+
* layerNames: string[],
|
|
22
|
+
* emptyReason: string,
|
|
23
|
+
* originalVertexCount: number,
|
|
24
|
+
* simplifiedVertexCount: number,
|
|
25
|
+
* droppedSmallFeatures: number,
|
|
26
|
+
* simplificationTolerance: number
|
|
27
|
+
* }} TileGenerationResult
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a readonly map-zero HTTP server.
|
|
32
|
+
*
|
|
33
|
+
* @param {{ packageDir: string, tileCache?: boolean, tileCacheSize?: number, tileMaxFeatures?: number, debugTiles?: boolean, debugLabels?: boolean }} options
|
|
34
|
+
* @returns {Promise<import('fastify').FastifyInstance>}
|
|
35
|
+
*/
|
|
36
|
+
export async function createMapZeroServer(options) {
|
|
37
|
+
const packageDir = resolve(options.packageDir);
|
|
38
|
+
const manifestPath = join(packageDir, 'manifest.json');
|
|
39
|
+
const stylesDir = join(packageDir, 'styles');
|
|
40
|
+
const manifest = await readJsonFile(manifestPath);
|
|
41
|
+
const gpkgPath = join(packageDir, String(manifest.data ?? 'data.gpkg'));
|
|
42
|
+
const defaultStyle = await readDefaultStyle(packageDir, manifest);
|
|
43
|
+
|
|
44
|
+
validateManifest(manifest);
|
|
45
|
+
await assertReadableFile(gpkgPath, 'GeoPackage');
|
|
46
|
+
|
|
47
|
+
const reader = openGeoPackageReader({
|
|
48
|
+
gpkgPath,
|
|
49
|
+
manifest,
|
|
50
|
+
hiddenFilters: createHiddenFilters(manifest, defaultStyle)
|
|
51
|
+
});
|
|
52
|
+
const assetVersion = String(Date.now());
|
|
53
|
+
const tileCache = options.tileCache === false
|
|
54
|
+
? null
|
|
55
|
+
: new TileCache(options.tileCacheSize ?? 500);
|
|
56
|
+
/** @type {Map<string, Promise<TileGenerationResult>>} */
|
|
57
|
+
const pendingTiles = new Map();
|
|
58
|
+
const app = Fastify({
|
|
59
|
+
logger: false
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
app.addHook('onClose', async () => {
|
|
63
|
+
tileCache?.clear();
|
|
64
|
+
pendingTiles.clear();
|
|
65
|
+
reader.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.setErrorHandler((error, request, reply) => {
|
|
69
|
+
const statusCode = Number(error.statusCode) || 500;
|
|
70
|
+
reply.status(statusCode).send({
|
|
71
|
+
error: statusCode >= 500 ? 'internal server error' : error.message
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
app.get('/', async (request, reply) => {
|
|
76
|
+
reply
|
|
77
|
+
.header('cache-control', 'no-store')
|
|
78
|
+
.type('text/html; charset=utf-8')
|
|
79
|
+
.send(createViewerHtml({ assetVersion }));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
app.get('/cesium', async (request, reply) => {
|
|
83
|
+
reply
|
|
84
|
+
.header('cache-control', 'no-store')
|
|
85
|
+
.type('text/html; charset=utf-8')
|
|
86
|
+
.send(createCesiumViewerHtml({ assetVersion }));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.get('/map-zero-ol.js', async (request, reply) => {
|
|
90
|
+
const moduleSource = await fs.readFile(new URL('../packages/ol/src/index.js', import.meta.url), 'utf8');
|
|
91
|
+
const cacheBustedSource = moduleSource.replace(
|
|
92
|
+
"from './labels.js';",
|
|
93
|
+
`from './labels.js?v=${encodeURIComponent(assetVersion)}';`
|
|
94
|
+
);
|
|
95
|
+
reply
|
|
96
|
+
.header('cache-control', 'no-store')
|
|
97
|
+
.type('text/javascript; charset=utf-8')
|
|
98
|
+
.send(cacheBustedSource);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
app.get('/labels.js', async (request, reply) => {
|
|
102
|
+
const moduleSource = await fs.readFile(new URL('../packages/ol/src/labels.js', import.meta.url), 'utf8');
|
|
103
|
+
reply
|
|
104
|
+
.header('cache-control', 'no-store')
|
|
105
|
+
.type('text/javascript; charset=utf-8')
|
|
106
|
+
.send(moduleSource);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
app.get('/vendor/pmtiles.js', async (request, reply) => {
|
|
110
|
+
const moduleSource = await fs.readFile(new URL('../node_modules/pmtiles/dist/esm/index.js', import.meta.url), 'utf8');
|
|
111
|
+
reply
|
|
112
|
+
.header('cache-control', 'public, max-age=31536000, immutable')
|
|
113
|
+
.type('text/javascript; charset=utf-8')
|
|
114
|
+
.send(moduleSource);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.get('/vendor/fflate.js', async (request, reply) => {
|
|
118
|
+
const moduleSource = await fs.readFile(new URL('../node_modules/fflate/esm/browser.js', import.meta.url), 'utf8');
|
|
119
|
+
reply
|
|
120
|
+
.header('cache-control', 'public, max-age=31536000, immutable')
|
|
121
|
+
.type('text/javascript; charset=utf-8')
|
|
122
|
+
.send(moduleSource);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
app.get('/map-zero-cesium.js', async (request, reply) => {
|
|
126
|
+
const moduleSource = await fs.readFile(new URL('../packages/cesium/src/index.js', import.meta.url), 'utf8');
|
|
127
|
+
const browserSource = moduleSource.replace(
|
|
128
|
+
"import {\n Cesium3DTileStyle,\n Cesium3DTileset\n} from 'cesium';",
|
|
129
|
+
"const { Cesium3DTileStyle, Cesium3DTileset } = globalThis.Cesium;"
|
|
130
|
+
);
|
|
131
|
+
reply
|
|
132
|
+
.header('cache-control', 'no-store')
|
|
133
|
+
.type('text/javascript; charset=utf-8')
|
|
134
|
+
.send(browserSource);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.get('/manifest.json', async (request, reply) => {
|
|
138
|
+
reply.header('cache-control', 'no-store').send(manifest);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const pmtilesPath = pmtilesArchivePath(packageDir, manifest);
|
|
142
|
+
if (pmtilesPath) {
|
|
143
|
+
app.get(`/${pmtilesPath.url}`, async (request, reply) => {
|
|
144
|
+
await sendRangeFile(request, reply, pmtilesPath.path, 'application/vnd.pmtiles');
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const tiles3dPath = tiles3dBasePath(packageDir, manifest);
|
|
149
|
+
if (tiles3dPath) {
|
|
150
|
+
app.get(`/${tiles3dPath.urlPrefix}/*`, async (request, reply) => {
|
|
151
|
+
const params = /** @type {Record<string, string>} */ (request.params);
|
|
152
|
+
const relativePath = params['*'] ?? '';
|
|
153
|
+
const filePath = safeJoin(tiles3dPath.pathPrefix, relativePath);
|
|
154
|
+
if (!filePath) {
|
|
155
|
+
reply.status(404).send({ error: 'file not found' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await sendRangeFile(request, reply, filePath, contentTypeFor3dTiles(filePath));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
app.get('/styles/:name', async (request, reply) => {
|
|
164
|
+
const { name } = /** @type {{ name: string }} */ (request.params);
|
|
165
|
+
const safeName = validateStyleName(name);
|
|
166
|
+
const stylePath = join(stylesDir, safeName);
|
|
167
|
+
const style = await readJsonFile(stylePath).catch(() => null);
|
|
168
|
+
|
|
169
|
+
if (!style) {
|
|
170
|
+
reply.status(404).send({ error: `style not found: ${safeName}` });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
reply.header('cache-control', 'no-store').send(style);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
app.get('/api/info', async (request, reply) => {
|
|
178
|
+
reply.header('cache-control', 'no-store').send(reader.getInfo(packageDir));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
app.get('/api/layers', async (request, reply) => {
|
|
182
|
+
reply.header('cache-control', 'no-store').send({
|
|
183
|
+
layers: reader.getLayers()
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
app.get('/api/tiles/:z/:x/:y.mvt', async (request, reply) => {
|
|
188
|
+
const { z, x, y } = /** @type {{ z: string, x: string, y: string }} */ (request.params);
|
|
189
|
+
const { layers, detail } = /** @type {{ layers?: string, detail?: string }} */ (request.query);
|
|
190
|
+
const layerIds = parseTileLayerQuery(layers);
|
|
191
|
+
const tileDetail = parseTileDetailQuery(detail);
|
|
192
|
+
const resolvedDetail = resolveTileDetail(z, tileDetail);
|
|
193
|
+
const label = tileLayerLabel(layerIds);
|
|
194
|
+
|
|
195
|
+
await sendMvtTile(reply, {
|
|
196
|
+
cacheKey: createTileCacheKey(packageDir, label, z, x, y, resolvedDetail),
|
|
197
|
+
cacheLabel: label,
|
|
198
|
+
z,
|
|
199
|
+
x,
|
|
200
|
+
y,
|
|
201
|
+
detail: resolvedDetail,
|
|
202
|
+
tileCache,
|
|
203
|
+
pendingTiles,
|
|
204
|
+
debugTiles: Boolean(options.debugTiles),
|
|
205
|
+
generate: () => encodeMvtTileSetWithStats(reader, z, x, y, layerIds, {
|
|
206
|
+
detail: resolvedDetail,
|
|
207
|
+
maxFeatures: options.tileMaxFeatures,
|
|
208
|
+
style: defaultStyle,
|
|
209
|
+
debugLabels: Boolean(options.debugLabels)
|
|
210
|
+
})
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
app.get('/api/tiles/:layer/:z/:x/:y.mvt', async (request, reply) => {
|
|
215
|
+
const { layer, z, x, y } = /** @type {{ layer: string, z: string, x: string, y: string }} */ (request.params);
|
|
216
|
+
const { detail } = /** @type {{ detail?: string }} */ (request.query);
|
|
217
|
+
const tileDetail = parseTileDetailQuery(detail);
|
|
218
|
+
const resolvedDetail = resolveTileDetail(z, tileDetail);
|
|
219
|
+
|
|
220
|
+
await sendMvtTile(reply, {
|
|
221
|
+
cacheKey: createTileCacheKey(packageDir, layer, z, x, y, resolvedDetail),
|
|
222
|
+
cacheLabel: layer,
|
|
223
|
+
z,
|
|
224
|
+
x,
|
|
225
|
+
y,
|
|
226
|
+
detail: resolvedDetail,
|
|
227
|
+
tileCache,
|
|
228
|
+
pendingTiles,
|
|
229
|
+
debugTiles: Boolean(options.debugTiles),
|
|
230
|
+
generate: () => encodeMvtTileWithStats(reader, layer, z, x, y, {
|
|
231
|
+
detail: resolvedDetail,
|
|
232
|
+
maxFeatures: options.tileMaxFeatures,
|
|
233
|
+
style: defaultStyle,
|
|
234
|
+
debugLabels: Boolean(options.debugLabels)
|
|
235
|
+
})
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return app;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Start a readonly map-zero HTTP server.
|
|
244
|
+
*
|
|
245
|
+
* @param {{
|
|
246
|
+
* packageDir: string,
|
|
247
|
+
* host: string,
|
|
248
|
+
* port: number,
|
|
249
|
+
* open?: boolean,
|
|
250
|
+
* tileCache?: boolean,
|
|
251
|
+
* tileCacheSize?: number,
|
|
252
|
+
* tileMaxFeatures?: number,
|
|
253
|
+
* debugTiles?: boolean,
|
|
254
|
+
* debugLabels?: boolean
|
|
255
|
+
* }} options
|
|
256
|
+
* @returns {Promise<{ app: import('fastify').FastifyInstance, url: string }>}
|
|
257
|
+
*/
|
|
258
|
+
export async function serveMapZero(options) {
|
|
259
|
+
const app = await createMapZeroServer(options);
|
|
260
|
+
await app.listen({
|
|
261
|
+
host: options.host,
|
|
262
|
+
port: options.port
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const url = `http://${options.host}:${options.port}`;
|
|
266
|
+
if (options.open) {
|
|
267
|
+
openBrowser(url);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
app,
|
|
272
|
+
url
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Serve one MVT tile through cache and in-flight request coalescing.
|
|
278
|
+
*
|
|
279
|
+
* @param {import('fastify').FastifyReply} reply
|
|
280
|
+
* @param {{
|
|
281
|
+
* cacheKey: string,
|
|
282
|
+
* cacheLabel: string,
|
|
283
|
+
* z: string,
|
|
284
|
+
* x: string,
|
|
285
|
+
* y: string,
|
|
286
|
+
* detail: string,
|
|
287
|
+
* tileCache: TileCache | null,
|
|
288
|
+
* pendingTiles: Map<string, Promise<TileGenerationResult>>,
|
|
289
|
+
* debugTiles: boolean,
|
|
290
|
+
* generate: () => TileGenerationResult
|
|
291
|
+
* }} options
|
|
292
|
+
*/
|
|
293
|
+
async function sendMvtTile(reply, options) {
|
|
294
|
+
const startedAt = performance.now();
|
|
295
|
+
const cached = /** @type {TileGenerationResult | undefined} */ (options.tileCache?.get(options.cacheKey));
|
|
296
|
+
if (cached) {
|
|
297
|
+
sendTileReply(reply, cached.buffer, 'hit');
|
|
298
|
+
logTileTiming(options, 'hit', startedAt, cached);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const pending = options.pendingTiles.get(options.cacheKey);
|
|
303
|
+
if (pending) {
|
|
304
|
+
const result = await pending;
|
|
305
|
+
sendTileReply(reply, result.buffer, 'hit');
|
|
306
|
+
logTileTiming(options, 'hit', startedAt, result);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const promise = Promise.resolve().then(options.generate);
|
|
311
|
+
options.pendingTiles.set(options.cacheKey, promise);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const result = await promise;
|
|
315
|
+
options.tileCache?.set(options.cacheKey, result);
|
|
316
|
+
sendTileReply(reply, result.buffer, 'miss');
|
|
317
|
+
logTileTiming(options, 'miss', startedAt, result);
|
|
318
|
+
} finally {
|
|
319
|
+
options.pendingTiles.delete(options.cacheKey);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param {import('fastify').FastifyReply} reply
|
|
325
|
+
* @param {Buffer} buffer
|
|
326
|
+
* @param {'hit' | 'miss'} cacheStatus
|
|
327
|
+
*/
|
|
328
|
+
function sendTileReply(reply, buffer, cacheStatus) {
|
|
329
|
+
reply
|
|
330
|
+
.header('X-MapZero-Cache', cacheStatus)
|
|
331
|
+
.type('application/vnd.mapbox-vector-tile')
|
|
332
|
+
.send(buffer);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @param {{
|
|
337
|
+
* cacheLabel: string,
|
|
338
|
+
* z: string,
|
|
339
|
+
* x: string,
|
|
340
|
+
* y: string,
|
|
341
|
+
* detail: string,
|
|
342
|
+
* debugTiles: boolean
|
|
343
|
+
* }} options
|
|
344
|
+
* @param {'hit' | 'miss'} cacheStatus
|
|
345
|
+
* @param {number} startedAt
|
|
346
|
+
* @param {TileGenerationResult} result
|
|
347
|
+
*/
|
|
348
|
+
function logTileTiming(options, cacheStatus, startedAt, result) {
|
|
349
|
+
if (!options.debugTiles) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const durationMs = Math.max(0, performance.now() - startedAt);
|
|
354
|
+
console.error(
|
|
355
|
+
`tile ${options.cacheLabel} ${options.z}/${options.x}/${options.y} ` +
|
|
356
|
+
`detail=${options.detail} cache=${cacheStatus} ` +
|
|
357
|
+
`bbox=${formatBbox(result.bbox)} layerNames=${result.layerNames.join(',')} ` +
|
|
358
|
+
`durationMs=${durationMs.toFixed(1)} originalFeatureCount=${result.originalFeatureCount} ` +
|
|
359
|
+
`encodedFeatureCount=${result.encodedFeatureCount} droppedFeatureCount=${result.droppedFeatureCount} ` +
|
|
360
|
+
`originalVertexCount=${result.originalVertexCount} simplifiedVertexCount=${result.simplifiedVertexCount} ` +
|
|
361
|
+
`droppedSmallFeatures=${result.droppedSmallFeatures} simplificationTolerance=${result.simplificationTolerance.toFixed(6)} ` +
|
|
362
|
+
`sizeBytes=${result.buffer.length} emptyReason=${result.emptyReason}`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {[number, number, number, number]} bbox
|
|
368
|
+
* @returns {string}
|
|
369
|
+
*/
|
|
370
|
+
function formatBbox(bbox) {
|
|
371
|
+
return bbox.map((value) => Number(value).toFixed(6)).join(',');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* @param {string} filePath
|
|
376
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
377
|
+
*/
|
|
378
|
+
async function readJsonFile(filePath) {
|
|
379
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} packageDir
|
|
384
|
+
* @param {Record<string, unknown>} manifest
|
|
385
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
386
|
+
*/
|
|
387
|
+
async function readDefaultStyle(packageDir, manifest) {
|
|
388
|
+
const styles = /** @type {Record<string, unknown> | undefined} */ (manifest.styles);
|
|
389
|
+
const defaultStylePath = styles?.default;
|
|
390
|
+
if (typeof defaultStylePath !== 'string') {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return readJsonFile(join(packageDir, defaultStylePath)).catch(() => null);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {string} filePath
|
|
399
|
+
* @param {string} label
|
|
400
|
+
*/
|
|
401
|
+
async function assertReadableFile(filePath, label) {
|
|
402
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
403
|
+
if (!stat || !stat.isFile()) {
|
|
404
|
+
throw new Error(`${label} file does not exist: ${filePath}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {Record<string, unknown>} manifest
|
|
410
|
+
*/
|
|
411
|
+
function validateManifest(manifest) {
|
|
412
|
+
if (manifest.format !== 'mapzero') {
|
|
413
|
+
throw new Error('manifest format must be mapzero');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!Array.isArray(manifest.layers)) {
|
|
417
|
+
throw new Error('manifest must contain a layers array');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {string} name
|
|
423
|
+
* @returns {string}
|
|
424
|
+
*/
|
|
425
|
+
function validateStyleName(name) {
|
|
426
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
427
|
+
throw httpError(400, 'invalid style name');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return name.endsWith('.json') ? name : `${name}.json`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {string | undefined} value
|
|
435
|
+
* @returns {string[] | undefined}
|
|
436
|
+
*/
|
|
437
|
+
function parseTileLayerQuery(value) {
|
|
438
|
+
if (!value) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (value === '__none__') {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const layers = value
|
|
447
|
+
.split(',')
|
|
448
|
+
.map((layer) => layer.trim())
|
|
449
|
+
.filter(Boolean);
|
|
450
|
+
|
|
451
|
+
if (layers.length === 0) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const layer of layers) {
|
|
456
|
+
if (!/^[A-Za-z0-9_-]+$/.test(layer)) {
|
|
457
|
+
throw httpError(400, 'invalid tile layer list');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return [...new Set(layers)];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* @param {string[] | undefined} layerIds
|
|
466
|
+
* @returns {string}
|
|
467
|
+
*/
|
|
468
|
+
function tileLayerLabel(layerIds) {
|
|
469
|
+
if (!layerIds) {
|
|
470
|
+
return '*';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (layerIds.length === 0) {
|
|
474
|
+
return '__none__';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return layerIds.join(',');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {string} packageDir
|
|
482
|
+
* @param {string} layerLabel
|
|
483
|
+
* @param {string} z
|
|
484
|
+
* @param {string} x
|
|
485
|
+
* @param {string} y
|
|
486
|
+
* @param {string} detail
|
|
487
|
+
* @returns {string}
|
|
488
|
+
*/
|
|
489
|
+
function createTileCacheKey(packageDir, layerLabel, z, x, y, detail) {
|
|
490
|
+
return `${packageDir}|${layerLabel}|${z}/${x}/${y}|${detail}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* @param {string | undefined} value
|
|
495
|
+
* @returns {string | undefined}
|
|
496
|
+
*/
|
|
497
|
+
function parseTileDetailQuery(value) {
|
|
498
|
+
if (!value) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!/^(overview|normal|full)$/.test(value)) {
|
|
503
|
+
throw httpError(400, 'invalid tile detail level');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @param {string} zValue
|
|
511
|
+
* @param {string | undefined} detail
|
|
512
|
+
* @returns {string}
|
|
513
|
+
*/
|
|
514
|
+
function resolveTileDetail(zValue, detail) {
|
|
515
|
+
if (detail) {
|
|
516
|
+
return detail;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const z = Number(zValue);
|
|
520
|
+
if (Number.isFinite(z) && z <= 11) {
|
|
521
|
+
return 'overview';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (Number.isFinite(z) && z <= 14) {
|
|
525
|
+
return 'normal';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return 'full';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* @param {string} packageDir
|
|
533
|
+
* @param {Record<string, unknown>} manifest
|
|
534
|
+
* @returns {{ url: string, path: string } | null}
|
|
535
|
+
*/
|
|
536
|
+
function pmtilesArchivePath(packageDir, manifest) {
|
|
537
|
+
const tiles = /** @type {{ format?: unknown, url?: unknown } | undefined} */ (manifest.tiles);
|
|
538
|
+
if (tiles?.format !== 'pmtiles' || typeof tiles.url !== 'string') {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const url = tiles.url.replace(/^\/+/, '');
|
|
543
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(url) || url.includes('..')) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
url,
|
|
549
|
+
path: join(packageDir, url)
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* @param {string} packageDir
|
|
555
|
+
* @param {Record<string, unknown>} manifest
|
|
556
|
+
* @returns {{ urlPrefix: string, pathPrefix: string } | null}
|
|
557
|
+
*/
|
|
558
|
+
function tiles3dBasePath(packageDir, manifest) {
|
|
559
|
+
const cesiumTilesets = /** @type {{ tilesets?: Record<string, unknown> } | undefined} */ (manifest.cesium)?.tilesets;
|
|
560
|
+
if (cesiumTilesets && typeof cesiumTilesets === 'object' && Object.keys(cesiumTilesets).length > 0) {
|
|
561
|
+
return {
|
|
562
|
+
urlPrefix: '3dtiles',
|
|
563
|
+
pathPrefix: join(packageDir, '3dtiles')
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const tiles3d = /** @type {{ format?: unknown, url?: unknown } | undefined} */ (manifest.tiles3d);
|
|
568
|
+
if (tiles3d?.format !== '3dtiles' || typeof tiles3d.url !== 'string') {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const url = tiles3d.url.replace(/^\/+/, '');
|
|
573
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(url) || url.includes('..')) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const urlPrefix = dirname(url).replaceAll('\\', '/');
|
|
578
|
+
if (!urlPrefix || urlPrefix === '.') {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
urlPrefix,
|
|
584
|
+
pathPrefix: join(packageDir, urlPrefix)
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @param {string} baseDir
|
|
590
|
+
* @param {string} relativePath
|
|
591
|
+
* @returns {string | null}
|
|
592
|
+
*/
|
|
593
|
+
function safeJoin(baseDir, relativePath) {
|
|
594
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(relativePath) || relativePath.includes('..')) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const base = resolve(baseDir);
|
|
599
|
+
const filePath = resolve(baseDir, relativePath);
|
|
600
|
+
return filePath.startsWith(`${base}/`) || filePath === base ? filePath : null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @param {string} filePath
|
|
605
|
+
* @returns {string}
|
|
606
|
+
*/
|
|
607
|
+
function contentTypeFor3dTiles(filePath) {
|
|
608
|
+
const ext = extname(filePath).toLowerCase();
|
|
609
|
+
if (ext === '.json') {
|
|
610
|
+
return 'application/json; charset=utf-8';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (ext === '.b3dm') {
|
|
614
|
+
return 'application/octet-stream';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (ext === '.glb') {
|
|
618
|
+
return 'model/gltf-binary';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return 'application/octet-stream';
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* @param {import('fastify').FastifyRequest} request
|
|
626
|
+
* @param {import('fastify').FastifyReply} reply
|
|
627
|
+
* @param {string} filePath
|
|
628
|
+
* @param {string} contentType
|
|
629
|
+
*/
|
|
630
|
+
async function sendRangeFile(request, reply, filePath, contentType) {
|
|
631
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
632
|
+
if (!stat?.isFile()) {
|
|
633
|
+
reply.status(404).send({ error: 'file not found' });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (contentType.startsWith('application/json')) {
|
|
638
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
639
|
+
reply
|
|
640
|
+
.header('cache-control', 'no-store')
|
|
641
|
+
.type(contentType)
|
|
642
|
+
.send(content);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const range = request.headers.range;
|
|
647
|
+
reply
|
|
648
|
+
.header('cache-control', 'no-store')
|
|
649
|
+
.type(contentType);
|
|
650
|
+
|
|
651
|
+
if (typeof range !== 'string') {
|
|
652
|
+
reply
|
|
653
|
+
.header('accept-ranges', 'none')
|
|
654
|
+
.header('content-length', stat.size)
|
|
655
|
+
.send(await fs.readFile(filePath));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
660
|
+
if (!match) {
|
|
661
|
+
reply.status(416).header('content-range', `bytes */${stat.size}`).send();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const start = match[1] ? Number(match[1]) : 0;
|
|
666
|
+
const end = match[2] ? Number(match[2]) : stat.size - 1;
|
|
667
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= stat.size) {
|
|
668
|
+
reply.status(416).header('content-range', `bytes */${stat.size}`).send();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const boundedEnd = Math.min(end, stat.size - 1);
|
|
673
|
+
reply
|
|
674
|
+
.status(206)
|
|
675
|
+
.header('content-range', `bytes ${start}-${boundedEnd}/${stat.size}`)
|
|
676
|
+
.header('content-length', boundedEnd - start + 1)
|
|
677
|
+
.send(await readFileRange(filePath, start, boundedEnd));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* @param {string} filePath
|
|
682
|
+
* @param {number} start
|
|
683
|
+
* @param {number} end
|
|
684
|
+
* @returns {Promise<Buffer>}
|
|
685
|
+
*/
|
|
686
|
+
async function readFileRange(filePath, start, end) {
|
|
687
|
+
const handle = await fs.open(filePath, 'r');
|
|
688
|
+
try {
|
|
689
|
+
const buffer = Buffer.alloc(end - start + 1);
|
|
690
|
+
const result = await handle.read(buffer, 0, buffer.length, start);
|
|
691
|
+
return result.bytesRead === buffer.length ? buffer : buffer.subarray(0, result.bytesRead);
|
|
692
|
+
} finally {
|
|
693
|
+
await handle.close();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* @param {string} url
|
|
699
|
+
*/
|
|
700
|
+
function openBrowser(url) {
|
|
701
|
+
const platform = process.platform;
|
|
702
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
703
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
704
|
+
const child = spawn(command, args, {
|
|
705
|
+
detached: true,
|
|
706
|
+
stdio: 'ignore'
|
|
707
|
+
});
|
|
708
|
+
child.unref();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* @param {number} statusCode
|
|
713
|
+
* @param {string} message
|
|
714
|
+
* @returns {Error & { statusCode: number }}
|
|
715
|
+
*/
|
|
716
|
+
function httpError(statusCode, message) {
|
|
717
|
+
const error = /** @type {Error & { statusCode: number }} */ (new Error(message));
|
|
718
|
+
error.statusCode = statusCode;
|
|
719
|
+
return error;
|
|
720
|
+
}
|