@superrouteca/smap-mapbox 0.0.0-stage → 1.1.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +138 -2
- package/examples/index.html +81 -0
- package/package.json +68 -4
- package/src/common/access.js +198 -0
- package/src/common/badge.js +56 -0
- package/src/common/embed.js +166 -0
- package/src/index.js +475 -0
- package/src/on-smap.js +101 -0
- package/types/index.d.ts +165 -0
- package/types/on-smap.d.ts +28 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0 — 2026-10-27
|
|
4
|
+
|
|
5
|
+
- First version. sMap in Mapbox GL JS: `new SMapMapbox(mapboxgl, options)` draws sMap's style with Mapbox GL JS
|
|
6
|
+
itself — the five colour schemes and saved custom styles (`custom:<id>`), any label language, basemap overrides
|
|
7
|
+
(`setBasemap`), 3D buildings — and keeps your own sources and layers when the style, language or overrides change.
|
|
8
|
+
- Map key or token on every sMap request (renewed through `getToken`), the nearest map node and the next one when a
|
|
9
|
+
node fails; your own `transformRequest` still handles every other host.
|
|
10
|
+
- Style Specification layers at the basemap's slots (`bottom`, `roads`, `labels`, `top`, and Mapbox's `middle`),
|
|
11
|
+
raster and vector tile layers.
|
|
12
|
+
- sMap's pins, lines and areas with their options, popups and Auto Zoom on a layer above that follows the camera.
|
|
13
|
+
- The data credit and every source credit in the attribution, with the sMap badge.
|
|
14
|
+
- `switchBasemap()` moves an existing Mapbox GL JS map onto sMap's basemap without touching its own layers.
|
|
15
|
+
- Mapbox GL JS on sMap: `loadMapboxStyle()`, `addMapboxLayers()` and `copyMapboxLayers()` put Mapbox GL JS styles
|
|
16
|
+
and layers on an sMap map, listing what is hosted by Mapbox and left out.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Goodneo Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Goodneo-Solutions/smap-sdk/main/assets/smap-logo-dark.svg">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Goodneo-Solutions/smap-sdk/main/assets/smap-logo.svg" alt="sMap" height="56">
|
|
4
|
+
</picture>
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
# sMap for Mapbox GL JS: `@superrouteca/smap-mapbox`
|
|
7
|
+
|
|
8
|
+
Two ways round:
|
|
9
|
+
|
|
10
|
+
- **sMap in Mapbox GL JS.** sMap's style drawn by Mapbox GL JS itself: the five colour schemes and your saved
|
|
11
|
+
custom styles, labels in any language, basemap overrides, 3D buildings and tilting, map key or token on every
|
|
12
|
+
request (renewed through `getToken`), the nearest map node and the next one when a node fails, the credits and the
|
|
13
|
+
sMap badge. sMap's pins, lines and areas with all their options, popups and Auto Zoom on a layer above.
|
|
14
|
+
- **Mapbox GL JS on sMap.** Mapbox GL JS styles and layers (from Mapbox Studio, Maputnik or an existing page) on an
|
|
15
|
+
sMap map, and an existing Mapbox GL JS map switched to sMap's basemap without rewriting its layers.
|
|
16
|
+
|
|
17
|
+
- Version 1.1.0, MIT licence (see `LICENSE` at the repository root)
|
|
18
|
+
- Mapbox GL JS 3 (you pass it in; it is a peer dependency and never bundled). Mapbox GL JS itself is used under
|
|
19
|
+
Mapbox's terms, which ask for a Mapbox access token (`mapboxgl.accessToken`).
|
|
20
|
+
- ES module with types
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @superrouteca/smap-mapbox mapbox-gl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
30
|
+
import '@superrouteca/smap/smap.css'; // popups and the Auto Zoom chooser of sMap's layers
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## sMap in Mapbox GL JS
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import mapboxgl from 'mapbox-gl';
|
|
37
|
+
import { SMapMapbox } from '@superrouteca/smap-mapbox';
|
|
38
|
+
|
|
39
|
+
mapboxgl.accessToken = 'pk.…'; // Mapbox's own terms
|
|
40
|
+
|
|
41
|
+
const smap = new SMapMapbox(mapboxgl, {
|
|
42
|
+
container: 'map',
|
|
43
|
+
apiKey: 'smk_…', // a map key that allows this site, or token / getToken
|
|
44
|
+
style: 'light', // light, white, grayscale, dark, black, or 'custom:sms_…'
|
|
45
|
+
language: 'en',
|
|
46
|
+
center: [-79.38, 43.65],
|
|
47
|
+
zoom: 12,
|
|
48
|
+
pitch: 50,
|
|
49
|
+
buildings3D: true,
|
|
50
|
+
});
|
|
51
|
+
await smap.ready;
|
|
52
|
+
const map = smap.map; // the Mapbox GL JS map, for everything else
|
|
53
|
+
|
|
54
|
+
smap.setBasemap({ water: { color: '#9cc0e6' }, pois: { visible: false }, roads: { width: 1.4 } });
|
|
55
|
+
smap.setStyle('dark');
|
|
56
|
+
smap.setLanguage('fr');
|
|
57
|
+
|
|
58
|
+
// Style Specification layers go into the Mapbox GL JS map, at a slot of the sMap basemap or under a layer:
|
|
59
|
+
smap.addSource('zones', { type: 'geojson', data: zones });
|
|
60
|
+
smap.addStyleLayer({ id: 'zones-fill', type: 'fill', source: 'zones', paint: { 'fill-color': '#f97316' } }, { slot: 'labels' });
|
|
61
|
+
smap.addRasterLayer('radar', { tiles: ['https://radar.example/{z}/{x}/{y}.png'], opacity: 0.6, slot: 'roads' });
|
|
62
|
+
|
|
63
|
+
// sMap's own layers (pins, numbers, route lines, popups, Auto Zoom):
|
|
64
|
+
smap.addPoints('stops', stops, {
|
|
65
|
+
marker: { fill: { property: 'status_color' }, border: { property: 'route_color' }, text: { property: 'stop_number' } },
|
|
66
|
+
popup: (p) => `<b>${p.stop_number}</b>`,
|
|
67
|
+
});
|
|
68
|
+
smap.addLines('routes', routes, { color: { property: 'color' }, width: 5, casing: true, arrows: true });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
What happens where:
|
|
72
|
+
|
|
73
|
+
- **The basemap** is sMap's style document, read from the nearest node with your key, with your overrides applied
|
|
74
|
+
(`setBasemap`, as on every sMap map) and drawn by Mapbox GL JS. `setStyle`, `setLanguage`, `setBasemap` and
|
|
75
|
+
`setBuildings3D` replace the basemap and keep your own sources and layers where they were.
|
|
76
|
+
- **Every request to sMap** goes through the adapter's `transformRequest`: to the node in use, with the token and
|
|
77
|
+
the map key. A node that fails (no answer or a 5xx) is tried last from then on. Pass your own `transformRequest`
|
|
78
|
+
for other hosts; it is called for everything that is not sMap's.
|
|
79
|
+
- **Slots**: `addStyleLayer(layer, { slot })` puts a layer under the basemap's roads (`roads`), labels (`labels`, or
|
|
80
|
+
Mapbox's own `middle`), over the land and water (`bottom`) or on top (`top`); without a slot or `before` a layer
|
|
81
|
+
goes on top in the order added.
|
|
82
|
+
- **sMap's own layers** (`addPoints`, `addLines`, `addPolygons`, `addData`, `setData`, `setVisible`, `removeLayer`,
|
|
83
|
+
`fitLayer`, `openPopup`, `closePopup`, `setAutoZoom`, `addWmsLayer`, `addWmtsLayer`, `addArcGisLayer`,
|
|
84
|
+
`addDeckLayer`, `loadLayers`) are drawn by an sMap map above the Mapbox GL JS map that follows its camera (tilt and
|
|
85
|
+
rotation included) and hears its clicks; its own moves (Auto Zoom, `fitLayer`) move the Mapbox GL JS map.
|
|
86
|
+
`await smap.overlay()` gives that sMap map; `await smap.call('toGeoJSON', 'stops')` answers a value.
|
|
87
|
+
- **Credits and badge**: the data credit comes from sMap's style; credits of layers drawn above are added beside
|
|
88
|
+
it; the sMap badge sits in the bottom-right corner.
|
|
89
|
+
- The map is in Web Mercator unless you pass `projection` (sMap's layers above follow Web Mercator only).
|
|
90
|
+
|
|
91
|
+
### An existing Mapbox GL JS map
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { switchBasemap, smapAccess, smapTransformRequest } from '@superrouteca/smap-mapbox';
|
|
95
|
+
|
|
96
|
+
const access = smapAccess({ getToken: () => myServer.mapToken() });
|
|
97
|
+
const map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/streets-v12', transformRequest: smapTransformRequest(access) });
|
|
98
|
+
// … the app adds its own sources and layers …
|
|
99
|
+
const smap = await switchBasemap(mapboxgl, map, { access, style: 'light' });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The Mapbox-hosted basemap (its `mapbox://` sources, the layers reading them, its background and sky) goes; every
|
|
103
|
+
other source and layer stays, on top of sMap's basemap. `keep(layer)` decides instead which layers stay. A map made
|
|
104
|
+
without `smapTransformRequest()` can use a map key (it travels in sMap's style URLs) but not a token.
|
|
105
|
+
|
|
106
|
+
## Mapbox GL JS on sMap
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
import { Map } from '@superrouteca/smap';
|
|
110
|
+
import { loadMapboxStyle, copyMapboxLayers, addMapboxLayers } from '@superrouteca/smap-mapbox/on-smap';
|
|
111
|
+
|
|
112
|
+
const smap = new Map({ container: 'map', apiKey: 'smk_…', style: 'light' });
|
|
113
|
+
await smap.ready;
|
|
114
|
+
|
|
115
|
+
const report = await loadMapboxStyle(smap, studioStyle); // a style document or its address
|
|
116
|
+
console.log(report.layers, report.skipped, report.warnings);
|
|
117
|
+
|
|
118
|
+
await copyMapboxLayers(mapboxMap, smap, { filter: (l) => l.id.startsWith('app-') });
|
|
119
|
+
await addMapboxLayers(smap, [{ id: 'dots', type: 'circle', source: 'orders' }], { sources: { orders: { type: 'geojson', data } } });
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Sources and layers go on the sMap map as they are (`addSource`, `addStyleLayer`). Left out, and listed in
|
|
123
|
+
`skipped`: sources hosted by Mapbox (`mapbox://`, `api.mapbox.com`; they need a Mapbox account), the layers reading
|
|
124
|
+
them, and `background` / `sky` / `fog` layers (they would cover sMap's basemap). A Mapbox GL JS v3 `slot` becomes an
|
|
125
|
+
sMap slot (`bottom` → `bottom`, `middle` → `labels`, `top` → `top`); options `slot` / `before` place layers that name
|
|
126
|
+
none; without any, a layer draws on top in the order added. `prefix` renames every source and layer; `layers` or
|
|
127
|
+
`filter` choose them. Icons of the style's own sprite are not loaded (a warning says so).
|
|
128
|
+
|
|
129
|
+
## Example
|
|
130
|
+
|
|
131
|
+
`examples/index.html` shows both ways. Serve the repository root (for example `npx serve .`) and open
|
|
132
|
+
`/mapbox/examples/?key=smk_…&mapbox=pk.…` with a map key that allows your host and a Mapbox access token.
|
|
133
|
+
|
|
134
|
+
## Tests
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
npm test # over a stand-in for Mapbox GL JS, which needs WebGL
|
|
138
|
+
npm run typecheck
|
|
139
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>sMap for Mapbox GL JS</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mapbox-gl@3.33.0/dist/mapbox-gl.css">
|
|
8
|
+
<link rel="stylesheet" href="../../js/dist/smap.css">
|
|
9
|
+
<style>
|
|
10
|
+
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; }
|
|
11
|
+
.bar { display: flex; gap: 8px; align-items: center; padding: 8px 12px; flex-wrap: wrap; }
|
|
12
|
+
.maps { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 12px 12px; }
|
|
13
|
+
.maps > div { height: 70vh; border: 1px solid #cbd5e1; border-radius: 6px; position: relative; }
|
|
14
|
+
h2 { font-size: 14px; margin: 0 12px; }
|
|
15
|
+
@media (max-width: 800px) { .maps { grid-template-columns: 1fr; } }
|
|
16
|
+
</style>
|
|
17
|
+
<!-- Serve the repository root (npx serve .) and open /mapbox/examples/?key=smk_…&mapbox=pk.… -->
|
|
18
|
+
<script src="https://cdn.jsdelivr.net/npm/mapbox-gl@3.33.0/dist/mapbox-gl.js"></script>
|
|
19
|
+
<script type="importmap">
|
|
20
|
+
{
|
|
21
|
+
"imports": {
|
|
22
|
+
"@superrouteca/smap": "../../js/dist/smap.mjs",
|
|
23
|
+
"@superrouteca/smap/client": "../../js/dist/smap-client.mjs",
|
|
24
|
+
"@superrouteca/smap-mapbox": "../src/index.js",
|
|
25
|
+
"@superrouteca/smap-mapbox/on-smap": "../src/on-smap.js"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<div class="bar">
|
|
32
|
+
<label>Scheme <select id="scheme"><option>light</option><option>white</option><option>grayscale</option><option>dark</option><option>black</option></select></label>
|
|
33
|
+
<label>Language <select id="lang"><option>en</option><option>fr</option><option>de</option><option>zh-Hans</option></select></label>
|
|
34
|
+
<label><input type="checkbox" id="buildings" checked> 3D buildings</label>
|
|
35
|
+
<button id="water">Blue water</button>
|
|
36
|
+
</div>
|
|
37
|
+
<h2>sMap in Mapbox GL JS (left) · Mapbox GL JS layers on an sMap map (right)</h2>
|
|
38
|
+
<div class="maps"><div id="mapbox"></div><div id="smap"></div></div>
|
|
39
|
+
<script type="module">
|
|
40
|
+
const mapboxgl = window.mapboxgl; // the browser build of Mapbox GL JS, loaded above
|
|
41
|
+
import { Map as SMap, popupTemplate } from '@superrouteca/smap';
|
|
42
|
+
import { SMapMapbox } from '@superrouteca/smap-mapbox';
|
|
43
|
+
import { addMapboxLayers } from '@superrouteca/smap-mapbox/on-smap';
|
|
44
|
+
|
|
45
|
+
const q = new URLSearchParams(location.search);
|
|
46
|
+
const apiKey = q.get('key') || undefined;
|
|
47
|
+
mapboxgl.accessToken = q.get('mapbox') || '';
|
|
48
|
+
const stops = [
|
|
49
|
+
{ lng: -79.3832, lat: 43.6532, n: 1, status: '#16a34a', name: 'City Hall' },
|
|
50
|
+
{ lng: -79.3871, lat: 43.6426, n: 2, status: '#f97316', name: 'CN Tower' },
|
|
51
|
+
{ lng: -79.3957, lat: 43.6677, n: 3, status: '#2563eb', name: 'Queen’s Park' },
|
|
52
|
+
{ lng: -79.3806, lat: 43.6629, n: 4, status: '#e11d48', name: 'Church St' },
|
|
53
|
+
];
|
|
54
|
+
const zone = { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [[[-79.392, 43.648], [-79.378, 43.648], [-79.378, 43.658], [-79.392, 43.658], [-79.392, 43.648]]] } };
|
|
55
|
+
|
|
56
|
+
// ① sMap in Mapbox GL JS
|
|
57
|
+
const smap = new SMapMapbox(mapboxgl, { container: 'mapbox', apiKey, style: 'light', language: 'en', center: [-79.387, 43.652], zoom: 14.5, pitch: 55, bearing: -20, buildings3D: true });
|
|
58
|
+
await smap.ready;
|
|
59
|
+
smap.addSource('zone', { type: 'geojson', data: zone });
|
|
60
|
+
smap.addStyleLayer({ id: 'zone-fill', type: 'fill', source: 'zone', paint: { 'fill-color': '#f97316', 'fill-opacity': 0.25 } }, { slot: 'roads' });
|
|
61
|
+
smap.addLines('route', { type: 'LineString', coordinates: stops.map((s) => [s.lng, s.lat]) }, { color: '#2563eb', width: 5, casing: true, arrows: true });
|
|
62
|
+
smap.addPoints('stops', stops, { marker: { fill: { property: 'status' }, border: '#1e293b', text: { property: 'n' } }, popup: popupTemplate('<b>{n}</b> {name}') });
|
|
63
|
+
|
|
64
|
+
const scheme = document.getElementById('scheme');
|
|
65
|
+
const lang = document.getElementById('lang');
|
|
66
|
+
scheme.onchange = () => smap.setStyle(scheme.value);
|
|
67
|
+
lang.onchange = () => smap.setLanguage(lang.value);
|
|
68
|
+
document.getElementById('buildings').onchange = (e) => smap.setBuildings3D(e.target.checked);
|
|
69
|
+
document.getElementById('water').onclick = () => smap.setBasemap({ water: { color: '#60a5fa' } });
|
|
70
|
+
|
|
71
|
+
// ② Mapbox GL JS layers on an sMap map
|
|
72
|
+
const plain = new SMap({ container: 'smap', apiKey, style: 'grayscale', language: 'en', center: [-79.387, 43.655], zoom: 13 });
|
|
73
|
+
await plain.ready;
|
|
74
|
+
const report = await addMapboxLayers(plain, [
|
|
75
|
+
{ id: 'zone-line', type: 'line', source: 'zone', paint: { 'line-color': '#f97316', 'line-width': 3 }, slot: 'middle' },
|
|
76
|
+
{ id: 'stop-dots', type: 'circle', source: 'stops', paint: { 'circle-radius': 7, 'circle-color': ['get', 'status'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } },
|
|
77
|
+
], { sources: { zone: { type: 'geojson', data: zone }, stops: { type: 'geojson', data: { type: 'FeatureCollection', features: stops.map((s) => ({ type: 'Feature', properties: s, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } })) } } } });
|
|
78
|
+
console.log('Mapbox GL JS layers on sMap', report);
|
|
79
|
+
</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superrouteca/smap-mapbox",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "sMap for Mapbox GL JS: maps from Superroute's sMap service in Mapbox GL JS, and Mapbox GL JS styles and layers on an sMap map.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Goodneo Solutions",
|
|
7
|
+
"homepage": "https://github.com/Goodneo-Solutions/smap-sdk/tree/main/mapbox#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Goodneo-Solutions/smap-sdk.git",
|
|
11
|
+
"directory": "mapbox"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"map",
|
|
15
|
+
"maps",
|
|
16
|
+
"mapbox",
|
|
17
|
+
"mapbox-gl",
|
|
18
|
+
"vector-tiles",
|
|
19
|
+
"smap",
|
|
20
|
+
"superroute"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"main": "./src/index.js",
|
|
25
|
+
"types": "./types/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./types/index.d.ts",
|
|
29
|
+
"default": "./src/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./on-smap": {
|
|
32
|
+
"types": "./types/on-smap.d.ts",
|
|
33
|
+
"default": "./src/on-smap.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"types",
|
|
40
|
+
"examples",
|
|
41
|
+
"README.md",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"prepack": "cp ../LICENSE LICENSE",
|
|
50
|
+
"typecheck": "tsc -p test/tsconfig.json",
|
|
51
|
+
"test": "node --test test/*.test.mjs"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"mapbox-gl": ">=3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"mapbox-gl": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@superrouteca/smap": "1.1.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/geojson": "7946.0.16",
|
|
66
|
+
"jsdom": "30.1.3",
|
|
67
|
+
"mapbox-gl": "3.33.0",
|
|
68
|
+
"typescript": "6.0.3"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// sMap tool adapters — access to the service: map key or token (renewed through getToken), the nearest node and the
|
|
2
|
+
// next one when a node fails. Kept identical in leaflet/, openlayers/ and mapbox/ (test/common.test.mjs checks); the
|
|
3
|
+
// node choice, the token renewal and the failover of requests are the JS client's (@superrouteca/smap/client).
|
|
4
|
+
import SMapClient from '@superrouteca/smap/client';
|
|
5
|
+
import * as sMapModule from '@superrouteca/smap';
|
|
6
|
+
|
|
7
|
+
const SDK = sMapModule.default && sMapModule.default.Map ? sMapModule.default : sMapModule;
|
|
8
|
+
const PLACEHOLDER = 'https://smap.invalid';
|
|
9
|
+
|
|
10
|
+
/** The SDK's own URL builder (rasterUrl, styleUrl of @superrouteca/smap 1.1.0); throws when the SDK is older. */
|
|
11
|
+
function builder(name) {
|
|
12
|
+
if (typeof SDK[name] !== 'function') { throw new Error('sMap: the adapters need @superrouteca/smap 1.1.0 or later (' + name + ')'); }
|
|
13
|
+
return SDK[name];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A path of the SDK's URL builders, without their address. */
|
|
17
|
+
function pathOf(url) { return url.slice(PLACEHOLDER.length); }
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_BASE = 'https://tile.srsr.ca';
|
|
20
|
+
export const FLAVORS = ['light', 'white', 'grayscale', 'dark', 'black'];
|
|
21
|
+
const CUSTOM = /^custom:([A-Za-z0-9_-]+)$/;
|
|
22
|
+
|
|
23
|
+
/** Checks a style name: a scheme or `custom:<id>`; anything else throws with the name in the message. */
|
|
24
|
+
export function checkStyle(style) {
|
|
25
|
+
const s = style == null ? 'light' : String(style);
|
|
26
|
+
if (FLAVORS.indexOf(s) >= 0 || CUSTOM.test(s)) { return s; }
|
|
27
|
+
throw new Error('sMap: unknown style "' + s + '" (use ' + FLAVORS.join(', ') + ' or custom:<id>)');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The path of one raster tile on an sMap node (interop contract §5, the SDK's rasterUrl): 256 px, or 512 px with `retina`. */
|
|
31
|
+
export function rasterPath(style, z, x, y, options) {
|
|
32
|
+
const o = options || {};
|
|
33
|
+
const url = builder('rasterUrl')(checkStyle(style), { lang: o.lang || o.language, retina: !!o.retina, baseUrl: PLACEHOLDER });
|
|
34
|
+
return pathOf(url).replace('{z}', String(z | 0)).replace('{x}', String(x | 0)).replace('{y}', String(y | 0));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The path of a style document (the SDK's styleUrl): a scheme or a saved custom style, with labels in `lang`. */
|
|
38
|
+
export function stylePath(style, lang) {
|
|
39
|
+
return pathOf(builder('styleUrl')(checkStyle(style), { lang: lang, baseUrl: PLACEHOLDER }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function strip(url) { return String(url).replace(/\/$/, ''); }
|
|
43
|
+
|
|
44
|
+
export class SMapAccess {
|
|
45
|
+
/**
|
|
46
|
+
* @param {Object} [o] baseUrl, token, getToken, apiKey, nodes, nearest, fetch, timeoutMs (as the JS client), or
|
|
47
|
+
* `client`: a JS client of your own.
|
|
48
|
+
*/
|
|
49
|
+
constructor(o) {
|
|
50
|
+
o = o || {};
|
|
51
|
+
this.client = o.client || new SMapClient({
|
|
52
|
+
baseUrl: o.baseUrl || DEFAULT_BASE,
|
|
53
|
+
token: o.token,
|
|
54
|
+
getToken: o.getToken,
|
|
55
|
+
apiKey: o.apiKey,
|
|
56
|
+
nodes: o.nodes,
|
|
57
|
+
nearest: o.nearest,
|
|
58
|
+
fetch: o.fetch,
|
|
59
|
+
timeoutMs: o.timeoutMs,
|
|
60
|
+
});
|
|
61
|
+
this.baseUrl = strip(this.client.baseUrl);
|
|
62
|
+
this.apiKey = o.apiKey || null;
|
|
63
|
+
this._getToken = o.getToken || null;
|
|
64
|
+
this._token = o.token || null;
|
|
65
|
+
this._seeds = (o.nodes || []).map(strip);
|
|
66
|
+
this._order = null;
|
|
67
|
+
this._timer = null;
|
|
68
|
+
this._starting = null;
|
|
69
|
+
this._stopped = false;
|
|
70
|
+
this._listeners = [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads the first token and the node order; resolves once both are known (never rejects for the nodes). */
|
|
74
|
+
start() {
|
|
75
|
+
if (!this._starting) {
|
|
76
|
+
const self = this;
|
|
77
|
+
this._starting = Promise.all([
|
|
78
|
+
this.refresh(),
|
|
79
|
+
this.client.nodes().then(function (order) { self._order = order; }, function () { self._order = [self.baseUrl]; }),
|
|
80
|
+
]).then(function () { return self; });
|
|
81
|
+
}
|
|
82
|
+
return this._starting;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Asks the client for the current token (getToken is called when it is missing or expires within 2 minutes). */
|
|
86
|
+
refresh() {
|
|
87
|
+
const self = this;
|
|
88
|
+
return this.client.token().then(function (t) {
|
|
89
|
+
const changed = t !== self._token;
|
|
90
|
+
self._token = t || null;
|
|
91
|
+
self._schedule();
|
|
92
|
+
if (changed) { self._emit('token', self._token); }
|
|
93
|
+
return self._token;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_schedule() {
|
|
98
|
+
clearTimeout(this._timer);
|
|
99
|
+
this._timer = null;
|
|
100
|
+
if (this._stopped || !this._getToken || !this._token) { return; }
|
|
101
|
+
const exp = SMapClient.tokenExpiry(this._token);
|
|
102
|
+
if (!exp) { return; }
|
|
103
|
+
// One second past the client's renewal point (2 minutes before expiry), so the client asks getToken again.
|
|
104
|
+
const wait = Math.max(5, exp - Math.floor(Date.now() / 1000) - 119) * 1000;
|
|
105
|
+
const self = this;
|
|
106
|
+
this._timer = setTimeout(function () {
|
|
107
|
+
self.refresh().catch(function () {
|
|
108
|
+
clearTimeout(self._timer);
|
|
109
|
+
self._timer = setTimeout(function () { self._schedule(); self.refresh().catch(function () {}); }, 30000);
|
|
110
|
+
});
|
|
111
|
+
}, Math.min(wait, 2147483000));
|
|
112
|
+
if (this._timer && typeof this._timer.unref === 'function') { this._timer.unref(); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The token to send now (null without one). */
|
|
116
|
+
token() { return this._token; }
|
|
117
|
+
|
|
118
|
+
/** The nodes in the order they are tried, nearest first (baseUrl alone before start() resolves). */
|
|
119
|
+
nodes() { return this._order ? this._order.slice() : [this.baseUrl]; }
|
|
120
|
+
|
|
121
|
+
/** The node requests go to now. */
|
|
122
|
+
head() { return (this._order && this._order[0]) || this.baseUrl; }
|
|
123
|
+
|
|
124
|
+
_nodeOf(url) {
|
|
125
|
+
const u = String(url);
|
|
126
|
+
const all = [this.baseUrl].concat(this._seeds, this._order || []);
|
|
127
|
+
for (let i = 0; i < all.length; i++) {
|
|
128
|
+
const n = all[i];
|
|
129
|
+
if (u === n || u.indexOf(n + '/') === 0 || u.indexOf(n + '?') === 0) { return n; }
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True for a URL on the sMap service (baseUrl, a seed or a listed node). */
|
|
135
|
+
isOurs(url) { return this._nodeOf(url) !== null; }
|
|
136
|
+
|
|
137
|
+
/** The headers an sMap request carries (token, map key); none for any other host. */
|
|
138
|
+
headers(url) {
|
|
139
|
+
const h = {};
|
|
140
|
+
if (!this.isOurs(url)) { return h; }
|
|
141
|
+
if (this._token) { h.Authorization = 'Bearer ' + this._token; }
|
|
142
|
+
if (this.apiKey) { h['X-SMap-Key'] = this.apiKey; }
|
|
143
|
+
return h;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** An sMap URL moved onto the node requests go to now; other URLs unchanged. */
|
|
147
|
+
rewrite(url) {
|
|
148
|
+
const n = this._nodeOf(url);
|
|
149
|
+
const head = this.head();
|
|
150
|
+
if (!n || n === head) { return url; }
|
|
151
|
+
return head + String(url).slice(n.length);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reports that a request to `url` failed: its node is tried last from now on. */
|
|
155
|
+
failed(url) {
|
|
156
|
+
const n = this._nodeOf(url);
|
|
157
|
+
if (!n || !this._order || this._order.length < 2) { return; }
|
|
158
|
+
// The client's own order is the one moved, so its requests and these rewrites agree.
|
|
159
|
+
if (typeof this.client.demote === 'function') { this.client.demote(n); }
|
|
160
|
+
else {
|
|
161
|
+
const i = this._order.indexOf(n);
|
|
162
|
+
if (i >= 0) { this._order.splice(i, 1); this._order.push(n); }
|
|
163
|
+
}
|
|
164
|
+
this._emit('nodes', this.nodes());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A path appended with the map key (styles and TileJSON: the service writes it into the tile URLs). */
|
|
168
|
+
withKey(path) {
|
|
169
|
+
if (!this.apiKey) { return path; }
|
|
170
|
+
return path + (path.indexOf('?') >= 0 ? '&' : '?') + 'key=' + encodeURIComponent(this.apiKey);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A style document (a scheme or `custom:<id>`) from the nearest node that answers. */
|
|
174
|
+
style(style, lang) {
|
|
175
|
+
return this.client.request(this.withKey(stylePath(style, lang))).then(function (r) { return r.json(); });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One raster tile as a Blob, from the nearest node that answers (contract §5). */
|
|
179
|
+
rasterTile(style, z, x, y, options) {
|
|
180
|
+
return this.client.request(rasterPath(style, z, x, y, options)).then(function (r) { return r.blob(); });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A `{z}/{x}/{y}` raster URL on the current node, for tools that load images by URL (map key in the query). */
|
|
184
|
+
rasterTemplate(style, options) {
|
|
185
|
+
const o = options || {};
|
|
186
|
+
return builder('rasterUrl')(checkStyle(style), { lang: o.lang || o.language, retina: !!o.retina, baseUrl: this.head(), apiKey: this.apiKey || undefined });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Called with ('token', token) after a renewal and ('nodes', order) after a node failed. */
|
|
190
|
+
on(fn) { this._listeners.push(fn); return this; }
|
|
191
|
+
|
|
192
|
+
_emit(type, value) { this._listeners.slice().forEach(function (fn) { try { fn(type, value); } catch (e) { /* listener error */ } }); }
|
|
193
|
+
|
|
194
|
+
/** Stops renewing the token. */
|
|
195
|
+
stop() { this._stopped = true; clearTimeout(this._timer); this._timer = null; this._listeners = []; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { SMapClient };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// sMap tool adapters — the sMap badge and the data credit shown in another tool's corner. Kept identical in leaflet/,
|
|
2
|
+
// openlayers/ and mapbox/ (test/common.test.mjs checks).
|
|
3
|
+
|
|
4
|
+
/** The data credit every sMap map keeps visible (the data licence requires it). */
|
|
5
|
+
export const OSM_ATTRIBUTION = '<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a>';
|
|
6
|
+
|
|
7
|
+
const ICON = '<svg viewBox="0 0 56 56" width="14" height="14" aria-hidden="true" focusable="false">' +
|
|
8
|
+
'<rect x="2" y="2" width="52" height="52" rx="13" fill="#2563eb"/>' +
|
|
9
|
+
'<path d="M38 15H22a8 8 0 0 0 0 16h12a8 8 0 0 1 0 16H18" fill="none" stroke="#fff" stroke-width="7" ' +
|
|
10
|
+
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
11
|
+
|
|
12
|
+
const CSS = [
|
|
13
|
+
'.smap-adapter-badge{display:flex;align-items:center;gap:4px;height:20px;padding:0 7px 0 3px;border-radius:10px;',
|
|
14
|
+
'background:rgba(255,255,255,.82);box-shadow:0 0 0 1px rgba(15,23,42,.12);color:#1e293b;',
|
|
15
|
+
'font:600 11px/20px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;',
|
|
16
|
+
'letter-spacing:.01em;pointer-events:none;-webkit-user-select:none;user-select:none;margin:4px 6px;width:max-content}',
|
|
17
|
+
'.smap-adapter-badge svg{display:block;flex:none;width:14px;height:14px}',
|
|
18
|
+
// An sMap map drawn inside another tool: the tool takes the input; only popups take clicks; the tool's corner
|
|
19
|
+
// shows the badge and the credit, so the embedded map's own are hidden.
|
|
20
|
+
'.smap-embedded{position:absolute;top:0;left:0;pointer-events:none;overflow:hidden}',
|
|
21
|
+
'.smap-embedded .smap-badge,.smap-embedded .maplibregl-ctrl-attrib{display:none!important}',
|
|
22
|
+
'.smap-embedded .maplibregl-popup{pointer-events:auto}',
|
|
23
|
+
].join('');
|
|
24
|
+
|
|
25
|
+
/** Adds the adapters' stylesheet to a document once. */
|
|
26
|
+
export function injectCss(doc) {
|
|
27
|
+
if (!doc || !doc.head || doc.getElementById('smap-adapter-css')) { return; }
|
|
28
|
+
const style = doc.createElement('style');
|
|
29
|
+
style.id = 'smap-adapter-css';
|
|
30
|
+
style.textContent = CSS;
|
|
31
|
+
doc.head.appendChild(style);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The badge element (not a link): the sMap icon and name on a semi-transparent pill. */
|
|
35
|
+
export function createBadge(doc) {
|
|
36
|
+
injectCss(doc);
|
|
37
|
+
const el = doc.createElement('div');
|
|
38
|
+
el.className = 'smap-adapter-badge';
|
|
39
|
+
el.setAttribute('aria-label', 'sMap');
|
|
40
|
+
el.innerHTML = ICON + '<span>sMap</span>';
|
|
41
|
+
return el;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The credits to show: the data credit first, then every other credit a source names (once each; a source's own
|
|
46
|
+
* OpenStreetMap credit is the data credit and is not repeated).
|
|
47
|
+
*/
|
|
48
|
+
export function mergeAttributions(list) {
|
|
49
|
+
const out = [OSM_ATTRIBUTION];
|
|
50
|
+
(list || []).forEach(function (a) {
|
|
51
|
+
if (!a || typeof a !== 'string') { return; }
|
|
52
|
+
if (/openstreetmap/i.test(a)) { return; }
|
|
53
|
+
if (out.indexOf(a) < 0) { out.push(a); }
|
|
54
|
+
});
|
|
55
|
+
return out;
|
|
56
|
+
}
|