styled-map-package 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.github/workflows/node.yml +30 -0
  2. package/.github/workflows/release.yml +47 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.nvmrc +1 -0
  5. package/LICENSE.md +7 -0
  6. package/README.md +28 -0
  7. package/bin/smp-download.js +83 -0
  8. package/bin/smp-view.js +52 -0
  9. package/bin/smp.js +11 -0
  10. package/eslint.config.js +17 -0
  11. package/lib/download.js +114 -0
  12. package/lib/index.js +6 -0
  13. package/lib/reader.js +150 -0
  14. package/lib/reporters.js +92 -0
  15. package/lib/server.js +64 -0
  16. package/lib/style-downloader.js +363 -0
  17. package/lib/tile-downloader.js +188 -0
  18. package/lib/types.ts +104 -0
  19. package/lib/utils/fetch.js +100 -0
  20. package/lib/utils/file-formats.js +85 -0
  21. package/lib/utils/geo.js +87 -0
  22. package/lib/utils/mapbox.js +155 -0
  23. package/lib/utils/misc.js +26 -0
  24. package/lib/utils/streams.js +162 -0
  25. package/lib/utils/style.js +174 -0
  26. package/lib/utils/templates.js +136 -0
  27. package/lib/writer.js +478 -0
  28. package/map-viewer/index.html +89 -0
  29. package/package.json +103 -0
  30. package/test/download-write-read.js +43 -0
  31. package/test/fixtures/invalid-styles/empty.json +1 -0
  32. package/test/fixtures/invalid-styles/missing-source.json +10 -0
  33. package/test/fixtures/invalid-styles/no-layers.json +4 -0
  34. package/test/fixtures/invalid-styles/no-sources.json +4 -0
  35. package/test/fixtures/invalid-styles/null.json +1 -0
  36. package/test/fixtures/invalid-styles/unsupported-version.json +5 -0
  37. package/test/fixtures/valid-styles/external-geojson.input.json +66 -0
  38. package/test/fixtures/valid-styles/external-geojson.output.json +93 -0
  39. package/test/fixtures/valid-styles/inline-geojson.input.json +421 -0
  40. package/test/fixtures/valid-styles/inline-geojson.output.json +1573 -0
  41. package/test/fixtures/valid-styles/maplibre-demotiles.input.json +831 -0
  42. package/test/fixtures/valid-styles/maplibre-unlabelled.input.json +496 -0
  43. package/test/fixtures/valid-styles/maplibre-unlabelled.output.json +1573 -0
  44. package/test/fixtures/valid-styles/minimal-labelled.input.json +37 -0
  45. package/test/fixtures/valid-styles/minimal-labelled.output.json +72 -0
  46. package/test/fixtures/valid-styles/minimal-sprites.input.json +37 -0
  47. package/test/fixtures/valid-styles/minimal-sprites.output.json +58 -0
  48. package/test/fixtures/valid-styles/minimal.input.json +54 -0
  49. package/test/fixtures/valid-styles/minimal.output.json +92 -0
  50. package/test/fixtures/valid-styles/multiple-sprites.input.json +46 -0
  51. package/test/fixtures/valid-styles/multiple-sprites.output.json +128 -0
  52. package/test/fixtures/valid-styles/raster-sources.input.json +33 -0
  53. package/test/fixtures/valid-styles/raster-sources.output.json +69 -0
  54. package/test/utils/assert-bbox-equal.js +19 -0
  55. package/test/utils/digest-stream.js +36 -0
  56. package/test/utils/image-streams.js +30 -0
  57. package/test/utils/reader-helper.js +72 -0
  58. package/test/write-read.js +620 -0
  59. package/tsconfig.json +18 -0
  60. package/types/buffer-peek-stream.d.ts +12 -0
