@superrouteca/smap-arcgis 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 ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0 — 2026-10-27
4
+
5
+ - First version.
6
+ - sMap as an ArcGIS basemap: vector tiles in sMap's style (`createSMapBasemap`, `createVectorTileLayer`; the five
7
+ colour schemes, custom styles and labels in any language, restyled in place) or sMap's raster tiles
8
+ (`createRasterTileLayer`). The map key or token on every request, the nearest map node and the next one when a node
9
+ fails (`installInterceptor`); the sMap badge and the OpenStreetMap credit (`addAttribution`).
10
+ - sMap's points, pins, lines and areas as ArcGIS graphics with the SDK's options and popups (`createGraphicsLayer`).
11
+ - ArcGIS REST services on an sMap map (`addArcGisService`): tile and vector tile services, and feature layers read as
12
+ GeoJSON and coloured by their renderer.
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,117 @@
1
- # Temporary Holding Version
1
+ # @superrouteca/smap-arcgis
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ sMap and the [ArcGIS Maps SDK for JavaScript](https://developers.arcgis.com/javascript/) both ways:
4
+
5
+ - **sMap as an ArcGIS basemap**: a `VectorTileLayer` with sMap's style and vector tiles (the five colour schemes and
6
+ your saved custom styles, labels in any language), or a `WebTileLayer` of sMap's raster tiles. The map key or token
7
+ goes on every request through ArcGIS request interceptors; requests go to the nearest map node and move to the next
8
+ one when a node fails; the sMap badge and the OpenStreetMap credit are shown.
9
+ - **sMap's points, pins, lines and areas as ArcGIS graphics**, with the options of the SDK's `addPoints` / `addLines` /
10
+ `addPolygons` and popups as ArcGIS popups.
11
+ - **ArcGIS REST services on an sMap map**: cached map and image services and vector tile services through the map's
12
+ `addArcGisLayer`, and feature layers queried as GeoJSON, coloured by their own renderer.
13
+
14
+ ```sh
15
+ npm install @superrouteca/smap-arcgis @arcgis/core
16
+ ```
17
+
18
+ `@arcgis/core` 5 is a peer dependency (not needed for `@superrouteca/smap-arcgis/services`). The adapter depends on
19
+ `@superrouteca/smap` (1.1.0) for the nodes, the key and the token.
20
+
21
+ ## Access
22
+
23
+ Every call takes the options of the sMap JS client: `apiKey` (a map key, `smk_…`, whose allowlist names your site),
24
+ or `token` / `getToken` (short-lived tokens from your server), plus `baseUrl` (default `https://tile.srsr.ca`),
25
+ `nodes` and `nearest`. Share one session between layers with `session: createSession({ … })`.
26
+
27
+ ## sMap as the basemap
28
+
29
+ ```js
30
+ import Map from '@arcgis/core/Map.js';
31
+ import MapView from '@arcgis/core/views/MapView.js';
32
+ import { createSMapBasemap, addAttribution } from '@superrouteca/smap-arcgis';
33
+
34
+ const basemap = await createSMapBasemap({ apiKey: 'smk_…', style: 'light', language: 'fr' });
35
+ const view = new MapView({ container: 'map', map: new Map({ basemap }), center: [2.35, 48.86], zoom: 12 });
36
+ addAttribution(view); // the sMap badge and the OpenStreetMap credit, bottom right
37
+ ```
38
+
39
+ An existing ArcGIS app switches its basemap and keeps every layer: `map.basemap = await createSMapBasemap({ … })`.
40
+
41
+ - `style`: `light`, `white`, `grayscale`, `dark`, `black`, or a saved custom style `custom:sms_…`.
42
+ - `raster: true` uses sMap's raster tiles (a `WebTileLayer`, `retina: true` for 512 px images): the exact sMap look
43
+ wherever vector tiles are not wanted, and in a `SceneView` as draped imagery.
44
+ - `basemap: { pois: { visible: false }, roads: { color: '#ffffff' } }` shows, hides and recolours the basemap by
45
+ class (vector), as the sMap map's `setBasemap`.
46
+ - `basemap.smap.setStyle(style)` / `setLanguage(language)` / `setBasemap(overrides)` (vector) restyle the layer in
47
+ place.
48
+
49
+ The layers on their own: `createVectorTileLayer(options)` and `createRasterTileLayer(options)`.
50
+
51
+ ### Keys, tokens and nodes
52
+
53
+ `createSMapBasemap` (and the layer functions) install one request interceptor per session
54
+ (`installInterceptor(session)`, returning `{ remove() }`): every request to an sMap node is sent to the node read from
55
+ now, with `Authorization: Bearer <token>` and `X-SMap-Key` headers; a network error or a 5xx answer moves the session
56
+ to the next node, so the tiles that follow come from it. A map key also travels in the style's own tile URLs, so key
57
+ access works even where a request does not pass through the interceptors. Tokens are renewed in the background
58
+ before they expire.
59
+
60
+ ArcGIS draws the Style Specification styles it supports; sMap's schemes keep to what it reads. 3D buildings are not
61
+ drawn by ArcGIS from vector tiles: in a `SceneView` the sMap basemap is a flat, draped layer.
62
+
63
+ ## sMap's layers as graphics
64
+
65
+ ```js
66
+ import { createGraphicsLayer } from '@superrouteca/smap-arcgis';
67
+ import { popupTemplate } from '@superrouteca/smap';
68
+
69
+ view.map.addMany([
70
+ createGraphicsLayer('zones', 'areas', zones, { fill: '#2563eb', opacity: 0.2, outline: '#2563eb' }),
71
+ createGraphicsLayer('route', 'lines', route, { color: { property: 'color' }, width: 5 }),
72
+ createGraphicsLayer('stops', 'points', stops, {
73
+ marker: { border: { property: 'color' } }, label: 'name',
74
+ popup: popupTemplate('<b>{name}</b><br>{address}'), popupTitle: 'Stop',
75
+ }),
76
+ ]);
77
+ ```
78
+
79
+ Points: circles, an icon, or pins (`pin` and `circle` shapes, fill, border); lines: colour per feature, width,
80
+ opacity, dashes; areas: fill, outline, opacity; `minzoom` / `maxzoom` become the layer's scale range; a `label` is a
81
+ text graphic beside each point. GeoJSON simplestyle properties colour a layer that gives no colour of its own. The
82
+ `popup` option is each graphic's popup content (a string, a DOM node or a promise of either). Numbers inside pins,
83
+ count badges, casings, arrows, clustering, extrusion and Auto Zoom are not drawn here; they are the sMap map's own.
84
+
85
+ ## ArcGIS services on an sMap map
86
+
87
+ ```js
88
+ import * as sMap from '@superrouteca/smap';
89
+ import { addArcGisService } from '@superrouteca/smap-arcgis/services';
90
+
91
+ const map = new sMap.Map({ container: 'map', apiKey: 'smk_…' });
92
+ await map.ready;
93
+ await addArcGisService(map, 'hillshade', 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', { slot: 'bottom' });
94
+ await addArcGisService(map, 'parcels', 'https://example.com/arcgis/rest/services/Parcels/FeatureServer/0', {
95
+ token: arcgisToken, where: "zoning = 'R1'", layerOptions: { popup: sMap.popupTemplate('{address}') },
96
+ });
97
+ ```
98
+
99
+ - Cached `MapServer` / `ImageServer` and `VectorTileServer` services go through the map's
100
+ `addArcGisLayer(id, { url, attribution, slot, ...layerOptions })`, which reads the service (its tile size, its
101
+ vector tile style, and its copyright text as the default attribution).
102
+ - One layer of a feature or map service (`…/FeatureServer/0`, `…/MapServer/2`) is queried as GeoJSON in WGS 84, page
103
+ by page (`maxFeatures`, default 50,000), coloured by the layer's simple or unique-value renderer, and added with
104
+ `map.addData` (points, lines and areas as `<id>`, `<id>:lines` and `<id>:areas`).
105
+ - `token` is an ArcGIS token for secured services. `slot` is passed only when given; a layer without one is drawn on
106
+ top, in the order added.
107
+ - Helpers: `queryFeatures(url, options)`, `serviceInfo(url)`, `parseServiceUrl(url)`, `rendererStyle`,
108
+ `symbolToSimplestyle`.
109
+
110
+ ## Example
111
+
112
+ [`examples/index.html`](examples/index.html): serve the repository root (`npx serve .`) and open
113
+ `/arcgis/examples/?key=smk_…` (`&raster=1` for raster tiles).
114
+
115
+ ## Licence
116
+
117
+ MIT. The map data is © OpenStreetMap contributors; the credit stays visible on every map.
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@superrouteca/smap-arcgis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "sMap for the ArcGIS Maps SDK for JavaScript: sMap basemaps (vector and raster), sMap layers as graphics, and ArcGIS REST services on sMap maps.",
5
+ "license": "MIT",
6
+ "author": "Goodneo Solutions",
7
+ "homepage": "https://github.com/Goodneo-Solutions/smap-sdk/tree/main/arcgis#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Goodneo-Solutions/smap-sdk.git",
11
+ "directory": "arcgis"
12
+ },
13
+ "keywords": [
14
+ "map",
15
+ "maps",
16
+ "arcgis",
17
+ "esri",
18
+ "vector-tiles",
19
+ "smap",
20
+ "superroute"
21
+ ],
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "main": "./src/index.js",
25
+ "module": "./src/index.js",
26
+ "types": "./types/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./types/index.d.ts",
30
+ "default": "./src/index.js"
31
+ },
32
+ "./services": {
33
+ "types": "./types/services.d.ts",
34
+ "default": "./src/services.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "src",
40
+ "types",
41
+ "README.md",
42
+ "CHANGELOG.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "scripts": {
49
+ "test": "node --import ./test/setup.mjs --test test/*.test.mjs",
50
+ "typecheck": "tsc -p test/tsconfig.json"
51
+ },
52
+ "dependencies": {
53
+ "@superrouteca/smap": "1.1.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@arcgis/core": "^5.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@arcgis/core": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@arcgis/core": "5.1.27",
65
+ "typescript": "6.0.3"
66
+ }
67
+ }
package/src/basemap.js ADDED
@@ -0,0 +1,170 @@
1
+ // sMap in the ArcGIS Maps SDK for JavaScript: a VectorTileLayer with sMap's style and vector tiles (the five schemes
2
+ // and custom styles, labels in any language), a WebTileLayer of sMap's raster tiles where vector tiles are not wanted,
3
+ // the map key / token on every request through ArcGIS request interceptors, the nearest node with failover, and the
4
+ // sMap badge with the OpenStreetMap credit.
5
+ import esriConfig from '@arcgis/core/config.js';
6
+ import Basemap from '@arcgis/core/Basemap.js';
7
+ import VectorTileLayer from '@arcgis/core/layers/VectorTileLayer.js';
8
+ import WebTileLayer from '@arcgis/core/layers/WebTileLayer.js';
9
+ import { applyBasemapOverrides } from '@superrouteca/smap';
10
+ import { createSession, ATTRIBUTION } from './session.js';
11
+
12
+ const installed = new WeakMap();
13
+
14
+ function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
15
+
16
+ function urlsFor(session) {
17
+ return new RegExp('^(' + session.origins().map(escapeRegExp).join('|') + ')(/|$)');
18
+ }
19
+
20
+ /**
21
+ * Adds an ArcGIS request interceptor for sMap URLs: each request goes to the node read from now (so a node that
22
+ * failed is left), with the token and the map key as headers; a failed request moves the session to the next node.
23
+ * Installed once per session; returns `{ interceptor, remove() }`.
24
+ */
25
+ export function installInterceptor(sessionOrOptions) {
26
+ const session = createSession(sessionOrOptions);
27
+ if (installed.has(session)) { return installed.get(session); }
28
+ const interceptor = {
29
+ urls: urlsFor(session),
30
+ before(params) {
31
+ if (!params || !params.url || !session.nodeOf(params.url)) { return undefined; }
32
+ params.url = session.rebase(params.url);
33
+ const ro = params.requestOptions || (params.requestOptions = {});
34
+ ro.headers = { ...(ro.headers || {}), ...session.headers() };
35
+ return undefined;
36
+ },
37
+ error(error) {
38
+ const details = (error && error.details) || {};
39
+ const url = details.url || (error && error.url);
40
+ const status = details.httpStatus || details.status || 0;
41
+ // 4xx answers are final (a refused key stays refused on every node); network errors and 5xx move on.
42
+ if (url && session.nodeOf(url) && (status === 0 || status >= 500)) { session.failed(url); }
43
+ },
44
+ };
45
+ const update = () => { interceptor.urls = urlsFor(session); };
46
+ const offNode = session.on('nodechange', update);
47
+ session.ready().then(update, () => {});
48
+ esriConfig.request.interceptors = esriConfig.request.interceptors || [];
49
+ esriConfig.request.interceptors.push(interceptor);
50
+ const handle = {
51
+ interceptor,
52
+ remove() {
53
+ offNode();
54
+ const list = esriConfig.request.interceptors;
55
+ const i = list.indexOf(interceptor);
56
+ if (i >= 0) { list.splice(i, 1); }
57
+ installed.delete(session);
58
+ },
59
+ };
60
+ installed.set(session, handle);
61
+ return handle;
62
+ }
63
+
64
+ /**
65
+ * A VectorTileLayer drawing sMap's vector tiles in a scheme or custom style, labels in `language`. The style is read
66
+ * through the session (nearest node, failover, key, token); tiles, fonts and sprites through the interceptor.
67
+ *
68
+ * `basemap` shows, hides and recolours classes (as the sMap map's setBasemap).
69
+ *
70
+ * @returns {Promise<VectorTileLayer>} with `smap: { session, style, language, setStyle, setLanguage, setBasemap, basemap }`
71
+ */
72
+ export async function createVectorTileLayer(options = {}) {
73
+ const session = createSession(options.session || options);
74
+ installInterceptor(session);
75
+ await session.ready();
76
+ let style = options.style || 'light';
77
+ let language = options.language;
78
+ let overrides = options.basemap || null;
79
+ // Basemap options (contract §1: show, hide and recolour by class), applied by the SDK's own function.
80
+ const styled = async () => {
81
+ const doc = await session.style(style, language);
82
+ return overrides && Object.keys(overrides).length ? applyBasemapOverrides(doc, overrides) : doc;
83
+ };
84
+ const layer = new VectorTileLayer({
85
+ id: options.id || 'smap-vector',
86
+ title: options.title || 'sMap',
87
+ style: await styled(),
88
+ ...(options.layerProps || {}),
89
+ });
90
+ const reload = async () => { await layer.loadStyle(await styled()); return layer; };
91
+ layer.smap = {
92
+ session,
93
+ get style() { return style; },
94
+ get language() { return language; },
95
+ basemap() { return overrides ? { ...overrides } : {}; },
96
+ setStyle(next) { style = next; return reload(); },
97
+ setLanguage(next) { language = next; return reload(); },
98
+ setBasemap(next) { overrides = next && Object.keys(next).length ? { ...next } : null; return reload(); },
99
+ };
100
+ return layer;
101
+ }
102
+
103
+ /**
104
+ * A WebTileLayer of sMap's raster tiles (contract §5): the same schemes, custom styles and languages as images drawn on
105
+ * sMap's nodes. `retina` reads 512 px tiles (the @2x images of the same tiles, shown in the 256 px grid). The layer's
106
+ * copyright is the OpenStreetMap credit.
107
+ */
108
+ export async function createRasterTileLayer(options = {}) {
109
+ const session = createSession(options.session || options);
110
+ installInterceptor(session);
111
+ await session.ready();
112
+ const style = options.style || 'light';
113
+ const urlTemplate = session.rasterTemplate(style, {
114
+ language: options.language,
115
+ retina: options.retina,
116
+ placeholders: { z: '{level}', x: '{col}', y: '{row}' },
117
+ });
118
+ const layer = new WebTileLayer({
119
+ id: options.id || 'smap-raster',
120
+ title: options.title || 'sMap',
121
+ urlTemplate,
122
+ copyright: ATTRIBUTION,
123
+ ...(options.layerProps || {}),
124
+ });
125
+ layer.smap = { session, style, language: options.language };
126
+ return layer;
127
+ }
128
+
129
+ /**
130
+ * An sMap basemap for a Map or WebMap (`map.basemap = await createSMapBasemap({ … })`): vector tiles by default,
131
+ * raster tiles with `raster: true`. An existing ArcGIS app switches its basemap to sMap and keeps its layers.
132
+ */
133
+ export async function createSMapBasemap(options = {}) {
134
+ const style = options.style || 'light';
135
+ const layer = options.raster ? await createRasterTileLayer(options) : await createVectorTileLayer(options);
136
+ const basemap = new Basemap({
137
+ id: options.basemapId || 'smap-' + style.replace(':', '-'),
138
+ title: options.title || 'sMap ' + style.replace(/^custom:/, ''),
139
+ baseLayers: [layer],
140
+ thumbnailUrl: layer.smap.session.rasterTemplate(style, { language: options.language }).replace('{z}', '3').replace('{x}', '2').replace('{y}', '2'),
141
+ });
142
+ basemap.smap = layer.smap;
143
+ return basemap;
144
+ }
145
+
146
+ const BADGE_ICON = '<svg viewBox="0 0 56 56" width="14" height="14" aria-hidden="true" focusable="false">' +
147
+ '<rect x="2" y="2" width="52" height="52" rx="13" fill="#2563eb"/>' +
148
+ '<path d="M38 15H22a8 8 0 0 0 0 16h12a8 8 0 0 1 0 16H18" fill="none" stroke="#fff" stroke-width="7" ' +
149
+ 'stroke-linecap="round" stroke-linejoin="round"/></svg>';
150
+
151
+ /**
152
+ * The sMap badge and the OpenStreetMap credit in the view's bottom-right corner (beside ArcGIS's own attribution,
153
+ * which stays). Returns the element.
154
+ */
155
+ export function addAttribution(view, options = {}) {
156
+ const doc = options.document || (view.container && view.container.ownerDocument) || globalThis.document;
157
+ const el = doc.createElement('div');
158
+ el.className = 'smap-arcgis-attribution esri-widget';
159
+ el.setAttribute('style', 'display:flex;gap:6px;align-items:center;padding:2px 6px;font:11px/16px Helvetica,Arial,sans-serif;background:rgba(255,255,255,.8)');
160
+ const badge = doc.createElement('span');
161
+ badge.className = 'smap-badge';
162
+ badge.setAttribute('style', 'display:inline-flex;gap:3px;align-items:center;font-weight:600');
163
+ badge.innerHTML = BADGE_ICON + '<span>sMap</span>';
164
+ const credit = doc.createElement('span');
165
+ credit.textContent = ATTRIBUTION;
166
+ el.appendChild(badge);
167
+ el.appendChild(credit);
168
+ view.ui.add(el, 'bottom-right');
169
+ return el;
170
+ }
@@ -0,0 +1,169 @@
1
+ // sMap's points, lines and areas as an ArcGIS GraphicsLayer, with the options of the sMap SDK's addPoints / addLines /
2
+ // addPolygons and the layer's popup as the graphics' PopupTemplate. GeoJSON simplestyle properties colour a layer that
3
+ // gives no colour of its own.
4
+ import Graphic from '@arcgis/core/Graphic.js';
5
+ import GraphicsLayer from '@arcgis/core/layers/GraphicsLayer.js';
6
+
7
+ const GEOMETRY_TYPES = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'];
8
+ const WGS84 = { wkid: 4326 };
9
+
10
+ /** sMap layer data (a FeatureCollection, a Feature, a geometry, or an array of points / features) as features. */
11
+ export function toFeatures(data) {
12
+ if (!data) { return []; }
13
+ if (data.type === 'FeatureCollection') { return data.features || []; }
14
+ if (data.type === 'Feature') { return [data]; }
15
+ if (GEOMETRY_TYPES.includes(data.type)) { return [{ type: 'Feature', properties: {}, geometry: data }]; }
16
+ if (Array.isArray(data)) {
17
+ return data.map((p) => {
18
+ if (p && p.type === 'Feature') { return p; }
19
+ let lng = p.lng ?? p.lon ?? p.longitude;
20
+ let lat = p.lat ?? p.latitude;
21
+ if (p.coordinates) { [lng, lat] = p.coordinates; }
22
+ return { type: 'Feature', id: p.id, properties: p, geometry: { type: 'Point', coordinates: [lng, lat] } };
23
+ });
24
+ }
25
+ return [];
26
+ }
27
+
28
+ function signedArea(ring) {
29
+ let a = 0;
30
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { a += (ring[i][0] - ring[j][0]) * (ring[i][1] + ring[j][1]); }
31
+ return a / 2; // > 0: clockwise
32
+ }
33
+
34
+ // ArcGIS polygons: outer rings clockwise, holes counter-clockwise (GeoJSON is the other way round).
35
+ function rings(polygon) {
36
+ return polygon.map((ring, i) => {
37
+ const cw = signedArea(ring) > 0;
38
+ return (i === 0) === cw ? ring : ring.slice().reverse();
39
+ });
40
+ }
41
+
42
+ /** GeoJSON geometry → ArcGIS geometry (autocast object, WGS 84); GeometryCollection → several. */
43
+ export function toArcGisGeometries(g) {
44
+ if (!g) { return []; }
45
+ switch (g.type) {
46
+ case 'Point': return [{ type: 'point', longitude: g.coordinates[0], latitude: g.coordinates[1], spatialReference: WGS84 }];
47
+ case 'MultiPoint': return [{ type: 'multipoint', points: g.coordinates, spatialReference: WGS84 }];
48
+ case 'LineString': return [{ type: 'polyline', paths: [g.coordinates], spatialReference: WGS84 }];
49
+ case 'MultiLineString': return [{ type: 'polyline', paths: g.coordinates, spatialReference: WGS84 }];
50
+ case 'Polygon': return [{ type: 'polygon', rings: rings(g.coordinates), spatialReference: WGS84 }];
51
+ case 'MultiPolygon': return [{ type: 'polygon', rings: g.coordinates.flatMap(rings), spatialReference: WGS84 }];
52
+ case 'GeometryCollection': return (g.geometries || []).flatMap(toArcGisGeometries);
53
+ default: return [];
54
+ }
55
+ }
56
+
57
+ function read(value, props, fallback, simplestyleKey) {
58
+ let v;
59
+ if (value === undefined || value === null) { v = simplestyleKey ? props[simplestyleKey] : undefined; }
60
+ else if (typeof value === 'object' && !Array.isArray(value) && value.property) { v = props[value.property] ?? value.default; }
61
+ else if (Array.isArray(value) && value.length === 2 && value[0] === 'get') { v = props[value[1]]; }
62
+ else { v = value; }
63
+ return v === undefined || v === null ? fallback : v;
64
+ }
65
+
66
+ function pinSvg(fill, border, width, round) {
67
+ const svg = round
68
+ ? '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="' + (15 - width / 2) + '" fill="' + fill + '" stroke="' + border + '" stroke-width="' + width + '"/></svg>'
69
+ : '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="42" 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="' + width + '" stroke-linejoin="round"/></svg>';
70
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
71
+ }
72
+
73
+ const SIZES = { small: 8, medium: 12, large: 18 };
74
+
75
+ /** The ArcGIS symbol of one feature for sMap point / line / area options. */
76
+ export function symbolFor(kind, props, options = {}) {
77
+ if (kind === 'points') {
78
+ const mk = options.marker;
79
+ if (mk) {
80
+ const round = mk.shape === 'circle' || mk.shape === 'vehicle';
81
+ const size = mk.size || 32;
82
+ const border = read(mk.border, props, read(options.color, props, '#2563eb', 'marker-color'));
83
+ return {
84
+ type: 'picture-marker', url: pinSvg(read(mk.fill, props, '#ffffff'), border, mk.borderWidth ?? 3, round),
85
+ width: size * 0.75, height: (round ? size : size * 42 / 32) * 0.75, yoffset: round ? 0 : size * 42 / 32 * 0.375,
86
+ };
87
+ }
88
+ if (options.icon) {
89
+ const s = (options.iconSize || 1) * 24;
90
+ return { type: 'picture-marker', url: options.icon, width: s, height: s };
91
+ }
92
+ const radius = options.radius ?? (SIZES[props['marker-size']] ? SIZES[props['marker-size']] / 2 : 6);
93
+ return {
94
+ type: 'simple-marker', style: 'circle', size: radius * 1.5,
95
+ color: read(options.color, props, '#2563eb', 'marker-color'),
96
+ outline: { color: read(options.strokeColor, props, '#ffffff'), width: (options.strokeWidth ?? 2) * 0.75 },
97
+ };
98
+ }
99
+ if (kind === 'lines') {
100
+ const color = read(options.color, props, '#2563eb', 'stroke');
101
+ const width = options.width ?? props['stroke-width'] ?? 4;
102
+ const dash = Array.isArray(options.dash) && options.dash.length ? 'dash' : 'solid';
103
+ return { type: 'simple-line', color: withAlpha(color, options.opacity ?? props['stroke-opacity']), width: width * 0.75, style: dash, cap: 'round', join: 'round' };
104
+ }
105
+ const fill = read(options.fill ?? options.color, props, '#2563eb', 'fill');
106
+ return {
107
+ type: 'simple-fill',
108
+ color: withAlpha(fill, options.opacity ?? props['fill-opacity'] ?? 0.3),
109
+ outline: { color: read(options.outline, props, fill, 'stroke'), width: (options.outlineWidth ?? 1) * 0.75 },
110
+ };
111
+ }
112
+
113
+ function withAlpha(color, alpha) {
114
+ if (alpha === undefined || alpha === null) { return color; }
115
+ if (typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color)) {
116
+ const n = (i) => parseInt(color.slice(i, i + 2), 16);
117
+ return [n(1), n(3), n(5), alpha];
118
+ }
119
+ return color;
120
+ }
121
+
122
+ /**
123
+ * A GraphicsLayer of sMap layer data. `kind` is `points`, `lines` or `areas` (the SDK's addPoints, addLines,
124
+ * addPolygons options apply); the `popup` option becomes each graphic's PopupTemplate (content from the feature's
125
+ * attributes; a promise is awaited by the popup), and `label` a text graphic beside each point.
126
+ */
127
+ export function createGraphicsLayer(id, kind, data, options = {}) {
128
+ if (!['points', 'lines', 'areas'].includes(kind)) { throw new Error('sMap: kind is points, lines or areas, not "' + kind + '"'); }
129
+ const graphics = [];
130
+ const want = { points: /Point$/, lines: /LineString$/, areas: /Polygon$/ }[kind];
131
+ for (const f of toFeatures(data)) {
132
+ const props = { ...(f.properties || {}) };
133
+ const parts = f.geometry && f.geometry.type === 'GeometryCollection' ? f.geometry.geometries || [] : [f.geometry];
134
+ const geometries = parts.filter((g) => g && want.test(g.type)).flatMap(toArcGisGeometries);
135
+ for (const geometry of geometries) {
136
+ const graphic = { geometry, attributes: props, symbol: symbolFor(kind, props, options) };
137
+ if (options.popup) {
138
+ graphic.popupTemplate = {
139
+ title: options.popupTitle || '',
140
+ content: (event) => {
141
+ const attrs = (event && event.graphic && event.graphic.attributes) || props;
142
+ return options.popup(attrs, f);
143
+ },
144
+ };
145
+ }
146
+ graphics.push(new Graphic(graphic));
147
+ if (kind === 'points' && options.label && props[options.label] != null) {
148
+ const right = options.labelPosition === 'right';
149
+ graphics.push(new Graphic({
150
+ geometry, attributes: props,
151
+ symbol: {
152
+ type: 'text', text: String(props[options.label]), color: '#333333', haloColor: '#ffffff', haloSize: 1.5,
153
+ font: { size: 9, family: 'sans-serif' }, horizontalAlignment: right ? 'left' : 'center',
154
+ xoffset: right ? 12 : 0, yoffset: right ? 0 : -14,
155
+ },
156
+ }));
157
+ }
158
+ }
159
+ }
160
+ const layer = new GraphicsLayer({ id, title: options.title || id, graphics, ...(options.layerProps || {}) });
161
+ if (options.minzoom !== undefined) { layer.minScale = zoomToScale(options.minzoom); }
162
+ if (options.maxzoom !== undefined) { layer.maxScale = zoomToScale(options.maxzoom); }
163
+ return layer;
164
+ }
165
+
166
+ /** The Web Mercator map scale at a zoom (96 dpi, 256 px tiles), for minScale / maxScale. */
167
+ export function zoomToScale(zoom) {
168
+ return 591657527.591555 / Math.pow(2, zoom);
169
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // @superrouteca/smap-arcgis — sMap and the ArcGIS Maps SDK for JavaScript both ways.
2
+ export { Session, createSession, FLAVORS, ATTRIBUTION } from './session.js';
3
+ export { installInterceptor, createVectorTileLayer, createRasterTileLayer, createSMapBasemap, addAttribution } from './basemap.js';
4
+ export { createGraphicsLayer, symbolFor, toArcGisGeometries, toFeatures, zoomToScale } from './graphics.js';
5
+ export { addArcGisService, queryFeatures, serviceInfo, parseServiceUrl, withToken, symbolToSimplestyle, rendererStyle } from './services.js';
6
+ export const version = '1.1.0';
@@ -0,0 +1,149 @@
1
+ // ArcGIS REST services on an sMap map (works without @arcgis/core): cached map and image services and vector tile
2
+ // services through the map's addArcGisLayer (the SDK reads them), feature layers (FeatureServer/<n>, MapServer/<n>) queried as GeoJSON and
3
+ // added with the map's addData, coloured from the service's own renderer (simple and unique-value renderers).
4
+
5
+ const KINDS = ['MapServer', 'ImageServer', 'VectorTileServer', 'FeatureServer'];
6
+
7
+ /** `{ kind, base, layerId, url }` of an ArcGIS REST service URL; kind ends in `Layer` for one layer of a service. */
8
+ export function parseServiceUrl(url) {
9
+ const u = String(url).split('?')[0].replace(/\/+$/, '');
10
+ const m = /^(.*\/(MapServer|ImageServer|VectorTileServer|FeatureServer))(?:\/(\d+))?$/.exec(u);
11
+ if (!m || !KINDS.includes(m[2])) { throw new Error('sMap: not an ArcGIS REST service URL: ' + url); }
12
+ const layerId = m[3] === undefined ? null : Number(m[3]);
13
+ return { kind: layerId === null ? m[2] : m[2] + 'Layer', base: m[1], layerId, url: u };
14
+ }
15
+
16
+ /** The URL with an ArcGIS token (secured services) in its query. */
17
+ export function withToken(url, token) {
18
+ if (!token) { return url; }
19
+ const u = new URL(url);
20
+ u.searchParams.set('token', token);
21
+ return u.toString();
22
+ }
23
+
24
+ async function getJson(url, query, options) {
25
+ const u = new URL(url);
26
+ for (const [k, v] of Object.entries(query)) { u.searchParams.set(k, v); }
27
+ if (options.token) { u.searchParams.set('token', options.token); }
28
+ const f = options.fetch || globalThis.fetch;
29
+ const res = await f(u.toString(), options.signal ? { signal: options.signal } : undefined);
30
+ if (!res.ok) { throw new Error('sMap: ArcGIS service answered HTTP ' + res.status + ' for ' + url); }
31
+ const body = await res.json();
32
+ if (body && body.error) { throw new Error('sMap: ArcGIS service error ' + (body.error.code || '') + ': ' + (body.error.message || '')); }
33
+ return body;
34
+ }
35
+
36
+ /** The service's (or layer's) description (`?f=json`). */
37
+ export function serviceInfo(url, options = {}) {
38
+ return getJson(String(url).split('?')[0], { f: 'json' }, options);
39
+ }
40
+
41
+ function hex(color) {
42
+ if (!Array.isArray(color)) { return null; }
43
+ const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
44
+ return '#' + h(color[0]) + h(color[1]) + h(color[2]);
45
+ }
46
+ const alpha = (color) => (Array.isArray(color) && color.length > 3 ? Math.round((color[3] / 255) * 100) / 100 : 1);
47
+
48
+ /** An ArcGIS symbol (esriSMS / esriSLS / esriSFS / esriPMS) as GeoJSON simplestyle properties. */
49
+ export function symbolToSimplestyle(symbol) {
50
+ if (!symbol) { return {}; }
51
+ const out = {};
52
+ switch (symbol.type) {
53
+ case 'esriSMS':
54
+ if (hex(symbol.color)) { out['marker-color'] = hex(symbol.color); }
55
+ if (symbol.size) { out['marker-size'] = symbol.size <= 6 ? 'small' : symbol.size >= 14 ? 'large' : 'medium'; }
56
+ break;
57
+ case 'esriSLS':
58
+ if (hex(symbol.color)) { out.stroke = hex(symbol.color); out['stroke-opacity'] = alpha(symbol.color); }
59
+ if (symbol.width) { out['stroke-width'] = Math.round((symbol.width / 0.75) * 10) / 10; }
60
+ break;
61
+ case 'esriSFS':
62
+ if (hex(symbol.color)) { out.fill = hex(symbol.color); out['fill-opacity'] = alpha(symbol.color); }
63
+ if (symbol.outline && hex(symbol.outline.color)) {
64
+ out.stroke = hex(symbol.outline.color);
65
+ if (symbol.outline.width) { out['stroke-width'] = Math.round((symbol.outline.width / 0.75) * 10) / 10; }
66
+ }
67
+ break;
68
+ default:
69
+ break;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The simplestyle properties a renderer gives one feature (simple and unique-value renderers; others give none). */
75
+ export function rendererStyle(renderer, properties) {
76
+ if (!renderer) { return {}; }
77
+ if (renderer.type === 'simple') { return symbolToSimplestyle(renderer.symbol); }
78
+ if (renderer.type === 'uniqueValue') {
79
+ const fields = [renderer.field1, renderer.field2, renderer.field3].filter(Boolean);
80
+ const value = fields.map((f) => (properties || {})[f]).join(renderer.fieldDelimiter || ',');
81
+ const hit = (renderer.uniqueValueInfos || []).find((i) => String(i.value) === value);
82
+ return symbolToSimplestyle(hit ? hit.symbol : renderer.defaultSymbol);
83
+ }
84
+ return {};
85
+ }
86
+
87
+ /**
88
+ * Every feature of a feature layer as a GeoJSON FeatureCollection in WGS 84, page by page (at most `maxFeatures`,
89
+ * default 50,000), each coloured by the layer's renderer unless `renderer: false`.
90
+ */
91
+ export async function queryFeatures(url, options = {}) {
92
+ const info = parseServiceUrl(url);
93
+ if (info.layerId === null) { throw new Error('sMap: name one layer of the service (…/FeatureServer/0): ' + url); }
94
+ const layerInfo = options.renderer === false ? null : await serviceInfo(info.url, options).catch(() => null);
95
+ const renderer = layerInfo && layerInfo.drawingInfo ? layerInfo.drawingInfo.renderer : null;
96
+ const max = options.maxFeatures ?? 50000;
97
+ const page = options.pageSize ?? 2000;
98
+ const features = [];
99
+ for (let offset = 0; features.length < max;) {
100
+ const fc = await getJson(info.url + '/query', {
101
+ where: options.where || '1=1', outFields: options.outFields || '*', outSR: '4326', f: 'geojson',
102
+ resultOffset: String(offset), resultRecordCount: String(Math.min(page, max - features.length)),
103
+ }, options);
104
+ const got = (fc && fc.features) || [];
105
+ features.push(...got);
106
+ offset += got.length;
107
+ const more = (fc && (fc.exceededTransferLimit || (fc.properties && fc.properties.exceededTransferLimit)));
108
+ if (!more || got.length === 0) { break; }
109
+ }
110
+ if (renderer) {
111
+ for (const f of features) { f.properties = { ...rendererStyle(renderer, f.properties), ...(f.properties || {}) }; }
112
+ }
113
+ return {
114
+ type: 'FeatureCollection',
115
+ features,
116
+ attribution: (layerInfo && layerInfo.copyrightText) || null,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * An ArcGIS REST service on an sMap map. Cached MapServer / ImageServer and VectorTileServer services go through
122
+ * `map.addArcGisLayer(id, { url, attribution, slot })`; one layer of a feature or map service is queried and added with
123
+ * `map.addData(id, features, options)` (points, lines and areas as `<id>`, `<id>:lines`, `<id>:areas`). A tile
124
+ * service's attribution defaults to its copyright text (read by the SDK); a feature layer's is returned (addData has
125
+ * no attribution option). A `slot` is passed only when given; `layerOptions` go to addArcGisLayer or addData.
126
+ *
127
+ * @returns {Promise<{ kind: string, id: string, attribution: string|null, features?: number }>}
128
+ */
129
+ export async function addArcGisService(map, id, url, options = {}) {
130
+ const info = parseServiceUrl(url);
131
+ const { token, slot, attribution: givenAttribution, layerOptions, ...rest } = options;
132
+ if (info.layerId === null && info.kind !== 'FeatureServer') {
133
+ if (typeof map.addArcGisLayer !== 'function') { throw new Error('sMap: this map has no addArcGisLayer (needs @superrouteca/smap 1.1.0 or later)'); }
134
+ // The SDK reads the service (tile size, vector tile style, copyright text as the default attribution).
135
+ const call = { ...(layerOptions || {}), url: withToken(info.url, token) };
136
+ if (givenAttribution) { call.attribution = givenAttribution; }
137
+ if (slot) { call.slot = slot; }
138
+ await map.addArcGisLayer(id, call);
139
+ return { kind: info.kind, id, attribution: givenAttribution || null };
140
+ }
141
+ if (info.layerId === null) { throw new Error('sMap: name one layer of the feature service (…/FeatureServer/0): ' + url); }
142
+ if (typeof map.addData !== 'function') { throw new Error('sMap: this map has no addData (needs @superrouteca/smap 1.1.0 or later)'); }
143
+ const fc = await queryFeatures(info.url, { token, ...rest });
144
+ const attribution = givenAttribution ?? fc.attribution;
145
+ const opts = { format: 'geojson', ...(layerOptions || {}) };
146
+ if (slot) { opts.slot = slot; }
147
+ map.addData(id, { type: 'FeatureCollection', features: fc.features }, opts);
148
+ return { kind: info.kind, id, attribution: attribution || null, features: fc.features.length };
149
+ }
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
+ }
@@ -0,0 +1,108 @@
1
+ // Types of @superrouteca/smap-arcgis.
2
+ import type Basemap from '@arcgis/core/Basemap.js';
3
+ import type VectorTileLayer from '@arcgis/core/layers/VectorTileLayer.js';
4
+ import type WebTileLayer from '@arcgis/core/layers/WebTileLayer.js';
5
+ import type GraphicsLayer from '@arcgis/core/layers/GraphicsLayer.js';
6
+ import type SMapClient from '@superrouteca/smap/client';
7
+ import type { LayerData, PointOptions, LineOptions, PolygonOptions, GeoJSONFeature, BasemapOverrides } from '@superrouteca/smap';
8
+
9
+ export * from './services.js';
10
+
11
+ /** A colour scheme, or a saved custom style as `custom:<id>`. */
12
+ export type StyleName = 'light' | 'white' | 'grayscale' | 'dark' | 'black' | `custom:${string}`;
13
+
14
+ /** How the adapter reaches sMap: the JS client's options, or a client of your own. */
15
+ export interface SessionOptions {
16
+ /** The public sMap address (default https://tile.srsr.ca). */
17
+ baseUrl?: string;
18
+ /** A map key (`smk_…`). */
19
+ apiKey?: string;
20
+ token?: string;
21
+ /** Called at start and again before each token expires. */
22
+ getToken?: () => string | Promise<string>;
23
+ nodes?: string[];
24
+ nearest?: boolean;
25
+ fetch?: typeof fetch;
26
+ timeoutMs?: number;
27
+ client?: SMapClient;
28
+ }
29
+
30
+ /** One connection to sMap: nearest node, failover, key and token (through the JS client), and the URLs tools need. */
31
+ export declare class Session {
32
+ constructor(options?: SessionOptions);
33
+ readonly client: SMapClient;
34
+ readonly baseUrl: string;
35
+ readonly apiKey: string | null;
36
+ ready(): Promise<this>;
37
+ node(): string;
38
+ nodes(): string[];
39
+ token(): string | null;
40
+ headers(): Record<string, string>;
41
+ origins(): string[];
42
+ nodeOf(url: string): string | null;
43
+ pathOf(url: string): string | null;
44
+ rebase(url: string): string;
45
+ failed(urlOrNode: string): boolean;
46
+ styleUrl(style?: StyleName, language?: string): string;
47
+ tileJsonUrl(source?: string): string;
48
+ rasterTemplate(style?: StyleName, options?: { language?: string; retina?: boolean; placeholders?: { z?: string; x?: string; y?: string } }): string;
49
+ rasterTileJsonUrl(style?: StyleName, options?: { language?: string }): string;
50
+ wmtsCapabilitiesUrl(): string;
51
+ fetch(url: string, init?: RequestInit): Promise<Response>;
52
+ style(style?: StyleName, language?: string): Promise<Record<string, unknown>>;
53
+ on(type: 'nodechange' | 'token', fn: (value: string | null) => void): () => void;
54
+ off(type: 'nodechange' | 'token', fn: (value: string | null) => void): void;
55
+ close(): void;
56
+ }
57
+ export declare function createSession(options?: SessionOptions | Session): Session;
58
+ export declare const FLAVORS: Array<'light' | 'white' | 'grayscale' | 'dark' | 'black'>;
59
+ export declare const ATTRIBUTION: string;
60
+ export declare const version: string;
61
+
62
+ export interface InterceptorHandle {
63
+ interceptor: { urls: RegExp; before(params: any): undefined; error(error: any): void };
64
+ remove(): void;
65
+ }
66
+ export declare function installInterceptor(session: Session | SessionOptions): InterceptorHandle;
67
+
68
+ interface LayerCommon extends SessionOptions {
69
+ session?: Session;
70
+ style?: StyleName;
71
+ /** Label language (BCP 47). */
72
+ language?: string;
73
+ id?: string;
74
+ title?: string;
75
+ /** Other properties of the ArcGIS layer. */
76
+ layerProps?: Record<string, unknown>;
77
+ }
78
+ export interface SMapLayerInfo {
79
+ session: Session;
80
+ readonly style: StyleName;
81
+ readonly language?: string;
82
+ }
83
+ export interface SMapVectorInfo extends SMapLayerInfo {
84
+ setStyle(style: StyleName): Promise<VectorTileLayer>;
85
+ setLanguage(language: string): Promise<VectorTileLayer>;
86
+ /** Basemap options by class (contract §1), replacing the previous ones; `{}` clears. */
87
+ setBasemap(overrides: BasemapOverrides): Promise<VectorTileLayer>;
88
+ basemap(): BasemapOverrides;
89
+ }
90
+ export declare function createVectorTileLayer(options?: LayerCommon & { basemap?: BasemapOverrides }): Promise<VectorTileLayer & { smap: SMapVectorInfo }>;
91
+ export declare function createRasterTileLayer(options?: LayerCommon & { retina?: boolean }): Promise<WebTileLayer & { smap: SMapLayerInfo }>;
92
+ export declare function createSMapBasemap(options?: LayerCommon & { raster?: boolean; retina?: boolean; basemapId?: string; basemap?: BasemapOverrides }): Promise<Basemap & { smap: SMapLayerInfo | SMapVectorInfo }>;
93
+ export declare function addAttribution(view: { ui: { add(el: HTMLElement, position: string): void }; container?: HTMLElement | null }, options?: { document?: Document }): HTMLElement;
94
+
95
+ export type GraphicsKind = 'points' | 'lines' | 'areas';
96
+ export interface GraphicsExtras {
97
+ /** Title of the popup (the `popup` option gives its content). */
98
+ popupTitle?: string;
99
+ title?: string;
100
+ layerProps?: Record<string, unknown>;
101
+ }
102
+ export declare function createGraphicsLayer(id: string, kind: 'points', data: LayerData, options?: PointOptions & GraphicsExtras): GraphicsLayer;
103
+ export declare function createGraphicsLayer(id: string, kind: 'lines', data: LayerData, options?: LineOptions & GraphicsExtras): GraphicsLayer;
104
+ export declare function createGraphicsLayer(id: string, kind: 'areas', data: LayerData, options?: PolygonOptions & GraphicsExtras): GraphicsLayer;
105
+ export declare function symbolFor(kind: GraphicsKind, properties: Record<string, unknown>, options?: Record<string, unknown>): Record<string, unknown>;
106
+ export declare function toArcGisGeometries(geometry: { type: string; coordinates?: unknown; geometries?: unknown[] } | null | undefined): Array<Record<string, unknown>>;
107
+ export declare function toFeatures(data: LayerData): GeoJSONFeature[];
108
+ export declare function zoomToScale(zoom: number): number;
@@ -0,0 +1,46 @@
1
+ // Types of @superrouteca/smap-arcgis/services (ArcGIS REST services on an sMap map; no @arcgis/core needed).
2
+ import type { Map as SMapMap } from '@superrouteca/smap';
3
+
4
+ export interface ServiceUrl {
5
+ kind: 'MapServer' | 'ImageServer' | 'VectorTileServer' | 'FeatureServer' | 'MapServerLayer' | 'FeatureServerLayer' | 'ImageServerLayer' | 'VectorTileServerLayer';
6
+ base: string;
7
+ layerId: number | null;
8
+ url: string;
9
+ }
10
+ export interface RequestOptions {
11
+ /** An ArcGIS token for secured services. */
12
+ token?: string;
13
+ fetch?: typeof fetch;
14
+ signal?: AbortSignal;
15
+ }
16
+ export interface QueryOptions extends RequestOptions {
17
+ where?: string;
18
+ outFields?: string;
19
+ /** Most features read (default 50,000). */
20
+ maxFeatures?: number;
21
+ /** Features per request (default 2,000). */
22
+ pageSize?: number;
23
+ /** Colour features by the layer's renderer (default true). */
24
+ renderer?: boolean;
25
+ }
26
+ export interface AddServiceOptions extends QueryOptions {
27
+ /** Credit shown beside the OpenStreetMap credit (default: the service's copyright text). */
28
+ attribution?: string;
29
+ /** Where the layer sits on the basemap; passed only when given. */
30
+ slot?: 'bottom' | 'roads' | 'labels' | 'top';
31
+ /** Options for addArcGisLayer (tile services: opacity, minzoom, maxzoom, layers, …) or addData (feature layers: colours, popups, … as for addPoints / addLines / addPolygons). */
32
+ layerOptions?: Record<string, unknown>;
33
+ }
34
+ export interface FeatureCollectionWithAttribution {
35
+ type: 'FeatureCollection';
36
+ features: Array<{ type: 'Feature'; properties: Record<string, unknown> | null; geometry: unknown }>;
37
+ attribution: string | null;
38
+ }
39
+
40
+ export declare function parseServiceUrl(url: string): ServiceUrl;
41
+ export declare function withToken(url: string, token?: string): string;
42
+ export declare function serviceInfo(url: string, options?: RequestOptions): Promise<Record<string, any>>;
43
+ export declare function symbolToSimplestyle(symbol: Record<string, any> | null | undefined): Record<string, string | number>;
44
+ export declare function rendererStyle(renderer: Record<string, any> | null | undefined, properties: Record<string, unknown> | null | undefined): Record<string, string | number>;
45
+ export declare function queryFeatures(url: string, options?: QueryOptions): Promise<FeatureCollectionWithAttribution>;
46
+ export declare function addArcGisService(map: SMapMap, id: string, url: string, options?: AddServiceOptions): Promise<{ kind: string; id: string; attribution: string | null; features?: number }>;