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,240 @@
1
+ import Database from 'better-sqlite3';
2
+
3
+ import { decodeGeoPackageGeometry } from '../geometry-read.js';
4
+ import { bboxIntersects, quoteIdentifier } from '../utils.js';
5
+ import { cleanRing } from './extrude.js';
6
+
7
+ const DEFAULT_LEVEL_HEIGHT = 3;
8
+
9
+ /**
10
+ * @typedef {{
11
+ * table: string,
12
+ * geometryColumn: string,
13
+ * rtree: string,
14
+ * bbox: [number, number, number, number]
15
+ * }} BuildingMetadata
16
+ */
17
+
18
+ /**
19
+ * @param {string} gpkgPath
20
+ * @returns {Database.Database}
21
+ */
22
+ export function openReadonlyGeoPackage(gpkgPath) {
23
+ const db = new Database(gpkgPath, { readonly: true, fileMustExist: true });
24
+ for (const pragma of ['query_only = ON', 'temp_store = MEMORY', 'cache_size = -65536']) {
25
+ try {
26
+ db.pragma(pragma);
27
+ } catch {
28
+ // Optional read tuning.
29
+ }
30
+ }
31
+ return db;
32
+ }
33
+
34
+ /**
35
+ * @param {Database.Database} db
36
+ * @param {[number, number, number, number]} fallbackBbox
37
+ * @returns {BuildingMetadata}
38
+ */
39
+ export function readBuildingsMetadata(db, fallbackBbox) {
40
+ const geometry = db.prepare(`
41
+ SELECT column_name
42
+ FROM gpkg_geometry_columns
43
+ WHERE table_name = 'buildings'
44
+ `).get();
45
+ if (!geometry?.column_name) {
46
+ throw new Error('GeoPackage does not contain a buildings geometry column');
47
+ }
48
+
49
+ const geometryColumn = String(geometry.column_name);
50
+ const rtree = `rtree_buildings_${geometryColumn}`;
51
+ const rtreeRow = db.prepare(`
52
+ SELECT name
53
+ FROM sqlite_master
54
+ WHERE type = 'table' AND name = ?
55
+ `).get(rtree);
56
+ if (!rtreeRow) {
57
+ throw new Error(`GeoPackage does not contain expected RTree table ${rtree}`);
58
+ }
59
+
60
+ const content = db.prepare(`
61
+ SELECT min_x, min_y, max_x, max_y
62
+ FROM gpkg_contents
63
+ WHERE table_name = 'buildings'
64
+ `).get();
65
+ const bbox = validBbox([content?.min_x, content?.min_y, content?.max_x, content?.max_y])
66
+ ? [Number(content.min_x), Number(content.min_y), Number(content.max_x), Number(content.max_y)]
67
+ : fallbackBbox;
68
+
69
+ return {
70
+ table: 'buildings',
71
+ geometryColumn,
72
+ rtree,
73
+ bbox
74
+ };
75
+ }
76
+
77
+ /**
78
+ * @param {Database.Database} db
79
+ * @param {BuildingMetadata} metadata
80
+ * @param {[number, number, number, number]} bbox
81
+ * @returns {number}
82
+ */
83
+ export function countBuildings(db, metadata, bbox) {
84
+ const [minX, minY, maxX, maxY] = bbox;
85
+ const row = db.prepare(`
86
+ SELECT COUNT(*) AS count
87
+ FROM ${quoteIdentifier(metadata.rtree)}
88
+ WHERE minx <= ?
89
+ AND maxx >= ?
90
+ AND miny <= ?
91
+ AND maxy >= ?
92
+ `).get(maxX, minX, maxY, minY);
93
+ return Number(row?.count ?? 0);
94
+ }
95
+
96
+ /**
97
+ * @param {Database.Database} db
98
+ * @param {BuildingMetadata} metadata
99
+ * @param {[number, number, number, number]} bbox
100
+ * @param {{ defaultHeight: number, limit?: number, warn?: (message: string) => void }} options
101
+ * @returns {{ footprints: import('./extrude.js').Footprint[], skipped: number }}
102
+ */
103
+ export function readBuildingFootprints(db, metadata, bbox, options) {
104
+ const [minX, minY, maxX, maxY] = bbox;
105
+ const limitClause = Number.isInteger(options.limit) && options.limit > 0 ? 'LIMIT ?' : '';
106
+ const params = [maxX, minX, maxY, minY];
107
+ if (limitClause) {
108
+ params.push(Number(options.limit));
109
+ }
110
+ const rows = db.prepare(`
111
+ SELECT feature_table.*
112
+ FROM ${quoteIdentifier(metadata.table)} AS feature_table
113
+ JOIN ${quoteIdentifier(metadata.rtree)} AS rtree_table
114
+ ON feature_table.rowid = rtree_table.id
115
+ WHERE rtree_table.minx <= ?
116
+ AND rtree_table.maxx >= ?
117
+ AND rtree_table.miny <= ?
118
+ AND rtree_table.maxy >= ?
119
+ ${limitClause}
120
+ `).all(...params);
121
+ const footprints = [];
122
+ let skipped = 0;
123
+
124
+ for (const row of rows) {
125
+ const geometry = decodeGeoPackageGeometry(row[metadata.geometryColumn]);
126
+ if (!geometry) {
127
+ skipped++;
128
+ continue;
129
+ }
130
+
131
+ const polygons = polygonsFromGeometry(geometry);
132
+ if (polygons.length === 0) {
133
+ skipped++;
134
+ continue;
135
+ }
136
+
137
+ const height = buildingHeight(row, options.defaultHeight);
138
+ for (const polygon of polygons) {
139
+ const outerRing = cleanRing(polygon[0] ?? []);
140
+ if (outerRing.length < 3) {
141
+ skipped++;
142
+ continue;
143
+ }
144
+
145
+ const footprintBbox = ringBbox(outerRing);
146
+ if (!bboxIntersects(footprintBbox, bbox)) {
147
+ continue;
148
+ }
149
+
150
+ footprints.push({
151
+ coordinates: outerRing,
152
+ height
153
+ });
154
+ }
155
+ }
156
+
157
+ return { footprints, skipped };
158
+ }
159
+
160
+ /**
161
+ * @param {{ type: string, coordinates: unknown }} geometry
162
+ * @returns {Array<Array<Array<[number, number]>>>}
163
+ */
164
+ function polygonsFromGeometry(geometry) {
165
+ if (geometry.type === 'Polygon' && Array.isArray(geometry.coordinates)) {
166
+ return /** @type {Array<Array<Array<[number, number]>>>} */ ([geometry.coordinates]);
167
+ }
168
+
169
+ if (geometry.type === 'MultiPolygon' && Array.isArray(geometry.coordinates)) {
170
+ return /** @type {Array<Array<Array<[number, number]>>>} */ (geometry.coordinates);
171
+ }
172
+
173
+ return [];
174
+ }
175
+
176
+ /**
177
+ * @param {Record<string, unknown>} row
178
+ * @param {number} defaultHeight
179
+ * @returns {number}
180
+ */
181
+ function buildingHeight(row, defaultHeight) {
182
+ const explicitHeight = parseMeters(row.height);
183
+ if (explicitHeight !== null) {
184
+ return explicitHeight;
185
+ }
186
+
187
+ const levels = Number(row['building:levels']);
188
+ if (Number.isFinite(levels) && levels > 0) {
189
+ return Math.min(levels * DEFAULT_LEVEL_HEIGHT, 500);
190
+ }
191
+
192
+ return defaultHeight;
193
+ }
194
+
195
+ /**
196
+ * @param {unknown} value
197
+ * @returns {number | null}
198
+ */
199
+ function parseMeters(value) {
200
+ if (value === null || value === undefined || value === '') {
201
+ return null;
202
+ }
203
+
204
+ const match = /^(-?\d+(?:[.,]\d+)?)/.exec(String(value).trim());
205
+ if (!match) {
206
+ return null;
207
+ }
208
+
209
+ const height = Number(match[1].replace(',', '.'));
210
+ return Number.isFinite(height) && height > 0 ? Math.min(height, 500) : null;
211
+ }
212
+
213
+ /**
214
+ * @param {Array<[number, number]>} ring
215
+ * @returns {[number, number, number, number]}
216
+ */
217
+ function ringBbox(ring) {
218
+ let minLon = Infinity;
219
+ let minLat = Infinity;
220
+ let maxLon = -Infinity;
221
+ let maxLat = -Infinity;
222
+ for (const [lon, lat] of ring) {
223
+ minLon = Math.min(minLon, lon);
224
+ minLat = Math.min(minLat, lat);
225
+ maxLon = Math.max(maxLon, lon);
226
+ maxLat = Math.max(maxLat, lat);
227
+ }
228
+ return [minLon, minLat, maxLon, maxLat];
229
+ }
230
+
231
+ /**
232
+ * @param {unknown[]} value
233
+ * @returns {boolean}
234
+ */
235
+ function validBbox(value) {
236
+ return value.length === 4 &&
237
+ value.every((part) => Number.isFinite(Number(part))) &&
238
+ Number(value[0]) < Number(value[2]) &&
239
+ Number(value[1]) < Number(value[3]);
240
+ }
@@ -0,0 +1,157 @@
1
+ import { decodeGeoPackageGeometry } from '../geometry-read.js';
2
+ import { quoteIdentifier } from '../utils.js';
3
+
4
+ const LAYER_ALIASES = {
5
+ aviation: 'aip',
6
+ aip: 'aviation'
7
+ };
8
+
9
+ /**
10
+ * @typedef {{
11
+ * id: string,
12
+ * table: string,
13
+ * geometryColumn: string,
14
+ * rtree: string,
15
+ * bbox: [number, number, number, number]
16
+ * }} LayerMetadata
17
+ */
18
+
19
+ /**
20
+ * @param {import('better-sqlite3').Database} db
21
+ * @param {Record<string, unknown>} manifest
22
+ * @param {string} layerId
23
+ * @returns {LayerMetadata}
24
+ */
25
+ export function readLayerMetadata(db, manifest, layerId) {
26
+ const manifestLayer = Array.isArray(manifest.layers)
27
+ ? manifest.layers.find((layer) => layer?.id === layerId) ??
28
+ manifest.layers.find((layer) => layer?.id === LAYER_ALIASES[layerId])
29
+ : null;
30
+ if (!manifestLayer?.table) {
31
+ throw new Error(`manifest does not contain layer: ${layerId}`);
32
+ }
33
+
34
+ const table = String(manifestLayer.table);
35
+ const geometry = db.prepare(`
36
+ SELECT column_name
37
+ FROM gpkg_geometry_columns
38
+ WHERE table_name = ?
39
+ `).get(table);
40
+ if (!geometry?.column_name) {
41
+ throw new Error(`GeoPackage does not contain a geometry column for ${layerId}`);
42
+ }
43
+
44
+ const geometryColumn = String(geometry.column_name);
45
+ const rtree = `rtree_${table}_${geometryColumn}`;
46
+ const rtreeRow = db.prepare(`
47
+ SELECT name
48
+ FROM sqlite_master
49
+ WHERE type = 'table' AND name = ?
50
+ `).get(rtree);
51
+ if (!rtreeRow) {
52
+ throw new Error(`GeoPackage does not contain expected RTree table ${rtree}`);
53
+ }
54
+
55
+ const content = db.prepare(`
56
+ SELECT min_x, min_y, max_x, max_y
57
+ FROM gpkg_contents
58
+ WHERE table_name = ?
59
+ `).get(table);
60
+ const fallback = Array.isArray(manifest.bbox) ? manifest.bbox.map(Number) : [-180, -90, 180, 90];
61
+ const bbox = validBbox([content?.min_x, content?.min_y, content?.max_x, content?.max_y])
62
+ ? [Number(content.min_x), Number(content.min_y), Number(content.max_x), Number(content.max_y)]
63
+ : /** @type {[number, number, number, number]} */ (fallback);
64
+
65
+ return {
66
+ id: layerId,
67
+ table,
68
+ geometryColumn,
69
+ rtree,
70
+ bbox
71
+ };
72
+ }
73
+
74
+ /**
75
+ * @param {import('better-sqlite3').Database} db
76
+ * @param {LayerMetadata} metadata
77
+ * @param {[number, number, number, number]} bbox
78
+ * @returns {number}
79
+ */
80
+ export function countLayerFeatures(db, metadata, bbox) {
81
+ const [minX, minY, maxX, maxY] = bbox;
82
+ const row = db.prepare(`
83
+ SELECT COUNT(*) AS count
84
+ FROM ${quoteIdentifier(metadata.rtree)}
85
+ WHERE minx <= ?
86
+ AND maxx >= ?
87
+ AND miny <= ?
88
+ AND maxy >= ?
89
+ `).get(maxX, minX, maxY, minY);
90
+ return Number(row?.count ?? 0);
91
+ }
92
+
93
+ /**
94
+ * @param {import('better-sqlite3').Database} db
95
+ * @param {LayerMetadata} metadata
96
+ * @param {[number, number, number, number]} bbox
97
+ * @param {{ limit?: number }} [options]
98
+ * @returns {Array<{ type: 'Feature', geometry: Record<string, unknown>, properties: Record<string, unknown> }>}
99
+ */
100
+ export function readLayerFeatures(db, metadata, bbox, options = {}) {
101
+ const [minX, minY, maxX, maxY] = bbox;
102
+ const limitClause = Number.isInteger(options.limit) && options.limit > 0 ? 'LIMIT ?' : '';
103
+ const params = [maxX, minX, maxY, minY];
104
+ if (limitClause) {
105
+ params.push(Number(options.limit));
106
+ }
107
+
108
+ const rows = db.prepare(`
109
+ SELECT feature_table.*
110
+ FROM ${quoteIdentifier(metadata.table)} AS feature_table
111
+ JOIN ${quoteIdentifier(metadata.rtree)} AS rtree_table
112
+ ON feature_table.rowid = rtree_table.id
113
+ WHERE rtree_table.minx <= ?
114
+ AND rtree_table.maxx >= ?
115
+ AND rtree_table.miny <= ?
116
+ AND rtree_table.maxy >= ?
117
+ ${limitClause}
118
+ `).all(...params);
119
+
120
+ return rows.map((row) => rowToFeature(row, metadata.geometryColumn)).filter(Boolean);
121
+ }
122
+
123
+ /**
124
+ * @param {Record<string, unknown>} row
125
+ * @param {string} geometryColumn
126
+ * @returns {{ type: 'Feature', geometry: Record<string, unknown>, properties: Record<string, unknown> } | null}
127
+ */
128
+ function rowToFeature(row, geometryColumn) {
129
+ const geometry = decodeGeoPackageGeometry(/** @type {Buffer | null} */ (row[geometryColumn]));
130
+ if (!geometry) {
131
+ return null;
132
+ }
133
+
134
+ const properties = {};
135
+ for (const [key, value] of Object.entries(row)) {
136
+ if (key !== geometryColumn) {
137
+ properties[key] = value;
138
+ }
139
+ }
140
+
141
+ return {
142
+ type: 'Feature',
143
+ geometry,
144
+ properties
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {unknown[]} value
150
+ * @returns {boolean}
151
+ */
152
+ function validBbox(value) {
153
+ return value.length === 4 &&
154
+ value.every((part) => Number.isFinite(Number(part))) &&
155
+ Number(value[0]) < Number(value[2]) &&
156
+ Number(value[1]) < Number(value[3]);
157
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @param {[number, number, number, number]} bbox
3
+ * @param {number} minZ
4
+ * @param {number} maxZ
5
+ * @returns {[number, number, number, number, number, number]}
6
+ */
7
+ export function bboxToRegion(bbox, minZ, maxZ) {
8
+ return [
9
+ degToRad(bbox[0]),
10
+ degToRad(bbox[1]),
11
+ degToRad(bbox[2]),
12
+ degToRad(bbox[3]),
13
+ minZ,
14
+ maxZ
15
+ ];
16
+ }
17
+
18
+ /**
19
+ * @param {{
20
+ * bbox: [number, number, number, number],
21
+ * maxHeight: number,
22
+ * children: Array<Record<string, unknown>>
23
+ * }} options
24
+ * @returns {Record<string, unknown>}
25
+ */
26
+ export function buildTileset(options) {
27
+ const region = bboxToRegion(options.bbox, 0, Math.max(1, options.maxHeight));
28
+ const geometricError = rootGeometricError(options.bbox);
29
+
30
+ return {
31
+ asset: {
32
+ version: '1.0',
33
+ gltfUpAxis: 'Z',
34
+ generator: 'map-zero'
35
+ },
36
+ geometricError,
37
+ root: {
38
+ boundingVolume: { region },
39
+ geometricError,
40
+ refine: 'ADD',
41
+ children: options.children
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @param {{ bbox: [number, number, number, number], maxHeight: number, uri: string }} options
48
+ * @returns {Record<string, unknown>}
49
+ */
50
+ export function buildContentNode(options) {
51
+ return {
52
+ boundingVolume: {
53
+ region: bboxToRegion(options.bbox, 0, Math.max(1, options.maxHeight))
54
+ },
55
+ geometricError: 0,
56
+ refine: 'ADD',
57
+ content: {
58
+ uri: options.uri
59
+ }
60
+ };
61
+ }
62
+
63
+ /**
64
+ * @param {[number, number, number, number]} bbox
65
+ * @returns {number}
66
+ */
67
+ function rootGeometricError(bbox) {
68
+ const lonSpan = Math.max(0.0001, bbox[2] - bbox[0]);
69
+ const latSpan = Math.max(0.0001, bbox[3] - bbox[1]);
70
+ return Math.max(50, Math.max(lonSpan, latSpan) * 111000);
71
+ }
72
+
73
+ function degToRad(value) {
74
+ return value * Math.PI / 180;
75
+ }
package/src/build.js ADDED
@@ -0,0 +1,134 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { resolve, join } from 'node:path';
3
+
4
+ import { createManifest } from './manifest.js';
5
+ import { normalizeLayerId } from './layers.js';
6
+ import { buildOsmGeoPackage, inferOsmBbox } from './osm.js';
7
+ import { createNeonDarkStyle } from './style.js';
8
+
9
+ /**
10
+ * Build a .mapzero package folder from an OSM PBF source.
11
+ *
12
+ * @param {{
13
+ * source: string,
14
+ * bbox?: [number, number, number, number],
15
+ * layers: string[],
16
+ * out: string,
17
+ * keepTemp?: boolean,
18
+ * batchSize?: number,
19
+ * debugBuild?: boolean,
20
+ * onProgress?: (event: {
21
+ * phase: 'stage' | 'progress' | 'summary',
22
+ * step: string,
23
+ * label?: string,
24
+ * message?: string,
25
+ * bytesRead?: number,
26
+ * totalBytes?: number,
27
+ * entities?: number,
28
+ * itemsDone?: number,
29
+ * totalItems?: number
30
+ * }) => void
31
+ * }} options
32
+ * @returns {Promise<{ outDir: string, counts: Record<string, number> }>}
33
+ */
34
+ export async function buildPackage(options) {
35
+ const source = resolve(options.source);
36
+ const outDir = resolve(options.out);
37
+ const stylesDir = join(outDir, 'styles');
38
+ const gpkgPath = join(outDir, 'data.gpkg');
39
+ const layers = [...new Set(options.layers.map(normalizeLayerId))];
40
+
41
+ options.onProgress?.({
42
+ phase: 'stage',
43
+ step: 'validate',
44
+ message: 'Validating input and preparing output folder'
45
+ });
46
+ const sourceStat = await assertReadableFile(source);
47
+ await fs.mkdir(stylesDir, { recursive: true });
48
+
49
+ const bbox = options.bbox ?? await inferOsmBbox(source, {
50
+ totalBytes: sourceStat.size,
51
+ onProgress: options.onProgress
52
+ });
53
+
54
+ options.onProgress?.({
55
+ phase: 'stage',
56
+ step: 'write-gpkg',
57
+ message: 'Writing GeoPackage'
58
+ });
59
+ const buildResult = await buildOsmGeoPackage(source, bbox, layers, gpkgPath, {
60
+ totalBytes: sourceStat.size,
61
+ batchSize: options.batchSize,
62
+ keepTemp: options.keepTemp,
63
+ tempPath: join(outDir, '.mapzero-build-tmp.sqlite'),
64
+ debugBuild: options.debugBuild,
65
+ onProgress: options.onProgress
66
+ });
67
+
68
+ options.onProgress?.({
69
+ phase: 'stage',
70
+ step: 'write-manifest',
71
+ message: 'Writing manifest and style'
72
+ });
73
+ await fs.writeFile(
74
+ join(outDir, 'manifest.json'),
75
+ `${JSON.stringify(createManifest({ outDir, bbox, layers }), null, 2)}\n`
76
+ );
77
+
78
+ await fs.writeFile(
79
+ join(stylesDir, 'neon-dark.json'),
80
+ `${JSON.stringify(createNeonDarkStyle(layers), null, 2)}\n`
81
+ );
82
+
83
+ return {
84
+ outDir,
85
+ counts: buildResult.counts
86
+ };
87
+ }
88
+
89
+ /**
90
+ * @param {string} filePath
91
+ * @returns {Promise<import('node:fs').Stats>}
92
+ */
93
+ async function assertReadableFile(filePath) {
94
+ const stat = await fs.stat(filePath).catch(() => null);
95
+
96
+ if (!stat || !stat.isFile()) {
97
+ throw new Error(`source file does not exist: ${filePath}`);
98
+ }
99
+
100
+ await assertOsmPbfFile(filePath, stat);
101
+
102
+ return stat;
103
+ }
104
+
105
+ /**
106
+ * @param {string} filePath
107
+ * @param {import('node:fs').Stats} stat
108
+ * @returns {Promise<void>}
109
+ */
110
+ async function assertOsmPbfFile(filePath, stat) {
111
+ if (stat.size < 16) {
112
+ throw new Error(`source file is too small to be an OSM PBF: ${filePath}`);
113
+ }
114
+
115
+ const handle = await fs.open(filePath, 'r');
116
+ try {
117
+ const buffer = Buffer.alloc(Math.min(1024, stat.size));
118
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
119
+ const header = buffer.subarray(0, bytesRead);
120
+ const textPrefix = header.toString('utf8', 0, Math.min(bytesRead, 128)).trimStart().toLowerCase();
121
+
122
+ if (textPrefix.startsWith('<!doctype html') || textPrefix.startsWith('<html')) {
123
+ throw new Error(
124
+ `source file is HTML, not an OSM PBF: ${filePath}. Check the download URL and re-download with a .osm.pbf URL`
125
+ );
126
+ }
127
+
128
+ if (!header.includes(Buffer.from('OSMHeader'))) {
129
+ throw new Error(`source file does not look like an OSM PBF: ${filePath}`);
130
+ }
131
+ } finally {
132
+ await handle.close();
133
+ }
134
+ }