@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.2
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/LICENSE +21 -0
- package/README.md +506 -2
- package/dist/core/basemap.svelte.d.ts +38 -0
- package/dist/core/basemap.svelte.js +163 -0
- package/dist/core/context.d.ts +5 -0
- package/dist/core/context.js +13 -0
- package/dist/core/create-map.svelte.d.ts +207 -0
- package/dist/core/create-map.svelte.js +376 -0
- package/dist/core/gl.d.ts +92 -0
- package/dist/core/gl.js +1 -0
- package/dist/core/interactions.svelte.d.ts +43 -0
- package/dist/core/interactions.svelte.js +230 -0
- package/dist/core/plugin-host.svelte.d.ts +46 -0
- package/dist/core/plugin-host.svelte.js +366 -0
- package/dist/core/plugin.d.ts +155 -0
- package/dist/core/plugin.js +1 -0
- package/dist/core/scoped-context.d.ts +7 -0
- package/dist/core/scoped-context.js +34 -0
- package/dist/data/format.d.ts +10 -0
- package/dist/data/format.js +24 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +9 -0
- package/dist/data/messages/en.json +52 -0
- package/dist/data/messages.d.ts +10 -0
- package/dist/data/messages.js +17 -0
- package/dist/data/metadata.d.ts +22 -0
- package/dist/data/metadata.js +110 -0
- package/dist/data/refs.d.ts +18 -0
- package/dist/data/refs.js +61 -0
- package/dist/data/scale-types.d.ts +7 -0
- package/dist/data/scale-types.js +1 -0
- package/dist/data/scale.d.ts +13 -0
- package/dist/data/scale.js +27 -0
- package/dist/data/selection.d.ts +7 -0
- package/dist/data/selection.js +31 -0
- package/dist/data/static-source.d.ts +4 -0
- package/dist/data/static-source.js +3 -0
- package/dist/data/types.d.ts +69 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +41 -0
- package/dist/plugins/icons.d.ts +21 -0
- package/dist/plugins/icons.js +73 -0
- package/dist/plugins/points-clusters.d.ts +60 -0
- package/dist/plugins/points-clusters.js +93 -0
- package/dist/plugins/points.svelte.d.ts +100 -0
- package/dist/plugins/points.svelte.js +501 -0
- package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
- package/dist/plugins/scale-resolution.svelte.js +111 -0
- package/dist/plugins/shapes.svelte.d.ts +29 -0
- package/dist/plugins/shapes.svelte.js +307 -0
- package/dist/plugins/shared.d.ts +6 -0
- package/dist/plugins/shared.js +18 -0
- package/dist/plugins/style.d.ts +57 -0
- package/dist/plugins/style.js +193 -0
- package/dist/presets/CollocationTooltip.svelte +132 -0
- package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
- package/dist/presets/MonitorClusterTooltip.svelte +149 -0
- package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
- package/dist/presets/MonitorReadingBox.svelte +58 -0
- package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
- package/dist/presets/MonitorTooltip.svelte +88 -0
- package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
- package/dist/presets/calheatscore.d.ts +24 -0
- package/dist/presets/calheatscore.js +22 -0
- package/dist/presets/collocation-sites.d.ts +18 -0
- package/dist/presets/collocation-sites.js +67 -0
- package/dist/presets/fire-groups.d.ts +26 -0
- package/dist/presets/fire-groups.js +51 -0
- package/dist/presets/forecast-zones.d.ts +25 -0
- package/dist/presets/forecast-zones.js +23 -0
- package/dist/presets/hms-fire.d.ts +26 -0
- package/dist/presets/hms-fire.js +87 -0
- package/dist/presets/hms-smoke.d.ts +19 -0
- package/dist/presets/hms-smoke.js +20 -0
- package/dist/presets/monitors.d.ts +48 -0
- package/dist/presets/monitors.js +243 -0
- package/dist/presets/reactive-props.svelte.d.ts +4 -0
- package/dist/presets/reactive-props.svelte.js +7 -0
- package/dist/presets/regions.d.ts +24 -0
- package/dist/presets/regions.js +22 -0
- package/dist/stores/abort.d.ts +16 -0
- package/dist/stores/abort.js +52 -0
- package/dist/stores/collocation-store.svelte.d.ts +25 -0
- package/dist/stores/collocation-store.svelte.js +72 -0
- package/dist/stores/hms-stores.svelte.d.ts +35 -0
- package/dist/stores/hms-stores.svelte.js +97 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.js +4 -0
- package/dist/stores/load-once.svelte.d.ts +25 -0
- package/dist/stores/load-once.svelte.js +93 -0
- package/dist/stores/monitors-store.svelte.d.ts +85 -0
- package/dist/stores/monitors-store.svelte.js +340 -0
- package/dist/theme.css +34 -0
- package/dist/ui/FeatureList.svelte +505 -0
- package/dist/ui/FeatureList.svelte.d.ts +14 -0
- package/dist/ui/Legend.svelte +213 -0
- package/dist/ui/Legend.svelte.d.ts +22 -0
- package/dist/ui/LegendBar.svelte +161 -0
- package/dist/ui/LegendBar.svelte.d.ts +11 -0
- package/dist/ui/MapControls.svelte +135 -0
- package/dist/ui/MapControls.svelte.d.ts +11 -0
- package/dist/ui/MapTooltip.svelte +158 -0
- package/dist/ui/MapTooltip.svelte.d.ts +28 -0
- package/dist/ui/SelectionBar.svelte +123 -0
- package/dist/ui/SelectionBar.svelte.d.ts +10 -0
- package/dist/ui/TooltipItem.svelte +72 -0
- package/dist/ui/TooltipItem.svelte.d.ts +11 -0
- package/dist/ui/ViewSummary.svelte +97 -0
- package/dist/ui/ViewSummary.svelte.d.ts +7 -0
- package/dist/ui/controls.css +45 -0
- package/dist/ui/data-credits.svelte.d.ts +9 -0
- package/dist/ui/data-credits.svelte.js +52 -0
- package/dist/ui/feature-list.d.ts +38 -0
- package/dist/ui/feature-list.js +95 -0
- package/dist/ui/legend-data.d.ts +41 -0
- package/dist/ui/legend-data.js +40 -0
- package/dist/ui/level-chip.d.ts +11 -0
- package/dist/ui/level-chip.js +15 -0
- package/dist/ui/value-format.d.ts +7 -0
- package/dist/ui/value-format.js +13 -0
- package/dist/ui/view-summary.d.ts +11 -0
- package/dist/ui/view-summary.js +33 -0
- package/dist/view/MapView.svelte +373 -0
- package/dist/view/MapView.svelte.d.ts +43 -0
- package/dist/view/create-gl-map.d.ts +34 -0
- package/dist/view/create-gl-map.js +182 -0
- package/dist/view/list-placement.d.ts +21 -0
- package/dist/view/list-placement.js +13 -0
- package/package.json +103 -5
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { untrack } from "svelte";
|
|
2
|
+
import { decodeRef, selectionId } from "../data/refs.js";
|
|
3
|
+
import { levelFor, NO_LEVEL } from "../data/scale.js";
|
|
4
|
+
import { createScaleResolver, NO_SCALE, datasetOf, scaleKey } from "./scale-resolution.svelte.js";
|
|
5
|
+
import { hitFromProps, SELECTED_OPACITY } from "./shared.js";
|
|
6
|
+
import { asDataURI, SELECTED_OVERLAY_PADDING, selectedOverlay } from "./icons.js";
|
|
7
|
+
import { colorExpression, opacityExpression, themeColors } from "./style.js";
|
|
8
|
+
/** The check image is drawn at this multiple of its size and scaled down, for sharp HiDPI. */
|
|
9
|
+
const IMAGE_RATIO = 2;
|
|
10
|
+
/** The check mark badge is sized for a marker this many pixels across. */
|
|
11
|
+
const CHECK_SIZE = 12;
|
|
12
|
+
const EMPTY = { type: "FeatureCollection", features: [] };
|
|
13
|
+
const DEFAULT_FILL_OPACITY = 0.6;
|
|
14
|
+
const OUTLINE_WIDTH = 1;
|
|
15
|
+
const LINE_WIDTH = 2;
|
|
16
|
+
const SELECTED_WIDTH = 3;
|
|
17
|
+
const LINE_TYPES = ["LineString", "MultiLineString"];
|
|
18
|
+
/** Twice the signed area of a ring, absolute. */
|
|
19
|
+
function ringArea(ring) {
|
|
20
|
+
let sum = 0;
|
|
21
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
22
|
+
sum += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
|
23
|
+
}
|
|
24
|
+
return Math.abs(sum);
|
|
25
|
+
}
|
|
26
|
+
/** Centroid of a ring (the area-weighted one; the vertex mean for a degenerate ring). */
|
|
27
|
+
function ringCentroid(ring) {
|
|
28
|
+
let area = 0;
|
|
29
|
+
let cx = 0;
|
|
30
|
+
let cy = 0;
|
|
31
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
32
|
+
const cross = ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
|
33
|
+
area += cross;
|
|
34
|
+
cx += (ring[i][0] + ring[i + 1][0]) * cross;
|
|
35
|
+
cy += (ring[i][1] + ring[i + 1][1]) * cross;
|
|
36
|
+
}
|
|
37
|
+
if (area === 0) {
|
|
38
|
+
const n = Math.max(ring.length - 1, 1);
|
|
39
|
+
const sum = ring.slice(0, n).reduce((a, p) => [a[0] + p[0], a[1] + p[1]], [0, 0]);
|
|
40
|
+
return [sum[0] / n, sum[1] / n];
|
|
41
|
+
}
|
|
42
|
+
return [cx / (3 * area), cy / (3 * area)];
|
|
43
|
+
}
|
|
44
|
+
/** The point used to place a feature in lists: its largest ring's centroid, or a line's midpoint. */
|
|
45
|
+
export function labelPoint(geometry) {
|
|
46
|
+
switch (geometry.type) {
|
|
47
|
+
case "Point":
|
|
48
|
+
return [geometry.coordinates[0], geometry.coordinates[1]];
|
|
49
|
+
case "Polygon":
|
|
50
|
+
case "MultiPolygon": {
|
|
51
|
+
const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
|
|
52
|
+
let best = null;
|
|
53
|
+
let bestArea = -1;
|
|
54
|
+
for (const polygon of polygons) {
|
|
55
|
+
const outer = polygon[0];
|
|
56
|
+
if (!outer?.length)
|
|
57
|
+
continue;
|
|
58
|
+
const area = ringArea(outer);
|
|
59
|
+
if (area > bestArea) {
|
|
60
|
+
best = outer;
|
|
61
|
+
bestArea = area;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return best ? ringCentroid(best) : null;
|
|
65
|
+
}
|
|
66
|
+
case "LineString":
|
|
67
|
+
case "MultiLineString": {
|
|
68
|
+
const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.coordinates;
|
|
69
|
+
const longest = lines.reduce((a, b) => (b.length > a.length ? b : a), []);
|
|
70
|
+
const mid = longest[Math.floor(longest.length / 2)];
|
|
71
|
+
return mid ? [mid[0], mid[1]] : null;
|
|
72
|
+
}
|
|
73
|
+
default:
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Polygons and lines, plain (neutral) or filled by value from a scale (choropleth), with a
|
|
79
|
+
* thin outline and a thicker highlight outline on selected features.
|
|
80
|
+
*/
|
|
81
|
+
export function shapesPlugin(options) {
|
|
82
|
+
const { id: pluginId, source, layerLabel, mode } = options;
|
|
83
|
+
const selectable = options.selectable ?? false;
|
|
84
|
+
const fillOpacity = options.fillOpacity ?? DEFAULT_FILL_OPACITY;
|
|
85
|
+
const choropleth = mode === "choropleth";
|
|
86
|
+
const outlineMode = options.outline ?? "theme";
|
|
87
|
+
const scales = createScaleResolver(pluginId, layerLabel, source);
|
|
88
|
+
/** Plain mode ignores the source's scale. */
|
|
89
|
+
const currentScale = () => (choropleth ? scales.current() : NO_SCALE);
|
|
90
|
+
const legend = () => (choropleth ? scales.legend() : null);
|
|
91
|
+
function features() {
|
|
92
|
+
const collection = source.features;
|
|
93
|
+
if (!collection)
|
|
94
|
+
return [];
|
|
95
|
+
const resolved = currentScale();
|
|
96
|
+
const scale = resolved === null || resolved === "unavailable" ? null : resolved;
|
|
97
|
+
const precision = choropleth ? scales.precision() : 1;
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const feature of collection.features) {
|
|
100
|
+
const props = feature.properties;
|
|
101
|
+
if (!props || !feature.geometry)
|
|
102
|
+
continue;
|
|
103
|
+
const ref = decodeRef(props.refKey);
|
|
104
|
+
const lngLat = labelPoint(feature.geometry);
|
|
105
|
+
if (!ref || !lngLat)
|
|
106
|
+
continue;
|
|
107
|
+
items.push({
|
|
108
|
+
refKey: props.refKey,
|
|
109
|
+
ref,
|
|
110
|
+
name: props.name,
|
|
111
|
+
value: props.value,
|
|
112
|
+
level: scale ? levelFor(props.value, scale) : NO_LEVEL,
|
|
113
|
+
units: scale?.units ?? "",
|
|
114
|
+
precision,
|
|
115
|
+
lngLat,
|
|
116
|
+
selectable: selectable && selectionId(ref) !== null
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return items;
|
|
120
|
+
}
|
|
121
|
+
const toHit = (props) => hitFromProps(pluginId, props);
|
|
122
|
+
function setup(ctx) {
|
|
123
|
+
let dead = false;
|
|
124
|
+
const { gl } = ctx;
|
|
125
|
+
const container = gl.getCanvas().closest?.("[data-mapsdk-map]") ?? null;
|
|
126
|
+
const theme = themeColors(container);
|
|
127
|
+
let sourceId = null;
|
|
128
|
+
let checkSourceId = null;
|
|
129
|
+
/** refKey → label point, from the latest data. */
|
|
130
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
131
|
+
let labels = new Map();
|
|
132
|
+
/** Namespaced ids of the layers currently on the map, in draw order. */
|
|
133
|
+
let layers = [];
|
|
134
|
+
/** The scale (by `scaleKey`) the layers on the map were built for. */
|
|
135
|
+
let builtKey = null;
|
|
136
|
+
let interactiveDone = false;
|
|
137
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
138
|
+
let applied = new Set();
|
|
139
|
+
function removeLayers() {
|
|
140
|
+
for (const layerId of layers.toReversed()) {
|
|
141
|
+
if (gl.getLayer(layerId))
|
|
142
|
+
gl.removeLayer(layerId);
|
|
143
|
+
}
|
|
144
|
+
layers = [];
|
|
145
|
+
builtKey = null;
|
|
146
|
+
}
|
|
147
|
+
function buildLayers(scale, src) {
|
|
148
|
+
removeLayers();
|
|
149
|
+
const isLine = ["match", ["geometry-type"], LINE_TYPES, true, false];
|
|
150
|
+
const lineColor = choropleth
|
|
151
|
+
? [
|
|
152
|
+
"case",
|
|
153
|
+
isLine,
|
|
154
|
+
colorExpression(scale, "value", theme.neutral),
|
|
155
|
+
theme.foreground
|
|
156
|
+
]
|
|
157
|
+
: theme.foreground;
|
|
158
|
+
const fill = ctx.addLayer("fill", {
|
|
159
|
+
type: "fill",
|
|
160
|
+
source: src,
|
|
161
|
+
paint: {
|
|
162
|
+
"fill-color": choropleth ? colorExpression(scale, "value", theme.neutral) : theme.neutral,
|
|
163
|
+
"fill-opacity": choropleth ? opacityExpression(scale, "value", fillOpacity) : fillOpacity
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const byLevel = outlineMode === "level" && choropleth;
|
|
167
|
+
const outline = outlineMode === "none"
|
|
168
|
+
? null
|
|
169
|
+
: ctx.addLayer("outline", {
|
|
170
|
+
type: "line",
|
|
171
|
+
source: src,
|
|
172
|
+
paint: {
|
|
173
|
+
"line-color": byLevel ? colorExpression(scale, "value", theme.neutral) : lineColor,
|
|
174
|
+
...(byLevel
|
|
175
|
+
? { "line-opacity": opacityExpression(scale, "value", fillOpacity) }
|
|
176
|
+
: {}),
|
|
177
|
+
"line-width": ["case", isLine, LINE_WIDTH, OUTLINE_WIDTH]
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const selectedLayer = ctx.addLayer("selected", {
|
|
181
|
+
type: "line",
|
|
182
|
+
source: src,
|
|
183
|
+
paint: {
|
|
184
|
+
"line-color": theme.highlight,
|
|
185
|
+
"line-width": SELECTED_WIDTH,
|
|
186
|
+
"line-opacity": SELECTED_OPACITY
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
const check = ctx.addLayer("check", {
|
|
190
|
+
type: "symbol",
|
|
191
|
+
source: checkSourceId,
|
|
192
|
+
layout: {
|
|
193
|
+
"icon-image": ctx.id("selected"),
|
|
194
|
+
"icon-size": 1 / IMAGE_RATIO,
|
|
195
|
+
"icon-allow-overlap": true,
|
|
196
|
+
"icon-ignore-placement": true
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
layers = outline ? [fill, outline, selectedLayer, check] : [fill, selectedLayer, check];
|
|
200
|
+
// Layer ids repeat across rebuilds; register them once.
|
|
201
|
+
if (!interactiveDone) {
|
|
202
|
+
interactiveDone = true;
|
|
203
|
+
ctx.interactive(outline ? [fill, outline] : [fill], toHit);
|
|
204
|
+
}
|
|
205
|
+
builtKey = scaleKey(scale);
|
|
206
|
+
}
|
|
207
|
+
/** One check-mark point per selected feature present in the data. */
|
|
208
|
+
function updateCheck(keys) {
|
|
209
|
+
if (!checkSourceId)
|
|
210
|
+
return;
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const key of keys) {
|
|
213
|
+
const at = labels.get(key);
|
|
214
|
+
if (at)
|
|
215
|
+
out.push({
|
|
216
|
+
type: "Feature",
|
|
217
|
+
geometry: { type: "Point", coordinates: at },
|
|
218
|
+
properties: {}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
gl.getSource(checkSourceId).setData({
|
|
222
|
+
type: "FeatureCollection",
|
|
223
|
+
features: out
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function applySelection(keys) {
|
|
227
|
+
if (!sourceId)
|
|
228
|
+
return;
|
|
229
|
+
updateCheck(keys);
|
|
230
|
+
for (const key of applied) {
|
|
231
|
+
if (!keys.has(key))
|
|
232
|
+
gl.removeFeatureState({ source: sourceId, id: key }, "selected");
|
|
233
|
+
}
|
|
234
|
+
for (const key of keys) {
|
|
235
|
+
if (!applied.has(key))
|
|
236
|
+
gl.setFeatureState({ source: sourceId, id: key }, { selected: true });
|
|
237
|
+
}
|
|
238
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
239
|
+
applied = new Set(keys);
|
|
240
|
+
}
|
|
241
|
+
function render(reactive, scale) {
|
|
242
|
+
// Plain objects: MapLibre structured-clones the data to its worker, which a proxy fails.
|
|
243
|
+
const data = ($state.snapshot(reactive) ?? {
|
|
244
|
+
type: "FeatureCollection",
|
|
245
|
+
features: []
|
|
246
|
+
});
|
|
247
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
248
|
+
labels = new Map();
|
|
249
|
+
for (const feature of data.features) {
|
|
250
|
+
const props = feature.properties;
|
|
251
|
+
const at = props && feature.geometry ? labelPoint(feature.geometry) : null;
|
|
252
|
+
if (props && at)
|
|
253
|
+
labels.set(props.refKey, at);
|
|
254
|
+
}
|
|
255
|
+
if (sourceId) {
|
|
256
|
+
gl.getSource(sourceId).setData(data);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
sourceId = ctx.addSource("shapes", { type: "geojson", data, promoteId: "refKey" });
|
|
260
|
+
checkSourceId = ctx.addSource("check", { type: "geojson", data: EMPTY });
|
|
261
|
+
}
|
|
262
|
+
if (scale === null || scale === "unavailable") {
|
|
263
|
+
removeLayers();
|
|
264
|
+
}
|
|
265
|
+
else if (builtKey !== scaleKey(scale)) {
|
|
266
|
+
buildLayers(scale, sourceId);
|
|
267
|
+
}
|
|
268
|
+
applySelection(untrack(() => ctx.selectedKeys));
|
|
269
|
+
}
|
|
270
|
+
if (typeof Image !== "undefined") {
|
|
271
|
+
const total = CHECK_SIZE + 2 * SELECTED_OVERLAY_PADDING;
|
|
272
|
+
const image = new Image(total * IMAGE_RATIO, total * IMAGE_RATIO);
|
|
273
|
+
image.onload = () => {
|
|
274
|
+
if (!dead)
|
|
275
|
+
ctx.addImage("selected", image);
|
|
276
|
+
};
|
|
277
|
+
image.src = asDataURI(selectedOverlay(theme.highlight, theme.highlightForeground, CHECK_SIZE));
|
|
278
|
+
}
|
|
279
|
+
if (choropleth)
|
|
280
|
+
scales.track(ctx, () => dead);
|
|
281
|
+
ctx.effect(() => {
|
|
282
|
+
const collection = source.features;
|
|
283
|
+
const scale = currentScale();
|
|
284
|
+
untrack(() => render(collection, scale));
|
|
285
|
+
});
|
|
286
|
+
ctx.effect(() => {
|
|
287
|
+
const keys = ctx.selectedKeys;
|
|
288
|
+
untrack(() => applySelection(keys));
|
|
289
|
+
});
|
|
290
|
+
return () => {
|
|
291
|
+
dead = true;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const plugin = {
|
|
295
|
+
id: pluginId,
|
|
296
|
+
label: layerLabel,
|
|
297
|
+
get dataset() {
|
|
298
|
+
return datasetOf(source);
|
|
299
|
+
},
|
|
300
|
+
setup,
|
|
301
|
+
legend,
|
|
302
|
+
selectable
|
|
303
|
+
};
|
|
304
|
+
if (options.listed ?? true)
|
|
305
|
+
plugin.features = features;
|
|
306
|
+
return plugin;
|
|
307
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExpressionSpecification } from "@maptiler/sdk";
|
|
2
|
+
import type { FeatureHit } from "../core/plugin.js";
|
|
3
|
+
/** 1 where the feature's `selected` state is set, else 0. */
|
|
4
|
+
export declare const SELECTED_OPACITY: ExpressionSpecification;
|
|
5
|
+
/** The hit for a rendered feature's properties (`refKey`, `name`), or null without a valid ref. */
|
|
6
|
+
export declare function hitFromProps(pluginId: string, props: Record<string, unknown>): FeatureHit | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { decodeRef } from "../data/refs.js";
|
|
2
|
+
/** 1 where the feature's `selected` state is set, else 0. */
|
|
3
|
+
export const SELECTED_OPACITY = [
|
|
4
|
+
"case",
|
|
5
|
+
["boolean", ["feature-state", "selected"], false],
|
|
6
|
+
1,
|
|
7
|
+
0
|
|
8
|
+
];
|
|
9
|
+
/** The hit for a rendered feature's properties (`refKey`, `name`), or null without a valid ref. */
|
|
10
|
+
export function hitFromProps(pluginId, props) {
|
|
11
|
+
const refKey = props.refKey;
|
|
12
|
+
if (typeof refKey !== "string")
|
|
13
|
+
return null;
|
|
14
|
+
const ref = decodeRef(refKey);
|
|
15
|
+
if (!ref)
|
|
16
|
+
return null;
|
|
17
|
+
return { pluginId, ref, refKey, name: typeof props.name === "string" ? props.name : "" };
|
|
18
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExpressionSpecification } from "@maptiler/sdk";
|
|
2
|
+
import type { Level, Scale } from "../data/scale-types.js";
|
|
3
|
+
/** Theme token read at plugin setup (getComputedStyle on the container) for unmatched values. */
|
|
4
|
+
export declare const NEUTRAL_COLOR_VAR = "--muted";
|
|
5
|
+
/** Concrete colors for map paint and marker images, read from the host's theme tokens. */
|
|
6
|
+
export interface ThemeColors {
|
|
7
|
+
/** Values no level covers, and levels with an empty color: `--muted`. */
|
|
8
|
+
neutral: string;
|
|
9
|
+
/** The selection ring: `--highlight`, else `--primary`. */
|
|
10
|
+
highlight: string;
|
|
11
|
+
/** The check mark on the highlight: `--highlight-foreground`, else `--primary-foreground`. */
|
|
12
|
+
highlightForeground: string;
|
|
13
|
+
/** Marker and cluster outlines and cluster counts: `--foreground`. */
|
|
14
|
+
foreground: string;
|
|
15
|
+
/** Markers drawn muted by default (see `icon.mutedColor`): `--muted-foreground`. */
|
|
16
|
+
mutedForeground: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The last resort when the host defines none of a color's tokens (or no DOM, as in unit tests).
|
|
20
|
+
* Presentation only, never data, and the only hardcoded colors in the plugins.
|
|
21
|
+
*/
|
|
22
|
+
export declare const FALLBACK_THEME_COLORS: Readonly<ThemeColors>;
|
|
23
|
+
/**
|
|
24
|
+
* Resolves the theme tokens on `container` (the `[data-mapsdk-map]` element) to colors MapLibre
|
|
25
|
+
* and marker images can use. Token values may be any CSS color (shadcn uses `oklch()`, which
|
|
26
|
+
* MapLibre can't parse), so each is drawn to a 1×1 canvas and read back as `rgba()`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function themeColors(container: Element | null): ThemeColors;
|
|
29
|
+
/**
|
|
30
|
+
* The raw values of every token `themeColors` reads, as one string: when it changes, the colors
|
|
31
|
+
* plugins baked into paint and images are stale.
|
|
32
|
+
*/
|
|
33
|
+
export declare function themeSignature(container: Element): string;
|
|
34
|
+
type Output = string | number;
|
|
35
|
+
/**
|
|
36
|
+
* What a lookup reads: the feature's `value` property, or an expression computing a value (e.g. a
|
|
37
|
+
* cluster's average). An expression that may have no value must be numeric and give
|
|
38
|
+
* `MISSING_VALUE` then (MapLibre won't mix null into a number expression).
|
|
39
|
+
*/
|
|
40
|
+
export type ValueInput = "value" | ExpressionSpecification;
|
|
41
|
+
/** Breakpoint input for null and non-numeric values: below every real level. */
|
|
42
|
+
export declare const MISSING_VALUE = -1;
|
|
43
|
+
/**
|
|
44
|
+
* True where the input is a reading by `numericValue`'s rule: a number, or a non-blank numeric
|
|
45
|
+
* string (same blank-string test as `numeric`; "Infinity" is the same accepted gap). A string
|
|
46
|
+
* that two different `to-number` fallbacks both pass through converted.
|
|
47
|
+
*/
|
|
48
|
+
export declare function hasNumericValue(input: ValueInput): ExpressionSpecification;
|
|
49
|
+
/** Per-level output for the level `input` falls in; `fallback` where no level applies. */
|
|
50
|
+
export declare function levelLookup(scale: Scale, input: ValueInput, fallback: Output, pick: (level: Level) => Output | null): ExpressionSpecification;
|
|
51
|
+
/** Fill color per level; unmatched values and levels with an empty color use `neutral`. */
|
|
52
|
+
export declare function colorExpression(scale: Scale, property: "value", neutral: string): ExpressionSpecification;
|
|
53
|
+
/** Opacity per level, or the plain `fallback` number when no level sets one. */
|
|
54
|
+
export declare function opacityExpression(scale: Scale, property: "value", fallback: number): ExpressionSpecification | number;
|
|
55
|
+
/** Level key per value ("" where no level applies), for filters and icon ids. */
|
|
56
|
+
export declare function levelKeyExpression(scale: Scale, input: ValueInput): ExpressionSpecification;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Theme token read at plugin setup (getComputedStyle on the container) for unmatched values. */
|
|
2
|
+
export const NEUTRAL_COLOR_VAR = "--muted";
|
|
3
|
+
/**
|
|
4
|
+
* The last resort when the host defines none of a color's tokens (or no DOM, as in unit tests).
|
|
5
|
+
* Presentation only, never data, and the only hardcoded colors in the plugins.
|
|
6
|
+
*/
|
|
7
|
+
export const FALLBACK_THEME_COLORS = {
|
|
8
|
+
neutral: "#9ca3af",
|
|
9
|
+
highlight: "#2563eb",
|
|
10
|
+
highlightForeground: "#ffffff",
|
|
11
|
+
foreground: "#111827",
|
|
12
|
+
mutedForeground: "#6b7280"
|
|
13
|
+
};
|
|
14
|
+
const THEME_TOKENS = {
|
|
15
|
+
neutral: [NEUTRAL_COLOR_VAR],
|
|
16
|
+
highlight: ["--highlight", "--primary"],
|
|
17
|
+
highlightForeground: ["--highlight-foreground", "--primary-foreground"],
|
|
18
|
+
foreground: ["--foreground"],
|
|
19
|
+
mutedForeground: ["--muted-foreground"]
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the theme tokens on `container` (the `[data-mapsdk-map]` element) to colors MapLibre
|
|
23
|
+
* and marker images can use. Token values may be any CSS color (shadcn uses `oklch()`, which
|
|
24
|
+
* MapLibre can't parse), so each is drawn to a 1×1 canvas and read back as `rgba()`.
|
|
25
|
+
*/
|
|
26
|
+
export function themeColors(container) {
|
|
27
|
+
const style = container && typeof getComputedStyle === "function" ? getComputedStyle(container) : null;
|
|
28
|
+
const read = (names, fallback) => {
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const raw = style?.getPropertyValue(name).trim() ?? "";
|
|
31
|
+
if (raw && CSS.supports("color", raw))
|
|
32
|
+
return toRgba(raw, fallback);
|
|
33
|
+
}
|
|
34
|
+
return fallback;
|
|
35
|
+
};
|
|
36
|
+
const out = { ...FALLBACK_THEME_COLORS };
|
|
37
|
+
for (const key of Object.keys(THEME_TOKENS)) {
|
|
38
|
+
out[key] = read(THEME_TOKENS[key], FALLBACK_THEME_COLORS[key]);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The raw values of every token `themeColors` reads, as one string: when it changes, the colors
|
|
44
|
+
* plugins baked into paint and images are stale.
|
|
45
|
+
*/
|
|
46
|
+
export function themeSignature(container) {
|
|
47
|
+
const style = getComputedStyle(container);
|
|
48
|
+
return Object.values(THEME_TOKENS)
|
|
49
|
+
.flat()
|
|
50
|
+
.map((name) => style.getPropertyValue(name).trim())
|
|
51
|
+
.join("|");
|
|
52
|
+
}
|
|
53
|
+
/** A color canvas leaves `fillStyle` at when it rejects a value. */
|
|
54
|
+
const SENTINEL = "#010203";
|
|
55
|
+
function toRgba(color, fallback) {
|
|
56
|
+
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
57
|
+
if (!ctx)
|
|
58
|
+
return color;
|
|
59
|
+
ctx.fillStyle = SENTINEL;
|
|
60
|
+
ctx.fillStyle = color;
|
|
61
|
+
// Unchanged: canvas couldn't parse it (an assignment it rejects is ignored), so not black.
|
|
62
|
+
if (ctx.fillStyle === SENTINEL && color.toLowerCase() !== SENTINEL)
|
|
63
|
+
return fallback;
|
|
64
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
65
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
66
|
+
return `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 1000) / 1000})`;
|
|
67
|
+
}
|
|
68
|
+
/** Breakpoint input for null and non-numeric values: below every real level. */
|
|
69
|
+
export const MISSING_VALUE = -1;
|
|
70
|
+
const MISSING = MISSING_VALUE;
|
|
71
|
+
function read(input) {
|
|
72
|
+
return typeof input === "string" ? ["get", input] : input;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The value as a number for a range lookup, like `levelFor`: a number, or a non-blank numeric
|
|
76
|
+
* string; anything else (null, missing, blank or non-numeric strings, booleans, arrays) is
|
|
77
|
+
* MISSING. `to-number` alone would read null, "", " " and false as 0 (the first level) and
|
|
78
|
+
* true as 1. There is no trim in expressions, so a string converting to 0 counts as blank unless
|
|
79
|
+
* it contains a "0" (every non-blank string Number() reads as 0 does).
|
|
80
|
+
*
|
|
81
|
+
* Accepted gap: "Infinity" and "1e999" convert to Infinity and step to the top level, while
|
|
82
|
+
* `levelFor` says NO_LEVEL (expressions have no clean finiteness check).
|
|
83
|
+
*/
|
|
84
|
+
function numeric(input) {
|
|
85
|
+
const v = ["var", "v"];
|
|
86
|
+
const n = ["var", "n"];
|
|
87
|
+
return [
|
|
88
|
+
"let",
|
|
89
|
+
"v",
|
|
90
|
+
read(input),
|
|
91
|
+
[
|
|
92
|
+
"case",
|
|
93
|
+
["==", ["typeof", v], "number"],
|
|
94
|
+
["to-number", v],
|
|
95
|
+
["==", ["typeof", v], "string"],
|
|
96
|
+
[
|
|
97
|
+
"let",
|
|
98
|
+
"n",
|
|
99
|
+
["to-number", v, MISSING],
|
|
100
|
+
["case", ["all", ["==", n, 0], ["!", ["in", "0", ["to-string", v]]]], MISSING, n]
|
|
101
|
+
],
|
|
102
|
+
MISSING
|
|
103
|
+
]
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* True where the input is a reading by `numericValue`'s rule: a number, or a non-blank numeric
|
|
108
|
+
* string (same blank-string test as `numeric`; "Infinity" is the same accepted gap). A string
|
|
109
|
+
* that two different `to-number` fallbacks both pass through converted.
|
|
110
|
+
*/
|
|
111
|
+
export function hasNumericValue(input) {
|
|
112
|
+
const v = ["var", "v"];
|
|
113
|
+
const a = ["var", "a"];
|
|
114
|
+
return [
|
|
115
|
+
"let",
|
|
116
|
+
"v",
|
|
117
|
+
read(input),
|
|
118
|
+
[
|
|
119
|
+
"case",
|
|
120
|
+
["==", ["typeof", v], "number"],
|
|
121
|
+
true,
|
|
122
|
+
["==", ["typeof", v], "string"],
|
|
123
|
+
[
|
|
124
|
+
"let",
|
|
125
|
+
"a",
|
|
126
|
+
["to-number", v, -1],
|
|
127
|
+
[
|
|
128
|
+
"all",
|
|
129
|
+
["==", a, ["to-number", v, -2]],
|
|
130
|
+
["!", ["all", ["==", a, 0], ["!", ["in", "0", v]]]]
|
|
131
|
+
]
|
|
132
|
+
],
|
|
133
|
+
false
|
|
134
|
+
]
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Half-open range lookup matching `levelFor`: a level covers [min, max), the value `max` goes
|
|
139
|
+
* to the next level, and a gap or an unbounded-below input falls to `fallback`.
|
|
140
|
+
*/
|
|
141
|
+
function rangeStep(scale, input, fallback, pick) {
|
|
142
|
+
const stops = [];
|
|
143
|
+
let last = null;
|
|
144
|
+
const levels = scale.levels
|
|
145
|
+
.filter((l) => l.min !== null)
|
|
146
|
+
.sort((a, b) => a.min - b.min);
|
|
147
|
+
for (const level of levels) {
|
|
148
|
+
const min = level.min;
|
|
149
|
+
// Close a gap left by the previous level's max.
|
|
150
|
+
if (last !== null && last < min)
|
|
151
|
+
stops.push(last, fallback);
|
|
152
|
+
stops.push(min, pick(level) ?? fallback);
|
|
153
|
+
last = level.max ?? null;
|
|
154
|
+
if (last === null)
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (last !== null)
|
|
158
|
+
stops.push(last, fallback);
|
|
159
|
+
if (stops.length === 0)
|
|
160
|
+
return ["literal", fallback];
|
|
161
|
+
return ["step", numeric(input), fallback, ...stops];
|
|
162
|
+
}
|
|
163
|
+
function categoryMatch(scale, input, fallback, pick) {
|
|
164
|
+
const arms = [];
|
|
165
|
+
for (const level of scale.levels) {
|
|
166
|
+
if (level.value === null)
|
|
167
|
+
continue;
|
|
168
|
+
arms.push(level.value, pick(level) ?? fallback);
|
|
169
|
+
}
|
|
170
|
+
if (arms.length === 0)
|
|
171
|
+
return ["literal", fallback];
|
|
172
|
+
return ["match", read(input), ...arms, fallback];
|
|
173
|
+
}
|
|
174
|
+
/** Per-level output for the level `input` falls in; `fallback` where no level applies. */
|
|
175
|
+
export function levelLookup(scale, input, fallback, pick) {
|
|
176
|
+
return scale.kind === "range"
|
|
177
|
+
? rangeStep(scale, input, fallback, pick)
|
|
178
|
+
: categoryMatch(scale, input, fallback, pick);
|
|
179
|
+
}
|
|
180
|
+
/** Fill color per level; unmatched values and levels with an empty color use `neutral`. */
|
|
181
|
+
export function colorExpression(scale, property, neutral) {
|
|
182
|
+
return levelLookup(scale, property, neutral, (level) => level.color || null);
|
|
183
|
+
}
|
|
184
|
+
/** Opacity per level, or the plain `fallback` number when no level sets one. */
|
|
185
|
+
export function opacityExpression(scale, property, fallback) {
|
|
186
|
+
if (!scale.levels.some((level) => level.opacity !== null))
|
|
187
|
+
return fallback;
|
|
188
|
+
return levelLookup(scale, property, fallback, (level) => level.opacity);
|
|
189
|
+
}
|
|
190
|
+
/** Level key per value ("" where no level applies), for filters and icon ids. */
|
|
191
|
+
export function levelKeyExpression(scale, input) {
|
|
192
|
+
return levelLookup(scale, input, "", (level) => level.key);
|
|
193
|
+
}
|