@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.10
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 +713 -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 +274 -0
- package/dist/core/create-map.svelte.js +591 -0
- package/dist/core/gl.d.ts +97 -0
- package/dist/core/gl.js +1 -0
- package/dist/core/interactions.svelte.d.ts +50 -0
- package/dist/core/interactions.svelte.js +330 -0
- package/dist/core/plugin-host.svelte.d.ts +71 -0
- package/dist/core/plugin-host.svelte.js +445 -0
- package/dist/core/plugin.d.ts +190 -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/core/shape-view.d.ts +27 -0
- package/dist/core/shape-view.js +323 -0
- package/dist/data/format.d.ts +10 -0
- package/dist/data/format.js +37 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +9 -0
- package/dist/data/messages/en.json +61 -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 +13 -0
- package/dist/data/selection.js +52 -0
- package/dist/data/static-source.d.ts +4 -0
- package/dist/data/static-source.js +3 -0
- package/dist/data/types.d.ts +74 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +42 -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 +105 -0
- package/dist/plugins/points.svelte.js +502 -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 +34 -0
- package/dist/plugins/shapes.svelte.js +325 -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/FireTooltip.svelte +70 -0
- package/dist/presets/FireTooltip.svelte.d.ts +13 -0
- package/dist/presets/MonitorClusterTooltip.svelte +168 -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/cluster-area.d.ts +13 -0
- package/dist/presets/cluster-area.js +34 -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 +30 -0
- package/dist/presets/hms-fire.js +86 -0
- package/dist/presets/hms-smoke.d.ts +20 -0
- package/dist/presets/hms-smoke.js +22 -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 +38 -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 +501 -0
- package/dist/ui/FeatureList.svelte.d.ts +14 -0
- package/dist/ui/Legend.svelte +214 -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 +397 -0
- package/dist/ui/MapTooltip.svelte.d.ts +37 -0
- package/dist/ui/PickNotice.svelte +56 -0
- package/dist/ui/PickNotice.svelte.d.ts +7 -0
- package/dist/ui/SelectionBar.svelte +123 -0
- package/dist/ui/SelectionBar.svelte.d.ts +10 -0
- package/dist/ui/TooltipItem.svelte +66 -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/card.d.ts +29 -0
- package/dist/ui/card.js +1 -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 +46 -0
- package/dist/ui/feature-list.js +115 -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 +8 -0
- package/dist/ui/value-format.js +20 -0
- package/dist/ui/view-summary.d.ts +11 -0
- package/dist/ui/view-summary.js +33 -0
- package/dist/view/MapView.svelte +443 -0
- package/dist/view/MapView.svelte.d.ts +51 -0
- package/dist/view/create-gl-map.d.ts +34 -0
- package/dist/view/create-gl-map.js +186 -0
- package/dist/view/list-placement.d.ts +21 -0
- package/dist/view/list-placement.js +13 -0
- package/package.json +104 -5
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { untrack } from "svelte";
|
|
2
|
+
/** For a `null` source scale: features still draw, all neutral, with no legend. */
|
|
3
|
+
export const NO_SCALE = {
|
|
4
|
+
id: "",
|
|
5
|
+
label: "",
|
|
6
|
+
units: "",
|
|
7
|
+
kind: "category",
|
|
8
|
+
source: null,
|
|
9
|
+
levels: []
|
|
10
|
+
};
|
|
11
|
+
/** The dataset of a source whose scale is a `ScaleRef`; `undefined` for a literal scale or none. */
|
|
12
|
+
export function datasetOf(source) {
|
|
13
|
+
const input = source.scale;
|
|
14
|
+
return input !== null && !("levels" in input) ? input.dataset : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Equal for scales equal by value. */
|
|
17
|
+
export function scaleKey(scale) {
|
|
18
|
+
return JSON.stringify(scale);
|
|
19
|
+
}
|
|
20
|
+
function isScale(input) {
|
|
21
|
+
return input !== null && "levels" in input;
|
|
22
|
+
}
|
|
23
|
+
/** The scale for `input` when it is available synchronously. */
|
|
24
|
+
function immediate(input) {
|
|
25
|
+
if (input === null)
|
|
26
|
+
return NO_SCALE;
|
|
27
|
+
return isScale(input) ? input : null;
|
|
28
|
+
}
|
|
29
|
+
/** Scale resolution shared by the plugins: abort on change, ignore late results. */
|
|
30
|
+
export function createScaleResolver(pluginId, layerLabel, source,
|
|
31
|
+
/** Title the legend with the entry type's label from the metadata once known. */
|
|
32
|
+
entryTypeTitle = false) {
|
|
33
|
+
/** The scale resolved for a source scale input, from the latest setup. */
|
|
34
|
+
let resolution = $state.raw(null);
|
|
35
|
+
/** The metadata's display precision and label for a source scale input, once known. */
|
|
36
|
+
let hint = $state.raw(null);
|
|
37
|
+
function current() {
|
|
38
|
+
const input = source.scale;
|
|
39
|
+
const now = immediate(input);
|
|
40
|
+
if (now)
|
|
41
|
+
return now;
|
|
42
|
+
return resolution?.input === input ? resolution.scale : null;
|
|
43
|
+
}
|
|
44
|
+
function legend() {
|
|
45
|
+
const status = source.status;
|
|
46
|
+
if (status.state === "error" || status.state === "unavailable") {
|
|
47
|
+
return { pluginId, status };
|
|
48
|
+
}
|
|
49
|
+
const scale = current();
|
|
50
|
+
if (scale === null)
|
|
51
|
+
return { pluginId, status: { state: "loading" } };
|
|
52
|
+
if (scale === "unavailable") {
|
|
53
|
+
const unavailable = { state: "unavailable", reason: "scale" };
|
|
54
|
+
return { pluginId, status: unavailable };
|
|
55
|
+
}
|
|
56
|
+
if (scale === NO_SCALE)
|
|
57
|
+
return null;
|
|
58
|
+
const title = entryTypeTitle && hint !== null && hint.input === source.scale && hint.label
|
|
59
|
+
? hint.label
|
|
60
|
+
: layerLabel;
|
|
61
|
+
return { pluginId, title, units: scale.units, scale };
|
|
62
|
+
}
|
|
63
|
+
function precision() {
|
|
64
|
+
if (hint !== null && hint.input === source.scale && hint.precision !== null) {
|
|
65
|
+
return hint.precision;
|
|
66
|
+
}
|
|
67
|
+
const scale = current();
|
|
68
|
+
return scale !== null && scale !== "unavailable" && scale.kind === "range" ? 0 : 1;
|
|
69
|
+
}
|
|
70
|
+
function track(ctx, isDead) {
|
|
71
|
+
ctx.effect(() => {
|
|
72
|
+
const input = source.scale;
|
|
73
|
+
if (input === null || isScale(input))
|
|
74
|
+
return;
|
|
75
|
+
if (untrack(() => hint)?.input === input)
|
|
76
|
+
return;
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
// A display hint only: a failure keeps the default.
|
|
79
|
+
ctx.metadata.entryType(input, controller.signal).then((info) => {
|
|
80
|
+
if (isDead() || controller.signal.aborted)
|
|
81
|
+
return;
|
|
82
|
+
hint =
|
|
83
|
+
info === "unavailable"
|
|
84
|
+
? { input, precision: null, label: null }
|
|
85
|
+
: { input, precision: info.precision, label: info.label };
|
|
86
|
+
}, () => { });
|
|
87
|
+
return () => controller.abort();
|
|
88
|
+
});
|
|
89
|
+
ctx.effect(() => {
|
|
90
|
+
const input = source.scale;
|
|
91
|
+
if (input === null || isScale(input))
|
|
92
|
+
return;
|
|
93
|
+
// Already resolved by an earlier setup; an "unavailable" result is retried.
|
|
94
|
+
const known = untrack(() => resolution);
|
|
95
|
+
if (known?.input === input && known.scale !== "unavailable")
|
|
96
|
+
return;
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
ctx.metadata.scale(input, controller.signal).then((scale) => {
|
|
99
|
+
if (!isDead() && !controller.signal.aborted)
|
|
100
|
+
resolution = { input, scale };
|
|
101
|
+
}, (error) => {
|
|
102
|
+
if (isDead() || controller.signal.aborted)
|
|
103
|
+
return;
|
|
104
|
+
console.warn(`map-sdk: plugin "${pluginId}" could not resolve its scale`, error);
|
|
105
|
+
resolution = { input, scale: "unavailable" };
|
|
106
|
+
});
|
|
107
|
+
return () => controller.abort();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { current, legend, precision, track };
|
|
111
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Geometry } from "geojson";
|
|
2
|
+
import type { MapPlugin } from "../core/plugin.js";
|
|
3
|
+
import type { DataSource, MapFeatureProperties } from "../data/types.js";
|
|
4
|
+
export interface ShapesOptions<P extends MapFeatureProperties> {
|
|
5
|
+
id: string;
|
|
6
|
+
source: DataSource<P>;
|
|
7
|
+
/** Already-translated layer name for the list and summary. */
|
|
8
|
+
layerLabel: string;
|
|
9
|
+
/** `choropleth` fills by the source's scale; `plain` is neutral with no legend. */
|
|
10
|
+
mode: "plain" | "choropleth";
|
|
11
|
+
selectable?: boolean;
|
|
12
|
+
/** Whether `features()` lists this plugin's features. Default true. */
|
|
13
|
+
listed?: boolean;
|
|
14
|
+
/** Fill opacity where a level sets none. Default 0.6. */
|
|
15
|
+
fillOpacity?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Edge of the shapes. `theme` (default) is a 1px `--foreground` line; `level` draws it in the
|
|
18
|
+
* level's color at the level's opacity (choropleth); `none` draws no outline layer
|
|
19
|
+
* (so LineString geometries are not drawn either).
|
|
20
|
+
*/
|
|
21
|
+
outline?: "theme" | "level" | "none";
|
|
22
|
+
/**
|
|
23
|
+
* False: hovering a shape shows no tooltip and doesn't hide what is beneath it
|
|
24
|
+
* (`MapPlugin.hoverable`); list focus still shows it. Default true.
|
|
25
|
+
*/
|
|
26
|
+
hoverable?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** The point used to place a feature in lists: its largest ring's centroid, or a line's midpoint. */
|
|
29
|
+
export declare function labelPoint(geometry: Geometry): [number, number] | null;
|
|
30
|
+
/**
|
|
31
|
+
* Polygons and lines, plain (neutral) or filled by value from a scale (choropleth), with a
|
|
32
|
+
* thin outline and a thicker highlight outline on selected features.
|
|
33
|
+
*/
|
|
34
|
+
export declare function shapesPlugin<P extends MapFeatureProperties>(options: ShapesOptions<P>): MapPlugin;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { geometryBbox } from "../core/shape-view.js";
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import { decodeRef, selectionId } from "../data/refs.js";
|
|
4
|
+
import { levelFor, NO_LEVEL } from "../data/scale.js";
|
|
5
|
+
import { createScaleResolver, NO_SCALE, datasetOf, scaleKey } from "./scale-resolution.svelte.js";
|
|
6
|
+
import { hitFromProps, SELECTED_OPACITY } from "./shared.js";
|
|
7
|
+
import { asDataURI, SELECTED_OVERLAY_PADDING, selectedOverlay } from "./icons.js";
|
|
8
|
+
import { colorExpression, opacityExpression, themeColors } from "./style.js";
|
|
9
|
+
/** The check image is drawn at this multiple of its size and scaled down, for sharp HiDPI. */
|
|
10
|
+
const IMAGE_RATIO = 2;
|
|
11
|
+
/** The check mark badge is sized for a marker this many pixels across. */
|
|
12
|
+
const CHECK_SIZE = 12;
|
|
13
|
+
const EMPTY = { type: "FeatureCollection", features: [] };
|
|
14
|
+
const DEFAULT_FILL_OPACITY = 0.6;
|
|
15
|
+
const OUTLINE_WIDTH = 1;
|
|
16
|
+
const LINE_WIDTH = 2;
|
|
17
|
+
const SELECTED_WIDTH = 3;
|
|
18
|
+
const LINE_TYPES = ["LineString", "MultiLineString"];
|
|
19
|
+
/** Twice the signed area of a ring, absolute. */
|
|
20
|
+
function ringArea(ring) {
|
|
21
|
+
let sum = 0;
|
|
22
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
23
|
+
sum += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
|
24
|
+
}
|
|
25
|
+
return Math.abs(sum);
|
|
26
|
+
}
|
|
27
|
+
/** Centroid of a ring (the area-weighted one; the vertex mean for a degenerate ring). */
|
|
28
|
+
function ringCentroid(ring) {
|
|
29
|
+
let area = 0;
|
|
30
|
+
let cx = 0;
|
|
31
|
+
let cy = 0;
|
|
32
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
33
|
+
const cross = ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
|
34
|
+
area += cross;
|
|
35
|
+
cx += (ring[i][0] + ring[i + 1][0]) * cross;
|
|
36
|
+
cy += (ring[i][1] + ring[i + 1][1]) * cross;
|
|
37
|
+
}
|
|
38
|
+
if (area === 0) {
|
|
39
|
+
const n = Math.max(ring.length - 1, 1);
|
|
40
|
+
const sum = ring.slice(0, n).reduce((a, p) => [a[0] + p[0], a[1] + p[1]], [0, 0]);
|
|
41
|
+
return [sum[0] / n, sum[1] / n];
|
|
42
|
+
}
|
|
43
|
+
return [cx / (3 * area), cy / (3 * area)];
|
|
44
|
+
}
|
|
45
|
+
/** The point used to place a feature in lists: its largest ring's centroid, or a line's midpoint. */
|
|
46
|
+
export function labelPoint(geometry) {
|
|
47
|
+
switch (geometry.type) {
|
|
48
|
+
case "Point":
|
|
49
|
+
return [geometry.coordinates[0], geometry.coordinates[1]];
|
|
50
|
+
case "Polygon":
|
|
51
|
+
case "MultiPolygon": {
|
|
52
|
+
const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
|
|
53
|
+
let best = null;
|
|
54
|
+
let bestArea = -1;
|
|
55
|
+
for (const polygon of polygons) {
|
|
56
|
+
const outer = polygon[0];
|
|
57
|
+
if (!outer?.length)
|
|
58
|
+
continue;
|
|
59
|
+
const area = ringArea(outer);
|
|
60
|
+
if (area > bestArea) {
|
|
61
|
+
best = outer;
|
|
62
|
+
bestArea = area;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return best ? ringCentroid(best) : null;
|
|
66
|
+
}
|
|
67
|
+
case "LineString":
|
|
68
|
+
case "MultiLineString": {
|
|
69
|
+
const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.coordinates;
|
|
70
|
+
const longest = lines.reduce((a, b) => (b.length > a.length ? b : a), []);
|
|
71
|
+
const mid = longest[Math.floor(longest.length / 2)];
|
|
72
|
+
return mid ? [mid[0], mid[1]] : null;
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Polygons and lines, plain (neutral) or filled by value from a scale (choropleth), with a
|
|
80
|
+
* thin outline and a thicker highlight outline on selected features.
|
|
81
|
+
*/
|
|
82
|
+
export function shapesPlugin(options) {
|
|
83
|
+
const { id: pluginId, source, layerLabel, mode } = options;
|
|
84
|
+
const selectable = options.selectable ?? false;
|
|
85
|
+
const fillOpacity = options.fillOpacity ?? DEFAULT_FILL_OPACITY;
|
|
86
|
+
const choropleth = mode === "choropleth";
|
|
87
|
+
const outlineMode = options.outline ?? "theme";
|
|
88
|
+
const scales = createScaleResolver(pluginId, layerLabel, source);
|
|
89
|
+
/** Plain mode ignores the source's scale. */
|
|
90
|
+
const currentScale = () => (choropleth ? scales.current() : NO_SCALE);
|
|
91
|
+
const legend = () => (choropleth ? scales.legend() : null);
|
|
92
|
+
// Each feature's label point and bbox, measured once per feature object (once per data load):
|
|
93
|
+
// `features()` runs on every move. Per plugin instance, so two maps share nothing.
|
|
94
|
+
const measured = new WeakMap();
|
|
95
|
+
const measure = (feature) => {
|
|
96
|
+
let m = measured.get(feature);
|
|
97
|
+
if (!m) {
|
|
98
|
+
m = { lngLat: labelPoint(feature.geometry), bbox: geometryBbox(feature.geometry) };
|
|
99
|
+
measured.set(feature, m);
|
|
100
|
+
}
|
|
101
|
+
return m;
|
|
102
|
+
};
|
|
103
|
+
function features() {
|
|
104
|
+
const collection = source.features;
|
|
105
|
+
if (!collection)
|
|
106
|
+
return [];
|
|
107
|
+
const resolved = currentScale();
|
|
108
|
+
const scale = resolved === null || resolved === "unavailable" ? null : resolved;
|
|
109
|
+
const precision = choropleth ? scales.precision() : 1;
|
|
110
|
+
const items = [];
|
|
111
|
+
for (const feature of collection.features) {
|
|
112
|
+
const props = feature.properties;
|
|
113
|
+
if (!props || !feature.geometry)
|
|
114
|
+
continue;
|
|
115
|
+
const ref = decodeRef(props.refKey);
|
|
116
|
+
if (!ref)
|
|
117
|
+
continue;
|
|
118
|
+
const { lngLat, bbox } = measure(feature);
|
|
119
|
+
if (!lngLat)
|
|
120
|
+
continue;
|
|
121
|
+
items.push({
|
|
122
|
+
refKey: props.refKey,
|
|
123
|
+
ref,
|
|
124
|
+
name: props.name,
|
|
125
|
+
value: props.value,
|
|
126
|
+
level: scale ? levelFor(props.value, scale) : NO_LEVEL,
|
|
127
|
+
units: scale?.units ?? "",
|
|
128
|
+
precision,
|
|
129
|
+
lngLat,
|
|
130
|
+
geometry: feature.geometry,
|
|
131
|
+
...(bbox && { bbox }),
|
|
132
|
+
selectable: selectable && selectionId(ref) !== null
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return items;
|
|
136
|
+
}
|
|
137
|
+
const toHit = (props) => hitFromProps(pluginId, props);
|
|
138
|
+
function setup(ctx) {
|
|
139
|
+
let dead = false;
|
|
140
|
+
const { gl } = ctx;
|
|
141
|
+
const container = gl.getCanvas().closest?.("[data-mapsdk-map]") ?? null;
|
|
142
|
+
const theme = themeColors(container);
|
|
143
|
+
let sourceId = null;
|
|
144
|
+
let checkSourceId = null;
|
|
145
|
+
/** refKey → label point, from the latest data. */
|
|
146
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
147
|
+
let labels = new Map();
|
|
148
|
+
/** Namespaced ids of the layers currently on the map, in draw order. */
|
|
149
|
+
let layers = [];
|
|
150
|
+
/** The scale (by `scaleKey`) the layers on the map were built for. */
|
|
151
|
+
let builtKey = null;
|
|
152
|
+
let interactiveDone = false;
|
|
153
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
154
|
+
let applied = new Set();
|
|
155
|
+
function removeLayers() {
|
|
156
|
+
for (const layerId of layers.toReversed()) {
|
|
157
|
+
if (gl.getLayer(layerId))
|
|
158
|
+
gl.removeLayer(layerId);
|
|
159
|
+
}
|
|
160
|
+
layers = [];
|
|
161
|
+
builtKey = null;
|
|
162
|
+
}
|
|
163
|
+
function buildLayers(scale, src) {
|
|
164
|
+
removeLayers();
|
|
165
|
+
const isLine = ["match", ["geometry-type"], LINE_TYPES, true, false];
|
|
166
|
+
const lineColor = choropleth
|
|
167
|
+
? [
|
|
168
|
+
"case",
|
|
169
|
+
isLine,
|
|
170
|
+
colorExpression(scale, "value", theme.neutral),
|
|
171
|
+
theme.foreground
|
|
172
|
+
]
|
|
173
|
+
: theme.foreground;
|
|
174
|
+
const fill = ctx.addLayer("fill", {
|
|
175
|
+
type: "fill",
|
|
176
|
+
source: src,
|
|
177
|
+
paint: {
|
|
178
|
+
"fill-color": choropleth ? colorExpression(scale, "value", theme.neutral) : theme.neutral,
|
|
179
|
+
"fill-opacity": choropleth ? opacityExpression(scale, "value", fillOpacity) : fillOpacity
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const byLevel = outlineMode === "level" && choropleth;
|
|
183
|
+
const outline = outlineMode === "none"
|
|
184
|
+
? null
|
|
185
|
+
: ctx.addLayer("outline", {
|
|
186
|
+
type: "line",
|
|
187
|
+
source: src,
|
|
188
|
+
paint: {
|
|
189
|
+
"line-color": byLevel ? colorExpression(scale, "value", theme.neutral) : lineColor,
|
|
190
|
+
...(byLevel
|
|
191
|
+
? { "line-opacity": opacityExpression(scale, "value", fillOpacity) }
|
|
192
|
+
: {}),
|
|
193
|
+
"line-width": ["case", isLine, LINE_WIDTH, OUTLINE_WIDTH]
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
const selectedLayer = ctx.addLayer("selected", {
|
|
197
|
+
type: "line",
|
|
198
|
+
source: src,
|
|
199
|
+
paint: {
|
|
200
|
+
"line-color": theme.highlight,
|
|
201
|
+
"line-width": SELECTED_WIDTH,
|
|
202
|
+
"line-opacity": SELECTED_OPACITY
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const check = ctx.addLayer("check", {
|
|
206
|
+
type: "symbol",
|
|
207
|
+
source: checkSourceId,
|
|
208
|
+
layout: {
|
|
209
|
+
"icon-image": ctx.id("selected"),
|
|
210
|
+
"icon-size": 1 / IMAGE_RATIO,
|
|
211
|
+
"icon-allow-overlap": true,
|
|
212
|
+
"icon-ignore-placement": true
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
layers = outline ? [fill, outline, selectedLayer, check] : [fill, selectedLayer, check];
|
|
216
|
+
// Layer ids repeat across rebuilds; register them once.
|
|
217
|
+
if (!interactiveDone) {
|
|
218
|
+
interactiveDone = true;
|
|
219
|
+
ctx.interactive(outline ? [fill, outline] : [fill], toHit);
|
|
220
|
+
}
|
|
221
|
+
builtKey = scaleKey(scale);
|
|
222
|
+
}
|
|
223
|
+
/** One check-mark point per selected feature present in the data. */
|
|
224
|
+
function updateCheck(keys) {
|
|
225
|
+
if (!checkSourceId)
|
|
226
|
+
return;
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const key of keys) {
|
|
229
|
+
const at = labels.get(key);
|
|
230
|
+
if (at)
|
|
231
|
+
out.push({
|
|
232
|
+
type: "Feature",
|
|
233
|
+
geometry: { type: "Point", coordinates: at },
|
|
234
|
+
properties: {}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
gl.getSource(checkSourceId).setData({
|
|
238
|
+
type: "FeatureCollection",
|
|
239
|
+
features: out
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function applySelection(keys) {
|
|
243
|
+
if (!sourceId)
|
|
244
|
+
return;
|
|
245
|
+
updateCheck(keys);
|
|
246
|
+
for (const key of applied) {
|
|
247
|
+
if (!keys.has(key))
|
|
248
|
+
gl.removeFeatureState({ source: sourceId, id: key }, "selected");
|
|
249
|
+
}
|
|
250
|
+
for (const key of keys) {
|
|
251
|
+
if (!applied.has(key))
|
|
252
|
+
gl.setFeatureState({ source: sourceId, id: key }, { selected: true });
|
|
253
|
+
}
|
|
254
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
255
|
+
applied = new Set(keys);
|
|
256
|
+
}
|
|
257
|
+
function render(reactive, scale) {
|
|
258
|
+
// Plain objects: MapLibre structured-clones the data to its worker, which a proxy fails.
|
|
259
|
+
const data = ($state.snapshot(reactive) ?? {
|
|
260
|
+
type: "FeatureCollection",
|
|
261
|
+
features: []
|
|
262
|
+
});
|
|
263
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, not reactive
|
|
264
|
+
labels = new Map();
|
|
265
|
+
for (const feature of data.features) {
|
|
266
|
+
const props = feature.properties;
|
|
267
|
+
const at = props && feature.geometry ? labelPoint(feature.geometry) : null;
|
|
268
|
+
if (props && at)
|
|
269
|
+
labels.set(props.refKey, at);
|
|
270
|
+
}
|
|
271
|
+
if (sourceId) {
|
|
272
|
+
gl.getSource(sourceId).setData(data);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
sourceId = ctx.addSource("shapes", { type: "geojson", data, promoteId: "refKey" });
|
|
276
|
+
checkSourceId = ctx.addSource("check", { type: "geojson", data: EMPTY });
|
|
277
|
+
}
|
|
278
|
+
if (scale === null || scale === "unavailable") {
|
|
279
|
+
removeLayers();
|
|
280
|
+
}
|
|
281
|
+
else if (builtKey !== scaleKey(scale)) {
|
|
282
|
+
buildLayers(scale, sourceId);
|
|
283
|
+
}
|
|
284
|
+
applySelection(untrack(() => ctx.selectedKeys));
|
|
285
|
+
}
|
|
286
|
+
if (typeof Image !== "undefined") {
|
|
287
|
+
const total = CHECK_SIZE + 2 * SELECTED_OVERLAY_PADDING;
|
|
288
|
+
const image = new Image(total * IMAGE_RATIO, total * IMAGE_RATIO);
|
|
289
|
+
image.onload = () => {
|
|
290
|
+
if (!dead)
|
|
291
|
+
ctx.addImage("selected", image);
|
|
292
|
+
};
|
|
293
|
+
image.src = asDataURI(selectedOverlay(theme.highlight, theme.highlightForeground, CHECK_SIZE));
|
|
294
|
+
}
|
|
295
|
+
if (choropleth)
|
|
296
|
+
scales.track(ctx, () => dead);
|
|
297
|
+
ctx.effect(() => {
|
|
298
|
+
const collection = source.features;
|
|
299
|
+
const scale = currentScale();
|
|
300
|
+
untrack(() => render(collection, scale));
|
|
301
|
+
});
|
|
302
|
+
ctx.effect(() => {
|
|
303
|
+
const keys = ctx.selectedKeys;
|
|
304
|
+
untrack(() => applySelection(keys));
|
|
305
|
+
});
|
|
306
|
+
return () => {
|
|
307
|
+
dead = true;
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const plugin = {
|
|
311
|
+
id: pluginId,
|
|
312
|
+
label: layerLabel,
|
|
313
|
+
get dataset() {
|
|
314
|
+
return datasetOf(source);
|
|
315
|
+
},
|
|
316
|
+
setup,
|
|
317
|
+
legend,
|
|
318
|
+
selectable
|
|
319
|
+
};
|
|
320
|
+
if (options.listed ?? true)
|
|
321
|
+
plugin.features = features;
|
|
322
|
+
if (options.hoverable === false)
|
|
323
|
+
plugin.hoverable = false;
|
|
324
|
+
return plugin;
|
|
325
|
+
}
|
|
@@ -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 {};
|