@superrouteca/smap-deckgl 0.0.0-stage → 1.1.1
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 +19 -0
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/package.json +62 -4
- package/src/basemap.js +129 -0
- package/src/index.js +8 -0
- package/src/kepler.js +31 -0
- package/src/layers.js +227 -0
- package/src/overlay.js +133 -0
- package/src/session.js +198 -0
- package/src/style.js +378 -0
- package/types/index.d.ts +168 -0
package/src/layers.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// sMap's points, pins, lines and areas as deck.gl layers, with the options of the sMap SDK's addPoints / addLines /
|
|
2
|
+
// addPolygons, so a layer looks the same on a deck.gl-only map as on an sMap map. GeoJSON simplestyle properties
|
|
3
|
+
// (marker-color, stroke, fill, …) colour a layer that gives no colour of its own, as on an sMap map.
|
|
4
|
+
import { ScatterplotLayer, IconLayer, TextLayer, PathLayer, GeoJsonLayer } from '@deck.gl/layers';
|
|
5
|
+
import { parseColor, evaluate } from './style.js';
|
|
6
|
+
|
|
7
|
+
const GEOMETRY_TYPES = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'];
|
|
8
|
+
|
|
9
|
+
/** sMap layer data (a FeatureCollection, a Feature, a geometry, or an array of points / features) as features. */
|
|
10
|
+
export function toFeatures(data) {
|
|
11
|
+
if (!data) { return []; }
|
|
12
|
+
if (data.type === 'FeatureCollection') { return data.features || []; }
|
|
13
|
+
if (data.type === 'Feature') { return [data]; }
|
|
14
|
+
if (GEOMETRY_TYPES.includes(data.type)) { return [{ type: 'Feature', properties: {}, geometry: data }]; }
|
|
15
|
+
if (Array.isArray(data)) {
|
|
16
|
+
return data.map((p) => {
|
|
17
|
+
if (p && p.type === 'Feature') { return p; }
|
|
18
|
+
let lng = p.lng ?? p.lon ?? p.longitude;
|
|
19
|
+
let lat = p.lat ?? p.latitude;
|
|
20
|
+
if (p.coordinates) { [lng, lat] = p.coordinates; }
|
|
21
|
+
return { type: 'Feature', id: p.id, properties: p, geometry: { type: 'Point', coordinates: [lng, lat] } };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A per-feature accessor for an sMap style value (a constant, `{ property, default }`, or an expression). */
|
|
28
|
+
export function accessor(value, fallback, simplestyleKey) {
|
|
29
|
+
const read = (f) => {
|
|
30
|
+
let v;
|
|
31
|
+
if (value === undefined || value === null) {
|
|
32
|
+
v = simplestyleKey && f.properties ? f.properties[simplestyleKey] : undefined;
|
|
33
|
+
} else if (Array.isArray(value)) {
|
|
34
|
+
v = evaluate(value, f, 0);
|
|
35
|
+
} else if (typeof value === 'object' && value.property) {
|
|
36
|
+
v = f.properties ? f.properties[value.property] : undefined;
|
|
37
|
+
if (v === undefined || v === null) { v = value.default; }
|
|
38
|
+
} else {
|
|
39
|
+
v = value;
|
|
40
|
+
}
|
|
41
|
+
return v === undefined || v === null ? fallback : v;
|
|
42
|
+
};
|
|
43
|
+
return read;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const colorOf = (get, opacity = 1) => (f) => {
|
|
47
|
+
const c = parseColor(get(f)) || [0, 0, 0, 255];
|
|
48
|
+
const o = typeof opacity === 'function' ? opacity(f) : opacity;
|
|
49
|
+
return [c[0], c[1], c[2], Math.round(c[3] * (o ?? 1))];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const PIN_H = 42 / 32;
|
|
53
|
+
|
|
54
|
+
function pinSvg(shape, fill, border, borderWidth) {
|
|
55
|
+
const w = Math.max(0.5, borderWidth);
|
|
56
|
+
const body = shape === 'circle' || shape === 'vehicle'
|
|
57
|
+
? '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32"><circle cx="16" cy="16" r="' + (15 - w / 2) + '" fill="' + fill + '" stroke="' + border + '" stroke-width="' + w + '"/></svg>'
|
|
58
|
+
: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="84" viewBox="0 0 32 42"><path d="M16 40.5C16 40.5 2.5 25 2.5 15.5a13.5 13.5 0 1 1 27 0C29.5 25 16 40.5 16 40.5Z" fill="' + fill + '" stroke="' + border + '" stroke-width="' + w + '" stroke-linejoin="round"/></svg>';
|
|
59
|
+
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(body);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const registry = new Map();
|
|
63
|
+
|
|
64
|
+
function remember(layer, options) {
|
|
65
|
+
if (options && options.popup) { registry.set(layer.id, options.popup); }
|
|
66
|
+
return layer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Points as deck.gl layers: circles (`color`, `radius`, `strokeColor`, `strokeWidth`), an `icon`, or route-map pins
|
|
71
|
+
* (`marker: { shape, fill, border, borderWidth, size, text, textColor, opacity }`), with a `label` under each.
|
|
72
|
+
*
|
|
73
|
+
* @returns {object[]} deck.gl layers, bottom first
|
|
74
|
+
*/
|
|
75
|
+
export function pointsLayers(id, data, options = {}) {
|
|
76
|
+
const features = toFeatures(data);
|
|
77
|
+
const out = [];
|
|
78
|
+
const pos = (f) => f.geometry.coordinates;
|
|
79
|
+
const pickable = Boolean(options.popup || options.onClick || options.pickable);
|
|
80
|
+
const onClick = options.onClick ? (info, event) => options.onClick(info.object, info, event) : undefined;
|
|
81
|
+
const mk = options.marker;
|
|
82
|
+
if (mk) {
|
|
83
|
+
const size = mk.size || 32;
|
|
84
|
+
const shape = mk.shape || 'pin';
|
|
85
|
+
const color = accessor(options.color, '#2563eb', 'marker-color');
|
|
86
|
+
const fill = accessor(mk.fill, '#ffffff');
|
|
87
|
+
const border = accessor(mk.border, undefined);
|
|
88
|
+
const opacity = accessor(mk.opacity, 1);
|
|
89
|
+
out.push(remember(new IconLayer({
|
|
90
|
+
id, data: features, pickable, onClick,
|
|
91
|
+
getPosition: pos,
|
|
92
|
+
getIcon: (f) => {
|
|
93
|
+
const b = border(f) ?? color(f);
|
|
94
|
+
const url = pinSvg(shape, fill(f), b, mk.borderWidth ?? 3);
|
|
95
|
+
const round = shape !== 'pin';
|
|
96
|
+
return { id: shape + '|' + fill(f) + '|' + b, url, width: 64, height: round ? 64 : 84, anchorY: round ? 32 : 84, mask: false };
|
|
97
|
+
},
|
|
98
|
+
getSize: shape === 'pin' ? size * PIN_H : size,
|
|
99
|
+
sizeUnits: 'pixels',
|
|
100
|
+
opacity: typeof mk.opacity === 'number' ? mk.opacity : 1,
|
|
101
|
+
getColor: (f) => [255, 255, 255, Math.round(255 * opacity(f))],
|
|
102
|
+
}), options));
|
|
103
|
+
if (mk.text !== undefined) {
|
|
104
|
+
const text = accessor(mk.text, '');
|
|
105
|
+
const textColor = accessor(mk.textColor, '#ffffff');
|
|
106
|
+
out.push(new TextLayer({
|
|
107
|
+
id: id + '-text', data: features.filter((f) => text(f) !== '' && text(f) !== null),
|
|
108
|
+
getPosition: pos, getText: (f) => String(text(f)), getColor: colorOf(textColor), getSize: Math.round(size * 0.42),
|
|
109
|
+
sizeUnits: 'pixels', characterSet: 'auto', fontWeight: 700,
|
|
110
|
+
getPixelOffset: shape === 'pin' ? [0, -Math.round(size * PIN_H - size / 2)] : [0, 0],
|
|
111
|
+
fontFamily: options.fontFamily || 'Helvetica, Arial, sans-serif',
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
} else if (options.icon) {
|
|
115
|
+
const s = options.iconSize || 1;
|
|
116
|
+
out.push(remember(new IconLayer({
|
|
117
|
+
id, data: features, pickable, onClick, getPosition: pos, sizeUnits: 'pixels', getSize: 32 * s,
|
|
118
|
+
getIcon: () => ({ id: options.icon, url: options.icon, width: 64, height: 64, anchorY: options.iconAnchor === 'bottom' ? 64 : 32 }),
|
|
119
|
+
}), options));
|
|
120
|
+
} else {
|
|
121
|
+
const color = accessor(options.color, '#2563eb', 'marker-color');
|
|
122
|
+
const sizes = { small: 5, medium: 7, large: 10 };
|
|
123
|
+
const radius = options.radius !== undefined ? () => options.radius
|
|
124
|
+
: (f) => sizes[(f.properties || {})['marker-size']] || 6;
|
|
125
|
+
out.push(remember(new ScatterplotLayer({
|
|
126
|
+
id, data: features, pickable, onClick, getPosition: pos,
|
|
127
|
+
radiusUnits: 'pixels', getRadius: radius,
|
|
128
|
+
getFillColor: colorOf(color), stroked: true, lineWidthUnits: 'pixels',
|
|
129
|
+
getLineColor: colorOf(accessor(options.strokeColor, '#ffffff')), getLineWidth: options.strokeWidth ?? 2,
|
|
130
|
+
}), options));
|
|
131
|
+
}
|
|
132
|
+
if (options.label) {
|
|
133
|
+
const right = options.labelPosition === 'right';
|
|
134
|
+
out.push(new TextLayer({
|
|
135
|
+
id: id + '-label', data: features.filter((f) => f.properties && f.properties[options.label] != null),
|
|
136
|
+
getPosition: pos, getText: (f) => String(f.properties[options.label]), getSize: 12, sizeUnits: 'pixels',
|
|
137
|
+
characterSet: 'auto', getColor: [51, 51, 51, 255], fontSettings: { sdf: true }, outlineWidth: 2, outlineColor: [255, 255, 255, 255],
|
|
138
|
+
getTextAnchor: right ? 'start' : 'middle', getAlignmentBaseline: right ? 'center' : 'top',
|
|
139
|
+
getPixelOffset: right ? [12, 0] : [0, 10],
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function paths(f) {
|
|
146
|
+
const g = f.geometry;
|
|
147
|
+
if (g.type === 'LineString') { return [g.coordinates]; }
|
|
148
|
+
if (g.type === 'MultiLineString') { return g.coordinates; }
|
|
149
|
+
if (g.type === 'Polygon') { return g.coordinates; }
|
|
150
|
+
if (g.type === 'MultiPolygon') { return g.coordinates.flat(); }
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Lines as deck.gl PathLayers: `color`, `width`, `opacity`, an optional `casing` and `shadow` under the line.
|
|
156
|
+
* (`dash` and `arrows` need deck.gl extensions and are not drawn.)
|
|
157
|
+
*/
|
|
158
|
+
export function linesLayers(id, data, options = {}) {
|
|
159
|
+
const rows = [];
|
|
160
|
+
for (const f of toFeatures(data)) { for (const p of paths(f)) { rows.push({ path: p, feature: f }); } }
|
|
161
|
+
const pickable = Boolean(options.popup || options.onClick || options.pickable);
|
|
162
|
+
const onClick = options.onClick ? (info, event) => options.onClick(info.object && info.object.feature, info, event) : undefined;
|
|
163
|
+
const color = accessor(options.color, '#2563eb', 'stroke');
|
|
164
|
+
const width = options.width ?? 4;
|
|
165
|
+
const strokeWidth = (r) => (options.width === undefined && r.feature.properties && r.feature.properties['stroke-width']) || width;
|
|
166
|
+
const out = [];
|
|
167
|
+
const under = (key, defaults) => {
|
|
168
|
+
const o = options[key];
|
|
169
|
+
if (!o) { return; }
|
|
170
|
+
const c = o === true ? {} : o;
|
|
171
|
+
const cColor = accessor(c.color, defaults.color);
|
|
172
|
+
out.push(new PathLayer({
|
|
173
|
+
id: id + '-' + key, data: rows, getPath: (r) => r.path, widthUnits: 'pixels', capRounded: true, jointRounded: true,
|
|
174
|
+
getWidth: c.width ?? defaults.width, getColor: (r) => colorOf(cColor, c.opacity ?? defaults.opacity)(r.feature),
|
|
175
|
+
}));
|
|
176
|
+
};
|
|
177
|
+
under('casing', { color: '#ffffff', width: Math.max(9, width + 4), opacity: 1 });
|
|
178
|
+
under('shadow', { color: '#000000', width: Math.max(7, width + 2), opacity: 0.3 });
|
|
179
|
+
const opacity = options.opacity ?? null;
|
|
180
|
+
out.push(remember(new PathLayer({
|
|
181
|
+
id, data: rows, pickable, onClick, getPath: (r) => r.path, widthUnits: 'pixels', capRounded: true, jointRounded: true,
|
|
182
|
+
getWidth: strokeWidth,
|
|
183
|
+
getColor: (r) => colorOf(color, opacity ?? ((r.feature.properties || {})['stroke-opacity'] ?? 1))(r.feature),
|
|
184
|
+
}), options));
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Areas as a deck.gl GeoJsonLayer: `fill` (or `color`), `opacity`, `outline`, `outlineWidth`, and `extrude`
|
|
190
|
+
* (`{ height, base }` in metres, a number or a property name) for 3D.
|
|
191
|
+
*/
|
|
192
|
+
export function polygonsLayers(id, data, options = {}) {
|
|
193
|
+
const features = toFeatures(data).filter((f) => f.geometry && /Polygon$/.test(f.geometry.type));
|
|
194
|
+
const pickable = Boolean(options.popup || options.onClick || options.pickable);
|
|
195
|
+
const onClick = options.onClick ? (info, event) => options.onClick(info.object, info, event) : undefined;
|
|
196
|
+
const fill = accessor(options.fill ?? options.color, '#2563eb', 'fill');
|
|
197
|
+
const outline = accessor(options.outline, undefined, 'stroke');
|
|
198
|
+
const opacity = options.opacity ?? null;
|
|
199
|
+
const num = (v, f) => (typeof v === 'string' ? Number((f.properties || {})[v]) || 0 : Number(v) || 0);
|
|
200
|
+
const ex = options.extrude;
|
|
201
|
+
return [remember(new GeoJsonLayer({
|
|
202
|
+
id, data: features, pickable, onClick,
|
|
203
|
+
filled: true, stroked: true, lineWidthUnits: 'pixels',
|
|
204
|
+
getFillColor: (f) => colorOf(fill, opacity ?? ((f.properties || {})['fill-opacity'] ?? (ex ? 0.9 : 0.3)))(f),
|
|
205
|
+
getLineColor: (f) => colorOf((g) => outline(g) ?? fill(g))(f),
|
|
206
|
+
getLineWidth: options.outlineWidth ?? 1,
|
|
207
|
+
extruded: Boolean(ex),
|
|
208
|
+
getElevation: ex ? (f) => Math.max(0, num(ex.height ?? 0, f) - num(ex.base ?? 0, f)) : 0,
|
|
209
|
+
}), options)];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A deck.gl `getTooltip` that shows the `popup` of the layer under the pointer (the layers made by pointsLayers,
|
|
214
|
+
* linesLayers and polygonsLayers with a `popup` option). Popups returning a promise or a DOM node are not shown here;
|
|
215
|
+
* on an sMap map use the map's own popups instead.
|
|
216
|
+
*/
|
|
217
|
+
export function popupTooltip(fallback) {
|
|
218
|
+
return (info) => {
|
|
219
|
+
const fn = info && info.layer ? registry.get(info.layer.id) : null;
|
|
220
|
+
if (fn && info.object) {
|
|
221
|
+
const feature = info.object.feature || info.object;
|
|
222
|
+
const html = fn(feature.properties || {}, feature);
|
|
223
|
+
if (typeof html === 'string' && html) { return { html, className: 'smap-deck-popup' }; }
|
|
224
|
+
}
|
|
225
|
+
return fallback ? fallback(info) : null;
|
|
226
|
+
};
|
|
227
|
+
}
|
package/src/overlay.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// deck.gl and an sMap map together, through the map's own deck.gl overlay (addDeckLayer of @superrouteca/smap 1.1.0:
|
|
2
|
+
// deck.gl's MapboxOverlay, interleaved with the basemap; one overlay per map).
|
|
3
|
+
// - createSMapDeck: a deck.gl app whose basemap is an sMap map, with deck.gl's own props (getTooltip, onClick, …).
|
|
4
|
+
// - deckLayers: deck.gl layers on an existing sMap map, kept by id, placed by slot or before a layer.
|
|
5
|
+
import * as deckMapbox from '@deck.gl/mapbox';
|
|
6
|
+
import { useDeck } from '@superrouteca/smap';
|
|
7
|
+
import { ATTRIBUTION } from './session.js';
|
|
8
|
+
|
|
9
|
+
// Options of createSMapDeck itself; every other option is the sMap map's.
|
|
10
|
+
const OWN_OPTIONS = ['deck', 'layers', 'sMap'];
|
|
11
|
+
|
|
12
|
+
let registered = null;
|
|
13
|
+
/** Hands deck.gl's map overlay module to the SDK once (its addDeckLayer draws through it). */
|
|
14
|
+
export function registerDeck(sMap) {
|
|
15
|
+
const use = (sMap && sMap.useDeck) || useDeck;
|
|
16
|
+
if (registered !== use) { use(deckMapbox); registered = use; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An sMap map with deck.gl drawn interleaved with the basemap (deck.gl layers between sMap's layers and sharing its
|
|
21
|
+
* depth, so 3D buildings and deck.gl columns hide each other correctly).
|
|
22
|
+
*
|
|
23
|
+
* @param {object} options sMap map options (container, apiKey / token / getToken, style, language, center, zoom, …),
|
|
24
|
+
* plus `layers` (deck.gl layers), `deck` (other deck.gl props of the overlay: getTooltip,
|
|
25
|
+
* onClick, pickingRadius, …) and `sMap` (the @superrouteca/smap module; imported when not given).
|
|
26
|
+
* @returns {Promise<{ map, overlay, setLayers(layers), setProps(props), remove() }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function createSMapDeck(options = {}) {
|
|
29
|
+
const sMap = options.sMap || await import('@superrouteca/smap');
|
|
30
|
+
registerDeck(sMap);
|
|
31
|
+
const mapOptions = {};
|
|
32
|
+
for (const k of Object.keys(options)) {
|
|
33
|
+
if (!OWN_OPTIONS.includes(k)) { mapOptions[k] = options[k]; }
|
|
34
|
+
}
|
|
35
|
+
const map = new sMap.Map(mapOptions);
|
|
36
|
+
await map.ready;
|
|
37
|
+
const { layers: deckPropLayers, ...deckProps } = options.deck || {};
|
|
38
|
+
const layers = deckLayers(map);
|
|
39
|
+
layers.set(options.layers || deckPropLayers || []);
|
|
40
|
+
// The overlay is the map's own (deckOverlay of @superrouteca/smap 1.1.0); its other props (tooltips, clicks) are
|
|
41
|
+
// deck.gl's own.
|
|
42
|
+
const overlay = () => map.deckOverlay();
|
|
43
|
+
const applyProps = (props) => {
|
|
44
|
+
if (!Object.keys(props).length) { return; }
|
|
45
|
+
map.deckOverlay(props);
|
|
46
|
+
};
|
|
47
|
+
applyProps(deckProps);
|
|
48
|
+
return {
|
|
49
|
+
map,
|
|
50
|
+
get overlay() { return overlay(); },
|
|
51
|
+
layers,
|
|
52
|
+
setLayers(next) { layers.set(next); return this; },
|
|
53
|
+
setProps(props) {
|
|
54
|
+
const { layers: l, ...rest } = props || {};
|
|
55
|
+
if (l) { layers.set(l); }
|
|
56
|
+
applyProps(rest);
|
|
57
|
+
return this;
|
|
58
|
+
},
|
|
59
|
+
pickObject(opts) { return overlay() ? overlay().pickObject(opts) : null; },
|
|
60
|
+
remove() { layers.clear(); map.remove(); },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* deck.gl layers on an sMap map, kept by id: `add` replaces a layer of the same id (deck.gl's way of updating a
|
|
66
|
+
* layer: make a new one with the same id), `set` replaces them all. Builds on the map's addDeckLayer /
|
|
67
|
+
* removeDeckLayer; `placement` is the map's `{ slot, before }` (a layer without one draws on top, as added).
|
|
68
|
+
*
|
|
69
|
+
* @param {object} map an sMap map (1.1.0 or later)
|
|
70
|
+
*/
|
|
71
|
+
export function deckLayers(map) {
|
|
72
|
+
if (!map || typeof map.addDeckLayer !== 'function') {
|
|
73
|
+
throw new Error('sMap: this map has no addDeckLayer (needs @superrouteca/smap 1.1.0 or later)');
|
|
74
|
+
}
|
|
75
|
+
registerDeck();
|
|
76
|
+
const current = new Map();
|
|
77
|
+
const api = {
|
|
78
|
+
add(layers, placement) {
|
|
79
|
+
for (const layer of [].concat(layers).flat()) {
|
|
80
|
+
if (!layer) { continue; }
|
|
81
|
+
if (placement && (placement.slot || placement.before)) { map.addDeckLayer(layer, placement); } else { map.addDeckLayer(layer); }
|
|
82
|
+
current.set(layer.id, layer);
|
|
83
|
+
}
|
|
84
|
+
return api;
|
|
85
|
+
},
|
|
86
|
+
remove(...ids) {
|
|
87
|
+
for (const id of ids.flat()) {
|
|
88
|
+
if (current.has(id)) { map.removeDeckLayer(id); current.delete(id); }
|
|
89
|
+
}
|
|
90
|
+
return api;
|
|
91
|
+
},
|
|
92
|
+
set(layers, placement) {
|
|
93
|
+
const next = new Set([].concat(layers || []).flat().filter(Boolean).map((l) => l.id));
|
|
94
|
+
for (const id of [...current.keys()]) { if (!next.has(id)) { api.remove(id); } }
|
|
95
|
+
return api.add(layers || [], placement);
|
|
96
|
+
},
|
|
97
|
+
get(id) { return current.get(id) || null; },
|
|
98
|
+
ids() { return [...current.keys()]; },
|
|
99
|
+
clear() { return api.remove([...current.keys()]); },
|
|
100
|
+
};
|
|
101
|
+
return api;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const BADGE_ICON = '<svg viewBox="0 0 56 56" width="14" height="14" aria-hidden="true" focusable="false">' +
|
|
105
|
+
'<rect x="2" y="2" width="52" height="52" rx="13" fill="#2563eb"/>' +
|
|
106
|
+
'<path d="M38 15H22a8 8 0 0 0 0 16h12a8 8 0 0 1 0 16H18" fill="none" stroke="#fff" stroke-width="7" ' +
|
|
107
|
+
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The sMap badge and the data credit in the bottom-right corner of a deck.gl canvas's container (for the deck.gl-only
|
|
111
|
+
* basemap, which has no attribution control of its own). Returns the element; `remove()` takes it away.
|
|
112
|
+
*
|
|
113
|
+
* @param {HTMLElement} container the element deck.gl draws in (position: relative or absolute)
|
|
114
|
+
* @param {{ attribution?: string|string[] }} [options] credits of other sources, shown after the OpenStreetMap one
|
|
115
|
+
*/
|
|
116
|
+
export function addAttribution(container, options = {}) {
|
|
117
|
+
const doc = container.ownerDocument;
|
|
118
|
+
const el = doc.createElement('div');
|
|
119
|
+
el.className = 'smap-deck-attribution';
|
|
120
|
+
el.setAttribute('style', 'position:absolute;right:0;bottom:0;z-index:1;display:flex;gap:6px;align-items:center;' +
|
|
121
|
+
'padding:2px 6px;background:rgba(255,255,255,.75);font:11px/16px Helvetica,Arial,sans-serif;color:#333;pointer-events:auto');
|
|
122
|
+
const badge = doc.createElement('span');
|
|
123
|
+
badge.className = 'smap-badge';
|
|
124
|
+
badge.setAttribute('style', 'display:inline-flex;gap:3px;align-items:center;font-weight:600');
|
|
125
|
+
badge.innerHTML = BADGE_ICON + '<span>sMap</span>';
|
|
126
|
+
const credit = doc.createElement('span');
|
|
127
|
+
const extra = [].concat(options.attribution || []).filter((a) => a && a !== ATTRIBUTION);
|
|
128
|
+
credit.textContent = [ATTRIBUTION, ...extra].join(' | ');
|
|
129
|
+
el.appendChild(badge);
|
|
130
|
+
el.appendChild(credit);
|
|
131
|
+
container.appendChild(el);
|
|
132
|
+
return el;
|
|
133
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// One connection to sMap for a tool adapter: the JS client of @superrouteca/smap (nearest node, failover, map key,
|
|
2
|
+
// token and its refresh) plus the URLs a tool needs on the node read from now (built by the SDK's styleUrl, rasterUrl
|
|
3
|
+
// and rasterTileJsonUrl) and the headers a tool's own requests carry. The node, key, token and URL logic is the
|
|
4
|
+
// SDK's; this file only asks it.
|
|
5
|
+
//
|
|
6
|
+
// The same file is used by @superrouteca/smap-deckgl, -arcgis and -cesium.
|
|
7
|
+
import SMapClient from '@superrouteca/smap/client';
|
|
8
|
+
import { styleUrl as sdkStyleUrl, rasterUrl as sdkRasterUrl, rasterTileJsonUrl as sdkRasterTileJsonUrl } from '@superrouteca/smap';
|
|
9
|
+
|
|
10
|
+
export const FLAVORS = ['light', 'white', 'grayscale', 'dark', 'black'];
|
|
11
|
+
export const ATTRIBUTION = '© OpenStreetMap contributors';
|
|
12
|
+
export const DEFAULT_BASE = 'https://tile.srsr.ca';
|
|
13
|
+
|
|
14
|
+
const strip = (u) => String(u).replace(/\/+$/, '');
|
|
15
|
+
|
|
16
|
+
/** `light` → `light`; `custom:sms_…` → `custom:sms_…`; anything else is refused with a message naming it. */
|
|
17
|
+
export function checkStyle(style) {
|
|
18
|
+
const s = style == null ? 'light' : String(style);
|
|
19
|
+
if (FLAVORS.includes(s)) { return s; }
|
|
20
|
+
if (/^custom:[A-Za-z0-9_-]+$/.test(s)) { return s; }
|
|
21
|
+
throw new Error('sMap: unknown style "' + s + '" (one of ' + FLAVORS.join(', ') + ', or custom:<id>)');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addQuery(url, params) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (const [k, v] of Object.entries(params)) {
|
|
27
|
+
if (v !== undefined && v !== null && v !== '') { parts.push(k + '=' + encodeURIComponent(v)); }
|
|
28
|
+
}
|
|
29
|
+
return parts.length ? url + (url.includes('?') ? '&' : '?') + parts.join('&') : url;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Session {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} options the JS client's options (baseUrl, apiKey, token, getToken, nodes, nearest, fetch,
|
|
35
|
+
* timeoutMs), or `{ client }` with a client of your own. `baseUrl` defaults to tile.srsr.ca.
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
if (options instanceof Session) { return options; }
|
|
39
|
+
this.client = options.client || new SMapClient({ ...options, baseUrl: options.baseUrl || DEFAULT_BASE });
|
|
40
|
+
this.baseUrl = this.client.baseUrl;
|
|
41
|
+
this.apiKey = options.apiKey || (options.client && options.client.apiKey) || null;
|
|
42
|
+
this._seeds = (options.nodes || []).map(strip);
|
|
43
|
+
this._order = [this.baseUrl];
|
|
44
|
+
this._token = options.token || null;
|
|
45
|
+
this._listeners = { nodechange: [], token: [] };
|
|
46
|
+
this._timer = null;
|
|
47
|
+
this._ready = null;
|
|
48
|
+
this._closed = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolves once the nearest node is known and a token (where one is used) is at hand. */
|
|
52
|
+
ready() {
|
|
53
|
+
if (!this._ready) {
|
|
54
|
+
this._ready = Promise.all([this.client.nodes(), this.client.token()]).then(([nodes, token]) => {
|
|
55
|
+
this._order = nodes && nodes.length ? nodes.slice() : [this.baseUrl];
|
|
56
|
+
this._setToken(token);
|
|
57
|
+
return this;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return this._ready;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The node read from now: the nearest one that has not failed. */
|
|
64
|
+
node() { return this._order[0] || this.baseUrl; }
|
|
65
|
+
|
|
66
|
+
/** Every node in the order they are tried. */
|
|
67
|
+
nodes() { return this._order.slice(); }
|
|
68
|
+
|
|
69
|
+
/** The token sent now (kept fresh in the background), or null. */
|
|
70
|
+
token() { return this._token; }
|
|
71
|
+
|
|
72
|
+
_setToken(token) {
|
|
73
|
+
const changed = token !== this._token;
|
|
74
|
+
this._token = token || null;
|
|
75
|
+
if (changed) { this._emit('token', this._token); }
|
|
76
|
+
this._schedule();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The client renews a token two minutes before it expires; the adapters read it synchronously (request hooks and
|
|
80
|
+
// tile URLs cannot wait), so it is asked again shortly before that moment.
|
|
81
|
+
_schedule() {
|
|
82
|
+
clearTimeout(this._timer);
|
|
83
|
+
if (this._closed || !this._token || !this.client.renewsToken) { return; }
|
|
84
|
+
const exp = SMapClient.tokenExpiry(this._token);
|
|
85
|
+
if (!exp) { return; }
|
|
86
|
+
const wait = Math.max(5, exp - Math.floor(Date.now() / 1000) - 110) * 1000;
|
|
87
|
+
this._timer = setTimeout(() => {
|
|
88
|
+
this.client.token().then((t) => this._setToken(t), () => { this._timer = setTimeout(() => this._schedule(), 30000); });
|
|
89
|
+
}, Math.min(wait, 2147483000));
|
|
90
|
+
if (this._timer && this._timer.unref) { this._timer.unref(); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The headers a request to sMap carries: the token and the map key. */
|
|
94
|
+
headers() {
|
|
95
|
+
const h = {};
|
|
96
|
+
if (this._token) { h.Authorization = 'Bearer ' + this._token; }
|
|
97
|
+
if (this.apiKey) { h['X-SMap-Key'] = this.apiKey; }
|
|
98
|
+
return h;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every address requests to sMap may go to (base, seeds, measured nodes). */
|
|
102
|
+
origins() { return [...new Set([this.baseUrl, ...this._seeds, ...this._order])]; }
|
|
103
|
+
|
|
104
|
+
/** The node a URL is on, or null when it is not an sMap URL. */
|
|
105
|
+
nodeOf(url) {
|
|
106
|
+
const u = String(url);
|
|
107
|
+
return this.origins().filter((o) => u === o || u.startsWith(o + '/')).sort((a, b) => b.length - a.length)[0] || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The path (with query) of an sMap URL, or null. */
|
|
111
|
+
pathOf(url) {
|
|
112
|
+
const n = this.nodeOf(url);
|
|
113
|
+
return n === null ? null : String(url).slice(n.length) || '/';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The same sMap URL on the node read from now; other URLs unchanged. */
|
|
117
|
+
rebase(url) {
|
|
118
|
+
const p = this.pathOf(url);
|
|
119
|
+
return p === null ? url : this.node() + p;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A node did not answer: it moves to the end and the next one is read from. Returns true when the node read from
|
|
124
|
+
* changed (callers then re-point their tile URLs).
|
|
125
|
+
*/
|
|
126
|
+
failed(urlOrNode) {
|
|
127
|
+
const n = this.nodeOf(urlOrNode) || strip(urlOrNode);
|
|
128
|
+
const i = this._order.indexOf(n);
|
|
129
|
+
if (i < 0 || this._order.length < 2) { return false; }
|
|
130
|
+
const before = this.node();
|
|
131
|
+
this._order.splice(i, 1);
|
|
132
|
+
this._order.push(n);
|
|
133
|
+
// The client's own order follows, so its requests (styles, TileJSON) and these tile URLs agree.
|
|
134
|
+
if (typeof this.client.demote === 'function') { this.client.demote(n); }
|
|
135
|
+
if (this.node() !== before) { this._emit('nodechange', this.node()); return true; }
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Style URL of a scheme or a saved custom style (`custom:<id>`), with labels in `language`, on the current node. */
|
|
140
|
+
styleUrl(style, language) {
|
|
141
|
+
return sdkStyleUrl(checkStyle(style), { baseUrl: this.node(), lang: language, apiKey: this.apiKey || undefined });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** TileJSON URL of a vector source (default the basemap). */
|
|
145
|
+
tileJsonUrl(source = 'basemap') {
|
|
146
|
+
return addQuery(this.node() + '/v1/tiles/' + encodeURIComponent(source), { key: this.apiKey });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Raster tile template (contract §5): `{z}/{x}/{y}` by default; `placeholders` renames them for tools with their
|
|
151
|
+
* own (`{ z: '{level}', x: '{col}', y: '{row}' }`).
|
|
152
|
+
*/
|
|
153
|
+
rasterTemplate(style, { language, retina = false, placeholders } = {}) {
|
|
154
|
+
const url = sdkRasterUrl(checkStyle(style), { baseUrl: this.node(), lang: language, retina, apiKey: this.apiKey || undefined });
|
|
155
|
+
const p = placeholders || {};
|
|
156
|
+
return url.replace('{z}', p.z || '{z}').replace('{x}', p.x || '{x}').replace('{y}', p.y || '{y}');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** TileJSON (raster) of a style. */
|
|
160
|
+
rasterTileJsonUrl(style, { language } = {}) {
|
|
161
|
+
return sdkRasterTileJsonUrl(checkStyle(style), { baseUrl: this.node(), lang: language, apiKey: this.apiKey || undefined });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** WMTS 1.0.0 capabilities of the raster styles. */
|
|
165
|
+
wmtsCapabilitiesUrl() {
|
|
166
|
+
return addQuery(this.node() + '/v1/raster/wmts', { SERVICE: 'WMTS', REQUEST: 'GetCapabilities', key: this.apiKey });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fetches a URL: sMap URLs through the client (nearest node, the next one on failure, token and key); any other URL
|
|
171
|
+
* with plain fetch.
|
|
172
|
+
*/
|
|
173
|
+
fetch(url, init) {
|
|
174
|
+
const path = this.pathOf(url);
|
|
175
|
+
if (path === null) { return globalThis.fetch(url, init); }
|
|
176
|
+
const extra = {};
|
|
177
|
+
const h = init && init.headers;
|
|
178
|
+
if (h) { for (const [k, v] of (typeof h.entries === 'function' ? h.entries() : Object.entries(h))) { extra[k] = v; } }
|
|
179
|
+
return this.client.request(path, extra);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The style document of a scheme or custom style, read through the client. */
|
|
183
|
+
style(style, language) {
|
|
184
|
+
return this.fetch(this.styleUrl(style, language)).then((r) => r.json());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
on(type, fn) { (this._listeners[type] || (this._listeners[type] = [])).push(fn); return () => this.off(type, fn); }
|
|
188
|
+
off(type, fn) { const l = this._listeners[type] || []; const i = l.indexOf(fn); if (i >= 0) { l.splice(i, 1); } }
|
|
189
|
+
_emit(type, value) { for (const fn of (this._listeners[type] || []).slice()) { try { fn(value); } catch (e) { /* listener */ } } }
|
|
190
|
+
|
|
191
|
+
/** Stops the token refresh. */
|
|
192
|
+
close() { this._closed = true; clearTimeout(this._timer); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A session from options, or the session given. */
|
|
196
|
+
export function createSession(options) {
|
|
197
|
+
return options instanceof Session ? options : new Session(options || {});
|
|
198
|
+
}
|