@@ -0,0 +1,162 @@
1
+ import { Readable, Writable, Transform } from 'readable-stream'
2
+
3
+ /** @import { TransformOptions } from 'readable-stream' */
4
+
5
+ /**
6
+ * Create a writable stream from an async function. Default concurrecy is 16 -
7
+ * this is the number of parallel functions that will be pending before
8
+ * backpressure is applied on the stream.
9
+ *
10
+ * @template {(...args: any[]) => Promise<void>} T
11
+ * @param {T} fn
12
+ * @returns {import('readable-stream').Writable}
13
+ */
14
+ export function writeStreamFromAsync(fn, { concurrency = 16 } = {}) {
15
+ return new Writable({
16
+ highWaterMark: concurrency,
17
+ objectMode: true,
18
+ write(chunk, encoding, callback) {
19
+ fn.apply(null, chunk).then(() => callback(), callback)
20
+ },
21
+ })
22
+ }
23
+
24
+ /**
25
+ * From https://github.com/nodejs/node/blob/430c0269/lib/internal/webstreams/adapters.js#L509
26
+ *
27
+ * @param {ReadableStream} readableStream
28
+ * @param {{
29
+ * highWaterMark? : number,
30
+ * encoding? : string,
31
+ * objectMode? : boolean,
32
+ * signal? : AbortSignal,
33
+ * }} [options]
34
+ * @returns {import('stream').Readable}
35
+ */
36
+
37
+ export function fromWebReadableStream(readableStream, options = {}) {
38
+ if (!isWebReadableStream(readableStream)) {
39
+ throw new Error('First argument must be a ReadableStream')
40
+ }
41
+
42
+ const { highWaterMark, encoding, objectMode = false, signal } = options
43
+
44
+ if (encoding !== undefined && !Buffer.isEncoding(encoding))
45
+ throw new Error('Invalid encoding')
46
+
47
+ const reader = readableStream.getReader()
48
+ let closed = false
49
+
50
+ const readable = new Readable({
51
+ objectMode,
52
+ highWaterMark,
53
+ encoding,
54
+ // @ts-ignore
55
+ signal,
56
+
57
+ read() {
58
+ reader.read().then(
59
+ (chunk) => {
60
+ if (chunk.done) {
61
+ // Value should always be undefined here.
62
+ readable.push(null)
63
+ } else {
64
+ readable.push(chunk.value)
65
+ }
66
+ },
67
+ (error) => readable.destroy(error),
68
+ )
69
+ },
70
+
71
+ destroy(error, callback) {
72
+ function done() {
73
+ try {
74
+ callback(error)
75
+ } catch (error) {
76
+ // In a next tick because this is happening within
77
+ // a promise context, and if there are any errors
78
+ // thrown we don't want those to cause an unhandled
79
+ // rejection. Let's just escape the promise and
80
+ // handle it separately.
81
+ process.nextTick(() => {
82
+ throw error
83
+ })
84
+ }
85
+ }
86
+
87
+ if (!closed) {
88
+ reader.cancel(error).then(done, done)
89
+ return
90
+ }
91
+ done()
92
+ },
93
+ })
94
+
95
+ reader.closed.then(
96
+ () => {
97
+ closed = true
98
+ },
99
+ (error) => {
100
+ closed = true
101
+ readable.destroy(error)
102
+ },
103
+ )
104
+
105
+ return readable
106
+ }
107
+
108
+ /**
109
+ * @param {unknown} obj
110
+ * @returns {obj is ReadableStream}
111
+ */
112
+ export function isWebReadableStream(obj) {
113
+ return !!(
114
+ typeof obj === 'object' &&
115
+ obj !== null &&
116
+ 'pipeThrough' in obj &&
117
+ typeof obj.pipeThrough === 'function' &&
118
+ 'getReader' in obj &&
119
+ typeof obj.getReader === 'function' &&
120
+ 'cancel' in obj &&
121
+ typeof obj.cancel === 'function'
122
+ )
123
+ }
124
+
125
+ /** @typedef {(opts: { totalBytes: number, chunkBytes: number }) => void} ProgressCallback */
126
+
127
+ /**
128
+ * Passthrough stream that counts the bytes passing through it. Pass an optional
129
+ * `onprogress` callback that will be called with the accumulated total byte
130
+ * count and the chunk byte count after each chunk.
131
+ */
132
+ export class ProgressStream extends Transform {
133
+ #onprogress
134
+ #byteLength = 0
135
+
136
+ /**
137
+ * @param {TransformOptions & { onprogress?: ProgressCallback }} [opts]
138
+ */
139
+ constructor({ onprogress, ...opts } = {}) {
140
+ super(opts)
141
+ this.#onprogress = onprogress
142
+ }
143
+
144
+ /** Total bytes that have passed through this stream */
145
+ get byteLength() {
146
+ return this.#byteLength
147
+ }
148
+
149
+ /**
150
+ * @param {Buffer | Uint8Array} chunk
151
+ * @param {Parameters<Transform['_transform']>[1]} encoding
152
+ * @param {Parameters<Transform['_transform']>[2]} callback
153
+ */
154
+ _transform(chunk, encoding, callback) {
155
+ this.#byteLength += chunk.length
156
+ this.#onprogress?.({
157
+ totalBytes: this.#byteLength,
158
+ chunkBytes: chunk.length,
159
+ })
160
+ callback(null, chunk)
161
+ }
162
+ }
@@ -0,0 +1,174 @@
1
+ import { expressions, validateStyleMin } from '@maplibre/maplibre-gl-style-spec'
2
+
3
+ /** @import {StyleSpecification, ExpressionSpecification, ValidationError} from '@maplibre/maplibre-gl-style-spec' */
4
+
5
+ /**
6
+ * For a given style, replace all font stacks (`text-field` properties) with the
7
+ * provided fonts. If no matching font is found, the first font in the stack is
8
+ * used.
9
+ *
10
+ * *Modifies the input style object*
11
+ *
12
+ * @param {StyleSpecification} style
13
+ * @param {string[]} fonts
14
+ */
15
+ export function replaceFontStacks(style, fonts) {
16
+ const mappedLayers = mapFontStacks(style.layers, (fontStack) => {
17
+ let match
18
+ for (const font of fontStack) {
19
+ if (fonts.includes(font)) {
20
+ match = font
21
+ break
22
+ }
23
+ }
24
+ return [match || fonts[0]]
25
+ })
26
+ style.layers = mappedLayers
27
+ return style
28
+ }
29
+
30
+ /**
31
+ * From given style layers, create a new style by calling the provided callback
32
+ * function on every font stack defined in the style.
33
+ *
34
+ * @param {StyleSpecification['layers']} layers
35
+ * @param {(fontStack: string[]) => string[]} callbackFn
36
+ * @returns {StyleSpecification['layers']}
37
+ */
38
+ export function mapFontStacks(layers, callbackFn) {
39
+ return layers.map((layer) => {
40
+ if (layer.type !== 'symbol' || !layer.layout || !layer.layout['text-font'])
41
+ return layer
42
+ const textFont = layer.layout['text-font']
43
+ let mappedValue
44
+ if (isExpression(textFont)) {
45
+ mappedValue = mapArrayExpressionValue(textFont, callbackFn)
46
+ } else if (Array.isArray(textFont)) {
47
+ mappedValue = callbackFn(textFont)
48
+ } else {
49
+ // Deprecated property function, unsupported, but within this module
50
+ // functions will have been migrated to expressions anyway.
51
+ console.warn(
52
+ 'Deprecated function definitions are not supported, font stack has not been transformed.',
53
+ )
54
+ console.dir(textFont, { depth: null })
55
+ return layer
56
+ }
57
+ return {
58
+ ...layer,
59
+ layout: {
60
+ ...layer.layout,
61
+ 'text-font': mappedValue,
62
+ },
63
+ }
64
+ })
65
+ }
66
+
67
+ /**
68
+ * See https://github.com/maplibre/maplibre-style-spec/blob/c2f01dbaa6c5fb8409126258b9464b450018e939/src/expression/index.ts#L128
69
+ *
70
+ * @param {unknown} value
71
+ * @returns {value is ExpressionSpecification}
72
+ */
73
+ function isExpression(value) {
74
+ return (
75
+ Array.isArray(value) &&
76
+ value.length > 0 &&
77
+ typeof value[0] === 'string' &&
78
+ value[0] in expressions
79
+ )
80
+ }
81
+
82
+ /**
83
+ * For an expression whose value is an array, map the array to a new array using
84
+ * the given callbackFn.
85
+ *
86
+ * @param {ExpressionSpecification} expression
87
+ * @param {(value: string[]) => string[]} callbackFn
88
+ * @returns {ExpressionSpecification}
89
+ */
90
+ function mapArrayExpressionValue(expression, callbackFn) {
91
+ // This only works for properties whose value is an array, because it relies
92
+ // on the style specification that array values must be declared with the
93
+ // `literal` expression.
94
+ if (expression[0] === 'literal' && Array.isArray(expression[1])) {
95
+ return ['literal', callbackFn(expression[1])]
96
+ } else {
97
+ // @ts-ignore
98
+ return [
99
+ expression[0],
100
+ ...expression.slice(1).map(
101
+ // @ts-ignore
102
+ (x) => {
103
+ if (isExpression(x)) {
104
+ return mapArrayExpressionValue(x, callbackFn)
105
+ } else {
106
+ return x
107
+ }
108
+ },
109
+ ),
110
+ ]
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @typedef {object} TileJSONPartial
116
+ * @property {string[]} tiles
117
+ * @property {string} [description]
118
+ * @property {string} [attribution]
119
+ * @property {object[]} [vector_layers]
120
+ * @property {import('./geo.js').BBox} [bounds]
121
+ * @property {number} [maxzoom]
122
+ * @property {number} [minzoom]
123
+ */
124
+
125
+ /**
126
+ *
127
+ * @param {unknown} tilejson
128
+ * @returns {asserts tilejson is TileJSONPartial}
129
+ */
130
+ export function assertTileJSON(tilejson) {
131
+ if (typeof tilejson !== 'object' || tilejson === null) {
132
+ throw new Error('Invalid TileJSON')
133
+ }
134
+ if (
135
+ !('tiles' in tilejson) ||
136
+ !Array.isArray(tilejson.tiles) ||
137
+ tilejson.tiles.length === 0 ||
138
+ tilejson.tiles.some((tile) => typeof tile !== 'string')
139
+ ) {
140
+ throw new Error('Invalid TileJSON: missing or invalid tiles property')
141
+ }
142
+ }
143
+
144
+ export const validateStyle =
145
+ /** @type {{ (style: unknown): style is StyleSpecification, errors: ValidationError[] }} */ (
146
+ (style) => {
147
+ validateStyle.errors = validateStyleMin(
148
+ /** @type {StyleSpecification} */ (style),
149
+ )
150
+ if (validateStyle.errors.length) return false
151
+ return true
152
+ }
153
+ )
154
+
155
+ /**
156
+ * Check whether a source is already inlined (e.g. does not reference a TileJSON or GeoJSON url)
157
+ *
158
+ * @param {import('@maplibre/maplibre-gl-style-spec').SourceSpecification} source
159
+ * @returns {source is import('../types.js').InlinedSource}
160
+ */
161
+ export function isInlinedSource(source) {
162
+ if (source.type === 'geojson') {
163
+ return typeof source.data === 'object'
164
+ } else if (
165
+ source.type === 'vector' ||
166
+ source.type === 'raster' ||
167
+ source.type === 'raster-dem'
168
+ ) {
169
+ return 'tiles' in source
170
+ } else {
171
+ // Video and image sources are not strictly "inlined", but we treat them as such.
172
+ return true
173
+ }
174
+ }
@@ -0,0 +1,136 @@
1
+ export const URI_SCHEME = 'smp' // "Styled Map Package"
2
+ export const URI_BASE = URI_SCHEME + '://maps.v1/'
3
+
4
+ // These constants determine the file format structure
5
+ export const STYLE_FILE = 'style.json'
6
+ const SOURCES_FOLDER = 's'
7
+ const SPRITES_FOLDER = 'sprites'
8
+ const FONTS_FOLDER = 'fonts'
9
+
10
+ // This must include placeholders `{z}`, `{x}`, `{y}`, since these are used to
11
+ // define the tile URL, and this is a TileJSON standard.
12
+ // The folder here is just `s` to minimize bytes used for filenames, which are
13
+ // included in the header of every tile in the zip file.
14
+ const TILE_FILE = SOURCES_FOLDER + '/{sourceId}/{z}/{x}/{y}{ext}'
15
+ // The pixel ratio and ext placeholders must be at the end of the string with no
16
+ // data between them, because this is the format defined in the MapLibre style spec.
17
+ const SPRITE_FILE = SPRITES_FOLDER + '/{id}/sprite{pixelRatio}{ext}'
18
+ // This must include placeholders `{fontstack}` and `{range}`, since these are
19
+ // part of the MapLibre style spec.
20
+ const GLYPH_FILE = FONTS_FOLDER + '/{fontstack}/{range}.pbf.gz'
21
+ export const GLYPH_URI = URI_BASE + GLYPH_FILE
22
+
23
+ const pathToResouceType = /** @type {const} */ ({
24
+ [TILE_FILE.split('/')[0] + '/']: 'tile',
25
+ [SPRITE_FILE.split('/')[0] + '/']: 'sprite',
26
+ [GLYPH_FILE.split('/')[0] + '/']: 'glyph',
27
+ })
28
+
29
+ /**
30
+ * @param {string} path
31
+ * @returns
32
+ */
33
+ export function getResourceType(path) {
34
+ if (path === 'style.json') return 'style'
35
+ for (const [prefix, type] of Object.entries(pathToResouceType)) {
36
+ if (path.startsWith(prefix)) return type
37
+ }
38
+ throw new Error(`Unknown resource type for path: ${path}`)
39
+ }
40
+
41
+ /**
42
+ * Determine the content type of a file based on its extension.
43
+ *
44
+ * @param {string} path
45
+ */
46
+ export function getContentType(path) {
47
+ if (path.endsWith('.json')) return 'application/json; charset=utf-8'
48
+ if (path.endsWith('.pbf.gz') || path.endsWith('.pbf'))
49
+ return 'application/x-protobuf'
50
+ if (path.endsWith('.png')) return 'image/png'
51
+ if (path.endsWith('.jpg')) return 'image/jpeg'
52
+ if (path.endsWith('.webp')) return 'image/webp'
53
+ if (path.endsWith('.mvt.gz') || path.endsWith('.mvt'))
54
+ return 'application/vnd.mapbox-vector-tile'
55
+ throw new Error(`Unknown content type for path: ${path}`)
56
+ }
57
+
58
+ /**
59
+ * Get the filename for a tile, given the TileInfo
60
+ *
61
+ * @param {import("type-fest").SetRequired<import("../writer.js").TileInfo, 'format'>} tileInfo
62
+ * @returns
63
+ */
64
+ export function getTileFilename({ sourceId, z, x, y, format }) {
65
+ const ext = '.' + format + (format === 'mvt' ? '.gz' : '')
66
+ return replaceVariables(TILE_FILE, { sourceId, z, x, y, ext })
67
+ }
68
+
69
+ /**
70
+ * Get a filename for a sprite file, given the sprite id, pixel ratio and extension
71
+ *
72
+ * @param {{ id: string, pixelRatio: number, ext: '.json' | '.png'}} spriteInfo
73
+ */
74
+ export function getSpriteFilename({ id, pixelRatio, ext }) {
75
+ return replaceVariables(SPRITE_FILE, {
76
+ id,
77
+ pixelRatio: getPixelRatioString(pixelRatio),
78
+ ext,
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Get the filename for a glyph file, given the fontstack and range
84
+ *
85
+ * @param {object} options
86
+ * @param {string} options.fontstack
87
+ * @param {import("../writer.js").GlyphRange} options.range
88
+ */
89
+ export function getGlyphFilename({ fontstack, range }) {
90
+ return replaceVariables(GLYPH_FILE, { fontstack, range })
91
+ }
92
+
93
+ /**
94
+ * Get the URI template for the sprites in the style
95
+ */
96
+ export function getSpriteUri(id = 'default') {
97
+ return (
98
+ URI_BASE + replaceVariables(SPRITE_FILE, { id, pixelRatio: '', ext: '' })
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Get the URI template for tiles in the style
104
+ *
105
+ * @param {object} opts
106
+ * @param {string} opts.sourceId
107
+ * @param {import("../writer.js").TileFormat} opts.format
108
+ * @returns
109
+ */
110
+ export function getTileUri({ sourceId, format }) {
111
+ const ext = '.' + format + (format === 'mvt' ? '.gz' : '')
112
+ return (
113
+ URI_BASE + TILE_FILE.replace('{sourceId}', sourceId).replace('{ext}', ext)
114
+ )
115
+ }
116
+
117
+ /**
118
+ * @param {number} pixelRatio
119
+ */
120
+ function getPixelRatioString(pixelRatio) {
121
+ return pixelRatio === 1 ? '' : `@${pixelRatio}x`
122
+ }
123
+
124
+ /**
125
+ * Replaces variables in a string with values provided in an object. Variables
126
+ * in the string are denoted by curly braces, e.g., {variableName}.
127
+ *
128
+ * @param {string} template - The string containing variables wrapped in curly braces.
129
+ * @param {Record<string, string | number>} variables - An object where the keys correspond to variable names and values correspond to the replacement values.
130
+ * @returns {string} The string with the variables replaced by their corresponding values.
131
+ */
132
+ export function replaceVariables(template, variables) {
133
+ return template.replace(/{(.*?)}/g, (match, varName) => {
134
+ return varName in variables ? String(variables[varName]) : match
135
+ })
136
+ }