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.
- package/.github/workflows/node.yml +30 -0
- package/.github/workflows/release.yml +47 -0
- package/.husky/pre-commit +1 -0
- package/.nvmrc +1 -0
- package/LICENSE.md +7 -0
- package/README.md +28 -0
- package/bin/smp-download.js +83 -0
- package/bin/smp-view.js +52 -0
- package/bin/smp.js +11 -0
- package/eslint.config.js +17 -0
- package/lib/download.js +114 -0
- package/lib/index.js +6 -0
- package/lib/reader.js +150 -0
- package/lib/reporters.js +92 -0
- package/lib/server.js +64 -0
- package/lib/style-downloader.js +363 -0
- package/lib/tile-downloader.js +188 -0
- package/lib/types.ts +104 -0
- package/lib/utils/fetch.js +100 -0
- package/lib/utils/file-formats.js +85 -0
- package/lib/utils/geo.js +87 -0
- package/lib/utils/mapbox.js +155 -0
- package/lib/utils/misc.js +26 -0
- package/lib/utils/streams.js +162 -0
- package/lib/utils/style.js +174 -0
- package/lib/utils/templates.js +136 -0
- package/lib/writer.js +478 -0
- package/map-viewer/index.html +89 -0
- package/package.json +103 -0
- package/test/download-write-read.js +43 -0
- package/test/fixtures/invalid-styles/empty.json +1 -0
- package/test/fixtures/invalid-styles/missing-source.json +10 -0
- package/test/fixtures/invalid-styles/no-layers.json +4 -0
- package/test/fixtures/invalid-styles/no-sources.json +4 -0
- package/test/fixtures/invalid-styles/null.json +1 -0
- package/test/fixtures/invalid-styles/unsupported-version.json +5 -0
- package/test/fixtures/valid-styles/external-geojson.input.json +66 -0
- package/test/fixtures/valid-styles/external-geojson.output.json +93 -0
- package/test/fixtures/valid-styles/inline-geojson.input.json +421 -0
- package/test/fixtures/valid-styles/inline-geojson.output.json +1573 -0
- package/test/fixtures/valid-styles/maplibre-demotiles.input.json +831 -0
- package/test/fixtures/valid-styles/maplibre-unlabelled.input.json +496 -0
- package/test/fixtures/valid-styles/maplibre-unlabelled.output.json +1573 -0
- package/test/fixtures/valid-styles/minimal-labelled.input.json +37 -0
- package/test/fixtures/valid-styles/minimal-labelled.output.json +72 -0
- package/test/fixtures/valid-styles/minimal-sprites.input.json +37 -0
- package/test/fixtures/valid-styles/minimal-sprites.output.json +58 -0
- package/test/fixtures/valid-styles/minimal.input.json +54 -0
- package/test/fixtures/valid-styles/minimal.output.json +92 -0
- package/test/fixtures/valid-styles/multiple-sprites.input.json +46 -0
- package/test/fixtures/valid-styles/multiple-sprites.output.json +128 -0
- package/test/fixtures/valid-styles/raster-sources.input.json +33 -0
- package/test/fixtures/valid-styles/raster-sources.output.json +69 -0
- package/test/utils/assert-bbox-equal.js +19 -0
- package/test/utils/digest-stream.js +36 -0
- package/test/utils/image-streams.js +30 -0
- package/test/utils/reader-helper.js +72 -0
- package/test/write-read.js +620 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|
package/bin/smp-view.js
ADDED
|
@@ -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)
|
package/eslint.config.js
ADDED
|
@@ -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
|
+
]
|
package/lib/download.js
ADDED
|
@@ -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
|
+
}
|
package/lib/reporters.js
ADDED
|
@@ -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
|
+
}
|