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
package/src/cli.js ADDED
@@ -0,0 +1,656 @@
1
+ #!/usr/bin/env node
2
+ import { clearLine, cursorTo } from 'node:readline';
3
+
4
+ import { Command, InvalidArgumentError } from 'commander';
5
+
6
+ import { buildPackage } from './build.js';
7
+ import { export3dTiles } from './3dtiles/export.js';
8
+ import { exportPmtiles } from './export-pmtiles.js';
9
+ import { LAYER_ALIASES, SUPPORTED_LAYERS } from './layers.js';
10
+ import { serveMapZero } from './server.js';
11
+ import { availableStylePresets, availableStyleThemes, writePackageStyle } from './style-command.js';
12
+ import { parseBbox, parseLayerList } from './utils.js';
13
+
14
+ const program = new Command();
15
+
16
+ program
17
+ .name('map-zero')
18
+ .description('Build and serve lightweight offline vector map packages from OSM PBF data.')
19
+ .version('0.1.0');
20
+
21
+ program
22
+ .command('build')
23
+ .description('Build a .mapzero folder package from an OSM PBF source.')
24
+ .argument('<source.osm.pbf>', 'OSM PBF source file')
25
+ .option('--bbox <bbox>', 'optional minLon,minLat,maxLon,maxLat; defaults to the full PBF extent', parseBboxOption)
26
+ .option('--layers <layers>', 'comma-separated logical layers; defaults to all supported layers', parseLayersOption)
27
+ .option('--batch-size <count>', 'geometry build batch size', parsePositiveIntegerOption, 5000)
28
+ .option('--keep-temp', 'keep the temporary SQLite build database')
29
+ .option('--debug-build', 'show build memory usage in progress logs')
30
+ .requiredOption('--out <output.mapzero>', 'output package folder')
31
+ .action(async (source, options) => {
32
+ const progress = createBuildProgressReporter();
33
+
34
+ try {
35
+ const result = await buildPackage({
36
+ source,
37
+ bbox: options.bbox,
38
+ layers: options.layers ?? [...SUPPORTED_LAYERS],
39
+ out: options.out,
40
+ batchSize: options.batchSize,
41
+ keepTemp: Boolean(options.keepTemp),
42
+ debugBuild: Boolean(options.debugBuild),
43
+ onProgress: progress.update
44
+ });
45
+
46
+ progress.finish();
47
+ console.log(`Built ${result.outDir}`);
48
+ for (const [layer, count] of Object.entries(result.counts)) {
49
+ console.log(` ${layer}: ${count}`);
50
+ }
51
+ } catch (error) {
52
+ progress.finish();
53
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
54
+ process.exitCode = 1;
55
+ }
56
+ });
57
+
58
+ program
59
+ .command('pmtiles')
60
+ .description('Export a .mapzero package to a static vector PMTiles archive.')
61
+ .argument('<package.mapzero>', 'map-zero package folder')
62
+ .option('--minzoom <zoom>', 'minimum zoom to export', parseZoomOption, 8)
63
+ .option('--maxzoom <zoom>', 'maximum zoom to export', parseZoomOption, 16)
64
+ .option('--workers <count>', 'parallel tile generation workers', parsePositiveIntegerOption, 1)
65
+ .option('--force', 'allow very large tile count exports')
66
+ .action(async (packageDir, options) => {
67
+ try {
68
+ const result = await exportPmtiles({
69
+ packageDir,
70
+ minZoom: options.minzoom,
71
+ maxZoom: options.maxzoom,
72
+ workers: options.workers,
73
+ force: Boolean(options.force),
74
+ onProgress: reportPmtilesProgress
75
+ });
76
+
77
+ console.log(`Exported ${result.outPath}`);
78
+ console.log(` zooms: ${result.minZoom}-${result.maxZoom}`);
79
+ console.log(` estimated tiles: ${result.estimatedTiles}`);
80
+ console.log(` written tiles: ${result.writtenTiles}`);
81
+ console.log(` skipped empty tiles: ${result.skippedEmptyTiles}`);
82
+ console.log(` size: ${formatBytes(result.outputBytes)}`);
83
+ } catch (error) {
84
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
85
+ process.exitCode = 1;
86
+ }
87
+ });
88
+
89
+ program
90
+ .command('3dtiles')
91
+ .description('Export Cesium 3D Tiles from a .mapzero package.')
92
+ .argument('<package.mapzero>', 'map-zero package folder')
93
+ .option('--out <dir>', 'output 3D Tiles folder; defaults to <package>/3dtiles')
94
+ .option('--layers <layers>', 'comma-separated 3D layers; defaults to all supported 3D layers', parse3dTilesLayersOption)
95
+ .option('--max-depth <count>', 'quadtree depth for building tiles', parseNonNegativeIntegerOption, 4)
96
+ .option('--max-features <count>', 'maximum features per leaf tile before subdivision', parsePositiveIntegerOption, 2500)
97
+ .option('--default-height <meters>', 'fallback building height in meters', parsePositiveNumberOption, 8)
98
+ .action(async (packageDir, options) => {
99
+ try {
100
+ const result = await export3dTiles({
101
+ packageDir,
102
+ out: options.out,
103
+ layers: options.layers,
104
+ maxDepth: options.maxDepth,
105
+ maxFeatures: options.maxFeatures,
106
+ defaultHeight: options.defaultHeight,
107
+ onProgress: report3dTilesProgress
108
+ });
109
+
110
+ console.log(`Exported ${result.tilesetPath}`);
111
+ console.log(` leaves: ${formatInteger(result.leafCount)}`);
112
+ console.log(` written tiles: ${formatInteger(result.writtenTiles)}`);
113
+ console.log(` skipped empty tiles: ${formatInteger(result.skippedTiles)}`);
114
+ console.log(` size: ${formatBytes(result.outputBytes)}`);
115
+ } catch (error) {
116
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
117
+ process.exitCode = 1;
118
+ }
119
+ });
120
+
121
+ program
122
+ .command('style')
123
+ .description('Rewrite package styles without rebuilding data.gpkg or tiles.pmtiles.')
124
+ .argument('<package.mapzero>', 'map-zero package folder')
125
+ .option('--preset <preset>', 'full style preset to write')
126
+ .option('--theme <theme.json>', 'compact style theme file or bundled theme name')
127
+ .option('--list-presets', 'list bundled full style presets')
128
+ .option('--list-themes', 'list bundled compact style themes')
129
+ .action(async (packageDir, options) => {
130
+ try {
131
+ if (options.listPresets) {
132
+ console.log(availableStylePresets().join('\n'));
133
+ return;
134
+ }
135
+
136
+ if (options.listThemes) {
137
+ console.log(availableStyleThemes().join('\n'));
138
+ return;
139
+ }
140
+
141
+ const result = await writePackageStyle({
142
+ packageDir,
143
+ preset: options.preset,
144
+ theme: options.theme
145
+ });
146
+ console.log(`Wrote ${result.sourceType} ${result.name} style: ${result.stylePath}`);
147
+ console.log(`Default style: ${result.styleUrl}`);
148
+ console.log('data.gpkg and tiles.pmtiles were not modified.');
149
+ } catch (error) {
150
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
151
+ process.exitCode = 1;
152
+ }
153
+ });
154
+
155
+ program
156
+ .command('serve')
157
+ .description('Serve a .mapzero package with a readonly HTTP API and OpenLayers viewer.')
158
+ .argument('<package.mapzero>', 'map-zero package folder')
159
+ .option('--port <port>', 'HTTP port', parsePortOption, 8080)
160
+ .option('--host <host>', 'HTTP host', '127.0.0.1')
161
+ .option('--open', 'open the viewer in the default browser')
162
+ .option('--tile-cache-size <entries>', 'maximum in-memory generated MVT tile cache entries', parseTileCacheSizeOption, 500)
163
+ .option('--tile-max-features <count>', 'maximum features to encode in one dynamic MVT tile', parseTileMaxFeaturesOption, 12000)
164
+ .option('--no-tile-cache', 'disable the in-memory generated MVT tile cache')
165
+ .option('--debug-tiles', 'log MVT tile cache and generation timings')
166
+ .option('--debug-labels', 'log rejected generated label candidates')
167
+ .action(async (packageDir, options) => {
168
+ try {
169
+ const result = await serveMapZero({
170
+ packageDir,
171
+ host: options.host,
172
+ port: options.port,
173
+ open: Boolean(options.open),
174
+ tileCache: options.tileCache,
175
+ tileCacheSize: options.tileCacheSize,
176
+ tileMaxFeatures: options.tileMaxFeatures,
177
+ debugTiles: Boolean(options.debugTiles),
178
+ debugLabels: Boolean(options.debugLabels)
179
+ });
180
+
181
+ console.log(`Serving ${packageDir}`);
182
+ console.log(`Open ${result.url}`);
183
+ } catch (error) {
184
+ console.error(`map-zero: ${error instanceof Error ? error.message : String(error)}`);
185
+ process.exitCode = 1;
186
+ }
187
+ });
188
+
189
+ await program.parseAsync(process.argv);
190
+
191
+ /**
192
+ * @param {string} value
193
+ * @returns {[number, number, number, number]}
194
+ */
195
+ function parseBboxOption(value) {
196
+ try {
197
+ return parseBbox(value);
198
+ } catch (error) {
199
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
200
+ }
201
+ }
202
+
203
+ /**
204
+ * @param {string} value
205
+ * @returns {string[]}
206
+ */
207
+ function parseLayersOption(value) {
208
+ try {
209
+ return parseLayerList(value, SUPPORTED_LAYERS, LAYER_ALIASES);
210
+ } catch (error) {
211
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
212
+ }
213
+ }
214
+
215
+ /**
216
+ * @param {string} value
217
+ * @returns {string[]}
218
+ */
219
+ function parse3dTilesLayersOption(value) {
220
+ try {
221
+ return parseLayerList(value, ['buildings', 'landuse', 'water', 'aip', 'railways', 'roads', 'boundaries'], LAYER_ALIASES);
222
+ } catch (error) {
223
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
224
+ }
225
+ }
226
+
227
+ /**
228
+ * @param {string} value
229
+ * @returns {number}
230
+ */
231
+ function parsePortOption(value) {
232
+ const port = Number(value);
233
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
234
+ throw new InvalidArgumentError('port must be an integer between 1 and 65535');
235
+ }
236
+
237
+ return port;
238
+ }
239
+
240
+ /**
241
+ * @param {string} value
242
+ * @returns {number}
243
+ */
244
+ function parseTileCacheSizeOption(value) {
245
+ const entries = Number(value);
246
+ if (!Number.isInteger(entries) || entries < 0) {
247
+ throw new InvalidArgumentError('tile cache size must be a non-negative integer');
248
+ }
249
+
250
+ return entries;
251
+ }
252
+
253
+ /**
254
+ * @param {string} value
255
+ * @returns {number}
256
+ */
257
+ function parseTileMaxFeaturesOption(value) {
258
+ return parsePositiveIntegerOption(value);
259
+ }
260
+
261
+ /**
262
+ * @param {string} value
263
+ * @returns {number}
264
+ */
265
+ function parsePositiveIntegerOption(value) {
266
+ const count = Number(value);
267
+ if (!Number.isInteger(count) || count < 1) {
268
+ throw new InvalidArgumentError('value must be a positive integer');
269
+ }
270
+
271
+ return count;
272
+ }
273
+
274
+ /**
275
+ * @param {string} value
276
+ * @returns {number}
277
+ */
278
+ function parseNonNegativeIntegerOption(value) {
279
+ const count = Number(value);
280
+ if (!Number.isInteger(count) || count < 0) {
281
+ throw new InvalidArgumentError('value must be a non-negative integer');
282
+ }
283
+
284
+ return count;
285
+ }
286
+
287
+ /**
288
+ * @param {string} value
289
+ * @returns {number}
290
+ */
291
+ function parsePositiveNumberOption(value) {
292
+ const count = Number(value);
293
+ if (!Number.isFinite(count) || count <= 0) {
294
+ throw new InvalidArgumentError('value must be a positive number');
295
+ }
296
+
297
+ return count;
298
+ }
299
+
300
+ /**
301
+ * @param {string} value
302
+ * @returns {number}
303
+ */
304
+ function parseZoomOption(value) {
305
+ const zoom = Number(value);
306
+ if (!Number.isInteger(zoom) || zoom < 0 || zoom > 22) {
307
+ throw new InvalidArgumentError('zoom must be an integer between 0 and 22');
308
+ }
309
+
310
+ return zoom;
311
+ }
312
+
313
+ /**
314
+ * @param {{
315
+ * phase: 'estimate' | 'zoom-progress' | 'zoom' | 'done',
316
+ * zoom?: number,
317
+ * tileCount?: number,
318
+ * tilesByZoom?: Array<{ zoom: number, tileCount: number }>,
319
+ * bbox?: [number, number, number, number],
320
+ * coverage?: { widthDegrees: number, heightDegrees: number, approximateAreaKm2: number },
321
+ * recommendation?: string[],
322
+ * highEstimate?: boolean,
323
+ * veryHighEstimate?: boolean,
324
+ * completedTiles?: number,
325
+ * totalTiles?: number,
326
+ * tilesPerSecond?: number,
327
+ * averageTileSize?: number,
328
+ * etaSeconds?: number | null,
329
+ * workers?: number,
330
+ * writtenTiles?: number,
331
+ * skippedEmptyTiles?: number,
332
+ * outputBytes?: number
333
+ * }} event
334
+ */
335
+ function reportPmtilesProgress(event) {
336
+ if (event.phase === 'estimate') {
337
+ console.log(`Estimated tiles: ${formatInteger(event.tileCount ?? 0)}`);
338
+ if (event.bbox) {
339
+ console.log(`Coverage bbox: ${event.bbox.map((value) => formatCoordinate(value)).join(',')}`);
340
+ }
341
+ if (event.coverage) {
342
+ console.log(
343
+ `Coverage size: ${event.coverage.widthDegrees.toFixed(3)} x ${event.coverage.heightDegrees.toFixed(3)} degrees, ` +
344
+ `~${formatInteger(Math.round(event.coverage.approximateAreaKm2))} km2`
345
+ );
346
+ }
347
+ if (event.tilesByZoom?.length) {
348
+ console.log('Estimated tiles by zoom:');
349
+ for (const item of event.tilesByZoom) {
350
+ console.log(` z${item.zoom}: ${formatInteger(item.tileCount)}`);
351
+ }
352
+ }
353
+ if (event.recommendation?.length) {
354
+ console.log('Recommendation:');
355
+ for (const line of event.recommendation) {
356
+ console.log(` - ${line}`);
357
+ }
358
+ }
359
+ if (event.highEstimate) {
360
+ console.log('Use --force only if this bbox and zoom range are intentional.');
361
+ }
362
+ return;
363
+ }
364
+
365
+ if (event.phase === 'zoom-progress') {
366
+ console.log(
367
+ `z${event.zoom}: ${formatInteger(event.completedTiles ?? 0)}/${formatInteger(event.totalTiles ?? event.tileCount ?? 0)} tiles, ` +
368
+ `${formatNumber(event.tilesPerSecond ?? 0)} tiles/s, ` +
369
+ `${formatInteger(event.writtenTiles ?? 0)} written, ${formatInteger(event.skippedEmptyTiles ?? 0)} empty, ` +
370
+ `avg ${formatBytes(Math.round(event.averageTileSize ?? 0))}, ETA ${formatDuration(event.etaSeconds)}`
371
+ );
372
+ return;
373
+ }
374
+
375
+ if (event.phase === 'zoom') {
376
+ console.log(
377
+ `z${event.zoom}: ${formatInteger(event.tileCount ?? 0)} tiles, ` +
378
+ `${formatInteger(event.writtenTiles ?? 0)} written, ${formatInteger(event.skippedEmptyTiles ?? 0)} empty, ` +
379
+ `${formatNumber(event.tilesPerSecond ?? 0)} tiles/s, avg ${formatBytes(Math.round(event.averageTileSize ?? 0))}`
380
+ );
381
+ return;
382
+ }
383
+
384
+ if (event.phase === 'done') {
385
+ console.log(`PMTiles size: ${formatBytes(event.outputBytes ?? 0)}`);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * @param {{
391
+ * phase: 'estimate' | 'leaf' | 'done',
392
+ * layerId?: string,
393
+ * leafIndex?: number,
394
+ * leafCount?: number,
395
+ * featureCount?: number,
396
+ * writtenTiles?: number,
397
+ * skippedTiles?: number,
398
+ * outputBytes?: number
399
+ * }} event
400
+ */
401
+ function report3dTilesProgress(event) {
402
+ if (event.phase === 'estimate') {
403
+ const layerId = event.layerId ? `${event.layerId}: ` : '';
404
+ console.log(
405
+ `3D Tiles plan: ${layerId}${formatInteger(event.leafCount ?? 0)} leaf tiles, ` +
406
+ `${formatInteger(event.featureCount ?? 0)} features`
407
+ );
408
+ return;
409
+ }
410
+
411
+ if (event.phase === 'leaf') {
412
+ const leafIndex = event.leafIndex ?? 0;
413
+ const leafCount = event.leafCount ?? 0;
414
+ if (leafIndex === leafCount || leafIndex % 10 === 0) {
415
+ console.log(
416
+ `3D Tiles: ${formatInteger(leafIndex)}/${formatInteger(leafCount)} leaves, ` +
417
+ `${formatInteger(event.writtenTiles ?? 0)} written, ${formatInteger(event.skippedTiles ?? 0)} empty`
418
+ );
419
+ }
420
+ return;
421
+ }
422
+
423
+ if (event.phase === 'done') {
424
+ console.log(`3D Tiles size: ${formatBytes(event.outputBytes ?? 0)}`);
425
+ }
426
+ }
427
+
428
+
429
+ /**
430
+ * @param {number} value
431
+ * @returns {string}
432
+ */
433
+ function formatCoordinate(value) {
434
+ return Number(value).toFixed(6).replace(/\.?0+$/, '');
435
+ }
436
+
437
+ /**
438
+ * @param {number} value
439
+ * @returns {string}
440
+ */
441
+ function formatNumber(value) {
442
+ return Number(value).toFixed(value >= 100 ? 0 : 1);
443
+ }
444
+
445
+ /**
446
+ * @param {number | null | undefined} seconds
447
+ * @returns {string}
448
+ */
449
+ function formatDuration(seconds) {
450
+ if (!Number.isFinite(Number(seconds))) {
451
+ return 'unknown';
452
+ }
453
+
454
+ const total = Math.max(0, Math.round(Number(seconds)));
455
+ if (total < 60) {
456
+ return `${total}s`;
457
+ }
458
+
459
+ const minutes = Math.floor(total / 60);
460
+ const remainingSeconds = total % 60;
461
+ if (minutes < 60) {
462
+ return `${minutes}m ${remainingSeconds}s`;
463
+ }
464
+
465
+ const hours = Math.floor(minutes / 60);
466
+ return `${hours}h ${minutes % 60}m`;
467
+ }
468
+
469
+ /**
470
+ * Create a small terminal progress renderer for long PBF builds.
471
+ *
472
+ * @returns {{ update: (event: {
473
+ * phase: 'stage' | 'progress' | 'summary',
474
+ * step: string,
475
+ * label?: string,
476
+ * message?: string,
477
+ * bytesRead?: number,
478
+ * totalBytes?: number,
479
+ * entities?: number,
480
+ * itemsDone?: number,
481
+ * totalItems?: number
482
+ * }) => void, finish: () => void }}
483
+ */
484
+ function createBuildProgressReporter() {
485
+ const stream = process.stderr;
486
+ const isInteractive = Boolean(stream.isTTY);
487
+ const reportedBuckets = new Map();
488
+ let hasActiveLine = false;
489
+ let lastRenderAt = 0;
490
+
491
+ return {
492
+ update(event) {
493
+ const text = formatProgressEvent(event);
494
+ if (!text) {
495
+ return;
496
+ }
497
+
498
+ if (event.phase === 'progress') {
499
+ if (!isInteractive) {
500
+ const bucket = progressBucket(event);
501
+ const previousBucket = reportedBuckets.get(event.step);
502
+
503
+ if (bucket !== null && bucket > 0 && bucket !== previousBucket) {
504
+ reportedBuckets.set(event.step, bucket);
505
+ stream.write(`${text}\n`);
506
+ }
507
+
508
+ return;
509
+ }
510
+
511
+ const now = Date.now();
512
+ const isFinal = isCompleteProgress(event);
513
+ if (!isFinal && now - lastRenderAt < 150) {
514
+ return;
515
+ }
516
+
517
+ clearLine(stream, 0);
518
+ cursorTo(stream, 0);
519
+ stream.write(text);
520
+ hasActiveLine = true;
521
+ lastRenderAt = now;
522
+ return;
523
+ }
524
+
525
+ if (hasActiveLine) {
526
+ clearLine(stream, 0);
527
+ cursorTo(stream, 0);
528
+ hasActiveLine = false;
529
+ }
530
+
531
+ stream.write(`${text}\n`);
532
+ },
533
+ finish() {
534
+ if (!hasActiveLine) {
535
+ return;
536
+ }
537
+
538
+ clearLine(stream, 0);
539
+ cursorTo(stream, 0);
540
+ hasActiveLine = false;
541
+ }
542
+ };
543
+ }
544
+
545
+ /**
546
+ * @param {{ bytesRead?: number, totalBytes?: number, itemsDone?: number, totalItems?: number }} event
547
+ * @returns {number | null}
548
+ */
549
+ function progressBucket(event) {
550
+ const percent = progressPercent(event);
551
+ if (percent === null) {
552
+ return null;
553
+ }
554
+
555
+ return Math.min(100, Math.floor(percent / 25) * 25);
556
+ }
557
+
558
+ /**
559
+ * @param {{
560
+ * phase: 'stage' | 'progress' | 'summary',
561
+ * step: string,
562
+ * label?: string,
563
+ * message?: string,
564
+ * bytesRead?: number,
565
+ * totalBytes?: number,
566
+ * entities?: number,
567
+ * itemsDone?: number,
568
+ * totalItems?: number
569
+ * }} event
570
+ * @returns {string}
571
+ */
572
+ function formatProgressEvent(event) {
573
+ if (event.phase === 'stage') {
574
+ return `> ${event.message ?? event.label ?? event.step}`;
575
+ }
576
+
577
+ if (event.phase === 'summary') {
578
+ return `> ${event.message ?? event.label ?? event.step}`;
579
+ }
580
+
581
+ const label = event.label ?? event.step;
582
+
583
+ if (Number.isFinite(event.bytesRead) && Number.isFinite(event.totalBytes) && event.totalBytes > 0) {
584
+ const percent = Math.min(100, Math.floor((Number(event.bytesRead) / Number(event.totalBytes)) * 100));
585
+ const entities = Number.isFinite(event.entities) ? `, ${formatInteger(Number(event.entities))} entities` : '';
586
+ return `${label}: ${percent}% (${formatBytes(Number(event.bytesRead))}/${formatBytes(Number(event.totalBytes))}${entities})`;
587
+ }
588
+
589
+ if (Number.isFinite(event.itemsDone) && Number.isFinite(event.totalItems) && event.totalItems > 0) {
590
+ const percent = Math.min(100, Math.floor((Number(event.itemsDone) / Number(event.totalItems)) * 100));
591
+ return `${label}: ${percent}% (${formatInteger(Number(event.itemsDone))}/${formatInteger(Number(event.totalItems))})`;
592
+ }
593
+
594
+ return label;
595
+ }
596
+
597
+ /**
598
+ * @param {{ bytesRead?: number, totalBytes?: number, itemsDone?: number, totalItems?: number }} event
599
+ * @returns {boolean}
600
+ */
601
+ function isCompleteProgress(event) {
602
+ if (Number.isFinite(event.bytesRead) && Number.isFinite(event.totalBytes)) {
603
+ return Number(event.bytesRead) >= Number(event.totalBytes);
604
+ }
605
+
606
+ if (Number.isFinite(event.itemsDone) && Number.isFinite(event.totalItems)) {
607
+ return Number(event.itemsDone) >= Number(event.totalItems);
608
+ }
609
+
610
+ return false;
611
+ }
612
+
613
+ /**
614
+ * @param {{ bytesRead?: number, totalBytes?: number, itemsDone?: number, totalItems?: number }} event
615
+ * @returns {number | null}
616
+ */
617
+ function progressPercent(event) {
618
+ if (Number.isFinite(event.bytesRead) && Number.isFinite(event.totalBytes) && Number(event.totalBytes) > 0) {
619
+ return Math.min(100, Math.floor((Number(event.bytesRead) / Number(event.totalBytes)) * 100));
620
+ }
621
+
622
+ if (Number.isFinite(event.itemsDone) && Number.isFinite(event.totalItems) && Number(event.totalItems) > 0) {
623
+ return Math.min(100, Math.floor((Number(event.itemsDone) / Number(event.totalItems)) * 100));
624
+ }
625
+
626
+ return null;
627
+ }
628
+
629
+ /**
630
+ * @param {number} value
631
+ * @returns {string}
632
+ */
633
+ function formatBytes(value) {
634
+ if (value < 1024) {
635
+ return `${value} B`;
636
+ }
637
+
638
+ const units = ['KB', 'MB', 'GB'];
639
+ let current = value / 1024;
640
+ for (const unit of units) {
641
+ if (current < 1024 || unit === units.at(-1)) {
642
+ return `${current.toFixed(current >= 100 ? 0 : 1)} ${unit}`;
643
+ }
644
+ current /= 1024;
645
+ }
646
+
647
+ return `${value} B`;
648
+ }
649
+
650
+ /**
651
+ * @param {number} value
652
+ * @returns {string}
653
+ */
654
+ function formatInteger(value) {
655
+ return new Intl.NumberFormat('en-US').format(value);
656
+ }