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
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
import { createWriteStream, promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { availableParallelism } from 'node:os';
|
|
4
|
+
import { Worker } from 'node:worker_threads';
|
|
5
|
+
|
|
6
|
+
import { openGeoPackageReader } from './gpkg-read.js';
|
|
7
|
+
import { detailForZoom, encodeMvtTileSetWithStats } from './mvt.js';
|
|
8
|
+
import { tileIdForZxy, writePmtilesArchive } from './pmtiles.js';
|
|
9
|
+
import { createHiddenFilters } from './style-filters.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MIN_ZOOM = 8;
|
|
12
|
+
const DEFAULT_MAX_ZOOM = 16;
|
|
13
|
+
const MAX_ZOOM = 22;
|
|
14
|
+
const LARGE_EXPORT_TILE_LIMIT = 100_000;
|
|
15
|
+
const VERY_LARGE_EXPORT_TILE_LIMIT = 500_000;
|
|
16
|
+
const WEB_MERCATOR_MAX_LAT = 85.05112878;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ minX: number, minY: number, maxX: number, maxY: number, tileCount: number }} TileRange
|
|
20
|
+
*
|
|
21
|
+
* @typedef {(event: {
|
|
22
|
+
* phase: 'estimate' | 'zoom-progress' | 'zoom' | 'done',
|
|
23
|
+
* zoom?: number,
|
|
24
|
+
* tileCount?: number,
|
|
25
|
+
* tilesByZoom?: Array<{ zoom: number, tileCount: number }>,
|
|
26
|
+
* bbox?: [number, number, number, number],
|
|
27
|
+
* coverage?: { widthDegrees: number, heightDegrees: number, approximateAreaKm2: number },
|
|
28
|
+
* recommendation?: string[],
|
|
29
|
+
* highEstimate?: boolean,
|
|
30
|
+
* veryHighEstimate?: boolean,
|
|
31
|
+
* completedTiles?: number,
|
|
32
|
+
* totalTiles?: number,
|
|
33
|
+
* tilesPerSecond?: number,
|
|
34
|
+
* averageTileSize?: number,
|
|
35
|
+
* etaSeconds?: number | null,
|
|
36
|
+
* workers?: number,
|
|
37
|
+
* writtenTiles?: number,
|
|
38
|
+
* skippedEmptyTiles?: number,
|
|
39
|
+
* outputBytes?: number,
|
|
40
|
+
* outputPath?: string
|
|
41
|
+
* }) => void} ExportProgress
|
|
42
|
+
*
|
|
43
|
+
* @typedef {{
|
|
44
|
+
* entries: import('./pmtiles.js').PmtilesEntry[],
|
|
45
|
+
* tileDataOffset: number,
|
|
46
|
+
* writtenTiles: number,
|
|
47
|
+
* skippedEmptyTiles: number
|
|
48
|
+
* }} ZoomExportResult
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Export a .mapzero package to a static vector PMTiles archive.
|
|
53
|
+
*
|
|
54
|
+
* @param {{
|
|
55
|
+
* packageDir: string,
|
|
56
|
+
* out?: string,
|
|
57
|
+
* minZoom?: number,
|
|
58
|
+
* maxZoom?: number,
|
|
59
|
+
* workers?: number,
|
|
60
|
+
* force?: boolean,
|
|
61
|
+
* onProgress?: ExportProgress
|
|
62
|
+
* }} options
|
|
63
|
+
* @returns {Promise<{ outPath: string, minZoom: number, maxZoom: number, estimatedTiles: number, writtenTiles: number, skippedEmptyTiles: number, outputBytes: number }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function exportPmtiles(options) {
|
|
66
|
+
const packageDir = resolve(options.packageDir);
|
|
67
|
+
const manifestPath = join(packageDir, 'manifest.json');
|
|
68
|
+
const manifest = await readJsonFile(manifestPath);
|
|
69
|
+
validateManifest(manifest);
|
|
70
|
+
|
|
71
|
+
const minZoom = options.minZoom ?? DEFAULT_MIN_ZOOM;
|
|
72
|
+
const maxZoom = options.maxZoom ?? DEFAULT_MAX_ZOOM;
|
|
73
|
+
validateZoomRange(minZoom, maxZoom);
|
|
74
|
+
const workers = normalizeWorkerCount(options.workers);
|
|
75
|
+
|
|
76
|
+
const outPath = resolve(options.out ?? join(packageDir, 'tiles.pmtiles'));
|
|
77
|
+
const manifestTileUrl = relativePackagePath(packageDir, outPath);
|
|
78
|
+
const bbox = normalizeBbox(/** @type {unknown} */ (manifest.bbox));
|
|
79
|
+
const defaultStyle = await readDefaultStyle(packageDir, manifest);
|
|
80
|
+
const gpkgPath = join(packageDir, String(manifest.data ?? 'data.gpkg'));
|
|
81
|
+
await assertReadableFile(gpkgPath, 'GeoPackage');
|
|
82
|
+
|
|
83
|
+
const rangesByZoom = createTileRangesByZoom(bbox, minZoom, maxZoom);
|
|
84
|
+
const estimate = createExportEstimate(rangesByZoom, bbox, minZoom, maxZoom);
|
|
85
|
+
const estimatedTiles = estimate.tileCount;
|
|
86
|
+
options.onProgress?.({
|
|
87
|
+
phase: 'estimate',
|
|
88
|
+
tileCount: estimatedTiles,
|
|
89
|
+
tilesByZoom: estimate.tilesByZoom,
|
|
90
|
+
bbox,
|
|
91
|
+
coverage: estimate.coverage,
|
|
92
|
+
recommendation: estimate.recommendation,
|
|
93
|
+
highEstimate: estimate.highEstimate,
|
|
94
|
+
veryHighEstimate: estimate.veryHighEstimate
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (estimatedTiles > LARGE_EXPORT_TILE_LIMIT && !options.force) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`PMTiles export would generate up to ${formatInteger(estimatedTiles)} tiles; use --force to proceed`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const reader = openGeoPackageReader({
|
|
104
|
+
gpkgPath,
|
|
105
|
+
manifest,
|
|
106
|
+
hiddenFilters: createHiddenFilters(manifest, defaultStyle)
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const tmpTileDataPath = `${outPath}.tiles-${process.pid}-${Date.now()}`;
|
|
110
|
+
await fs.mkdir(dirname(outPath), { recursive: true });
|
|
111
|
+
const tileDataStream = createWriteStream(tmpTileDataPath, { flags: 'w' });
|
|
112
|
+
/** @type {import('./pmtiles.js').PmtilesEntry[]} */
|
|
113
|
+
const entries = [];
|
|
114
|
+
let tileDataOffset = 0;
|
|
115
|
+
let writtenTiles = 0;
|
|
116
|
+
let skippedEmptyTiles = 0;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
if (workers <= 1) {
|
|
120
|
+
for (const [zoom, range] of rangesByZoom.entries()) {
|
|
121
|
+
const result = await exportZoomSequential({
|
|
122
|
+
reader,
|
|
123
|
+
manifest,
|
|
124
|
+
defaultStyle,
|
|
125
|
+
zoom,
|
|
126
|
+
range,
|
|
127
|
+
tileDataStream,
|
|
128
|
+
tileDataOffset,
|
|
129
|
+
onProgress: options.onProgress,
|
|
130
|
+
workers
|
|
131
|
+
});
|
|
132
|
+
entries.push(...result.entries);
|
|
133
|
+
tileDataOffset = result.tileDataOffset;
|
|
134
|
+
writtenTiles += result.writtenTiles;
|
|
135
|
+
skippedEmptyTiles += result.skippedEmptyTiles;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
reader.close();
|
|
139
|
+
for (const [zoom, range] of rangesByZoom.entries()) {
|
|
140
|
+
const result = await exportZoomParallel({
|
|
141
|
+
packageDir,
|
|
142
|
+
gpkgPath,
|
|
143
|
+
manifest,
|
|
144
|
+
defaultStyle,
|
|
145
|
+
zoom,
|
|
146
|
+
range,
|
|
147
|
+
tileDataStream,
|
|
148
|
+
tileDataOffset,
|
|
149
|
+
workers,
|
|
150
|
+
onProgress: options.onProgress
|
|
151
|
+
});
|
|
152
|
+
entries.push(...result.entries);
|
|
153
|
+
tileDataOffset = result.tileDataOffset;
|
|
154
|
+
writtenTiles += result.writtenTiles;
|
|
155
|
+
skippedEmptyTiles += result.skippedEmptyTiles;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await closeWriteStream(tileDataStream);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
tileDataStream.destroy();
|
|
162
|
+
throw error;
|
|
163
|
+
} finally {
|
|
164
|
+
try {
|
|
165
|
+
reader.close();
|
|
166
|
+
} catch {
|
|
167
|
+
// Parallel exports close the setup reader before workers open their own readonly handles.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (entries.length === 0) {
|
|
172
|
+
await fs.rm(tmpTileDataPath, { force: true });
|
|
173
|
+
throw new Error('PMTiles export produced no non-empty tiles');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const metadata = createPmtilesMetadata(manifest, defaultStyle, minZoom, maxZoom, bbox);
|
|
177
|
+
const archive = await writePmtilesArchive({
|
|
178
|
+
outPath,
|
|
179
|
+
tileDataPath: tmpTileDataPath,
|
|
180
|
+
entries,
|
|
181
|
+
metadata,
|
|
182
|
+
minZoom,
|
|
183
|
+
maxZoom,
|
|
184
|
+
bbox,
|
|
185
|
+
centerZoom: Math.min(Math.max(12, minZoom), maxZoom)
|
|
186
|
+
});
|
|
187
|
+
await fs.rm(tmpTileDataPath, { force: true });
|
|
188
|
+
await updateManifestTiles(manifestPath, manifest, {
|
|
189
|
+
url: manifestTileUrl,
|
|
190
|
+
minZoom,
|
|
191
|
+
maxZoom
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
options.onProgress?.({
|
|
195
|
+
phase: 'done',
|
|
196
|
+
writtenTiles,
|
|
197
|
+
skippedEmptyTiles,
|
|
198
|
+
outputBytes: archive.bytes,
|
|
199
|
+
outputPath: outPath
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
outPath,
|
|
204
|
+
minZoom,
|
|
205
|
+
maxZoom,
|
|
206
|
+
estimatedTiles,
|
|
207
|
+
writtenTiles,
|
|
208
|
+
skippedEmptyTiles,
|
|
209
|
+
outputBytes: archive.bytes
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {{
|
|
215
|
+
* reader: ReturnType<typeof openGeoPackageReader>,
|
|
216
|
+
* manifest: Record<string, unknown>,
|
|
217
|
+
* defaultStyle: Record<string, unknown> | null,
|
|
218
|
+
* zoom: number,
|
|
219
|
+
* range: TileRange,
|
|
220
|
+
* tileDataStream: import('node:fs').WriteStream,
|
|
221
|
+
* tileDataOffset: number,
|
|
222
|
+
* workers: number,
|
|
223
|
+
* onProgress?: ExportProgress
|
|
224
|
+
* }} options
|
|
225
|
+
* @returns {Promise<ZoomExportResult>}
|
|
226
|
+
*/
|
|
227
|
+
async function exportZoomSequential(options) {
|
|
228
|
+
const layerIds = activeLayerIdsForZoom(options.manifest, options.defaultStyle, options.zoom);
|
|
229
|
+
const detail = detailForZoom(options.zoom);
|
|
230
|
+
const progress = createZoomProgress(options.zoom, options.range.tileCount, options.workers, options.onProgress);
|
|
231
|
+
/** @type {import('./pmtiles.js').PmtilesEntry[]} */
|
|
232
|
+
const entries = [];
|
|
233
|
+
let tileDataOffset = options.tileDataOffset;
|
|
234
|
+
let writtenTiles = 0;
|
|
235
|
+
let skippedEmptyTiles = 0;
|
|
236
|
+
let tileBytes = 0;
|
|
237
|
+
|
|
238
|
+
for (const task of tileTasksForRange(options.zoom, options.range)) {
|
|
239
|
+
const result = encodeMvtTileSetWithStats(options.reader, options.zoom, task.x, task.y, layerIds, {
|
|
240
|
+
detail,
|
|
241
|
+
style: options.defaultStyle
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (result.encodedFeatureCount === 0) {
|
|
245
|
+
skippedEmptyTiles += 1;
|
|
246
|
+
progress.update({ writtenTiles, skippedEmptyTiles, tileBytes });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await writeStreamChunk(options.tileDataStream, result.buffer);
|
|
251
|
+
entries.push({
|
|
252
|
+
tileId: task.tileId,
|
|
253
|
+
offset: tileDataOffset,
|
|
254
|
+
length: result.buffer.length,
|
|
255
|
+
runLength: 1
|
|
256
|
+
});
|
|
257
|
+
tileDataOffset += result.buffer.length;
|
|
258
|
+
writtenTiles += 1;
|
|
259
|
+
tileBytes += result.buffer.length;
|
|
260
|
+
progress.update({ writtenTiles, skippedEmptyTiles, tileBytes });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
progress.finish({ writtenTiles, skippedEmptyTiles, tileBytes });
|
|
264
|
+
return {
|
|
265
|
+
entries,
|
|
266
|
+
tileDataOffset,
|
|
267
|
+
writtenTiles,
|
|
268
|
+
skippedEmptyTiles
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {{
|
|
274
|
+
* packageDir: string,
|
|
275
|
+
* gpkgPath: string,
|
|
276
|
+
* manifest: Record<string, unknown>,
|
|
277
|
+
* defaultStyle: Record<string, unknown> | null,
|
|
278
|
+
* zoom: number,
|
|
279
|
+
* range: TileRange,
|
|
280
|
+
* tileDataStream: import('node:fs').WriteStream,
|
|
281
|
+
* tileDataOffset: number,
|
|
282
|
+
* workers: number,
|
|
283
|
+
* onProgress?: ExportProgress
|
|
284
|
+
* }} options
|
|
285
|
+
* @returns {Promise<ZoomExportResult>}
|
|
286
|
+
*/
|
|
287
|
+
function exportZoomParallel(options) {
|
|
288
|
+
const layerIds = activeLayerIdsForZoom(options.manifest, options.defaultStyle, options.zoom);
|
|
289
|
+
const detail = detailForZoom(options.zoom);
|
|
290
|
+
const progress = createZoomProgress(options.zoom, options.range.tileCount, options.workers, options.onProgress);
|
|
291
|
+
const iterator = tileTasksForRange(options.zoom, options.range);
|
|
292
|
+
/** @type {import('./pmtiles.js').PmtilesEntry[]} */
|
|
293
|
+
const entries = [];
|
|
294
|
+
const workerCount = Math.min(options.workers, Math.max(1, options.range.tileCount));
|
|
295
|
+
const workers = [];
|
|
296
|
+
let tileDataOffset = options.tileDataOffset;
|
|
297
|
+
let writtenTiles = 0;
|
|
298
|
+
let skippedEmptyTiles = 0;
|
|
299
|
+
let tileBytes = 0;
|
|
300
|
+
let activeJobs = 0;
|
|
301
|
+
let nextJobId = 0;
|
|
302
|
+
let closedWorkers = 0;
|
|
303
|
+
let closing = false;
|
|
304
|
+
let writeChain = Promise.resolve();
|
|
305
|
+
|
|
306
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
307
|
+
/**
|
|
308
|
+
* @param {Error} error
|
|
309
|
+
*/
|
|
310
|
+
const fail = (error) => {
|
|
311
|
+
if (closing) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
closing = true;
|
|
315
|
+
for (const worker of workers) {
|
|
316
|
+
worker.terminate();
|
|
317
|
+
}
|
|
318
|
+
rejectPromise(error);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const maybeClose = () => {
|
|
322
|
+
if (closing || activeJobs > 0) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
closing = true;
|
|
327
|
+
for (const worker of workers) {
|
|
328
|
+
worker.postMessage({ type: 'close' });
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {Worker} worker
|
|
334
|
+
*/
|
|
335
|
+
const assign = (worker) => {
|
|
336
|
+
if (closing) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const next = iterator.next();
|
|
341
|
+
if (next.done) {
|
|
342
|
+
maybeClose();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
activeJobs += 1;
|
|
347
|
+
worker.postMessage({
|
|
348
|
+
id: nextJobId,
|
|
349
|
+
z: options.zoom,
|
|
350
|
+
x: next.value.x,
|
|
351
|
+
y: next.value.y,
|
|
352
|
+
tileId: next.value.tileId,
|
|
353
|
+
layerIds,
|
|
354
|
+
detail
|
|
355
|
+
});
|
|
356
|
+
nextJobId += 1;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
for (let index = 0; index < workerCount; index += 1) {
|
|
360
|
+
const worker = new Worker(new URL('./pmtiles-worker.js', import.meta.url), {
|
|
361
|
+
workerData: {
|
|
362
|
+
packageDir: options.packageDir,
|
|
363
|
+
gpkgPath: options.gpkgPath,
|
|
364
|
+
manifest: options.manifest,
|
|
365
|
+
defaultStyle: options.defaultStyle
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
workers.push(worker);
|
|
369
|
+
|
|
370
|
+
worker.on('message', (message) => {
|
|
371
|
+
if (message?.type === 'closed') {
|
|
372
|
+
closedWorkers += 1;
|
|
373
|
+
worker.terminate();
|
|
374
|
+
if (closedWorkers === workers.length) {
|
|
375
|
+
progress.finish({ writtenTiles, skippedEmptyTiles, tileBytes });
|
|
376
|
+
resolvePromise({
|
|
377
|
+
entries,
|
|
378
|
+
tileDataOffset,
|
|
379
|
+
writtenTiles,
|
|
380
|
+
skippedEmptyTiles
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (message?.type === 'error') {
|
|
387
|
+
fail(new Error(message.message || 'PMTiles worker failed'));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (message?.type !== 'tile') {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
(async () => {
|
|
396
|
+
try {
|
|
397
|
+
activeJobs -= 1;
|
|
398
|
+
if (message.empty) {
|
|
399
|
+
skippedEmptyTiles += 1;
|
|
400
|
+
} else {
|
|
401
|
+
const buffer = Buffer.from(message.buffer);
|
|
402
|
+
await (writeChain = writeChain.then(async () => {
|
|
403
|
+
await writeStreamChunk(options.tileDataStream, buffer);
|
|
404
|
+
entries.push({
|
|
405
|
+
tileId: message.tileId,
|
|
406
|
+
offset: tileDataOffset,
|
|
407
|
+
length: buffer.length,
|
|
408
|
+
runLength: 1
|
|
409
|
+
});
|
|
410
|
+
tileDataOffset += buffer.length;
|
|
411
|
+
}));
|
|
412
|
+
writtenTiles += 1;
|
|
413
|
+
tileBytes += buffer.length;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
progress.update({ writtenTiles, skippedEmptyTiles, tileBytes });
|
|
417
|
+
assign(worker);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
420
|
+
}
|
|
421
|
+
})();
|
|
422
|
+
});
|
|
423
|
+
worker.on('error', fail);
|
|
424
|
+
worker.on('exit', (code) => {
|
|
425
|
+
if (!closing && code !== 0) {
|
|
426
|
+
fail(new Error(`PMTiles worker exited with code ${code}`));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const worker of workers) {
|
|
432
|
+
assign(worker);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {number} zoom
|
|
439
|
+
* @param {number} totalTiles
|
|
440
|
+
* @param {number} workers
|
|
441
|
+
* @param {ExportProgress | undefined} onProgress
|
|
442
|
+
* @returns {{ update: (counts: { writtenTiles: number, skippedEmptyTiles: number, tileBytes: number }) => void, finish: (counts: { writtenTiles: number, skippedEmptyTiles: number, tileBytes: number }) => void }}
|
|
443
|
+
*/
|
|
444
|
+
function createZoomProgress(zoom, totalTiles, workers, onProgress) {
|
|
445
|
+
const startedAt = Date.now();
|
|
446
|
+
let completedTiles = 0;
|
|
447
|
+
let lastReportAt = 0;
|
|
448
|
+
|
|
449
|
+
const eventFor = (counts) => {
|
|
450
|
+
const elapsedSeconds = Math.max(0.001, (Date.now() - startedAt) / 1000);
|
|
451
|
+
const tilesPerSecond = completedTiles / elapsedSeconds;
|
|
452
|
+
const remainingTiles = Math.max(0, totalTiles - completedTiles);
|
|
453
|
+
return {
|
|
454
|
+
zoom,
|
|
455
|
+
workers,
|
|
456
|
+
completedTiles,
|
|
457
|
+
totalTiles,
|
|
458
|
+
tileCount: totalTiles,
|
|
459
|
+
writtenTiles: counts.writtenTiles,
|
|
460
|
+
skippedEmptyTiles: counts.skippedEmptyTiles,
|
|
461
|
+
averageTileSize: counts.writtenTiles > 0 ? counts.tileBytes / counts.writtenTiles : 0,
|
|
462
|
+
tilesPerSecond,
|
|
463
|
+
etaSeconds: tilesPerSecond > 0 ? remainingTiles / tilesPerSecond : null
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
update(counts) {
|
|
469
|
+
completedTiles += 1;
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
if (now - lastReportAt < 1000 && completedTiles < totalTiles) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
lastReportAt = now;
|
|
476
|
+
onProgress?.({
|
|
477
|
+
phase: 'zoom-progress',
|
|
478
|
+
...eventFor(counts)
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
finish(counts) {
|
|
483
|
+
completedTiles = totalTiles;
|
|
484
|
+
onProgress?.({
|
|
485
|
+
phase: 'zoom',
|
|
486
|
+
...eventFor(counts)
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* @param {string} filePath
|
|
494
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
495
|
+
*/
|
|
496
|
+
async function readJsonFile(filePath) {
|
|
497
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* @param {Record<string, unknown>} manifest
|
|
502
|
+
*/
|
|
503
|
+
function validateManifest(manifest) {
|
|
504
|
+
if (manifest.format !== 'mapzero') {
|
|
505
|
+
throw new Error('manifest format must be mapzero');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!Array.isArray(manifest.layers)) {
|
|
509
|
+
throw new Error('manifest must contain a layers array');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* @param {number} minZoom
|
|
515
|
+
* @param {number} maxZoom
|
|
516
|
+
*/
|
|
517
|
+
function validateZoomRange(minZoom, maxZoom) {
|
|
518
|
+
if (!Number.isInteger(minZoom) || !Number.isInteger(maxZoom) || minZoom < 0 || maxZoom > MAX_ZOOM || minZoom > maxZoom) {
|
|
519
|
+
throw new Error(`zoom range must use integers with 0 <= minzoom <= maxzoom <= ${MAX_ZOOM}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {number | undefined} value
|
|
525
|
+
* @returns {number}
|
|
526
|
+
*/
|
|
527
|
+
function normalizeWorkerCount(value) {
|
|
528
|
+
const requested = value ?? 1;
|
|
529
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
530
|
+
throw new Error('workers must be a positive integer');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return Math.min(requested, Math.max(1, availableParallelism()));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* @param {unknown} bbox
|
|
538
|
+
* @returns {[number, number, number, number]}
|
|
539
|
+
*/
|
|
540
|
+
function normalizeBbox(bbox) {
|
|
541
|
+
if (!Array.isArray(bbox) || bbox.length !== 4) {
|
|
542
|
+
throw new Error('manifest bbox must be [minLon,minLat,maxLon,maxLat]');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const values = bbox.map(Number);
|
|
546
|
+
if (values.some((value) => !Number.isFinite(value)) || values[0] >= values[2] || values[1] >= values[3]) {
|
|
547
|
+
throw new Error('manifest bbox is invalid');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return /** @type {[number, number, number, number]} */ (values);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* @param {string} packageDir
|
|
555
|
+
* @param {string} outPath
|
|
556
|
+
* @returns {string}
|
|
557
|
+
*/
|
|
558
|
+
function relativePackagePath(packageDir, outPath) {
|
|
559
|
+
const relativePath = relative(packageDir, outPath).split(sep).join('/');
|
|
560
|
+
if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
|
|
561
|
+
throw new Error('PMTiles output must be inside the .mapzero package folder');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return relativePath;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* @param {string} filePath
|
|
569
|
+
* @param {string} label
|
|
570
|
+
*/
|
|
571
|
+
async function assertReadableFile(filePath, label) {
|
|
572
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
573
|
+
if (!stat?.isFile()) {
|
|
574
|
+
throw new Error(`${label} file does not exist: ${filePath}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* @param {string} packageDir
|
|
580
|
+
* @param {Record<string, unknown>} manifest
|
|
581
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
582
|
+
*/
|
|
583
|
+
async function readDefaultStyle(packageDir, manifest) {
|
|
584
|
+
const styles = /** @type {Record<string, unknown> | undefined} */ (manifest.styles);
|
|
585
|
+
const defaultStylePath = styles?.default;
|
|
586
|
+
if (typeof defaultStylePath !== 'string') {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return readJsonFile(join(packageDir, defaultStylePath)).catch(() => null);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* @param {[number, number, number, number]} bbox
|
|
595
|
+
* @param {number} minZoom
|
|
596
|
+
* @param {number} maxZoom
|
|
597
|
+
* @returns {Map<number, TileRange>}
|
|
598
|
+
*/
|
|
599
|
+
function createTileRangesByZoom(bbox, minZoom, maxZoom) {
|
|
600
|
+
const rangesByZoom = new Map();
|
|
601
|
+
for (let z = minZoom; z <= maxZoom; z += 1) {
|
|
602
|
+
const range = tileRangeForBbox(bbox, z);
|
|
603
|
+
rangesByZoom.set(z, range);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return rangesByZoom;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* @param {number} z
|
|
611
|
+
* @param {TileRange} range
|
|
612
|
+
* @returns {Generator<{ z: number, x: number, y: number, tileId: number }>}
|
|
613
|
+
*/
|
|
614
|
+
function* tileTasksForRange(z, range) {
|
|
615
|
+
for (let x = range.minX; x <= range.maxX; x += 1) {
|
|
616
|
+
for (let y = range.minY; y <= range.maxY; y += 1) {
|
|
617
|
+
yield {
|
|
618
|
+
z,
|
|
619
|
+
x,
|
|
620
|
+
y,
|
|
621
|
+
tileId: tileIdForZxy(z, x, y)
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* @param {Map<number, TileRange>} rangesByZoom
|
|
629
|
+
* @param {[number, number, number, number]} bbox
|
|
630
|
+
* @param {number} minZoom
|
|
631
|
+
* @param {number} maxZoom
|
|
632
|
+
* @returns {{
|
|
633
|
+
* tileCount: number,
|
|
634
|
+
* tilesByZoom: Array<{ zoom: number, tileCount: number }>,
|
|
635
|
+
* coverage: { widthDegrees: number, heightDegrees: number, approximateAreaKm2: number },
|
|
636
|
+
* recommendation: string[],
|
|
637
|
+
* highEstimate: boolean,
|
|
638
|
+
* veryHighEstimate: boolean
|
|
639
|
+
* }}
|
|
640
|
+
*/
|
|
641
|
+
function createExportEstimate(rangesByZoom, bbox, minZoom, maxZoom) {
|
|
642
|
+
const tilesByZoom = [...rangesByZoom.entries()].map(([zoom, range]) => ({
|
|
643
|
+
zoom,
|
|
644
|
+
tileCount: range.tileCount
|
|
645
|
+
}));
|
|
646
|
+
const tileCount = tilesByZoom.reduce((sum, item) => sum + item.tileCount, 0);
|
|
647
|
+
const coverage = estimateCoverage(bbox);
|
|
648
|
+
const regional = isRegionalCoverage(coverage);
|
|
649
|
+
const highEstimate = tileCount > LARGE_EXPORT_TILE_LIMIT;
|
|
650
|
+
const veryHighEstimate = tileCount > VERY_LARGE_EXPORT_TILE_LIMIT;
|
|
651
|
+
const recommendation = createExportRecommendation({
|
|
652
|
+
minZoom,
|
|
653
|
+
maxZoom,
|
|
654
|
+
tileCount,
|
|
655
|
+
coverage,
|
|
656
|
+
regional,
|
|
657
|
+
highEstimate,
|
|
658
|
+
veryHighEstimate
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
tileCount,
|
|
663
|
+
tilesByZoom,
|
|
664
|
+
coverage,
|
|
665
|
+
recommendation,
|
|
666
|
+
highEstimate,
|
|
667
|
+
veryHighEstimate
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @param {[number, number, number, number]} bbox
|
|
673
|
+
* @returns {{ widthDegrees: number, heightDegrees: number, approximateAreaKm2: number }}
|
|
674
|
+
*/
|
|
675
|
+
function estimateCoverage(bbox) {
|
|
676
|
+
const [minLon, minLat, maxLon, maxLat] = bbox;
|
|
677
|
+
const widthDegrees = maxLon - minLon;
|
|
678
|
+
const heightDegrees = maxLat - minLat;
|
|
679
|
+
const meanLatRad = (((minLat + maxLat) / 2) * Math.PI) / 180;
|
|
680
|
+
const widthKm = Math.max(0, widthDegrees) * 111.32 * Math.max(0.01, Math.cos(meanLatRad));
|
|
681
|
+
const heightKm = Math.max(0, heightDegrees) * 110.57;
|
|
682
|
+
return {
|
|
683
|
+
widthDegrees,
|
|
684
|
+
heightDegrees,
|
|
685
|
+
approximateAreaKm2: widthKm * heightKm
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* @param {{ approximateAreaKm2: number, widthDegrees: number, heightDegrees: number }} coverage
|
|
691
|
+
* @returns {boolean}
|
|
692
|
+
*/
|
|
693
|
+
function isRegionalCoverage(coverage) {
|
|
694
|
+
return coverage.approximateAreaKm2 >= 2_500
|
|
695
|
+
|| coverage.widthDegrees >= 1.5
|
|
696
|
+
|| coverage.heightDegrees >= 1.5;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* @param {{
|
|
701
|
+
* minZoom: number,
|
|
702
|
+
* maxZoom: number,
|
|
703
|
+
* tileCount: number,
|
|
704
|
+
* coverage: { approximateAreaKm2: number },
|
|
705
|
+
* regional: boolean,
|
|
706
|
+
* highEstimate: boolean,
|
|
707
|
+
* veryHighEstimate: boolean
|
|
708
|
+
* }} options
|
|
709
|
+
* @returns {string[]}
|
|
710
|
+
*/
|
|
711
|
+
function createExportRecommendation(options) {
|
|
712
|
+
const recommendation = [];
|
|
713
|
+
|
|
714
|
+
if (options.regional) {
|
|
715
|
+
recommendation.push('Large regional exports are usually practical up to z12-z13.');
|
|
716
|
+
if (options.maxZoom > 13) {
|
|
717
|
+
recommendation.push('Use separate city exports for z14+ instead of one high-zoom regional archive.');
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
recommendation.push('City-scale exports are usually practical at z14-z16.');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (options.highEstimate) {
|
|
724
|
+
recommendation.push('The estimated tile count is high; reduce --maxzoom, tighten the package bbox, or split the export.');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (options.veryHighEstimate) {
|
|
728
|
+
recommendation.push('This export is very large and may take a long time even with empty tiles skipped.');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (options.maxZoom >= 15 && options.coverage.approximateAreaKm2 > 2_500) {
|
|
732
|
+
recommendation.push('For region-scale PMTiles, export overview tiles first, then create separate city packages for detailed zooms.');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (options.minZoom > 8) {
|
|
736
|
+
recommendation.push('Consider keeping z8-z10 for smooth overview navigation if the package is meant for browsing.');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return recommendation;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* @param {[number, number, number, number]} bbox
|
|
744
|
+
* @param {number} z
|
|
745
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
746
|
+
*/
|
|
747
|
+
function tileRangeForBbox(bbox, z) {
|
|
748
|
+
const [minLon, minLat, maxLon, maxLat] = bbox;
|
|
749
|
+
const minX = lonToTileX(minLon, z);
|
|
750
|
+
const maxX = lonToTileX(maxLon, z);
|
|
751
|
+
const minY = latToTileY(maxLat, z);
|
|
752
|
+
const maxY = latToTileY(minLat, z);
|
|
753
|
+
return {
|
|
754
|
+
minX,
|
|
755
|
+
maxX,
|
|
756
|
+
minY,
|
|
757
|
+
maxY,
|
|
758
|
+
tileCount: Math.max(0, maxX - minX + 1) * Math.max(0, maxY - minY + 1)
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* @param {number} lon
|
|
764
|
+
* @param {number} z
|
|
765
|
+
* @returns {number}
|
|
766
|
+
*/
|
|
767
|
+
function lonToTileX(lon, z) {
|
|
768
|
+
const n = 2 ** z;
|
|
769
|
+
return clamp(Math.floor(((lon + 180) / 360) * n), 0, n - 1);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* @param {number} lat
|
|
774
|
+
* @param {number} z
|
|
775
|
+
* @returns {number}
|
|
776
|
+
*/
|
|
777
|
+
function latToTileY(lat, z) {
|
|
778
|
+
const n = 2 ** z;
|
|
779
|
+
const clampedLat = clamp(lat, -WEB_MERCATOR_MAX_LAT, WEB_MERCATOR_MAX_LAT);
|
|
780
|
+
const latRad = (clampedLat * Math.PI) / 180;
|
|
781
|
+
const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
|
|
782
|
+
return clamp(Math.floor(y), 0, n - 1);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* @param {number} value
|
|
787
|
+
* @param {number} min
|
|
788
|
+
* @param {number} max
|
|
789
|
+
* @returns {number}
|
|
790
|
+
*/
|
|
791
|
+
function clamp(value, min, max) {
|
|
792
|
+
return Math.max(min, Math.min(max, value));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* @param {number} value
|
|
797
|
+
* @returns {string}
|
|
798
|
+
*/
|
|
799
|
+
function formatInteger(value) {
|
|
800
|
+
return new Intl.NumberFormat('en-US').format(value);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* @param {Record<string, unknown>} manifest
|
|
805
|
+
* @param {Record<string, unknown> | null} style
|
|
806
|
+
* @param {number} zoom
|
|
807
|
+
* @returns {string[]}
|
|
808
|
+
*/
|
|
809
|
+
function activeLayerIdsForZoom(manifest, style, zoom) {
|
|
810
|
+
const layers = /** @type {Array<Record<string, unknown>>} */ (manifest.layers ?? []);
|
|
811
|
+
const visualLayers = layers
|
|
812
|
+
.filter((layer) => {
|
|
813
|
+
const rule = layerStyleRule(style, layer);
|
|
814
|
+
if (rule.visible === false) {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (Number.isFinite(rule.minZoom) && zoom < Number(rule.minZoom)) {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (Number.isFinite(rule.maxZoom) && zoom > Number(rule.maxZoom)) {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return true;
|
|
827
|
+
})
|
|
828
|
+
.map((layer) => String(layer.id));
|
|
829
|
+
|
|
830
|
+
return visualLayers;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* @param {Record<string, unknown> | null} style
|
|
835
|
+
* @param {Record<string, unknown>} layer
|
|
836
|
+
* @returns {Record<string, unknown>}
|
|
837
|
+
*/
|
|
838
|
+
function layerStyleRule(style, layer) {
|
|
839
|
+
const styleLayers = /** @type {Record<string, unknown> | undefined} */ (style?.layers);
|
|
840
|
+
const rule = /** @type {Record<string, unknown>} */ (styleLayers?.[String(layer.style ?? layer.id)] ?? {});
|
|
841
|
+
const visibility = rule.visibility && typeof rule.visibility === 'object'
|
|
842
|
+
? /** @type {Record<string, unknown>} */ (rule.visibility)
|
|
843
|
+
: null;
|
|
844
|
+
|
|
845
|
+
return visibility
|
|
846
|
+
? {
|
|
847
|
+
...rule,
|
|
848
|
+
visible: visibility.visible ?? rule.visible,
|
|
849
|
+
minZoom: visibility.minZoom ?? rule.minZoom,
|
|
850
|
+
maxZoom: visibility.maxZoom ?? rule.maxZoom
|
|
851
|
+
}
|
|
852
|
+
: rule;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* @param {import('node:fs').WriteStream} stream
|
|
857
|
+
* @param {Buffer} chunk
|
|
858
|
+
* @returns {Promise<void>}
|
|
859
|
+
*/
|
|
860
|
+
function writeStreamChunk(stream, chunk) {
|
|
861
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
862
|
+
if (stream.write(chunk)) {
|
|
863
|
+
resolvePromise();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const cleanup = () => {
|
|
868
|
+
stream.off('drain', onDrain);
|
|
869
|
+
stream.off('error', onError);
|
|
870
|
+
};
|
|
871
|
+
const onDrain = () => {
|
|
872
|
+
cleanup();
|
|
873
|
+
resolvePromise();
|
|
874
|
+
};
|
|
875
|
+
const onError = (error) => {
|
|
876
|
+
cleanup();
|
|
877
|
+
rejectPromise(error);
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
stream.once('drain', onDrain);
|
|
881
|
+
stream.once('error', onError);
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* @param {import('node:fs').WriteStream} stream
|
|
887
|
+
* @returns {Promise<void>}
|
|
888
|
+
*/
|
|
889
|
+
function closeWriteStream(stream) {
|
|
890
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
891
|
+
stream.end(resolvePromise);
|
|
892
|
+
stream.once('error', rejectPromise);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* @param {Record<string, unknown>} manifest
|
|
898
|
+
* @param {Record<string, unknown> | null} style
|
|
899
|
+
* @param {number} minZoom
|
|
900
|
+
* @param {number} maxZoom
|
|
901
|
+
* @param {[number, number, number, number]} bbox
|
|
902
|
+
* @returns {Record<string, unknown>}
|
|
903
|
+
*/
|
|
904
|
+
function createPmtilesMetadata(manifest, style, minZoom, maxZoom, bbox) {
|
|
905
|
+
const layers = /** @type {Array<Record<string, unknown>>} */ (manifest.layers ?? []);
|
|
906
|
+
return {
|
|
907
|
+
tilejson: '3.0.0',
|
|
908
|
+
name: manifest.name ?? 'map-zero',
|
|
909
|
+
version: String(manifest.version ?? 1),
|
|
910
|
+
scheme: 'xyz',
|
|
911
|
+
type: 'overlay',
|
|
912
|
+
format: 'pbf',
|
|
913
|
+
bounds: bbox,
|
|
914
|
+
center: [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2, Math.min(Math.max(12, minZoom), maxZoom)],
|
|
915
|
+
minzoom: minZoom,
|
|
916
|
+
maxzoom: maxZoom,
|
|
917
|
+
vector_layers: layers.map((layer) => ({
|
|
918
|
+
id: String(layer.id),
|
|
919
|
+
fields: fieldsForLayer(style, layer)
|
|
920
|
+
}))
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* @param {Record<string, unknown> | null} style
|
|
926
|
+
* @param {Record<string, unknown>} layer
|
|
927
|
+
* @returns {Record<string, string>}
|
|
928
|
+
*/
|
|
929
|
+
function fieldsForLayer(style, layer) {
|
|
930
|
+
const fields = {
|
|
931
|
+
id: 'String',
|
|
932
|
+
name: 'String',
|
|
933
|
+
layer: 'String'
|
|
934
|
+
};
|
|
935
|
+
const byProperty = /** @type {Record<string, unknown> | undefined} */ (layerStyleRule(style, layer).byProperty);
|
|
936
|
+
if (byProperty) {
|
|
937
|
+
for (const key of Object.keys(byProperty)) {
|
|
938
|
+
fields[key] = 'String';
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return fields;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* @param {string} manifestPath
|
|
947
|
+
* @param {Record<string, unknown>} manifest
|
|
948
|
+
* @param {{ url: string, minZoom: number, maxZoom: number }} tiles
|
|
949
|
+
*/
|
|
950
|
+
async function updateManifestTiles(manifestPath, manifest, tiles) {
|
|
951
|
+
const updated = {
|
|
952
|
+
...manifest,
|
|
953
|
+
tiles: {
|
|
954
|
+
format: 'pmtiles',
|
|
955
|
+
url: tiles.url,
|
|
956
|
+
minZoom: tiles.minZoom,
|
|
957
|
+
maxZoom: tiles.maxZoom,
|
|
958
|
+
type: 'mvt'
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
await fs.writeFile(manifestPath, `${JSON.stringify(updated, null, 2)}\n`);
|
|
962
|
+
}
|