styled-map-package-api 5.0.0-pre.3 → 5.0.0-pre.4

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 (94) hide show
  1. package/README.md +94 -0
  2. package/dist/download.d.ts +11 -21
  3. package/dist/fallbacks.d.ts +32 -0
  4. package/dist/from-mbtiles.d.ts +1 -3
  5. package/dist/index.d.ts +11 -24
  6. package/dist/reader.d.ts +28 -12
  7. package/dist/server.d.ts +23 -14
  8. package/dist/style-downloader.d.ts +13 -19
  9. package/dist/tile-downloader.d.ts +13 -23
  10. package/dist/types.d.ts +61 -0
  11. package/dist/utils/errors.d.ts +2 -4
  12. package/dist/utils/fetch.d.ts +3 -8
  13. package/dist/utils/file-formats.d.ts +3 -10
  14. package/dist/utils/geo.d.ts +17 -9
  15. package/dist/utils/mapbox.d.ts +8 -10
  16. package/dist/utils/misc.d.ts +3 -5
  17. package/dist/utils/streams.d.ts +6 -10
  18. package/dist/utils/style.d.ts +27 -16
  19. package/dist/utils/templates.d.ts +30 -25
  20. package/dist/validator.d.ts +66 -0
  21. package/dist/writer.d.ts +157 -4
  22. package/lib/download.js +125 -0
  23. package/lib/fallbacks.js +157 -0
  24. package/lib/from-mbtiles.js +131 -0
  25. package/lib/index.js +12 -0
  26. package/lib/reader.js +360 -0
  27. package/lib/server.js +222 -0
  28. package/lib/style-downloader.js +369 -0
  29. package/lib/tile-downloader.js +189 -0
  30. package/lib/types.ts +99 -0
  31. package/lib/utils/errors.js +24 -0
  32. package/lib/utils/fetch.js +104 -0
  33. package/lib/utils/file-formats.js +92 -0
  34. package/lib/utils/geo.js +97 -0
  35. package/lib/utils/mapbox.js +155 -0
  36. package/{dist/utils/misc.d.cts → lib/utils/misc.js} +9 -5
  37. package/lib/utils/streams.js +101 -0
  38. package/lib/utils/style.js +206 -0
  39. package/lib/utils/templates.js +165 -0
  40. package/lib/validator.js +789 -0
  41. package/lib/writer.js +652 -0
  42. package/package.json +30 -78
  43. package/dist/download.cjs +0 -100
  44. package/dist/download.d.cts +0 -63
  45. package/dist/download.js +0 -76
  46. package/dist/from-mbtiles.cjs +0 -127
  47. package/dist/from-mbtiles.d.cts +0 -14
  48. package/dist/from-mbtiles.js +0 -103
  49. package/dist/index.cjs +0 -46
  50. package/dist/index.d.cts +0 -24
  51. package/dist/index.js +0 -16
  52. package/dist/reader.cjs +0 -287
  53. package/dist/reader.d.cts +0 -67
  54. package/dist/reader.js +0 -259
  55. package/dist/server.cjs +0 -73
  56. package/dist/server.d.cts +0 -45
  57. package/dist/server.js +0 -49
  58. package/dist/style-downloader.cjs +0 -314
  59. package/dist/style-downloader.d.cts +0 -118
  60. package/dist/style-downloader.js +0 -290
  61. package/dist/tile-downloader.cjs +0 -156
  62. package/dist/tile-downloader.d.cts +0 -82
  63. package/dist/tile-downloader.js +0 -124
  64. package/dist/types-Bhn0-Ldk.d.cts +0 -201
  65. package/dist/types-Bhn0-Ldk.d.ts +0 -201
  66. package/dist/utils/errors.cjs +0 -41
  67. package/dist/utils/errors.d.cts +0 -18
  68. package/dist/utils/errors.js +0 -16
  69. package/dist/utils/fetch.cjs +0 -97
  70. package/dist/utils/fetch.d.cts +0 -50
  71. package/dist/utils/fetch.js +0 -63
  72. package/dist/utils/file-formats.cjs +0 -96
  73. package/dist/utils/file-formats.d.cts +0 -32
  74. package/dist/utils/file-formats.js +0 -70
  75. package/dist/utils/geo.cjs +0 -84
  76. package/dist/utils/geo.d.cts +0 -46
  77. package/dist/utils/geo.js +0 -56
  78. package/dist/utils/mapbox.cjs +0 -121
  79. package/dist/utils/mapbox.d.cts +0 -43
  80. package/dist/utils/mapbox.js +0 -91
  81. package/dist/utils/misc.cjs +0 -39
  82. package/dist/utils/misc.js +0 -13
  83. package/dist/utils/streams.cjs +0 -99
  84. package/dist/utils/streams.d.cts +0 -49
  85. package/dist/utils/streams.js +0 -73
  86. package/dist/utils/style.cjs +0 -126
  87. package/dist/utils/style.d.cts +0 -66
  88. package/dist/utils/style.js +0 -98
  89. package/dist/utils/templates.cjs +0 -124
  90. package/dist/utils/templates.d.cts +0 -79
  91. package/dist/utils/templates.js +0 -85
  92. package/dist/writer.cjs +0 -539
  93. package/dist/writer.d.cts +0 -4
  94. package/dist/writer.js +0 -516
