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.
- package/README.md +94 -0
- package/dist/download.d.ts +11 -21
- package/dist/fallbacks.d.ts +32 -0
- package/dist/from-mbtiles.d.ts +1 -3
- package/dist/index.d.ts +11 -24
- package/dist/reader.d.ts +28 -12
- package/dist/server.d.ts +23 -14
- package/dist/style-downloader.d.ts +13 -19
- package/dist/tile-downloader.d.ts +13 -23
- package/dist/types.d.ts +61 -0
- package/dist/utils/errors.d.ts +2 -4
- package/dist/utils/fetch.d.ts +3 -8
- package/dist/utils/file-formats.d.ts +3 -10
- package/dist/utils/geo.d.ts +17 -9
- package/dist/utils/mapbox.d.ts +8 -10
- package/dist/utils/misc.d.ts +3 -5
- package/dist/utils/streams.d.ts +6 -10
- package/dist/utils/style.d.ts +27 -16
- package/dist/utils/templates.d.ts +30 -25
- package/dist/validator.d.ts +66 -0
- package/dist/writer.d.ts +157 -4
- package/lib/download.js +125 -0
- package/lib/fallbacks.js +157 -0
- package/lib/from-mbtiles.js +131 -0
- package/lib/index.js +12 -0
- package/lib/reader.js +360 -0
- package/lib/server.js +222 -0
- package/lib/style-downloader.js +369 -0
- package/lib/tile-downloader.js +189 -0
- package/lib/types.ts +99 -0
- package/lib/utils/errors.js +24 -0
- package/lib/utils/fetch.js +104 -0
- package/lib/utils/file-formats.js +92 -0
- package/lib/utils/geo.js +97 -0
- package/lib/utils/mapbox.js +155 -0
- package/{dist/utils/misc.d.cts → lib/utils/misc.js} +9 -5
- package/lib/utils/streams.js +101 -0
- package/lib/utils/style.js +206 -0
- package/lib/utils/templates.js +165 -0
- package/lib/validator.js +789 -0
- package/lib/writer.js +652 -0
- package/package.json +30 -78
- package/dist/download.cjs +0 -100
- package/dist/download.d.cts +0 -63
- package/dist/download.js +0 -76
- package/dist/from-mbtiles.cjs +0 -127
- package/dist/from-mbtiles.d.cts +0 -14
- package/dist/from-mbtiles.js +0 -103
- package/dist/index.cjs +0 -46
- package/dist/index.d.cts +0 -24
- package/dist/index.js +0 -16
- package/dist/reader.cjs +0 -287
- package/dist/reader.d.cts +0 -67
- package/dist/reader.js +0 -259
- package/dist/server.cjs +0 -73
- package/dist/server.d.cts +0 -45
- package/dist/server.js +0 -49
- package/dist/style-downloader.cjs +0 -314
- package/dist/style-downloader.d.cts +0 -118
- package/dist/style-downloader.js +0 -290
- package/dist/tile-downloader.cjs +0 -156
- package/dist/tile-downloader.d.cts +0 -82
- package/dist/tile-downloader.js +0 -124
- package/dist/types-Bhn0-Ldk.d.cts +0 -201
- package/dist/types-Bhn0-Ldk.d.ts +0 -201
- package/dist/utils/errors.cjs +0 -41
- package/dist/utils/errors.d.cts +0 -18
- package/dist/utils/errors.js +0 -16
- package/dist/utils/fetch.cjs +0 -97
- package/dist/utils/fetch.d.cts +0 -50
- package/dist/utils/fetch.js +0 -63
- package/dist/utils/file-formats.cjs +0 -96
- package/dist/utils/file-formats.d.cts +0 -32
- package/dist/utils/file-formats.js +0 -70
- package/dist/utils/geo.cjs +0 -84
- package/dist/utils/geo.d.cts +0 -46
- package/dist/utils/geo.js +0 -56
- package/dist/utils/mapbox.cjs +0 -121
- package/dist/utils/mapbox.d.cts +0 -43
- package/dist/utils/mapbox.js +0 -91
- package/dist/utils/misc.cjs +0 -39
- package/dist/utils/misc.js +0 -13
- package/dist/utils/streams.cjs +0 -99
- package/dist/utils/streams.d.cts +0 -49
- package/dist/utils/streams.js +0 -73
- package/dist/utils/style.cjs +0 -126
- package/dist/utils/style.d.cts +0 -66
- package/dist/utils/style.js +0 -98
- package/dist/utils/templates.cjs +0 -124
- package/dist/utils/templates.d.cts +0 -79
- package/dist/utils/templates.js +0 -85
- package/dist/writer.cjs +0 -539
- package/dist/writer.d.cts +0 -4
- 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
|
+
}
|