map-zero 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/docs/api.md +66 -0
- package/docs/architecture.md +87 -0
- package/docs/cartography.md +77 -0
- package/docs/cesium.md +107 -0
- package/docs/openlayers.md +98 -0
- package/docs/styles.md +103 -0
- package/package.json +51 -0
- package/packages/cesium/package.json +13 -0
- package/packages/cesium/src/index.js +405 -0
- package/packages/ol/package.json +14 -0
- package/packages/ol/src/index.js +1705 -0
- package/packages/ol/src/labels.js +977 -0
- package/src/3dtiles/b3dm.js +38 -0
- package/src/3dtiles/clipper-surfaces.js +317 -0
- package/src/3dtiles/export.js +768 -0
- package/src/3dtiles/extrude.js +301 -0
- package/src/3dtiles/flat.js +531 -0
- package/src/3dtiles/glb.js +178 -0
- package/src/3dtiles/gpkg-buildings.js +240 -0
- package/src/3dtiles/gpkg-features.js +157 -0
- package/src/3dtiles/tileset.js +75 -0
- package/src/build.js +134 -0
- package/src/cli.js +656 -0
- package/src/export-pmtiles.js +962 -0
- package/src/geometry-read.js +50 -0
- package/src/gpkg-read.js +460 -0
- package/src/gpkg.js +567 -0
- package/src/html.js +593 -0
- package/src/layers.js +357 -0
- package/src/manifest.js +29 -0
- package/src/mvt.js +2593 -0
- package/src/ol.js +5 -0
- package/src/osm.js +2110 -0
- package/src/pmtiles-worker.js +70 -0
- package/src/pmtiles.js +260 -0
- package/src/server.js +720 -0
- package/src/style-command.js +78 -0
- package/src/style-filters.js +76 -0
- package/src/style-presets.js +93 -0
- package/src/style-themes.js +235 -0
- package/src/style.js +13 -0
- package/src/tile-cache.js +59 -0
- package/src/utils.js +222 -0
- package/styles/presets/light.json +4655 -0
- package/styles/presets/monochrome.json +4655 -0
- package/styles/presets/neon-dark-3d.json +90 -0
- package/styles/presets/neon-dark.json +4690 -0
- package/styles/presets/tactical.json +4690 -0
- package/styles/themes/neon-dark.theme.json +20 -0
package/src/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
|
+
}
|