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
package/lib/types.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type {
2
+ SourceSpecification,
3
+ StyleSpecification,
4
+ ValidationError,
5
+ GeoJSONSourceSpecification,
6
+ VectorSourceSpecification,
7
+ RasterSourceSpecification,
8
+ RasterDEMSourceSpecification,
9
+ } from '@maplibre/maplibre-gl-style-spec'
10
+ import type { GeoJSON, BBox } from 'geojson'
11
+ import type { Readable } from 'stream'
12
+ import type { Except, SetRequired, Simplify } from 'type-fest'
13
+
14
+ import { SUPPORTED_SOURCE_TYPES } from './writer.js'
15
+
16
+ export type InputSource = Extract<
17
+ SourceSpecification,
18
+ { type: (typeof SUPPORTED_SOURCE_TYPES)[number] }
19
+ >
20
+ type TransformInlinedSource<T extends SourceSpecification> =
21
+ T extends GeoJSONSourceSpecification
22
+ ? OmitUnion<T, 'data'> & { data: GeoJSON }
23
+ : T extends
24
+ | VectorSourceSpecification
25
+ | RasterSourceSpecification
26
+ | RasterDEMSourceSpecification
27
+ ? SetRequired<OmitUnion<T, 'url'>, 'tiles'>
28
+ : T
29
+ /**
30
+ * This is a slightly stricter version of SourceSpecification that requires
31
+ * sources to be inlined (e.g. no urls to TileJSON or GeoJSON files).
32
+ */
33
+ export type InlinedSource = TransformInlinedSource<SourceSpecification>
34
+ type SupportedInlinedSource = Extract<
35
+ InlinedSource,
36
+ { type: (typeof SUPPORTED_SOURCE_TYPES)[number] }
37
+ >
38
+ /**
39
+ * This is a slightly stricter version of StyleSpecification that requires
40
+ * sources to be inlined (e.g. no urls to TileJSON or GeoJSON files).
41
+ */
42
+ export type StyleInlinedSources = Omit<StyleSpecification, 'sources'> & {
43
+ sources: {
44
+ [_: string]: InlinedSource
45
+ }
46
+ }
47
+
48
+ export type SMPSource = TransformSMPInputSource<SupportedInlinedSource>
49
+ /**
50
+ * This is a slightly stricter version of StyleSpecification that is provided in
51
+ * a Styled Map Package. Tile sources must have tile URLs inlined (they cannot
52
+ * refer to a TileJSON url), and they must have bounds, minzoom, and maxzoom.
53
+ * GeoJSON sources must have inlined GeoJSON (not a URL to a GeoJSON file).
54
+ */
55
+ export type SMPStyle = TransformSMPStyle<StyleSpecification>
56
+
57
+ export type TransformSMPInputSource<T extends SupportedInlinedSource> =
58
+ T extends GeoJSONSourceSpecification
59
+ ? T & { data: { bbox: BBox } }
60
+ : T extends RasterSourceSpecification | VectorSourceSpecification
61
+ ? SetRequired<T, 'bounds' | 'minzoom' | 'maxzoom'>
62
+ : T
63
+
64
+ type TransformSMPStyle<T extends StyleSpecification> = Omit<T, 'sources'> & {
65
+ metadata: {
66
+ 'smp:bounds': [number, number, number, number]
67
+ 'smp:maxzoom': 0
68
+ 'smp:sourceFolders': { [_: string]: string }
69
+ }
70
+ sources: {
71
+ [_: string]: SMPSource
72
+ }
73
+ }
74
+
75
+ export interface ValidateStyle {
76
+ (style: unknown): style is StyleSpecification
77
+ errors: Array<ValidationError>
78
+ }
79
+
80
+ export interface DownloadStream extends Readable {
81
+ iterator(
82
+ ...args: Parameters<Readable['iterator']>
83
+ ): AsyncIterableIterator<Buffer>
84
+ [Symbol.asyncIterator](): AsyncIterableIterator<Buffer>
85
+ }
86
+
87
+ export type RequiredUnion<T> = T extends any ? Required<T> : never
88
+ export type OmitUnion<T, K extends keyof any> = T extends unknown
89
+ ? Omit<T, K>
90
+ : never
91
+
92
+ type SetRequiredIfPresent<
93
+ BaseType,
94
+ Keys extends keyof any,
95
+ > = BaseType extends unknown
96
+ ? Keys extends keyof BaseType
97
+ ? Simplify<
98
+ // Pick just the keys that are optional from the base type.
99
+ Except<BaseType, Keys> &
100
+ // Pick the keys that should be required from the base type and make them required.
101
+ Required<Pick<BaseType, Keys>>
102
+ >
103
+ : never
104
+ : never
@@ -0,0 +1,100 @@
1
+ import ky from 'ky'
2
+ import { pEvent } from 'p-event'
3
+ import pLimit from 'p-limit'
4
+
5
+ import { fromWebReadableStream, ProgressStream } from './streams.js'
6
+
7
+ /**
8
+ * @typedef {object} DownloadResponse
9
+ * @property {import('stream').Readable} body Node ReadableStream of the response body
10
+ * @property {string | null} mimeType Content mime-type (from http content-type header)
11
+ * @property {number | null} contentLength Content length in bytes (from http content-length header)
12
+ */
13
+
14
+ /**
15
+ * A wrapper for fetch that limits the number of concurrent downloads.
16
+ */
17
+ export class FetchQueue {
18
+ /** @type {import('p-limit').LimitFunction} */
19
+ #limit
20
+ /** @param {number} concurrency */
21
+ constructor(concurrency) {
22
+ this.#limit = pLimit(concurrency)
23
+ }
24
+
25
+ get activeCount() {
26
+ return this.#limit.activeCount
27
+ }
28
+
29
+ /**
30
+ * Fetch a URL, limiting the number of concurrent downloads. Resolves with a
31
+ * `DownloadResponse`, which is a parsed from the Fetch `Response` objects,
32
+ * with `body` as a Node readable stream, and the MIME type and content length
33
+ * of the response.
34
+ *
35
+ * NB: The response body stream must be consumed to the end, otherwise the
36
+ * queue will never be emptied.
37
+ *
38
+ * @param {string} url
39
+ * @param {{ onprogress?: import('./streams.js').ProgressCallback }} opts
40
+ * @returns {Promise<DownloadResponse>}
41
+ */
42
+ fetch(url, { onprogress } = {}) {
43
+ // This is wrapped like this so that pLimit limits concurrent `fetchStream`
44
+ // calls, which only resolve when the body is completely downloaded, but
45
+ // this method will return a response as soon as it is available. NB: If the
46
+ // body of a response is never "consumed" (e.g. by reading it to the end),
47
+ // the fetchStream function will never resolve, and the limit will never be
48
+ // released.
49
+ return new Promise((resolveResponse, rejectResponse) => {
50
+ this.#limit(fetchStream, {
51
+ url,
52
+ onresponse: resolveResponse,
53
+ onerror: rejectResponse,
54
+ onprogress,
55
+ })
56
+ })
57
+ }
58
+ }
59
+
60
+ /**
61
+ * This will resolve when the download is complete, regardless of success or
62
+ * failure, but a readable stream is available before download via the
63
+ * onReadStream param. This strange function signature is used for limiting the
64
+ * number of simultaneous downloads, but still being able to expose the Response
65
+ * as soon as it is available. This is implmented this way to avoid creating
66
+ * unnecessary closures, which is important here because we can have thousands
67
+ * of tile requests.
68
+ *
69
+ * @param {object} opts
70
+ * @param {string} opts.url
71
+ * @param {(response: DownloadResponse) => void} opts.onresponse
72
+ * @param {(err: Error) => void} opts.onerror
73
+ * @param {import('./streams.js').ProgressCallback} [opts.onprogress]
74
+ * @returns {Promise<void>}
75
+ */
76
+ async function fetchStream({ url, onresponse, onerror, onprogress }) {
77
+ try {
78
+ const response = await ky(url, { retry: 3 })
79
+ if (!response.body) {
80
+ throw new Error('No body in response')
81
+ }
82
+ const body = fromWebReadableStream(response.body)
83
+ const contentType = response.headers.get('content-type')
84
+ const mimeType =
85
+ typeof contentType === 'string' ? contentType.split(';')[0] : null
86
+ const contentLengthHeader = response.headers.get('content-length')
87
+ const contentLength =
88
+ contentLengthHeader === null ? null : parseInt(contentLengthHeader, 10)
89
+ onresponse({
90
+ body: onprogress ? body.pipe(new ProgressStream({ onprogress })) : body,
91
+ mimeType,
92
+ contentLength,
93
+ })
94
+ // Wait for the read stream to end before resolving this function, so that
95
+ // we limit concurrent downloads
96
+ await pEvent(body, 'end')
97
+ } catch (err) {
98
+ onerror(err instanceof Error ? err : new Error('Unknown error'))
99
+ }
100
+ }
@@ -0,0 +1,85 @@
1
+ import BufferPeerStream from 'buffer-peek-stream'
2
+
3
+ import { hasOwn } from './misc.js'
4
+
5
+ const peek = BufferPeerStream.promise
6
+
7
+ const MAGIC_BYTES = /** @type {const} */ ({
8
+ png: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
9
+ jpg: [0xff, 0xd8, 0xff],
10
+ // eslint-disable-next-line no-sparse-arrays
11
+ webp: [0x52, 0x49, 0x46, 0x46, , , , , 0x57, 0x45, 0x42, 0x50],
12
+ // Include the compression-type byte, which is always 0x08 (DEFLATE) for gzip
13
+ gz: [0x1f, 0x8b, 0x08],
14
+ })
15
+
16
+ const MIME_TYPES = /** @type {const} */ ({
17
+ 'image/png': 'png',
18
+ 'image/jpeg': 'jpg',
19
+ 'image/webp': 'webp',
20
+ })
21
+
22
+ /** @type {Map<number, keyof typeof MAGIC_BYTES>} */
23
+ const magicByteMap = new Map()
24
+ for (const [ext, bytes] of Object.entries(MAGIC_BYTES)) {
25
+ magicByteMap.set(
26
+ bytes[0],
27
+ // @ts-ignore
28
+ ext,
29
+ )
30
+ }
31
+ /**
32
+ * For a given buffer, determine the tile format based on the magic bytes.
33
+ * Will throw for unknown file types.
34
+ * Smaller and faster version of magic-bytes.js due to the limited use case.
35
+ *
36
+ * @param {Buffer | Uint8Array} buf
37
+ * @returns {import("../writer.js").TileFormat}
38
+ */
39
+ export function getTileFormatFromBuffer(buf) {
40
+ const ext = magicByteMap.get(buf[0])
41
+ if (!ext) {
42
+ throw new Error('Unknown file type')
43
+ }
44
+ const sig = MAGIC_BYTES[ext]
45
+ for (let i = 1; i < sig.length; i++) {
46
+ if (typeof sig[i] !== 'undefined' && sig[i] !== buf[i]) {
47
+ throw new Error('Unknown file type')
48
+ }
49
+ }
50
+ if (ext === 'gz') {
51
+ // Gzipped tiles are always MVT
52
+ return 'mvt'
53
+ }
54
+ return ext
55
+ }
56
+
57
+ /**
58
+ * Determine the tile format from either a readable stream, buffer or Uint8Array
59
+ * from the magic bytes at the start of the file. Used if data is served without
60
+ * a content-type header.
61
+ *
62
+ * @param {import('stream').Readable} tileData
63
+ * @returns {Promise<[import("../writer.js").TileFormat, import('stream').Readable]>}
64
+ */
65
+ export async function getTileFormatFromStream(tileData) {
66
+ // NB: The buffer-peek-stream library uses the peeked bytes as the high
67
+ // water mark for the transform stream, so we set this to the default high
68
+ // water mark of 16KB
69
+ const [peekedData, outputStream] = await peek(tileData, 16 * 1024)
70
+ tileData = outputStream
71
+ const format = getTileFormatFromBuffer(peekedData)
72
+ return [format, outputStream]
73
+ }
74
+
75
+ /**
76
+ * Get the tile format from a MIME type. Throws for unsupported types.
77
+ *
78
+ * @param {string} mimeType
79
+ * @returns {import("../writer.js").TileFormat}
80
+ */
81
+ export function getFormatFromMimeType(mimeType) {
82
+ if (mimeType.startsWith('application/')) return 'mvt'
83
+ if (hasOwn(MIME_TYPES, mimeType)) return MIME_TYPES[mimeType]
84
+ throw new Error('Unsupported MIME type ' + mimeType)
85
+ }
@@ -0,0 +1,87 @@
1
+ // Adapted from https://github.com/mapbox/tilebelt
2
+
3
+ const r2d = 180 / Math.PI
4
+
5
+ /** Spherical Mercator max bounds, rounded to 6 decimal places */
6
+ export const MAX_BOUNDS = /** @type {BBox} */ ([
7
+ -180, -85.051129, 180, 85.051129,
8
+ ])
9
+
10
+ /**
11
+ * @typedef {[number, number, number, number]} BBox
12
+ */
13
+
14
+ /**
15
+ * Return the bounding box for the given tile.
16
+ *
17
+ * @param {{ x: number, y: number, z: number }} tile
18
+ * @returns {BBox} Bounding Box [w, s, e, n]
19
+ */
20
+ export function tileToBBox({ x, y, z }) {
21
+ const e = tile2lon({ x: x + 1, z })
22
+ const w = tile2lon({ x, z })
23
+ const s = tile2lat({ y: y + 1, z })
24
+ const n = tile2lat({ y, z })
25
+ return [w, s, e, n]
26
+ }
27
+
28
+ /**
29
+ * @param {{ x: number, y: number, z: number }} tile
30
+ */
31
+ export function getQuadkey({ x, y, z }) {
32
+ let quadkey = ''
33
+ let mask
34
+ for (let i = z; i > 0; i--) {
35
+ mask = 1 << (i - 1)
36
+ quadkey += (x & mask ? 1 : 0) + (y & mask ? 2 : 0)
37
+ }
38
+ return quadkey
39
+ }
40
+
41
+ /**
42
+ * From an array of tile URL templates, get the URL for the given tile.
43
+ *
44
+ * @param {string[]} urls
45
+ * @param {{ x: number, y: number, z: number, scheme?: 'xyz' | 'tms' }} opts
46
+ */
47
+ export function getTileUrl(urls, { x, y, z, scheme = 'xyz' }) {
48
+ const bboxEspg3857 = tileToBBox({ x, y: Math.pow(2, z) - y - 1, z })
49
+ const quadkey = getQuadkey({ x, y, z })
50
+
51
+ return urls[(x + y) % urls.length]
52
+ .replace('{prefix}', (x % 16).toString(16) + (y % 16).toString(16))
53
+ .replace(/{z}/g, String(z))
54
+ .replace(/{x}/g, String(x))
55
+ .replace(/{y}/g, String(scheme === 'tms' ? Math.pow(2, z) - y - 1 : y))
56
+ .replace('{quadkey}', quadkey)
57
+ .replace('{bbox-epsg-3857}', bboxEspg3857.join(','))
58
+ }
59
+
60
+ /**
61
+ * Returns a bbox that is the smallest bounding box that contains all the input bboxes.
62
+ *
63
+ * @param {[BBox, ...BBox[]]} bboxes
64
+ * @returns {BBox} Bounding Box [w, s, e, n]
65
+ */
66
+ export function unionBBox(bboxes) {
67
+ let [w, s, e, n] = bboxes[0]
68
+ for (let i = 1; i < bboxes.length; i++) {
69
+ const [w1, s1, e1, n1] = bboxes[i]
70
+ w = Math.min(w, w1)
71
+ s = Math.min(s, s1)
72
+ e = Math.max(e, e1)
73
+ n = Math.max(n, n1)
74
+ }
75
+ return [w, s, e, n]
76
+ }
77
+
78
+ /** @param {{ x: number, z: number }} opts */
79
+ function tile2lon({ x, z }) {
80
+ return (x / Math.pow(2, z)) * 360 - 180
81
+ }
82
+
83
+ /** @param {{ y: number, z: number }} opts */
84
+ function tile2lat({ y, z }) {
85
+ const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z)
86
+ return r2d * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
87
+ }
@@ -0,0 +1,155 @@
1
+ 'use strict'
2
+ // from https://github.com/mapbox/mapbox-gl-js/blob/495a695/src/util/mapbox.js
3
+
4
+ const API_URL = 'https://api.mapbox.com'
5
+ const HELP = 'See https://www.mapbox.com/api-documentation/#access-tokens'
6
+
7
+ /**
8
+ * @typedef {object} URLObject
9
+ * @property {string} protocol
10
+ * @property {string} authority
11
+ * @property {string} path
12
+ * @property {string[]} params
13
+ */
14
+
15
+ /**
16
+ * @param {URLObject} urlObject
17
+ * @param {string} [accessToken]
18
+ */
19
+ function makeAPIURL(urlObject, accessToken) {
20
+ const apiUrlObject = parseUrl(API_URL)
21
+ urlObject.protocol = apiUrlObject.protocol
22
+ urlObject.authority = apiUrlObject.authority
23
+
24
+ if (!accessToken) {
25
+ throw new Error(
26
+ `An API access token is required to use a Mapbox style. ${HELP}`,
27
+ )
28
+ }
29
+ if (accessToken[0] === 's') {
30
+ throw new Error(
31
+ `Use a public access token (pk.*) not a secret access token (sk.*). ${HELP}`,
32
+ )
33
+ }
34
+
35
+ urlObject.params.push(`access_token=${accessToken}`)
36
+ return formatUrl(urlObject)
37
+ }
38
+
39
+ /** @param {string} url */
40
+ export function isMapboxURL(url) {
41
+ return url.indexOf('mapbox:') === 0
42
+ }
43
+
44
+ /**
45
+ * @param {string} url
46
+ * @param {string} [accessToken]
47
+ */
48
+ export function normalizeStyleURL(url, accessToken) {
49
+ if (!isMapboxURL(url)) return url
50
+ if (!accessToken) throw new Error('Mapbox styles require an access token')
51
+ const urlObject = parseUrl(url)
52
+ urlObject.path = `/styles/v1${urlObject.path}`
53
+ return makeAPIURL(urlObject, accessToken)
54
+ }
55
+
56
+ /**
57
+ * @param {string} url
58
+ * @param {string} [accessToken]
59
+ */
60
+ export function normalizeGlyphsURL(url, accessToken) {
61
+ if (!isMapboxURL(url)) return url
62
+ if (!accessToken) throw new Error('Mapbox styles require an access token')
63
+ const urlObject = parseUrl(url)
64
+ urlObject.path = `/fonts/v1${urlObject.path}`
65
+ return makeAPIURL(urlObject, accessToken)
66
+ }
67
+
68
+ /**
69
+ * @param {string} url
70
+ * @param {string} [accessToken]
71
+ */
72
+ export function normalizeSourceURL(url, accessToken) {
73
+ if (!isMapboxURL(url)) return url
74
+ if (!accessToken) throw new Error('Mapbox styles require an access token')
75
+ const urlObject = parseUrl(url)
76
+ urlObject.path = `/v4/${urlObject.authority}.json`
77
+ // TileJSON requests need a secure flag appended to their URLs so
78
+ // that the server knows to send SSL-ified resource references.
79
+ urlObject.params.push('secure')
80
+ return makeAPIURL(urlObject, accessToken)
81
+ }
82
+
83
+ /**
84
+ * @param {string} url
85
+ * @param {'' | '@2x'} format
86
+ * @param {'.png' | '.json'} extension
87
+ * @param {string} [accessToken]
88
+ */
89
+ export function normalizeSpriteURL(url, format, extension, accessToken) {
90
+ const urlObject = parseUrl(url)
91
+ if (!isMapboxURL(url)) {
92
+ urlObject.path += `${format}${extension}`
93
+ return formatUrl(urlObject)
94
+ }
95
+ urlObject.path = `/styles/v1${urlObject.path}/sprite${format}${extension}`
96
+ return makeAPIURL(urlObject, accessToken)
97
+ }
98
+
99
+ const imageExtensionRe = /(\.(png|jpg)\d*)(?=$)/
100
+
101
+ /**
102
+ * @param {any} tileURL
103
+ * @param {string} sourceURL
104
+ * @param {256 | 512} [tileSize]
105
+ * @param {{ devicePixelRatio?: number; supportsWebp?: boolean; }} [opts]
106
+ */
107
+ export function normalizeTileURL(
108
+ tileURL,
109
+ sourceURL,
110
+ tileSize,
111
+ { devicePixelRatio = 1, supportsWebp = false } = {},
112
+ ) {
113
+ if (!sourceURL || !isMapboxURL(sourceURL)) return tileURL
114
+
115
+ const urlObject = parseUrl(tileURL)
116
+
117
+ // The v4 mapbox tile API supports 512x512 image tiles only when @2x
118
+ // is appended to the tile URL. If `tileSize: 512` is specified for
119
+ // a Mapbox raster source force the @2x suffix even if a non hidpi device.
120
+ const suffix = devicePixelRatio >= 2 || tileSize === 512 ? '@2x' : ''
121
+ const extension = supportsWebp ? '.webp' : '$1'
122
+ urlObject.path = urlObject.path.replace(
123
+ imageExtensionRe,
124
+ `${suffix}${extension}`,
125
+ )
126
+
127
+ return formatUrl(urlObject)
128
+ }
129
+
130
+ const urlRe = /^(\w+):\/\/([^/?]*)(\/[^?]+)?\??(.+)?/
131
+
132
+ /**
133
+ * @param {string} url
134
+ * @returns {URLObject}
135
+ */
136
+ function parseUrl(url) {
137
+ const parts = url.match(urlRe)
138
+ if (!parts) {
139
+ throw new Error('Unable to parse URL object')
140
+ }
141
+ return {
142
+ protocol: parts[1],
143
+ authority: parts[2],
144
+ path: parts[3] || '/',
145
+ params: parts[4] ? parts[4].split('&') : [],
146
+ }
147
+ }
148
+
149
+ /**
150
+ * @param {URLObject} obj
151
+ */
152
+ function formatUrl(obj) {
153
+ const params = obj.params.length ? `?${obj.params.join('&')}` : ''
154
+ return `${obj.protocol}://${obj.authority}${obj.path}${params}`
155
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Dumb and quick clone an object. Won't keep undefined properties. Types could
3
+ * be tighted so that return type excludes undefined properties, but not really
4
+ * needed.
5
+ *
6
+ * @template T
7
+ * @param {T} obj
8
+ * @returns {T}
9
+ */
10
+ export function clone(obj) {
11
+ return JSON.parse(JSON.stringify(obj))
12
+ }
13
+
14
+ export function noop() {}
15
+
16
+ /**
17
+ * Like `Object.hasOwn`, but refines the type of `key`.
18
+ *
19
+ * @template {Record<string, unknown>} T
20
+ * @param {T} obj
21
+ * @param {string} key
22
+ * @returns {key is (keyof T)}
23
+ */
24
+ export function hasOwn(obj, key) {
25
+ return Object.hasOwn(obj, key)
26
+ }