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.
Files changed (52) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +220 -0
  4. package/docs/api.md +66 -0
  5. package/docs/architecture.md +87 -0
  6. package/docs/cartography.md +77 -0
  7. package/docs/cesium.md +107 -0
  8. package/docs/openlayers.md +98 -0
  9. package/docs/styles.md +103 -0
  10. package/package.json +51 -0
  11. package/packages/cesium/package.json +13 -0
  12. package/packages/cesium/src/index.js +405 -0
  13. package/packages/ol/package.json +14 -0
  14. package/packages/ol/src/index.js +1705 -0
  15. package/packages/ol/src/labels.js +977 -0
  16. package/src/3dtiles/b3dm.js +38 -0
  17. package/src/3dtiles/clipper-surfaces.js +317 -0
  18. package/src/3dtiles/export.js +768 -0
  19. package/src/3dtiles/extrude.js +301 -0
  20. package/src/3dtiles/flat.js +531 -0
  21. package/src/3dtiles/glb.js +178 -0
  22. package/src/3dtiles/gpkg-buildings.js +240 -0
  23. package/src/3dtiles/gpkg-features.js +157 -0
  24. package/src/3dtiles/tileset.js +75 -0
  25. package/src/build.js +134 -0
  26. package/src/cli.js +656 -0
  27. package/src/export-pmtiles.js +962 -0
  28. package/src/geometry-read.js +50 -0
  29. package/src/gpkg-read.js +460 -0
  30. package/src/gpkg.js +567 -0
  31. package/src/html.js +593 -0
  32. package/src/layers.js +357 -0
  33. package/src/manifest.js +29 -0
  34. package/src/mvt.js +2593 -0
  35. package/src/ol.js +5 -0
  36. package/src/osm.js +2110 -0
  37. package/src/pmtiles-worker.js +70 -0
  38. package/src/pmtiles.js +260 -0
  39. package/src/server.js +720 -0
  40. package/src/style-command.js +78 -0
  41. package/src/style-filters.js +76 -0
  42. package/src/style-presets.js +93 -0
  43. package/src/style-themes.js +235 -0
  44. package/src/style.js +13 -0
  45. package/src/tile-cache.js +59 -0
  46. package/src/utils.js +222 -0
  47. package/styles/presets/light.json +4655 -0
  48. package/styles/presets/monochrome.json +4655 -0
  49. package/styles/presets/neon-dark-3d.json +90 -0
  50. package/styles/presets/neon-dark.json +4690 -0
  51. package/styles/presets/tactical.json +4690 -0
  52. 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
+ }