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