package/lib/writer.js ADDED
@@ -0,0 +1,652 @@
1
+ import { validateStyleMin, migrate } from '@maplibre/maplibre-gl-style-spec'
2
+ import { bbox } from '@turf/bbox'
3
+ import { excludeKeys } from 'filter-obj'
4
+ import { ZipWriter } from 'zip-writer'
5
+
6
+ import { getTileFormatFromStream } from './utils/file-formats.js'
7
+ import { MAX_BOUNDS, tileToBBox, unionBBox } from './utils/geo.js'
8
+ import { clone } from './utils/misc.js'
9
+ import { writeStreamFromAsync } from './utils/streams.js'
10
+ import { replaceFontStacks } from './utils/style.js'
11
+ import {
12
+ FONTS_FOLDER,
13
+ FORMAT_VERSION,
14
+ getGlyphFilename,
15
+ getSpriteFilename,
16
+ getSpriteUri,
17
+ getTileFilename,
18
+ getTileUri,
19
+ GLYPH_URI,
20
+ SOURCES_FOLDER,
21
+ STYLE_FILE,
22
+ VERSION_FILE,
23
+ } from './utils/templates.js'
24
+
25
+ /** @typedef {string | Uint8Array | ReadableStream } Source */
26
+ /** @typedef {`${number}-${number}`} GlyphRange */
27
+ /** @typedef {'png' | 'mvt' | 'jpg' | 'webp'} TileFormat */
28
+ /**
29
+ * @typedef {object} SourceInfo
30
+ * @property {import('./types.js').SMPSource} source
31
+ * @property {string} encodedSourceId
32
+ * @property {TileFormat} [format]
33
+ */
34
+ /**
35
+ * @typedef {object} TileInfo
36
+ * @property {number} z Zoom level
37
+ * @property {number} x Tile column (XYZ scheme)
38
+ * @property {number} y Tile row (XYZ scheme, origin at top-left). If your source uses TMS, convert with {@link import('./utils/geo.js').tmsToXyzY} before passing.
39
+ * @property {string} sourceId
40
+ * @property {TileFormat} [format]
41
+ */
42
+ /**
43
+ * @typedef {object} GlyphInfo
44
+ * @property {string} font
45
+ * @property {GlyphRange} range
46
+ */
47
+
48
+ /** @import { StyleSpecification } from '@maplibre/maplibre-gl-style-spec' */
49
+ /** @import { InputSource, SMPSource } from './types.js' */
50
+
51
+ export const SUPPORTED_SOURCE_TYPES = /** @type {const} */ ([
52
+ 'raster',
53
+ 'vector',
54
+ 'geojson',
55
+ ])
56
+
57
+ /**
58
+ * @typedef {object} WriterOptions
59
+ * @property {boolean} [dedupe] When true, duplicate tiles (with identical
60
+ * content) are stored only once in the archive. Additional entries in the
61
+ * central directory point to the same data. This reduces file size for
62
+ * tilesets with many repeated tiles (e.g. ocean tiles).
63
+ */
64
+
65
+ /**
66
+ * Write a styled map package to a stream. Stream `writer.outputStream` to a
67
+ * destination, e.g. `fs.createWriteStream('my-map.styledmap')`. You must call
68
+ * `witer.finish()` and then wait for your writable stream to `finish` before
69
+ * using the output.
70
+ */
71
+ export class Writer {
72
+ #zipWriter = new ZipWriter()
73
+ /** @type {Set<string>} */
74
+ #addedFiles = new Set()
75
+ /** @type {Set<string>} */
76
+ #fonts = new Set()
77
+ /** @type {Set<string>} */
78
+ #addedSpriteIds = new Set()
79
+ /** @type {Map<string, SourceInfo>} */
80
+ #sources = new Map()
81
+ /** @type {StyleSpecification} */
82
+ #style
83
+ /** @type {ReadableStream<Uint8Array>} */
84
+ #outputStream
85
+ /** @type {ReadableStreamDefaultController<Uint8Array>} */
86
+ #outputController
87
+ /** @type {boolean} */
88
+ #dedupe
89
+ /** @type {Map<string, string>} hash → first entry name */
90
+ #tileHashes = new Map()
91
+ /** @type {Array<{ name: string, originalName: string }>} */
92
+ #duplicateEntries = []
93
+
94
+ static SUPPORTED_SOURCE_TYPES = SUPPORTED_SOURCE_TYPES
95
+
96
+ /**
97
+ * @param {any} style A v7 or v8 MapLibre style. v7 styles will be migrated to
98
+ * v8. (There are currently no typescript declarations for v7 styles, hence
99
+ * this is typed as `any` and validated internally)
100
+ * @param {WriterOptions} [options]
101
+ */
102
+ constructor(style, { dedupe = false } = {}) {
103
+ if (!style || !('version' in style)) {
104
+ throw new Error('Invalid style')
105
+ }
106
+ if (style.version !== 7 && style.version !== 8) {
107
+ throw new Error(`Invalid style: Unsupported version v${style.version}`)
108
+ }
109
+ // Basic validation so migrate can work - more validation is done later
110
+ if (!Array.isArray(style.layers)) {
111
+ throw new Error('Invalid style: missing layers property')
112
+ }
113
+
114
+ const styleCopy = clone(style)
115
+ // This mutates the style, so we work on a clone
116
+ migrate(styleCopy)
117
+ const errors = validateStyleMin(styleCopy)
118
+ if (errors.length) {
119
+ throw new AggregateError(errors, 'Invalid style')
120
+ }
121
+ this.#style = styleCopy
122
+ this.#dedupe = dedupe
123
+
124
+ for (const [sourceId, source] of Object.entries(this.#style.sources)) {
125
+ if (source.type !== 'geojson') continue
126
+ // Eagerly add GeoJSON sources - if they reference data via a URL and data
127
+ // is not added, these sources will be excluded from the resulting SMP
128
+ this.#addSource(sourceId, source)
129
+ }
130
+
131
+ const zipReader = this.#zipWriter.readable.getReader()
132
+ /** @type {ReadableStreamDefaultController<Uint8Array>} */
133
+ let outputController
134
+ this.#outputStream = new ReadableStream({
135
+ start(controller) {
136
+ outputController = controller
137
+ },
138
+ async pull(controller) {
139
+ try {
140
+ const { done, value } = await zipReader.read()
141
+ if (done) {
142
+ controller.close()
143
+ } else {
144
+ controller.enqueue(/** @type {Uint8Array} */ (value))
145
+ }
146
+ } catch (err) {
147
+ controller.error(err)
148
+ }
149
+ },
150
+ cancel(reason) {
151
+ zipReader.cancel(reason)
152
+ },
153
+ })
154
+ // @ts-ignore - outputController is set synchronously in the start callback above
155
+ this.#outputController = outputController
156
+ }
157
+
158
+ /**
159
+ * @returns {ReadableStream<Uint8Array>} Readable stream of the styled map package
160
+ */
161
+ get outputStream() {
162
+ return this.#outputStream
163
+ }
164
+
165
+ /**
166
+ * Abort the output stream with an error. Call this if an error occurs during
167
+ * writing to propagate the error to consumers of `outputStream`.
168
+ *
169
+ * @param {Error} reason
170
+ */
171
+ abort(reason) {
172
+ this.#outputController.error(reason)
173
+ }
174
+
175
+ #getBounds() {
176
+ /** @type {import('./utils/geo.js').BBox | undefined} */
177
+ let bounds
178
+ let maxzoom = 0
179
+ for (const { source } of this.#sources.values()) {
180
+ if (source.type === 'geojson') {
181
+ if (isEmptyFeatureCollection(source.data)) continue
182
+ // GeoJSON source always increases the bounds of the map
183
+ const bbox = get2DBBox(source.data.bbox)
184
+ bounds = bounds ? unionBBox([bounds, bbox]) : [...bbox]
185
+ } else {
186
+ // For raster and vector tile sources, a source with a higher max zoom
187
+ // overrides the bounds from lower zooms, because bounds from lower zoom
188
+ // tiles do not really reflect actual bounds (imagine a source of zoom 0
189
+ // - a single tile covers the whole world)
190
+ if (source.maxzoom < maxzoom) continue
191
+ if (source.maxzoom === maxzoom) {
192
+ bounds = bounds ? unionBBox([bounds, source.bounds]) : source.bounds
193
+ } else {
194
+ bounds = source.bounds
195
+ maxzoom = source.maxzoom
196
+ }
197
+ }
198
+ }
199
+ return bounds
200
+ }
201
+
202
+ #getMaxZoom() {
203
+ let maxzoom = 0
204
+ for (const { source } of this.#sources.values()) {
205
+ const sourceMaxzoom =
206
+ // For GeoJSON sources, the maxzoom is 16 unless otherwise set
207
+ source.type === 'geojson' ? source.maxzoom || 16 : source.maxzoom
208
+ maxzoom = Math.max(maxzoom, sourceMaxzoom)
209
+ }
210
+ return maxzoom
211
+ }
212
+
213
+ /**
214
+ * Add a source definition to the styled map package
215
+ *
216
+ * @param {string} sourceId
217
+ * @param {InputSource} source
218
+ * @returns {SourceInfo}
219
+ */
220
+ #addSource(sourceId, source) {
221
+ const encodedSourceId = encodeSourceId(this.#sources.size)
222
+ // Most of the body of this function is just to keep Typescript happy.
223
+ // Makes it more verbose, but makes it more type safe.
224
+ const tileSourceOverrides = {
225
+ minzoom: 0,
226
+ maxzoom: 0,
227
+ bounds: /** @type {import('./utils/geo.js').BBox} */ ([...MAX_BOUNDS]),
228
+ tiles: /** @type {string[]} */ ([]),
229
+ }
230
+ /** @type {SMPSource} */
231
+ let smpSource
232
+ switch (source.type) {
233
+ case 'raster':
234
+ case 'vector':
235
+ smpSource = {
236
+ ...excludeKeys(source, ['tiles', 'url', 'scheme']),
237
+ scheme: 'xyz',
238
+ ...tileSourceOverrides,
239
+ }
240
+ break
241
+ case 'geojson':
242
+ smpSource = {
243
+ ...source,
244
+ maxzoom: 0,
245
+ data:
246
+ typeof source.data !== 'string'
247
+ ? // Add a bbox property to the GeoJSON data if it doesn't already have one
248
+ { ...source.data, bbox: source.data.bbox || bbox(source.data) }
249
+ : // If GeoJSON data is referenced by a URL, start with an empty FeatureCollection
250
+ {
251
+ type: 'FeatureCollection',
252
+ features: [],
253
+ bbox: [0, 0, 0, 0],
254
+ },
255
+ }
256
+ break
257
+ }
258
+ const sourceInfo = {
259
+ source: smpSource,
260
+ encodedSourceId,
261
+ }
262
+ this.#sources.set(sourceId, sourceInfo)
263
+ return sourceInfo
264
+ }
265
+
266
+ /**
267
+ * Add a tile to the styled map package. Coordinates must use the XYZ scheme
268
+ * (origin at top-left / north-west). If your tiles use TMS coordinates,
269
+ * convert the Y value first with `tmsToXyzY({ y, z })` from `utils/geo.js`.
270
+ *
271
+ * @param {Source} tileData
272
+ * @param {TileInfo} opts
273
+ */
274
+ async addTile(tileData, { z, x, y, sourceId, format }) {
275
+ let sourceInfo = this.#sources.get(sourceId)
276
+ if (!sourceInfo) {
277
+ const source = this.#style.sources[sourceId]
278
+ if (!source) {
279
+ throw new Error(`Source not referenced in style.json: ${sourceId}`)
280
+ }
281
+ if (source.type !== 'raster' && source.type !== 'vector') {
282
+ throw new Error(`Unsupported source type: ${source.type}`)
283
+ }
284
+ sourceInfo = this.#addSource(sourceId, source)
285
+ }
286
+ const { source, encodedSourceId } = sourceInfo
287
+ // Mainly to keep Typescript happy...
288
+ if (source.type !== 'raster' && source.type !== 'vector') {
289
+ throw new Error(`Unsupported source type: ${source.type}`)
290
+ }
291
+
292
+ if (!format) {
293
+ // @ts-ignore - node:stream/web.ReadableStream is incompatible with global ReadableStream type
294
+ ;[format, tileData] = await getTileFormatFromStream(toWebStream(tileData))
295
+ }
296
+
297
+ if (!sourceInfo.format) {
298
+ sourceInfo.format = format
299
+ } else if (sourceInfo.format !== format) {
300
+ throw new Error(
301
+ `Tile format mismatch for source ${sourceId}: expected ${sourceInfo.format}, got ${format}`,
302
+ )
303
+ }
304
+
305
+ const bbox = tileToBBox({ z, x, y })
306
+ // We calculate the bounds from the tiles at the max zoom level, because at
307
+ // lower zooms the tile bbox is much larger than the actual bounding box
308
+ if (z > source.maxzoom) {
309
+ source.maxzoom = z
310
+ source.bounds = bbox
311
+ } else if (z === source.maxzoom) {
312
+ source.bounds = unionBBox([source.bounds, bbox])
313
+ }
314
+
315
+ const name = getTileFilename({ sourceId: encodedSourceId, z, x, y, format })
316
+
317
+ if (this.#dedupe) {
318
+ const data = await toUint8Array(tileData)
319
+ const hash = await hashData(data)
320
+ if (this.#addedFiles.has(name)) {
321
+ throw new Error(`${name} already added`)
322
+ }
323
+ this.#addedFiles.add(name)
324
+ const existingName = this.#tileHashes.get(hash)
325
+ if (existingName) {
326
+ this.#duplicateEntries.push({ name, originalName: existingName })
327
+ return
328
+ }
329
+ this.#tileHashes.set(hash, name)
330
+ const readable = toWebStream(data)
331
+ await this.#zipWriter.addEntry({ readable, name, store: true })
332
+ return
333
+ }
334
+
335
+ // Tiles are stored without compression, because tiles are normally stored
336
+ // as a compressed format.
337
+ return this.#append(tileData, { name, store: true })
338
+ }
339
+
340
+ /**
341
+ * Create a write stream for adding tiles to the styled map package
342
+ *
343
+ * @param {object} opts
344
+ * @param {number} [opts.concurrency=16] The number of concurrent writes
345
+ *
346
+ * @returns
347
+ */
348
+ createTileWriteStream({ concurrency = 16 } = {}) {
349
+ return writeStreamFromAsync(this.addTile.bind(this), { concurrency })
350
+ }
351
+
352
+ /**
353
+ * Add a sprite to the styled map package
354
+ *
355
+ * @param {object} options
356
+ * @param {Source} options.json
357
+ * @param {Source} options.png
358
+ * @param {number} [options.pixelRatio]
359
+ * @param {string} [options.id='default']
360
+ * @returns {Promise<void>}
361
+ */
362
+ async addSprite({ json, png, pixelRatio = 1, id = 'default' }) {
363
+ this.#addedSpriteIds.add(id)
364
+ const jsonName = getSpriteFilename({ id, pixelRatio, ext: '.json' })
365
+ const pngName = getSpriteFilename({ id, pixelRatio, ext: '.png' })
366
+ await Promise.all([
367
+ this.#append(json, { name: jsonName }),
368
+ this.#append(png, { name: pngName }),
369
+ ])
370
+ }
371
+
372
+ /**
373
+ * Add glyphs to the styled map package
374
+ *
375
+ * @param {Source} glyphData
376
+ * @param {GlyphInfo} glyphInfo
377
+ * @returns {Promise<void>}
378
+ */
379
+ addGlyphs(glyphData, { font: fontName, range }) {
380
+ this.#fonts.add(fontName)
381
+ const name = getGlyphFilename({ fontstack: fontName, range })
382
+ return this.#append(glyphData, { name })
383
+ }
384
+
385
+ /**
386
+ * Create a write stream for adding glyphs to the styled map package
387
+ *
388
+ * @param {object} opts
389
+ * @param {number} [opts.concurrency=16] The number of concurrent writes
390
+ * @returns
391
+ */
392
+ createGlyphWriteStream({ concurrency = 16 } = {}) {
393
+ return writeStreamFromAsync(this.addGlyphs.bind(this), { concurrency })
394
+ }
395
+
396
+ /**
397
+ * Finalize the styled map package and write the style to the archive.
398
+ * This method must be called to complete the archive.
399
+ * You must wait for your destination write stream to 'finish' before using the output.
400
+ */
401
+ async finish() {
402
+ await this.#append(FORMAT_VERSION, { name: VERSION_FILE })
403
+ this.#prepareStyle()
404
+ const style = JSON.stringify(this.#style)
405
+ await this.#append(style, { name: STYLE_FILE })
406
+ let entries = await this.#zipWriter.entries()
407
+
408
+ if (this.#duplicateEntries.length > 0) {
409
+ const entriesByName = new Map(entries.map((e) => [e.name, e]))
410
+ for (const { name, originalName } of this.#duplicateEntries) {
411
+ const original = entriesByName.get(originalName)
412
+ if (!original) {
413
+ throw new Error(`Original entry ${originalName} not found`)
414
+ }
415
+ entries.push({ ...original, name })
416
+ }
417
+ }
418
+
419
+ const sortedEntries = sortEntries(entries)
420
+ await this.#zipWriter.finalize({ entries: sortedEntries })
421
+ }
422
+
423
+ /**
424
+ * Mutates the style object to prepare it for writing to the archive.
425
+ * Deterministic: can be run more than once with the same result.
426
+ */
427
+ #prepareStyle() {
428
+ if (this.#sources.size === 0) {
429
+ throw new Error('Missing sources: add at least one source')
430
+ }
431
+ if (this.#style.glyphs && this.#fonts.size === 0) {
432
+ throw new Error(
433
+ 'Missing fonts: style references glyphs but no fonts added',
434
+ )
435
+ }
436
+
437
+ // Replace any referenced font stacks with a single font choice based on the
438
+ // fonts available in this offline map package.
439
+ replaceFontStacks(this.#style, [...this.#fonts])
440
+
441
+ // Use a custom URL schema for referencing glyphs and sprites
442
+ if (this.#style.glyphs) {
443
+ this.#style.glyphs = GLYPH_URI
444
+ }
445
+ if (typeof this.#style.sprite === 'string') {
446
+ if (!this.#addedSpriteIds.has('default')) {
447
+ throw new Error(
448
+ 'Missing sprite: style references sprite but none added',
449
+ )
450
+ }
451
+ this.#style.sprite = getSpriteUri()
452
+ } else if (Array.isArray(this.#style.sprite)) {
453
+ this.#style.sprite = this.#style.sprite.map(({ id }) => {
454
+ if (!this.#addedSpriteIds.has(id)) {
455
+ throw new Error(
456
+ `Missing sprite: style references sprite ${id} but none added`,
457
+ )
458
+ }
459
+ return { id, url: getSpriteUri(id) }
460
+ })
461
+ }
462
+
463
+ this.#style.sources = {}
464
+ for (const [sourceId, { source, encodedSourceId, format = 'mvt' }] of this
465
+ .#sources) {
466
+ if (source.type === 'geojson' && isEmptyFeatureCollection(source.data)) {
467
+ // Skip empty GeoJSON sources
468
+ continue
469
+ }
470
+ this.#style.sources[sourceId] = source
471
+ if (!('tiles' in source)) continue
472
+ // Add a tile URL (with custom schema) for each tile source
473
+ source.tiles = [getTileUri({ sourceId: encodedSourceId, format })]
474
+ }
475
+
476
+ this.#style.layers = this.#style.layers.filter(
477
+ (layer) => !('source' in layer) || !!this.#style.sources[layer.source],
478
+ )
479
+
480
+ /** @type {Record<string, any>} */
481
+ const metadata = this.#style.metadata || (this.#style.metadata = {})
482
+ const bounds = this.#getBounds()
483
+ if (bounds) {
484
+ metadata['smp:bounds'] = bounds
485
+ const [w, s, e, n] = bounds
486
+ this.#style.center = [w + (e - w) / 2, s + (n - s) / 2]
487
+ }
488
+ metadata['smp:maxzoom'] = this.#getMaxZoom()
489
+ /** @type {Record<string, string>} */
490
+ metadata['smp:sourceFolders'] = {}
491
+ for (const [sourceId, { encodedSourceId }] of this.#sources) {
492
+ metadata['smp:sourceFolders'][sourceId] =
493
+ SOURCES_FOLDER + '/' + encodedSourceId
494
+ }
495
+ this.#style.zoom = Math.max(0, this.#getMaxZoom() - 2)
496
+ }
497
+
498
+ /**
499
+ *
500
+ * @param {Source} source
501
+ * @param {{ name: string, store?: boolean }} options
502
+ * @returns {Promise<void>}
503
+ */
504
+ async #append(source, { name, store = false }) {
505
+ name = name.normalize('NFC')
506
+ if (this.#addedFiles.has(name)) {
507
+ throw new Error(`${name} already added`)
508
+ }
509
+ this.#addedFiles.add(name)
510
+ const readable = toWebStream(source)
511
+ await this.#zipWriter.addEntry({ readable, name, store })
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Simple encoding to keep file names in the Zip as short as possible.
517
+ *
518
+ * @param {number} sourceIndex
519
+ */
520
+ function encodeSourceId(sourceIndex) {
521
+ return sourceIndex.toString(36)
522
+ }
523
+
524
+ /**
525
+ * Convert a source to a web ReadableStream for use with ZipWriter.
526
+ *
527
+ * @param {Source} source
528
+ * @returns {ReadableStream<Uint8Array>}
529
+ */
530
+ function toWebStream(source) {
531
+ if (typeof source === 'string') {
532
+ const bytes = new TextEncoder().encode(source)
533
+ return new ReadableStream({
534
+ start(controller) {
535
+ controller.enqueue(bytes)
536
+ controller.close()
537
+ },
538
+ })
539
+ }
540
+ if (source instanceof Uint8Array) {
541
+ return new ReadableStream({
542
+ start(controller) {
543
+ controller.enqueue(
544
+ new Uint8Array(source.buffer, source.byteOffset, source.byteLength),
545
+ )
546
+ controller.close()
547
+ },
548
+ })
549
+ }
550
+ // Web ReadableStream
551
+ return /** @type {ReadableStream<Uint8Array>} */ (source)
552
+ }
553
+
554
+ /** @param {import('geojson').GeoJSON} data */
555
+ function isEmptyFeatureCollection(data) {
556
+ return data.type === 'FeatureCollection' && data.features.length === 0
557
+ }
558
+
559
+ /**
560
+ * Strictly a GeoJSON bounding box could be 3D, but we only support 2D bounding
561
+ * @param {import('geojson').BBox} bbox
562
+ * @returns {import('./utils/geo.js').BBox}
563
+ */
564
+ function get2DBBox(bbox) {
565
+ if (bbox.length === 4) return bbox
566
+ return [bbox[0], bbox[1], bbox[3], bbox[4]]
567
+ }
568
+
569
+ /**
570
+ * Consume a Source into a Uint8Array buffer.
571
+ *
572
+ * @param {Source} source
573
+ * @returns {Promise<Uint8Array>}
574
+ */
575
+ async function toUint8Array(source) {
576
+ if (source instanceof Uint8Array) return source
577
+ if (typeof source === 'string') return new TextEncoder().encode(source)
578
+ // ReadableStream — consume all chunks
579
+ const reader = /** @type {ReadableStream<Uint8Array>} */ (source).getReader()
580
+ const chunks = []
581
+ let totalLength = 0
582
+ while (true) {
583
+ const { done, value } = await reader.read()
584
+ if (done) break
585
+ chunks.push(value)
586
+ totalLength += value.byteLength
587
+ }
588
+ const result = new Uint8Array(totalLength)
589
+ let offset = 0
590
+ for (const chunk of chunks) {
591
+ result.set(chunk, offset)
592
+ offset += chunk.byteLength
593
+ }
594
+ return result
595
+ }
596
+
597
+ /**
598
+ * Compute a SHA-256 hex digest of binary data.
599
+ *
600
+ * @param {Uint8Array} data
601
+ * @returns {Promise<string>}
602
+ */
603
+ async function hashData(data) {
604
+ /** @type {any} */
605
+ let c = globalThis.crypto
606
+ if (!c) {
607
+ c = (await import('node:crypto')).webcrypto
608
+ }
609
+ // @ts-ignore - Uint8Array is a valid BufferSource despite TS type mismatch with ArrayBufferLike
610
+ const buf = await c.subtle.digest('SHA-256', data)
611
+ return Array.from(new Uint8Array(buf))
612
+ .map((b) => b.toString(16).padStart(2, '0'))
613
+ .join('')
614
+ }
615
+
616
+ /**
617
+ * Sort zip entries so that the most important entries come first in the
618
+ * central directory, which improves read speed (the map can be displayed
619
+ * before the entire central directory is indexed).
620
+ *
621
+ * @template {{ name: string }} T
622
+ * @param {T[]} entries
623
+ * @returns {T[]}
624
+ */
625
+ function sortEntries(entries) {
626
+ return [...entries].sort((a, b) => {
627
+ if (a.name === VERSION_FILE) return -1
628
+ if (b.name === VERSION_FILE) return 1
629
+ if (a.name === STYLE_FILE) return -1
630
+ if (b.name === STYLE_FILE) return 1
631
+ const foldersA = a.name.split('/')
632
+ const foldersB = b.name.split('/')
633
+ const aIsFirst =
634
+ foldersA[0] === FONTS_FOLDER && foldersA[2] === '0-255.pbf.gz'
635
+ const bIsFirst =
636
+ foldersB[0] === FONTS_FOLDER && foldersB[2] === '0-255.pbf.gz'
637
+ if (aIsFirst && bIsFirst)
638
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
639
+ if (aIsFirst) return -1
640
+ if (bIsFirst) return 1
641
+ if (foldersA[0] === SOURCES_FOLDER && foldersB[0] !== SOURCES_FOLDER)
642
+ return -1
643
+ if (foldersB[0] === SOURCES_FOLDER && foldersA[0] !== SOURCES_FOLDER)
644
+ return 1
645
+ if (foldersA[0] === SOURCES_FOLDER && foldersB[0] === SOURCES_FOLDER) {
646
+ const zoomA = +foldersA[2]
647
+ const zoomB = +foldersB[2]
648
+ return zoomA - zoomB
649
+ }
650
+ return 0
651
+ })
652
+ }