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,30 @@
1
+ name: Node.js Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ node-version: [18.x, 20.x, 22.x]
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Node.js ${{ matrix.node-version }}
22
+ uses: actions/setup-node@v4
23
+ with:
24
+ node-version: ${{ matrix.node-version }}
25
+
26
+ - name: Install dependencies
27
+ run: npm ci
28
+
29
+ - name: Run tests
30
+ run: npm test
@@ -0,0 +1,47 @@
1
+ name: release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ semver:
7
+ description: 'The semver to use'
8
+ required: true
9
+ default: 'patch'
10
+ type: choice
11
+ options:
12
+ # - auto
13
+ - patch
14
+ - minor
15
+ - major
16
+ - prerelease
17
+ # - prepatch
18
+ # - preminor
19
+ # - premajor
20
+ pull_request:
21
+ types: [closed]
22
+ branches: [main]
23
+
24
+ jobs:
25
+ release:
26
+ runs-on: ubuntu-latest
27
+ permissions:
28
+ contents: write
29
+ issues: write
30
+ pull-requests: write
31
+ steps:
32
+ - name: Use Node.js 20
33
+ uses: actions/setup-node@v4
34
+ with:
35
+ node-version: 20
36
+ - uses: actions/checkout@v4
37
+ - uses: nearform-actions/optic-release-automation-action@v4
38
+ with:
39
+ commit-message: 'Release {version}'
40
+ sync-semver-tags: true
41
+ access: 'public'
42
+ semver: ${{ github.event.inputs.semver }}
43
+ notify-linked-issues: false
44
+ # optional: set this secret in your repo config for publishing to NPM
45
+ npm-token: ${{ secrets.NPM_TOKEN }}
46
+ build-command: |
47
+ npm ci
@@ -0,0 +1 @@
1
+ npx lint-staged
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20
package/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2024 Awana Digital
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Styled Map Package
2
+
3
+ A Styled Map Package (`.smp`) file is a Zip archive containing all the resources needed to serve a Maplibre vector styled map offline. This includes the style JSON, vector and raster tiles, glyphs (fonts), the sprite image, and the sprite metadata.
4
+
5
+ ## Installation
6
+
7
+ Install globally to use the `smp` command.
8
+
9
+ ```sh
10
+ npm install --global styled-map-package
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Download an online map to a styled map package file, specifying the bounding box (west, south, east, north) and max zoom level.
16
+
17
+ ```sh
18
+ smp download https://demotiles.maplibre.org/style.json \
19
+ --bbox '-180,-80,180,80' \
20
+ --zoom 5 \
21
+ --output demotiles.smp
22
+ ```
23
+
24
+ Start a server and open in the default browser.
25
+
26
+ ```sh
27
+ smp view demotiles.smp --open
28
+ ```
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { Command, InvalidArgumentError } from 'commander'
3
+ import fs from 'fs'
4
+ import { pipeline } from 'stream/promises'
5
+
6
+ import download from '../lib/download.js'
7
+ import { ttyReporter } from '../lib/reporters.js'
8
+
9
+ const program = new Command()
10
+
11
+ program
12
+ .description('Download a map style for offline usage')
13
+ .option('-o, --output [file]', 'output file (if omitted, writes to stdout)')
14
+ .requiredOption(
15
+ '-b, --bbox <west,south,east,north>',
16
+ 'bounding box of area to download e.g. 11,47,12,47.5',
17
+ parseBbox,
18
+ )
19
+ .requiredOption(
20
+ '-z, --zoom <number>',
21
+ 'max zoom level to download',
22
+ parseZoom,
23
+ )
24
+ .option(
25
+ '-t, --token <token>',
26
+ 'Mapbox access token (necessary for Mapbox styles)',
27
+ )
28
+ .argument('<styleUrl>', 'URL to style to download', parseUrl)
29
+ .action(async (styleUrl, { bbox, zoom, output, token }) => {
30
+ const reporter = ttyReporter()
31
+ const readStream = download({
32
+ bbox,
33
+ maxzoom: zoom,
34
+ styleUrl,
35
+ onprogress: (p) => reporter.write(p),
36
+ accessToken: token,
37
+ })
38
+ const outputStream = output ? fs.createWriteStream(output) : process.stdout
39
+ await pipeline(readStream, outputStream)
40
+ })
41
+
42
+ program.parseAsync(process.argv)
43
+
44
+ /** @param {string} z */
45
+ function parseZoom(z) {
46
+ const zoom = parseInt(z)
47
+ if (isNaN(zoom) || zoom < 0 || zoom > 22) {
48
+ throw new InvalidArgumentError(
49
+ 'Zoom must be a whole number (integer) between 0 and 22.',
50
+ )
51
+ }
52
+ return zoom
53
+ }
54
+
55
+ /** @param {string} bbox */
56
+ function parseBbox(bbox) {
57
+ const bounds = bbox.split(',').map((s) => parseFloat(s.trim()))
58
+ if (bounds.length !== 4) {
59
+ throw new InvalidArgumentError(
60
+ 'Bounding box must have 4 values separated by commas.',
61
+ )
62
+ }
63
+ if (bounds.some(isNaN)) {
64
+ throw new InvalidArgumentError('Bounding box values must be numbers.')
65
+ }
66
+ return bounds
67
+ }
68
+
69
+ /** @param {string} url */
70
+ function parseUrl(url) {
71
+ try {
72
+ return new URL(url).toString()
73
+ } catch (e) {
74
+ const message =
75
+ e !== null &&
76
+ typeof e === 'object' &&
77
+ 'message' in e &&
78
+ typeof e.message === 'string'
79
+ ? e.message
80
+ : 'Invalid URL'
81
+ throw new InvalidArgumentError(message)
82
+ }
83
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import fastifyStatic from '@fastify/static'
3
+ import { Command } from 'commander'
4
+ import fastify from 'fastify'
5
+ import openApp from 'open'
6
+
7
+ import path from 'node:path'
8
+
9
+ import smpServer from '../lib/server.js'
10
+
11
+ const program = new Command()
12
+
13
+ program
14
+ .description('Preview a styled map package in a web browser')
15
+ .option('-o, --open', 'open in the default web browser')
16
+ .option('-p, --port <number>', 'port to serve on', parseInt, 3000)
17
+ .argument('<file>', 'file to serve')
18
+ .action(async (filepath, { open, port }) => {
19
+ const address = await serve({ port, filepath })
20
+ console.log(`server listening on ${address}`)
21
+ if (open) {
22
+ await openApp(address)
23
+ }
24
+ })
25
+
26
+ program.parseAsync(process.argv)
27
+
28
+ /**
29
+ * Serve a styled map package on the given port (defaults to 3000). Use the
30
+ * fastify plugin in `./server.js` for more flexibility.
31
+ *
32
+ * @param {object} opts
33
+ * @param {number} [opts.port]
34
+ * @param {string} opts.filepath
35
+ * @returns
36
+ */
37
+ function serve({ port = 3000, filepath }) {
38
+ const server = fastify()
39
+
40
+ server.register(fastifyStatic, {
41
+ root: new URL('../map-viewer', import.meta.url),
42
+ serve: false,
43
+ })
44
+ server.get('/', async (request, reply) => {
45
+ return reply.sendFile('index.html')
46
+ })
47
+
48
+ server.register(smpServer, {
49
+ filepath: path.relative(process.cwd(), filepath),
50
+ })
51
+ return server.listen({ port })
52
+ }
package/bin/smp.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander'
3
+
4
+ const program = new Command()
5
+
6
+ program
7
+ .name('smp')
8
+ .command('download', 'Download a map style to a styled map package file')
9
+ .command('view', 'Preview a styled map package in a web browser')
10
+
11
+ program.parse(process.argv)
@@ -0,0 +1,17 @@
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+
4
+ /** @type {import('eslint').Linter.FlatConfig[]} */
5
+ export default [
6
+ js.configs.recommended,
7
+ {
8
+ languageOptions: {
9
+ ecmaVersion: 'latest',
10
+ sourceType: 'module',
11
+ globals: {
12
+ ...globals.node,
13
+ },
14
+ },
15
+ rules: {},
16
+ },
17
+ ]
@@ -0,0 +1,114 @@
1
+ import { Transform } from 'readable-stream'
2
+ import { pipeline } from 'stream/promises'
3
+
4
+ import Writer from '../lib/writer.js'
5
+ import StyleDownloader from './style-downloader.js'
6
+
7
+ /**
8
+ * @typedef {object} DownloadProgress
9
+ * @property {import('./tile-downloader.js').TileDownloadStats & { done: boolean }} tiles
10
+ * @property {{ done: boolean }} style
11
+ * @property {{ downloaded: number, done: boolean }} sprites
12
+ * @property {import('./style-downloader.js').GlyphDownloadStats & { done: boolean }} glyphs
13
+ * @property {{ totalBytes: number, done: boolean }} output
14
+ * @property {number} elapsedMs
15
+ */
16
+
17
+ /**
18
+ * Download a map style and its resources for a given bounding box and max zoom
19
+ * level. Returns a readable stream of a "styled map package", a zip file
20
+ * containing all the resources needed to serve the style offline.
21
+ *
22
+ * @param {object} opts
23
+ * @param {import("./utils/geo.js").BBox} opts.bbox Bounding box to download tiles for
24
+ * @param {number} opts.maxzoom Max zoom level to download tiles for
25
+ * @param {string} opts.styleUrl URL of the style to download
26
+ * @param { (progress: DownloadProgress) => void } [opts.onprogress] Optional callback for reporting progress
27
+ * @param {string} [opts.accessToken]
28
+ * @returns {import('./types.js').DownloadStream} Readable stream of the output styled map file
29
+ */
30
+ export default function download({
31
+ bbox,
32
+ maxzoom,
33
+ styleUrl,
34
+ onprogress,
35
+ accessToken,
36
+ }) {
37
+ const downloader = new StyleDownloader(styleUrl, {
38
+ concurrency: 24,
39
+ mapboxAccessToken: accessToken,
40
+ })
41
+
42
+ let start = Date.now()
43
+ /** @type {DownloadProgress} */
44
+ let progress = {
45
+ tiles: { downloaded: 0, totalBytes: 0, total: 0, skipped: 0, done: false },
46
+ style: { done: false },
47
+ sprites: { downloaded: 0, done: false },
48
+ glyphs: { downloaded: 0, total: 0, totalBytes: 0, done: false },
49
+ output: { totalBytes: 0, done: false },
50
+ elapsedMs: 0,
51
+ }
52
+
53
+ const sizeCounter = new Transform({
54
+ transform(chunk, encoding, cb) {
55
+ handleProgress({
56
+ output: {
57
+ totalBytes: progress.output.totalBytes + chunk.length,
58
+ done: false,
59
+ },
60
+ })
61
+ cb(null, chunk)
62
+ },
63
+ final(cb) {
64
+ handleProgress({ output: { ...progress.output, done: true } })
65
+ cb()
66
+ },
67
+ })
68
+
69
+ /** @param {Partial<DownloadProgress>} update */
70
+ function handleProgress(update) {
71
+ progress = { ...progress, ...update, elapsedMs: Date.now() - start }
72
+ onprogress?.(progress)
73
+ }
74
+
75
+ ;(async () => {
76
+ const style = await downloader.getStyle()
77
+ const writer = new Writer(style)
78
+ handleProgress({ style: { done: true } })
79
+ writer.outputStream.pipe(sizeCounter)
80
+ writer.on('error', (err) => sizeCounter.destroy(err))
81
+
82
+ try {
83
+ for await (const spriteInfo of downloader.getSprites()) {
84
+ await writer.addSprite(spriteInfo)
85
+ handleProgress({
86
+ sprites: { downloaded: progress.sprites.downloaded + 1, done: false },
87
+ })
88
+ }
89
+ handleProgress({ sprites: { ...progress.sprites, done: true } })
90
+
91
+ const tiles = downloader.getTiles({
92
+ bounds: bbox,
93
+ maxzoom,
94
+ onprogress: (tileStats) =>
95
+ handleProgress({ tiles: { ...tileStats, done: false } }),
96
+ })
97
+ await pipeline(tiles, writer.createTileWriteStream({ concurrency: 24 }))
98
+ handleProgress({ tiles: { ...progress.tiles, done: true } })
99
+
100
+ const glyphs = downloader.getGlyphs({
101
+ onprogress: (glyphStats) =>
102
+ handleProgress({ glyphs: { ...glyphStats, done: false } }),
103
+ })
104
+ await pipeline(glyphs, writer.createGlyphWriteStream())
105
+ handleProgress({ glyphs: { ...progress.glyphs, done: true } })
106
+
107
+ writer.finish()
108
+ } catch (err) {
109
+ writer.outputStream.destroy(/** @type {Error} */ (err))
110
+ }
111
+ })()
112
+
113
+ return sizeCounter
114
+ }
package/lib/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { default as Reader } from './reader.js'
2
+ export { default as Writer } from './writer.js'
3
+ export { default as Server } from './server.js'
4
+ export { default as StyleDownloader } from './style-downloader.js'
5
+ export { downloadTiles } from './tile-downloader.js'
6
+ export { default as download } from './download.js'
package/lib/reader.js ADDED
@@ -0,0 +1,150 @@
1
+ import intoStream from 'into-stream'
2
+ import { open } from 'yauzl-promise'
3
+
4
+ import { json } from 'node:stream/consumers'
5
+
6
+ import { validateStyle } from './utils/style.js'
7
+ import {
8
+ getContentType,
9
+ getResourceType,
10
+ STYLE_FILE,
11
+ URI_BASE,
12
+ } from './utils/templates.js'
13
+
14
+ /**
15
+ * @typedef {object} Resource
16
+ * @property {string} resourceType
17
+ * @property {string} contentType
18
+ * @property {number} contentLength
19
+ * @property {import('stream').Readable} stream
20
+ * @property {'gzip'} [contentEncoding]
21
+ */
22
+
23
+ /**
24
+ * A low-level reader for styled map packages. Returns resources in the package
25
+ * as readable streams, for serving over HTTP for example.
26
+ */
27
+ export default class Reader {
28
+ /** @type {Promise<import('yauzl-promise').ZipFile>} */
29
+ #zipPromise
30
+ #entriesPromise
31
+ /** @type {undefined | Promise<void>} */
32
+ #closePromise
33
+
34
+ /**
35
+ * @param {string | import('yauzl-promise').ZipFile} filepathOrZip Path to styled map package (`.styledmap`) file, or an instance of yauzl ZipFile
36
+ */
37
+ constructor(filepathOrZip) {
38
+ const zipPromise = (this.#zipPromise =
39
+ typeof filepathOrZip === 'string'
40
+ ? open(filepathOrZip)
41
+ : Promise.resolve(filepathOrZip))
42
+ this.#entriesPromise = (async () => {
43
+ /** @type {Map<string, import('yauzl-promise').Entry>} */
44
+ const entries = new Map()
45
+ if (this.#closePromise) return entries
46
+ const zip = await zipPromise
47
+ if (this.#closePromise) return entries
48
+ for await (const entry of zip) {
49
+ if (this.#closePromise) return entries
50
+ entries.set(entry.filename, entry)
51
+ }
52
+ return entries
53
+ })()
54
+ }
55
+
56
+ /**
57
+ * Get the style JSON from the styled map package. The URLs in the style JSON
58
+ * will be transformed to use the provided base URL.
59
+ *
60
+ * @param {string | null} [baseUrl] Base URL where you plan to serve the resources in this styled map package, e.g. `http://localhost:3000/maps/styleA`
61
+ * @returns {Promise<import('./types.js').SMPStyle>}
62
+ */
63
+ async getStyle(baseUrl = null) {
64
+ const styleEntry = (await this.#entriesPromise).get(STYLE_FILE)
65
+ if (!styleEntry) throw new Error(`File not found: ${STYLE_FILE}`)
66
+ const stream = await styleEntry.openReadStream()
67
+ const style = await json(stream)
68
+ if (!validateStyle(style)) {
69
+ throw new AggregateError(validateStyle.errors, 'Invalid style')
70
+ }
71
+ if (typeof style.glyphs === 'string') {
72
+ style.glyphs = getUrl(style.glyphs, baseUrl)
73
+ }
74
+ if (typeof style.sprite === 'string') {
75
+ style.sprite = getUrl(style.sprite, baseUrl)
76
+ } else if (Array.isArray(style.sprite)) {
77
+ style.sprite = style.sprite.map(({ id, url }) => {
78
+ return { id, url: getUrl(url, baseUrl) }
79
+ })
80
+ }
81
+ for (const source of Object.values(style.sources)) {
82
+ if ('tiles' in source && source.tiles) {
83
+ source.tiles = source.tiles.map((tile) => getUrl(tile, baseUrl))
84
+ }
85
+ }
86
+ // Hard to get this type-safe without a validation function. Instead we
87
+ // trust the Writer and the tests for now.
88
+ return /** @type {import('./types.js').SMPStyle} */ (style)
89
+ }
90
+
91
+ /**
92
+ * Get a resource from the styled map package. The path should be relative to
93
+ * the root of the package.
94
+ *
95
+ * @param {string} path
96
+ * @returns {Promise<Resource>}
97
+ */
98
+ async getResource(path) {
99
+ if (path[0] === '/') path = path.slice(1)
100
+ if (path === STYLE_FILE) {
101
+ const styleJSON = JSON.stringify(await this.getStyle())
102
+ return {
103
+ contentType: 'application/json; charset=utf-8',
104
+ contentLength: Buffer.byteLength(styleJSON, 'utf8'),
105
+ resourceType: 'style',
106
+ stream: intoStream(styleJSON),
107
+ }
108
+ }
109
+ const entry = (await this.#entriesPromise).get(path)
110
+ if (!entry) throw new Error(`File not found: ${path}`)
111
+ const resourceType = getResourceType(path)
112
+ const contentType = getContentType(path)
113
+ const stream = await entry.openReadStream()
114
+ /** @type {Resource} */
115
+ const resource = {
116
+ resourceType,
117
+ contentType,
118
+ contentLength: entry.uncompressedSize,
119
+ stream,
120
+ }
121
+ if (path.endsWith('.gz')) {
122
+ resource.contentEncoding = 'gzip'
123
+ }
124
+ return resource
125
+ }
126
+
127
+ /**
128
+ * Close the styled map package file (should be called after reading the file to avoid memory leaks)
129
+ */
130
+ async close() {
131
+ if (this.#closePromise) return this.#closePromise
132
+ this.#closePromise = (async () => {
133
+ const zip = await this.#zipPromise
134
+ await zip.close()
135
+ })()
136
+ return this.#closePromise
137
+ }
138
+ }
139
+
140
+ /**
141
+ * @param {string} smpUri
142
+ * @param {string | null} baseUrl
143
+ */
144
+ function getUrl(smpUri, baseUrl) {
145
+ if (!smpUri.startsWith(URI_BASE)) {
146
+ throw new Error(`Invalid SMP URI: ${smpUri}`)
147
+ }
148
+ if (typeof baseUrl !== 'string') return smpUri
149
+ return smpUri.replace(URI_BASE, baseUrl + '/')
150
+ }
@@ -0,0 +1,92 @@
1
+ import chalk, { chalkStderr } from 'chalk'
2
+ import logSymbols from 'log-symbols'
3
+ import ora from 'ora'
4
+ import prettyBytes from 'pretty-bytes'
5
+ import prettyMilliseconds from 'pretty-ms'
6
+ import { Writable } from 'readable-stream'
7
+
8
+ chalk.level = chalkStderr.level
9
+
10
+ const TASKS = /** @type {const} */ ([
11
+ 'style',
12
+ 'sprites',
13
+ 'tiles',
14
+ 'glyphs',
15
+ 'output',
16
+ ])
17
+
18
+ const TASK_LABEL = /** @type {const} */ ({
19
+ style: 'Downloading Map Style',
20
+ sprites: 'Downloading Sprites',
21
+ tiles: 'Downloading Tiles',
22
+ glyphs: 'Downloading Glyphs',
23
+ output: 'Writing Styled Map Package',
24
+ })
25
+
26
+ const TASK_SUFFIX =
27
+ /** @type {{ [K in (typeof TASKS)[number]]: (progress: import('./download.js').DownloadProgress[K]) => string }} */ ({
28
+ style: () => '',
29
+ sprites: ({ downloaded }) => `${downloaded}`,
30
+ tiles: ({ total, skipped, totalBytes, downloaded }) => {
31
+ const formattedTotal = total.toLocaleString()
32
+ const formattedCompleted = (downloaded + skipped)
33
+ .toLocaleString()
34
+ .padStart(formattedTotal.length)
35
+ return `${formattedCompleted}/${formattedTotal} (${prettyBytes(totalBytes)})`
36
+ },
37
+ glyphs: ({ total, downloaded, totalBytes }) =>
38
+ `${downloaded}/${total} (${prettyBytes(totalBytes)})`,
39
+ output: ({ totalBytes }) => `${prettyBytes(totalBytes)}`,
40
+ })
41
+
42
+ /**
43
+ * A writable stream to reporting download progress to a TTY terminal. Write
44
+ * progress messages to this stream for a pretty-printed progress task-list in
45
+ * the terminal.
46
+ */
47
+ export function ttyReporter() {
48
+ /** @type {import('./download.js').DownloadProgress | undefined} */
49
+ let stats
50
+ let current = 0
51
+ /** @type {import('ora').Ora} */
52
+ let spinner
53
+ return new Writable({
54
+ objectMode: true,
55
+ // @ts-ignore - missing type def
56
+ construct(cb) {
57
+ process.stderr.write('\n')
58
+ spinner = ora(TASK_LABEL[TASKS[current]]).start()
59
+ cb()
60
+ },
61
+ /** @param {ArrayLike<{ chunk: import('./download.js').DownloadProgress, encoding: string }>} chunks */
62
+ writev(chunks, cb) {
63
+ stats = chunks[chunks.length - 1].chunk
64
+ while (current < TASKS.length && stats[TASKS[current]].done) {
65
+ spinner.suffixText = chalk.dim(
66
+ TASK_SUFFIX[TASKS[current]](
67
+ // @ts-ignore - too complicated for TS
68
+ stats[TASKS[current]],
69
+ ),
70
+ )
71
+ spinner.succeed()
72
+ if (++current < TASKS.length) {
73
+ spinner = ora(TASK_LABEL[TASKS[current]]).start()
74
+ }
75
+ }
76
+ if (current < TASKS.length) {
77
+ spinner.suffixText = chalk.dim(
78
+ TASK_SUFFIX[TASKS[current]](
79
+ // @ts-ignore - too complicated for TS
80
+ stats[TASKS[current]],
81
+ ),
82
+ )
83
+ } else {
84
+ process.stderr.write(
85
+ `${chalk.green(logSymbols.success)} Completed in ${prettyMilliseconds(stats.elapsedMs)}\n`,
86
+ )
87
+ }
88
+
89
+ cb()
90
+ },
91
+ })
92
+ }