map-zero 0.1.0 → 0.2.1

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/src/package.js ADDED
@@ -0,0 +1,343 @@
1
+ import { createWriteStream, promises as fs } from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { once } from 'node:events';
4
+
5
+ const ZIP_VERSION = 20;
6
+ const STORE_METHOD = 0;
7
+ const MAX_ZIP_UINT32 = 0xffffffff;
8
+ const DOS_TIME_ZERO = 0;
9
+ const DOS_DATE_1980_01_01 = 33;
10
+
11
+ /**
12
+ * Create a portable zip for copying a generated map-zero package into an app.
13
+ *
14
+ * The archive includes manifest.json, styles, PMTiles, and 3D Tiles. The source
15
+ * GeoPackage is intentionally excluded unless requested because it is large and
16
+ * not needed by static OpenLayers/Cesium consumers.
17
+ *
18
+ * @param {{ packageDir: string, out?: string, includeGpkg?: boolean }} options
19
+ * @returns {Promise<{ outPath: string, fileCount: number, inputBytes: number, outputBytes: number, includedGpkg: boolean }>}
20
+ */
21
+ export async function packageMapZero(options) {
22
+ const packageDir = resolve(options.packageDir);
23
+ const manifestPath = join(packageDir, 'manifest.json');
24
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
25
+ validateManifest(manifest);
26
+
27
+ const packageBaseName = basename(packageDir);
28
+ const outPath = resolve(options.out ?? join(dirname(packageDir), `${packageBaseName}.zip`));
29
+ const entries = await collectPackageEntries(packageDir, manifest, {
30
+ includeGpkg: Boolean(options.includeGpkg),
31
+ zipRoot: packageBaseName
32
+ });
33
+
34
+ const result = await writeZip(outPath, entries);
35
+ return {
36
+ outPath,
37
+ fileCount: entries.length,
38
+ inputBytes: result.inputBytes,
39
+ outputBytes: result.outputBytes,
40
+ includedGpkg: Boolean(options.includeGpkg)
41
+ };
42
+ }
43
+
44
+ /**
45
+ * @param {string} packageDir
46
+ * @param {Record<string, any>} manifest
47
+ * @param {{ includeGpkg: boolean, zipRoot: string }} options
48
+ * @returns {Promise<Array<{ filePath: string, zipPath: string, size: number }>>}
49
+ */
50
+ async function collectPackageEntries(packageDir, manifest, options) {
51
+ /** @type {Array<{ filePath: string, zipPath: string, size: number }>} */
52
+ const entries = [];
53
+ const seen = new Set();
54
+ const addFile = async (relativePath, required = true) => {
55
+ const normalized = safePackageRelativePath(relativePath);
56
+ if (!normalized) {
57
+ if (required) throw new Error(`invalid package path: ${relativePath}`);
58
+ return;
59
+ }
60
+
61
+ const filePath = join(packageDir, normalized);
62
+ let stat;
63
+ try {
64
+ stat = await fs.stat(filePath);
65
+ } catch (error) {
66
+ if (required) throw new Error(`required package file is missing: ${normalized}`);
67
+ return;
68
+ }
69
+ if (!stat.isFile()) {
70
+ if (required) throw new Error(`package path is not a file: ${normalized}`);
71
+ return;
72
+ }
73
+ if (stat.size > MAX_ZIP_UINT32) {
74
+ throw new Error(`file is too large for non-Zip64 output: ${normalized}`);
75
+ }
76
+ if (seen.has(normalized)) return;
77
+ seen.add(normalized);
78
+ entries.push({
79
+ filePath,
80
+ zipPath: `${options.zipRoot}/${normalized}`,
81
+ size: stat.size
82
+ });
83
+ };
84
+
85
+ await addFile('manifest.json');
86
+ await addManifestStyles(manifest, addFile);
87
+
88
+ const pmtilesUrl = manifest.tiles?.format === 'pmtiles' ? manifest.tiles.url : null;
89
+ if (typeof pmtilesUrl === 'string') {
90
+ await addFile(pmtilesUrl);
91
+ }
92
+
93
+ const tiles3dUrl = manifest.tiles3d?.format === '3dtiles' ? manifest.tiles3d.url : null;
94
+ if (typeof tiles3dUrl === 'string') {
95
+ const tiles3dDir = dirname(safePackageRelativePath(tiles3dUrl) ?? '');
96
+ if (!tiles3dDir || tiles3dDir === '.') {
97
+ throw new Error(`invalid 3D Tiles URL in manifest: ${tiles3dUrl}`);
98
+ }
99
+ await addDirectory(packageDir, tiles3dDir, options.zipRoot, entries, seen);
100
+ }
101
+
102
+ if (options.includeGpkg) {
103
+ await addFile(String(manifest.data ?? 'data.gpkg'));
104
+ }
105
+
106
+ return entries.sort((a, b) => a.zipPath.localeCompare(b.zipPath));
107
+ }
108
+
109
+ /**
110
+ * @param {Record<string, any>} manifest
111
+ * @param {(relativePath: string, required?: boolean) => Promise<void>} addFile
112
+ */
113
+ async function addManifestStyles(manifest, addFile) {
114
+ const styles = manifest.styles && typeof manifest.styles === 'object' ? manifest.styles : {};
115
+ const stylePaths = new Set(Object.values(styles).filter((value) => typeof value === 'string'));
116
+ for (const stylePath of stylePaths) {
117
+ await addFile(stylePath);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @param {string} packageDir
123
+ * @param {string} relativeDir
124
+ * @param {string} zipRoot
125
+ * @param {Array<{ filePath: string, zipPath: string, size: number }>} entries
126
+ * @param {Set<string>} seen
127
+ */
128
+ async function addDirectory(packageDir, relativeDir, zipRoot, entries, seen) {
129
+ const normalizedDir = safePackageRelativePath(relativeDir);
130
+ if (!normalizedDir) {
131
+ throw new Error(`invalid package directory: ${relativeDir}`);
132
+ }
133
+
134
+ const dirPath = join(packageDir, normalizedDir);
135
+ let stat;
136
+ try {
137
+ stat = await fs.stat(dirPath);
138
+ } catch (error) {
139
+ throw new Error(`required package directory is missing: ${normalizedDir}`);
140
+ }
141
+ if (!stat.isDirectory()) {
142
+ throw new Error(`package path is not a directory: ${normalizedDir}`);
143
+ }
144
+
145
+ for (const item of await listFiles(dirPath)) {
146
+ const normalized = relative(packageDir, item).split(sep).join('/');
147
+ if (seen.has(normalized)) continue;
148
+ const itemStat = await fs.stat(item);
149
+ if (itemStat.size > MAX_ZIP_UINT32) {
150
+ throw new Error(`file is too large for non-Zip64 output: ${normalized}`);
151
+ }
152
+ seen.add(normalized);
153
+ entries.push({
154
+ filePath: item,
155
+ zipPath: `${zipRoot}/${normalized}`,
156
+ size: itemStat.size
157
+ });
158
+ }
159
+ }
160
+
161
+ /**
162
+ * @param {string} dir
163
+ * @returns {Promise<string[]>}
164
+ */
165
+ async function listFiles(dir) {
166
+ const out = [];
167
+ const items = await fs.readdir(dir, { withFileTypes: true });
168
+ items.sort((a, b) => a.name.localeCompare(b.name));
169
+ for (const item of items) {
170
+ const itemPath = join(dir, item.name);
171
+ if (item.isDirectory()) {
172
+ out.push(...await listFiles(itemPath));
173
+ } else if (item.isFile()) {
174
+ out.push(itemPath);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * @param {string} outPath
182
+ * @param {Array<{ filePath: string, zipPath: string, size: number }>} entries
183
+ * @returns {Promise<{ inputBytes: number, outputBytes: number }>}
184
+ */
185
+ async function writeZip(outPath, entries) {
186
+ if (entries.length > 0xffff) {
187
+ throw new Error('zip has too many files for non-Zip64 output');
188
+ }
189
+
190
+ await fs.mkdir(dirname(outPath), { recursive: true });
191
+ const stream = createWriteStream(outPath);
192
+ const centralDirectory = [];
193
+ let offset = 0;
194
+ let inputBytes = 0;
195
+
196
+ try {
197
+ for (const entry of entries) {
198
+ const data = await fs.readFile(entry.filePath);
199
+ const crc = crc32(data);
200
+ const name = Buffer.from(entry.zipPath, 'utf8');
201
+ const localHeader = createLocalHeader(name, crc, data.length);
202
+ const entryOffset = offset;
203
+ await writeBuffer(stream, localHeader);
204
+ await writeBuffer(stream, data);
205
+ offset += localHeader.length + data.length;
206
+ inputBytes += data.length;
207
+ if (offset > MAX_ZIP_UINT32) {
208
+ throw new Error('zip output is too large for non-Zip64 output');
209
+ }
210
+ centralDirectory.push(createCentralDirectoryHeader(name, crc, data.length, entryOffset));
211
+ }
212
+
213
+ const centralStart = offset;
214
+ for (const header of centralDirectory) {
215
+ await writeBuffer(stream, header);
216
+ offset += header.length;
217
+ }
218
+ const centralSize = offset - centralStart;
219
+ if (offset > MAX_ZIP_UINT32 || centralSize > MAX_ZIP_UINT32) {
220
+ throw new Error('zip output is too large for non-Zip64 output');
221
+ }
222
+ await writeBuffer(stream, createEndOfCentralDirectory(entries.length, centralSize, centralStart));
223
+ offset += 22;
224
+ } catch (error) {
225
+ stream.destroy();
226
+ await fs.rm(outPath, { force: true }).catch(() => undefined);
227
+ throw error;
228
+ }
229
+
230
+ stream.end();
231
+ await once(stream, 'finish');
232
+ return {
233
+ inputBytes,
234
+ outputBytes: offset
235
+ };
236
+ }
237
+
238
+ /**
239
+ * @param {import('node:fs').WriteStream} stream
240
+ * @param {Buffer} buffer
241
+ * @returns {Promise<void>}
242
+ */
243
+ async function writeBuffer(stream, buffer) {
244
+ if (!stream.write(buffer)) {
245
+ await once(stream, 'drain');
246
+ }
247
+ }
248
+
249
+ function createLocalHeader(name, crc, size) {
250
+ const header = Buffer.alloc(30 + name.length);
251
+ header.writeUInt32LE(0x04034b50, 0);
252
+ header.writeUInt16LE(ZIP_VERSION, 4);
253
+ header.writeUInt16LE(0x0800, 6);
254
+ header.writeUInt16LE(STORE_METHOD, 8);
255
+ header.writeUInt16LE(DOS_TIME_ZERO, 10);
256
+ header.writeUInt16LE(DOS_DATE_1980_01_01, 12);
257
+ header.writeUInt32LE(crc, 14);
258
+ header.writeUInt32LE(size, 18);
259
+ header.writeUInt32LE(size, 22);
260
+ header.writeUInt16LE(name.length, 26);
261
+ header.writeUInt16LE(0, 28);
262
+ name.copy(header, 30);
263
+ return header;
264
+ }
265
+
266
+ function createCentralDirectoryHeader(name, crc, size, offset) {
267
+ const header = Buffer.alloc(46 + name.length);
268
+ header.writeUInt32LE(0x02014b50, 0);
269
+ header.writeUInt16LE(ZIP_VERSION, 4);
270
+ header.writeUInt16LE(ZIP_VERSION, 6);
271
+ header.writeUInt16LE(0x0800, 8);
272
+ header.writeUInt16LE(STORE_METHOD, 10);
273
+ header.writeUInt16LE(DOS_TIME_ZERO, 12);
274
+ header.writeUInt16LE(DOS_DATE_1980_01_01, 14);
275
+ header.writeUInt32LE(crc, 16);
276
+ header.writeUInt32LE(size, 20);
277
+ header.writeUInt32LE(size, 24);
278
+ header.writeUInt16LE(name.length, 28);
279
+ header.writeUInt16LE(0, 30);
280
+ header.writeUInt16LE(0, 32);
281
+ header.writeUInt16LE(0, 34);
282
+ header.writeUInt16LE(0, 36);
283
+ header.writeUInt32LE(0, 38);
284
+ header.writeUInt32LE(offset, 42);
285
+ name.copy(header, 46);
286
+ return header;
287
+ }
288
+
289
+ function createEndOfCentralDirectory(entryCount, centralSize, centralStart) {
290
+ const header = Buffer.alloc(22);
291
+ header.writeUInt32LE(0x06054b50, 0);
292
+ header.writeUInt16LE(0, 4);
293
+ header.writeUInt16LE(0, 6);
294
+ header.writeUInt16LE(entryCount, 8);
295
+ header.writeUInt16LE(entryCount, 10);
296
+ header.writeUInt32LE(centralSize, 12);
297
+ header.writeUInt32LE(centralStart, 16);
298
+ header.writeUInt16LE(0, 20);
299
+ return header;
300
+ }
301
+
302
+ /**
303
+ * @param {Buffer} data
304
+ * @returns {number}
305
+ */
306
+ function crc32(data) {
307
+ let crc = 0xffffffff;
308
+ for (const byte of data) {
309
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
310
+ }
311
+ return (crc ^ 0xffffffff) >>> 0;
312
+ }
313
+
314
+ const CRC32_TABLE = new Uint32Array(256);
315
+ for (let i = 0; i < 256; i += 1) {
316
+ let c = i;
317
+ for (let j = 0; j < 8; j += 1) {
318
+ c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
319
+ }
320
+ CRC32_TABLE[i] = c >>> 0;
321
+ }
322
+
323
+ /**
324
+ * @param {Record<string, unknown>} manifest
325
+ */
326
+ function validateManifest(manifest) {
327
+ if (manifest.format !== 'mapzero') {
328
+ throw new Error('manifest format must be mapzero');
329
+ }
330
+ }
331
+
332
+ /**
333
+ * @param {unknown} value
334
+ * @returns {string | null}
335
+ */
336
+ function safePackageRelativePath(value) {
337
+ if (typeof value !== 'string' || value.length === 0) return null;
338
+ const normalized = value.replaceAll('\\', '/').replace(/^\/+/, '');
339
+ if (!/^[A-Za-z0-9._/-]+$/.test(normalized) || normalized.includes('..')) {
340
+ return null;
341
+ }
342
+ return normalized;
343
+ }
package/src/server.js CHANGED
@@ -114,6 +114,18 @@ export async function createMapZeroServer(options) {
114
114
  .send(moduleSource);
115
115
  });
116
116
 
117
+ app.get('/vendor/pmtiles-worker.js', async (request, reply) => {
118
+ const moduleSource = await fs.readFile(new URL('../node_modules/pmtiles/dist/esm/index.js', import.meta.url), 'utf8');
119
+ const browserSource = moduleSource
120
+ .replaceAll('from"fflate"', 'from"/vendor/fflate.js"')
121
+ .replaceAll("from 'fflate'", "from '/vendor/fflate.js'")
122
+ .replaceAll('from "fflate"', 'from "/vendor/fflate.js"');
123
+ reply
124
+ .header('cache-control', 'public, max-age=31536000, immutable')
125
+ .type('text/javascript; charset=utf-8')
126
+ .send(browserSource);
127
+ });
128
+
117
129
  app.get('/vendor/fflate.js', async (request, reply) => {
118
130
  const moduleSource = await fs.readFile(new URL('../node_modules/fflate/esm/browser.js', import.meta.url), 'utf8');
119
131
  reply
@@ -125,8 +137,8 @@ export async function createMapZeroServer(options) {
125
137
  app.get('/map-zero-cesium.js', async (request, reply) => {
126
138
  const moduleSource = await fs.readFile(new URL('../packages/cesium/src/index.js', import.meta.url), 'utf8');
127
139
  const browserSource = moduleSource.replace(
128
- "import {\n Cesium3DTileStyle,\n Cesium3DTileset\n} from 'cesium';",
129
- "const { Cesium3DTileStyle, Cesium3DTileset } = globalThis.Cesium;"
140
+ "import {\n Cesium3DTileColorBlendMode,\n Cesium3DTileStyle,\n Cesium3DTileset,\n ImageryLayer\n} from 'cesium';",
141
+ "const { Cesium3DTileColorBlendMode, Cesium3DTileStyle, Cesium3DTileset, ImageryLayer } = globalThis.Cesium;"
130
142
  );
131
143
  reply
132
144
  .header('cache-control', 'no-store')
@@ -134,6 +146,30 @@ export async function createMapZeroServer(options) {
134
146
  .send(browserSource);
135
147
  });
136
148
 
149
+ app.get('/imagery.js', async (request, reply) => {
150
+ const moduleSource = await fs.readFile(new URL('../packages/cesium/src/imagery.js', import.meta.url), 'utf8');
151
+ const browserSource = moduleSource.replace(
152
+ "import {\n Event,\n Rectangle,\n WebMercatorTilingScheme\n} from 'cesium';",
153
+ "const { Event, Rectangle, WebMercatorTilingScheme } = globalThis.Cesium;"
154
+ );
155
+ reply
156
+ .header('cache-control', 'no-store')
157
+ .type('text/javascript; charset=utf-8')
158
+ .send(browserSource);
159
+ });
160
+
161
+ app.get('/imagery-worker.js', async (request, reply) => {
162
+ const moduleSource = await fs.readFile(new URL('../packages/cesium/src/imagery-worker.js', import.meta.url), 'utf8');
163
+ const browserSource = moduleSource
164
+ .replace("import Feature from 'ol/Feature.js';", "import Feature from 'https://esm.sh/ol@10.9.0/Feature.js';")
165
+ .replace("import MVT from 'ol/format/MVT.js';", "import MVT from 'https://esm.sh/ol@10.9.0/format/MVT.js';")
166
+ .replace("import { PMTiles } from 'pmtiles';", "import { PMTiles } from '/vendor/pmtiles-worker.js';");
167
+ reply
168
+ .header('cache-control', 'no-store')
169
+ .type('text/javascript; charset=utf-8')
170
+ .send(browserSource);
171
+ });
172
+
137
173
  app.get('/manifest.json', async (request, reply) => {
138
174
  reply.header('cache-control', 'no-store').send(manifest);
139
175
  });
@@ -556,14 +592,6 @@ function pmtilesArchivePath(packageDir, manifest) {
556
592
  * @returns {{ urlPrefix: string, pathPrefix: string } | null}
557
593
  */
558
594
  function tiles3dBasePath(packageDir, manifest) {
559
- const cesiumTilesets = /** @type {{ tilesets?: Record<string, unknown> } | undefined} */ (manifest.cesium)?.tilesets;
560
- if (cesiumTilesets && typeof cesiumTilesets === 'object' && Object.keys(cesiumTilesets).length > 0) {
561
- return {
562
- urlPrefix: '3dtiles',
563
- pathPrefix: join(packageDir, '3dtiles')
564
- };
565
- }
566
-
567
595
  const tiles3d = /** @type {{ format?: unknown, url?: unknown } | undefined} */ (manifest.tiles3d);
568
596
  if (tiles3d?.format !== '3dtiles' || typeof tiles3d.url !== 'string') {
569
597
  return null;
@@ -27,7 +27,7 @@ export async function writePackageStyle(options) {
27
27
  throw new Error('manifest must be a mapzero package with layers');
28
28
  }
29
29
 
30
- const selectedLayers = manifest.layers.map((layer) => String(layer.id));
30
+ const selectedLayers = manifest.layers.map(String);
31
31
  const sourceType = options.theme ? 'theme' : 'preset';
32
32
  const styleDocument = options.theme
33
33
  ? createStyleFromTheme(options.theme, selectedLayers)
@@ -15,9 +15,8 @@ export function createHiddenFilters(manifest, style) {
15
15
  }
16
16
 
17
17
  for (const layer of manifestLayers) {
18
- const layerRecord = /** @type {Record<string, unknown>} */ (layer);
19
- const layerId = String(layerRecord.id);
20
- const styleId = String(layerRecord.style ?? layerId);
18
+ const layerId = String(layer);
19
+ const styleId = layerId;
21
20
  const styleRule = /** @type {Record<string, unknown> | undefined} */ (styleLayers[styleId] ?? styleLayers[layerAlias(styleId)]);
22
21
  const byProperty = /** @type {Record<string, unknown> | undefined} */ (styleRule?.byProperty);
23
22
 
@@ -1,90 +0,0 @@
1
- {
2
- "format": "mapzero-style",
3
- "version": 1,
4
- "name": "neon-dark-3d",
5
- "background": "#000000",
6
- "drawOrder": [
7
- "landuse",
8
- "water",
9
- "aip",
10
- "railways",
11
- "roads",
12
- "boundaries",
13
- "buildings",
14
- "pois"
15
- ],
16
- "layers": {
17
- "landuse": {
18
- "visible": true,
19
- "fill": "#174626",
20
- "fillOpacity": 0.56,
21
- "body": {
22
- "color": "#39ff88",
23
- "opacity": 0.5
24
- }
25
- },
26
- "water": {
27
- "visible": true,
28
- "fill": "#005dff",
29
- "fillOpacity": 0.62,
30
- "body": {
31
- "color": "#18cfff",
32
- "opacity": 0.78
33
- }
34
- },
35
- "railways": {
36
- "visible": true,
37
- "body": {
38
- "color": "#ffcc33",
39
- "opacity": 0.95
40
- },
41
- "stroke": "#ffcc33",
42
- "strokeOpacity": 0.95
43
- },
44
- "roads": {
45
- "visible": true,
46
- "body": {
47
- "color": "#1efcff",
48
- "opacity": 1
49
- },
50
- "stroke": "#1efcff",
51
- "strokeOpacity": 1
52
- },
53
- "boundaries": {
54
- "visible": true,
55
- "body": {
56
- "color": "#a970ff",
57
- "opacity": 1
58
- },
59
- "stroke": "#a970ff",
60
- "strokeOpacity": 1
61
- },
62
- "buildings": {
63
- "visible": true,
64
- "fill": "#ff22ff",
65
- "fillOpacity": 0.62,
66
- "body": {
67
- "color": "#ff22ff",
68
- "opacity": 0.82
69
- },
70
- "stroke": "#ff22ff",
71
- "strokeOpacity": 0.82
72
- },
73
- "pois": {
74
- "visible": true,
75
- "body": {
76
- "color": "#ffcc33",
77
- "opacity": 0.9
78
- }
79
- },
80
- "aip": {
81
- "visible": true,
82
- "fill": "#00eaff",
83
- "fillOpacity": 1,
84
- "body": {
85
- "color": "#eaffff",
86
- "opacity": 1
87
- }
88
- }
89
- }
90
- }