@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +487 -2
  3. package/dist/core/basemap.svelte.d.ts +38 -0
  4. package/dist/core/basemap.svelte.js +163 -0
  5. package/dist/core/context.d.ts +5 -0
  6. package/dist/core/context.js +13 -0
  7. package/dist/core/create-map.svelte.d.ts +207 -0
  8. package/dist/core/create-map.svelte.js +376 -0
  9. package/dist/core/gl.d.ts +92 -0
  10. package/dist/core/gl.js +1 -0
  11. package/dist/core/interactions.svelte.d.ts +42 -0
  12. package/dist/core/interactions.svelte.js +213 -0
  13. package/dist/core/plugin-host.svelte.d.ts +46 -0
  14. package/dist/core/plugin-host.svelte.js +366 -0
  15. package/dist/core/plugin.d.ts +148 -0
  16. package/dist/core/plugin.js +1 -0
  17. package/dist/core/scoped-context.d.ts +7 -0
  18. package/dist/core/scoped-context.js +34 -0
  19. package/dist/data/format.d.ts +10 -0
  20. package/dist/data/format.js +24 -0
  21. package/dist/data/index.d.ts +9 -0
  22. package/dist/data/index.js +9 -0
  23. package/dist/data/messages/en.json +51 -0
  24. package/dist/data/messages.d.ts +10 -0
  25. package/dist/data/messages.js +17 -0
  26. package/dist/data/metadata.d.ts +22 -0
  27. package/dist/data/metadata.js +110 -0
  28. package/dist/data/refs.d.ts +18 -0
  29. package/dist/data/refs.js +61 -0
  30. package/dist/data/scale-types.d.ts +7 -0
  31. package/dist/data/scale-types.js +1 -0
  32. package/dist/data/scale.d.ts +13 -0
  33. package/dist/data/scale.js +27 -0
  34. package/dist/data/selection.d.ts +7 -0
  35. package/dist/data/selection.js +31 -0
  36. package/dist/data/static-source.d.ts +4 -0
  37. package/dist/data/static-source.js +3 -0
  38. package/dist/data/types.d.ts +69 -0
  39. package/dist/data/types.js +1 -0
  40. package/dist/index.d.ts +56 -0
  41. package/dist/index.js +41 -0
  42. package/dist/plugins/icons.d.ts +21 -0
  43. package/dist/plugins/icons.js +73 -0
  44. package/dist/plugins/points-clusters.d.ts +60 -0
  45. package/dist/plugins/points-clusters.js +93 -0
  46. package/dist/plugins/points.svelte.d.ts +93 -0
  47. package/dist/plugins/points.svelte.js +473 -0
  48. package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
  49. package/dist/plugins/scale-resolution.svelte.js +111 -0
  50. package/dist/plugins/shapes.svelte.d.ts +29 -0
  51. package/dist/plugins/shapes.svelte.js +307 -0
  52. package/dist/plugins/shared.d.ts +6 -0
  53. package/dist/plugins/shared.js +18 -0
  54. package/dist/plugins/style.d.ts +57 -0
  55. package/dist/plugins/style.js +193 -0
  56. package/dist/presets/CollocationTooltip.svelte +132 -0
  57. package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
  58. package/dist/presets/MonitorClusterTooltip.svelte +149 -0
  59. package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
  60. package/dist/presets/MonitorReadingBox.svelte +58 -0
  61. package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
  62. package/dist/presets/MonitorTooltip.svelte +88 -0
  63. package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
  64. package/dist/presets/calheatscore.d.ts +24 -0
  65. package/dist/presets/calheatscore.js +22 -0
  66. package/dist/presets/collocation-sites.d.ts +18 -0
  67. package/dist/presets/collocation-sites.js +67 -0
  68. package/dist/presets/fire-groups.d.ts +26 -0
  69. package/dist/presets/fire-groups.js +51 -0
  70. package/dist/presets/forecast-zones.d.ts +25 -0
  71. package/dist/presets/forecast-zones.js +23 -0
  72. package/dist/presets/hms-fire.d.ts +26 -0
  73. package/dist/presets/hms-fire.js +87 -0
  74. package/dist/presets/hms-smoke.d.ts +19 -0
  75. package/dist/presets/hms-smoke.js +20 -0
  76. package/dist/presets/monitors.d.ts +48 -0
  77. package/dist/presets/monitors.js +243 -0
  78. package/dist/presets/reactive-props.svelte.d.ts +4 -0
  79. package/dist/presets/reactive-props.svelte.js +7 -0
  80. package/dist/presets/regions.d.ts +24 -0
  81. package/dist/presets/regions.js +22 -0
  82. package/dist/stores/abort.d.ts +16 -0
  83. package/dist/stores/abort.js +52 -0
  84. package/dist/stores/collocation-store.svelte.d.ts +25 -0
  85. package/dist/stores/collocation-store.svelte.js +72 -0
  86. package/dist/stores/hms-stores.svelte.d.ts +35 -0
  87. package/dist/stores/hms-stores.svelte.js +97 -0
  88. package/dist/stores/index.d.ts +4 -0
  89. package/dist/stores/index.js +4 -0
  90. package/dist/stores/load-once.svelte.d.ts +25 -0
  91. package/dist/stores/load-once.svelte.js +93 -0
  92. package/dist/stores/monitors-store.svelte.d.ts +85 -0
  93. package/dist/stores/monitors-store.svelte.js +340 -0
  94. package/dist/theme.css +34 -0
  95. package/dist/ui/FeatureList.svelte +501 -0
  96. package/dist/ui/FeatureList.svelte.d.ts +14 -0
  97. package/dist/ui/Legend.svelte +172 -0
  98. package/dist/ui/Legend.svelte.d.ts +13 -0
  99. package/dist/ui/LegendBar.svelte +161 -0
  100. package/dist/ui/LegendBar.svelte.d.ts +11 -0
  101. package/dist/ui/MapControls.svelte +135 -0
  102. package/dist/ui/MapControls.svelte.d.ts +11 -0
  103. package/dist/ui/MapTooltip.svelte +158 -0
  104. package/dist/ui/MapTooltip.svelte.d.ts +28 -0
  105. package/dist/ui/SelectionBar.svelte +123 -0
  106. package/dist/ui/SelectionBar.svelte.d.ts +10 -0
  107. package/dist/ui/TooltipItem.svelte +66 -0
  108. package/dist/ui/TooltipItem.svelte.d.ts +11 -0
  109. package/dist/ui/ViewSummary.svelte +97 -0
  110. package/dist/ui/ViewSummary.svelte.d.ts +7 -0
  111. package/dist/ui/controls.css +45 -0
  112. package/dist/ui/data-credits.svelte.d.ts +9 -0
  113. package/dist/ui/data-credits.svelte.js +52 -0
  114. package/dist/ui/feature-list.d.ts +31 -0
  115. package/dist/ui/feature-list.js +80 -0
  116. package/dist/ui/legend-data.d.ts +41 -0
  117. package/dist/ui/legend-data.js +40 -0
  118. package/dist/ui/level-chip.d.ts +11 -0
  119. package/dist/ui/level-chip.js +15 -0
  120. package/dist/ui/value-format.d.ts +7 -0
  121. package/dist/ui/value-format.js +13 -0
  122. package/dist/ui/view-summary.d.ts +11 -0
  123. package/dist/ui/view-summary.js +33 -0
  124. package/dist/view/MapView.svelte +373 -0
  125. package/dist/view/MapView.svelte.d.ts +43 -0
  126. package/dist/view/create-gl-map.d.ts +34 -0
  127. package/dist/view/create-gl-map.js +182 -0
  128. package/dist/view/list-placement.d.ts +21 -0
  129. package/dist/view/list-placement.js +13 -0
  130. package/package.json +103 -5
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Routes click and hover events on plugin layers to feature hits. One per map.
3
+ *
4
+ * Listens on the map itself (`click`, `mousemove`, `mouseout`) and queries only the registered
5
+ * layers, so registrations can come and go without re-binding listeners. Layers nobody registered
6
+ * are never hit. A blocking layer (e.g. a cluster bubble, which zooms instead) is never hit either,
7
+ * but when it is topmost at a point nothing beneath it is hit or hovered.
8
+ */
9
+ export class InteractionRouter {
10
+ // Bookkeeping only; nothing reads these reactively.
11
+ /* eslint-disable svelte/prefer-svelte-reactivity */
12
+ #registrations = new Set();
13
+ #pickHandlers = new Set();
14
+ /* eslint-enable svelte/prefer-svelte-reactivity */
15
+ #gl = null;
16
+ #canvas = null;
17
+ /**
18
+ * The `pointerType` of the latest `pointerdown` on the canvas, for clicks whose original event
19
+ * has none (Safari before 17 dispatches `click` as a `MouseEvent`). Cleared after each click.
20
+ */
21
+ #lastPointerType = null;
22
+ #hovered = $state.raw(null);
23
+ /** Whether the canvas cursor is set to a pointer. */
24
+ #pointer = false;
25
+ /** The top hit under the pointer and where it is. Reactive, for the tooltip. */
26
+ get hovered() {
27
+ return this.#hovered;
28
+ }
29
+ /** Records the layers whose features `toHit` turns into hits. Returns an unregister function. */
30
+ register(pluginId, layerIds, toHit, priority = 0) {
31
+ return this.#add({ pluginId, layerIds: [...layerIds], toHit, blocking: false, priority });
32
+ }
33
+ /**
34
+ * Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
35
+ * nothing (calling `onClick` with that feature instead) and the hover is `toHover`'s hit for
36
+ * it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
37
+ * unregister function.
38
+ */
39
+ registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0) {
40
+ return this.#add({
41
+ pluginId,
42
+ layerIds: [...layerIds],
43
+ toHit: toHover ?? (() => null),
44
+ blocking: true,
45
+ onClick,
46
+ priority
47
+ });
48
+ }
49
+ #add(registration) {
50
+ const { pluginId } = registration;
51
+ this.#registrations.add(registration);
52
+ return () => {
53
+ if (!this.#registrations.delete(registration))
54
+ return;
55
+ if (this.#hovered?.hit.pluginId === pluginId)
56
+ this.#hovered = null;
57
+ // The pointer may have been over this plugin; the next move sets it again.
58
+ this.#setPointer(false);
59
+ };
60
+ }
61
+ /** Listens to `gl` from now on. */
62
+ attach(gl) {
63
+ if (this.#gl === gl)
64
+ return;
65
+ this.detach();
66
+ this.#gl = gl;
67
+ gl.on("click", this.#onClick);
68
+ gl.on("mousemove", this.#onMove);
69
+ gl.on("mouseout", this.#onOut);
70
+ this.#canvas = gl.getCanvas();
71
+ this.#canvas.addEventListener("pointerdown", this.#onPointerDown);
72
+ }
73
+ /** Stops listening and clears `hovered`. */
74
+ detach() {
75
+ const gl = this.#gl;
76
+ if (!gl)
77
+ return;
78
+ gl.off("click", this.#onClick);
79
+ gl.off("mousemove", this.#onMove);
80
+ gl.off("mouseout", this.#onOut);
81
+ this.#canvas?.removeEventListener("pointerdown", this.#onPointerDown);
82
+ this.#setPointer(false);
83
+ this.#canvas = null;
84
+ this.#lastPointerType = null;
85
+ this.#gl = null;
86
+ this.#hovered = null;
87
+ }
88
+ /**
89
+ * Hits under a canvas point, topmost first, de-duplicated by `refKey`. `[]` when detached or
90
+ * when a blocking layer is topmost.
91
+ */
92
+ hitsAt(point) {
93
+ const found = this.#lookup(point);
94
+ return found.blocked ? [] : found.hits;
95
+ }
96
+ #lookup(point) {
97
+ const none = { hits: [], hover: null, blocked: false, blocker: null, blockedBy: null };
98
+ const gl = this.#gl;
99
+ if (!gl)
100
+ return none;
101
+ // Locals for one lookup; nothing reads them reactively.
102
+ /* eslint-disable svelte/prefer-svelte-reactivity */
103
+ const byLayer = new Map();
104
+ for (const registration of this.#registrations) {
105
+ for (const id of registration.layerIds) {
106
+ // MapLibre errors on querying a missing layer (e.g. mid basemap change).
107
+ if (!byLayer.has(id) && gl.getLayer(id))
108
+ byLayer.set(id, registration);
109
+ }
110
+ }
111
+ if (byLayer.size === 0)
112
+ return none;
113
+ const hits = [];
114
+ let hover = null;
115
+ let hoverPriority = -Infinity;
116
+ const seen = new Set();
117
+ /* eslint-enable svelte/prefer-svelte-reactivity */
118
+ const features = gl.queryRenderedFeatures(point, { layers: [...byLayer.keys()] });
119
+ const top = features[0] && byLayer.get(features[0].layer.id);
120
+ if (top?.blocking) {
121
+ const properties = features[0].properties ?? {};
122
+ const hit = top.toHit(properties);
123
+ return {
124
+ hits: [],
125
+ hover: null,
126
+ blocked: true,
127
+ blocker: hit && { ...hit, pluginId: top.pluginId },
128
+ blockedBy: { registration: top, feature: { properties, geometry: features[0].geometry } }
129
+ };
130
+ }
131
+ for (const feature of features) {
132
+ const registration = byLayer.get(feature.layer.id);
133
+ if (!registration || registration.blocking)
134
+ continue;
135
+ const hit = registration.toHit(feature.properties ?? {});
136
+ if (!hit || seen.has(hit.refKey))
137
+ continue;
138
+ seen.add(hit.refKey);
139
+ const withPlugin = { ...hit, pluginId: registration.pluginId };
140
+ hits.push(withPlugin);
141
+ // Strictly greater, so a tie keeps the topmost.
142
+ if (registration.priority > hoverPriority) {
143
+ hover = withPlugin;
144
+ hoverPriority = registration.priority;
145
+ }
146
+ }
147
+ return { hits, hover, blocked: false, blocker: null, blockedBy: null };
148
+ }
149
+ /**
150
+ * Called for every click with at least one hit. `additive` is true for a shift, ⌘, or Ctrl
151
+ * click, or a touch tap; selection rules decide what it means for the plugin's mode.
152
+ */
153
+ onPick(handler) {
154
+ this.#pickHandlers.add(handler);
155
+ return () => {
156
+ this.#pickHandlers.delete(handler);
157
+ };
158
+ }
159
+ #onPointerDown = (e) => {
160
+ this.#lastPointerType = e.pointerType || null;
161
+ };
162
+ #onClick = (e) => {
163
+ const recorded = this.#lastPointerType;
164
+ this.#lastPointerType = null;
165
+ const found = this.#lookup([e.point.x, e.point.y]);
166
+ if (found.blockedBy) {
167
+ const { registration, feature } = found.blockedBy;
168
+ try {
169
+ registration.onClick?.(feature);
170
+ }
171
+ catch (error) {
172
+ console.error("map-sdk: a blocking layer's click handler failed", error);
173
+ }
174
+ return;
175
+ }
176
+ const { hits } = found;
177
+ if (hits.length === 0)
178
+ return;
179
+ const o = e.originalEvent;
180
+ const pointerType = o?.pointerType || recorded;
181
+ const event = {
182
+ hits,
183
+ additive: Boolean(o?.shiftKey || o?.metaKey || o?.ctrlKey || pointerType === "touch"),
184
+ lngLat: [e.lngLat.lng, e.lngLat.lat]
185
+ };
186
+ for (const handler of [...this.#pickHandlers]) {
187
+ try {
188
+ handler(event);
189
+ }
190
+ catch (error) {
191
+ console.error("map-sdk: a pick handler failed", error);
192
+ }
193
+ }
194
+ };
195
+ #onMove = (e) => {
196
+ const found = this.#lookup([e.point.x, e.point.y]);
197
+ const hit = found.blocked ? found.blocker : found.hover;
198
+ this.#hovered = hit ? { hit, lngLat: [e.lngLat.lng, e.lngLat.lat] } : null;
199
+ this.#setPointer(found.blocked || hit !== null);
200
+ };
201
+ #onOut = () => {
202
+ this.#hovered = null;
203
+ this.#setPointer(false);
204
+ };
205
+ /** Sets the canvas cursor only when it changes, so the engine's own cursors are left alone. */
206
+ #setPointer(on) {
207
+ if (on === this.#pointer)
208
+ return;
209
+ this.#pointer = on;
210
+ if (this.#canvas)
211
+ this.#canvas.style.cursor = on ? "pointer" : "";
212
+ }
213
+ }
@@ -0,0 +1,46 @@
1
+ import type { GlMapLike } from "./gl.js";
2
+ import type { InteractionRouter } from "./interactions.svelte.js";
3
+ import type { MapPlugin, Providers } from "./plugin.js";
4
+ export interface PluginHostOptions {
5
+ mapUid: string;
6
+ providers: Providers;
7
+ router: InteractionRouter;
8
+ /** Reactive: read inside a plugin's effects. */
9
+ selectedKeys(pluginId: string): ReadonlySet<string>;
10
+ }
11
+ /**
12
+ * Runs plugins against a GL map. Plugins are set up only when the map's style is loaded, and
13
+ * everything they add through their `PluginContext` is removed on teardown, whatever their own
14
+ * teardown does. The plugin list outlives any one GL map.
15
+ *
16
+ * Plugins draw in list order, later on top: each plugin's layers stay together, below the first
17
+ * layer of the next plugin in the list that has any.
18
+ */
19
+ export declare class PluginHost {
20
+ #private;
21
+ constructor(opts: PluginHostOptions);
22
+ /** In draw order, bottom first. Reactive. */
23
+ get plugins(): readonly MapPlugin[];
24
+ /**
25
+ * Adds `plugin` on top, or just below the plugin `before` names. Throws when the same plugin
26
+ * object is set up on another map at the moment (one plugin object per map).
27
+ */
28
+ add(plugin: MapPlugin, before?: string): void;
29
+ /**
30
+ * Moves a plugin to the top, or just below the plugin `before` names, with its layers. Does
31
+ * nothing for an unknown `id` or when `before` is `id`.
32
+ */
33
+ move(id: string, before?: string): void;
34
+ remove(id: string): void;
35
+ /** Uses `gl` from now on, setting up every plugin once its style is loaded. */
36
+ attach(gl: GlMapLike): void;
37
+ /**
38
+ * Tears every plugin down and stops waiting for a style, before the map's style is replaced:
39
+ * nothing may touch a style on its way out. `restyle` sets them up again.
40
+ */
41
+ suspend(): void;
42
+ /** Tears down and sets up every plugin again (after a basemap change), once the style loads. */
43
+ restyle(): void;
44
+ /** Tears down every plugin. The gl is not touched after this returns. */
45
+ detach(): void;
46
+ }
@@ -0,0 +1,366 @@
1
+ import { untrack } from "svelte";
2
+ /** Setup runs on whichever of these fires first. */
3
+ const STYLE_READY_EVENTS = ["style.load", "idle"];
4
+ /**
5
+ * Marks a plugin object with the host it is set up on, while it is. A plugin object keeps
6
+ * per-map state (its source and layer ids, its features), so it belongs to one map at a time.
7
+ * A property on the plugin, not a registry: nothing here is shared between maps.
8
+ */
9
+ const OWNER = Symbol("map-sdk plugin host");
10
+ function ownedElsewhere(plugin, host) {
11
+ const owner = plugin[OWNER];
12
+ return owner !== undefined && owner !== host;
13
+ }
14
+ function ownershipError(plugin) {
15
+ return new Error(`map-sdk: plugin "${plugin.id}" is already set up on another map; create one plugin per map`);
16
+ }
17
+ /**
18
+ * Runs plugins against a GL map. Plugins are set up only when the map's style is loaded, and
19
+ * everything they add through their `PluginContext` is removed on teardown, whatever their own
20
+ * teardown does. The plugin list outlives any one GL map.
21
+ *
22
+ * Plugins draw in list order, later on top: each plugin's layers stay together, below the first
23
+ * layer of the next plugin in the list that has any.
24
+ */
25
+ export class PluginHost {
26
+ #opts;
27
+ #plugins = $state.raw([]);
28
+ // Bookkeeping only; nothing reads it reactively.
29
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
30
+ #running = new Map();
31
+ #gl = null;
32
+ /** True once the current gl's style has loaded and plugins are set up on it. */
33
+ #live = false;
34
+ /**
35
+ * The pending one-shot listener on `#gl` for `style.load` and `idle`, if waiting. `idle` covers
36
+ * a style that already loaded while `isStyleLoaded()` is false because sources are loading.
37
+ */
38
+ #onStyleLoad = null;
39
+ constructor(opts) {
40
+ this.#opts = opts;
41
+ }
42
+ /** In draw order, bottom first. Reactive. */
43
+ get plugins() {
44
+ return this.#plugins;
45
+ }
46
+ /**
47
+ * The host's own reads are untracked, so calling `add`, `attach`, etc. from inside an effect
48
+ * doesn't make that effect depend on the plugin list.
49
+ */
50
+ get #list() {
51
+ return untrack(() => this.#plugins);
52
+ }
53
+ /**
54
+ * Adds `plugin` on top, or just below the plugin `before` names. Throws when the same plugin
55
+ * object is set up on another map at the moment (one plugin object per map).
56
+ */
57
+ add(plugin, before) {
58
+ if (this.#list.some((p) => p.id === plugin.id)) {
59
+ throw new Error(`map-sdk: a plugin with id "${plugin.id}" is already added`);
60
+ }
61
+ if (ownedElsewhere(plugin, this))
62
+ throw ownershipError(plugin);
63
+ this.#plugins = this.#inserted(this.#list, plugin, before);
64
+ if (this.#live)
65
+ this.#setup(plugin);
66
+ }
67
+ /**
68
+ * Moves a plugin to the top, or just below the plugin `before` names, with its layers. Does
69
+ * nothing for an unknown `id` or when `before` is `id`.
70
+ */
71
+ move(id, before) {
72
+ const plugin = this.#list.find((p) => p.id === id);
73
+ if (!plugin || before === id)
74
+ return;
75
+ this.#plugins = this.#inserted(this.#list.filter((p) => p !== plugin), plugin, before);
76
+ const running = this.#running.get(id);
77
+ if (!running || running.gl !== this.#gl)
78
+ return;
79
+ const { gl } = running;
80
+ const beforeId = this.#layerAbove(id);
81
+ for (const layerId of running.layers) {
82
+ try {
83
+ if (gl.getLayer(layerId))
84
+ gl.moveLayer(layerId, beforeId);
85
+ }
86
+ catch (error) {
87
+ console.error(`map-sdk: plugin "${id}" moving layer ${layerId} failed`, error);
88
+ }
89
+ }
90
+ }
91
+ /** `list` with `plugin` inserted before `before` (at the end without it). */
92
+ #inserted(list, plugin, before) {
93
+ if (before === undefined)
94
+ return [...list, plugin];
95
+ const at = list.findIndex((p) => p.id === before);
96
+ if (at < 0)
97
+ throw new Error(`map-sdk: no plugin with id "${before}"`);
98
+ return [...list.slice(0, at), plugin, ...list.slice(at)];
99
+ }
100
+ /**
101
+ * The bottom layer of the plugins drawn above `pluginId` on the current gl: where its layers
102
+ * go. `undefined` (the top) when none above has a layer.
103
+ */
104
+ #layerAbove(pluginId) {
105
+ const gl = this.#gl;
106
+ if (!gl)
107
+ return undefined;
108
+ const list = this.#list;
109
+ for (const plugin of list.slice(list.findIndex((p) => p.id === pluginId) + 1)) {
110
+ const running = this.#running.get(plugin.id);
111
+ if (running?.gl !== gl)
112
+ continue;
113
+ const layerId = running.layers.find((l) => gl.getLayer(l));
114
+ if (layerId !== undefined)
115
+ return layerId;
116
+ }
117
+ return undefined;
118
+ }
119
+ remove(id) {
120
+ if (!this.#list.some((p) => p.id === id))
121
+ return;
122
+ this.#teardown(id);
123
+ this.#plugins = this.#list.filter((p) => p.id !== id);
124
+ }
125
+ /** Uses `gl` from now on, setting up every plugin once its style is loaded. */
126
+ attach(gl) {
127
+ if (this.#gl === gl)
128
+ return;
129
+ if (this.#gl)
130
+ this.detach();
131
+ this.#gl = gl;
132
+ this.#whenStyleLoaded();
133
+ }
134
+ /**
135
+ * Tears every plugin down and stops waiting for a style, before the map's style is replaced:
136
+ * nothing may touch a style on its way out. `restyle` sets them up again.
137
+ */
138
+ suspend() {
139
+ if (!this.#gl)
140
+ return;
141
+ this.#cancelStyleWait();
142
+ this.#teardownAll();
143
+ }
144
+ /** Tears down and sets up every plugin again (after a basemap change), once the style loads. */
145
+ restyle() {
146
+ if (!this.#gl)
147
+ return;
148
+ this.#teardownAll();
149
+ this.#whenStyleLoaded();
150
+ }
151
+ /** Tears down every plugin. The gl is not touched after this returns. */
152
+ detach() {
153
+ const gl = this.#gl;
154
+ if (!gl)
155
+ return;
156
+ this.#cancelStyleWait();
157
+ this.#teardownAll();
158
+ this.#gl = null;
159
+ }
160
+ #whenStyleLoaded() {
161
+ const gl = this.#gl;
162
+ this.#cancelStyleWait();
163
+ if (gl.isStyleLoaded()) {
164
+ this.#setupAll();
165
+ return;
166
+ }
167
+ const onStyleLoad = () => {
168
+ // Stale if the host was detached, re-attached, or restyled since.
169
+ if (this.#onStyleLoad !== onStyleLoad || this.#gl !== gl)
170
+ return;
171
+ this.#cancelStyleWait();
172
+ this.#setupAll();
173
+ };
174
+ this.#onStyleLoad = onStyleLoad;
175
+ for (const type of STYLE_READY_EVENTS)
176
+ gl.on(type, onStyleLoad);
177
+ }
178
+ #cancelStyleWait() {
179
+ const listener = this.#onStyleLoad;
180
+ if (listener && this.#gl) {
181
+ for (const type of STYLE_READY_EVENTS)
182
+ this.#gl.off(type, listener);
183
+ }
184
+ this.#onStyleLoad = null;
185
+ }
186
+ #setupAll() {
187
+ const gl = this.#gl;
188
+ this.#live = true;
189
+ for (const plugin of this.#list) {
190
+ // A plugin's setup may add, remove, or detach; follow the host's current state.
191
+ if (this.#gl !== gl || !this.#live)
192
+ return;
193
+ if (!this.#list.includes(plugin) || this.#running.has(plugin.id))
194
+ continue;
195
+ // Another map set this plugin object up after it was added here.
196
+ if (ownedElsewhere(plugin, this)) {
197
+ console.error(ownershipError(plugin));
198
+ continue;
199
+ }
200
+ this.#setup(plugin);
201
+ }
202
+ }
203
+ #teardownAll() {
204
+ this.#live = false;
205
+ for (const plugin of this.#list)
206
+ this.#teardown(plugin.id);
207
+ }
208
+ #setup(plugin) {
209
+ const gl = this.#gl;
210
+ const running = {
211
+ plugin,
212
+ gl,
213
+ dead: false,
214
+ teardown: null,
215
+ effects: [],
216
+ layers: [],
217
+ sources: [],
218
+ images: [],
219
+ listeners: [],
220
+ unregisters: []
221
+ };
222
+ this.#running.set(plugin.id, running);
223
+ if (Object.isExtensible(plugin))
224
+ plugin[OWNER] = this;
225
+ const ctx = this.#context(plugin.id, running);
226
+ let teardown;
227
+ try {
228
+ // Untracked: setup may be called from inside an effect (e.g. MapView's).
229
+ teardown = untrack(() => plugin.setup(ctx));
230
+ }
231
+ catch (error) {
232
+ console.error(`map-sdk: plugin "${plugin.id}" setup failed`, error);
233
+ if (this.#running.get(plugin.id) === running)
234
+ this.#teardown(plugin.id);
235
+ return;
236
+ }
237
+ if (!running.dead) {
238
+ running.teardown = teardown;
239
+ return;
240
+ }
241
+ // Torn down during its own setup (it removed itself or detached the host).
242
+ try {
243
+ untrack(teardown);
244
+ }
245
+ catch (error) {
246
+ console.error(`map-sdk: plugin "${plugin.id}" teardown failed`, error);
247
+ }
248
+ }
249
+ #teardown(pluginId) {
250
+ const running = this.#running.get(pluginId);
251
+ if (!running)
252
+ return;
253
+ this.#running.delete(pluginId);
254
+ running.dead = true;
255
+ const owned = running.plugin;
256
+ if (owned[OWNER] === this)
257
+ delete owned[OWNER];
258
+ const { gl } = running;
259
+ const attempt = (what, fn) => {
260
+ try {
261
+ fn();
262
+ }
263
+ catch (error) {
264
+ console.error(`map-sdk: plugin "${pluginId}" ${what} failed`, error);
265
+ }
266
+ };
267
+ // Effects stop first so none re-runs against a half-removed plugin.
268
+ for (const stop of running.effects)
269
+ attempt("effect cleanup", stop);
270
+ if (running.teardown)
271
+ attempt("teardown", untrack.bind(null, running.teardown));
272
+ // A style change may already have dropped these, so check before removing.
273
+ for (const id of running.layers.toReversed()) {
274
+ attempt(`removing layer ${id}`, () => gl.getLayer(id) && gl.removeLayer(id));
275
+ }
276
+ for (const id of running.sources.toReversed()) {
277
+ attempt(`removing source ${id}`, () => gl.getSource(id) && gl.removeSource(id));
278
+ }
279
+ for (const id of running.images) {
280
+ attempt(`removing image ${id}`, () => gl.hasImage(id) && gl.removeImage(id));
281
+ }
282
+ for (const { type, layerId, listener } of running.listeners) {
283
+ attempt(`removing ${type} listener`, () => gl.off(type, layerId, listener));
284
+ }
285
+ for (const unregister of running.unregisters)
286
+ attempt("unregistering", unregister);
287
+ }
288
+ #context(pluginId, running) {
289
+ const { mapUid, providers, router, selectedKeys } = this.#opts;
290
+ const { gl } = running;
291
+ const layerAbove = () => this.#layerAbove(pluginId);
292
+ const id = (local) => `${mapUid}:${pluginId}:${local}`;
293
+ const addTracked = (full, layer) => {
294
+ if (running.dead)
295
+ return full;
296
+ gl.addLayer(layer, layerAbove());
297
+ // A re-added layer (a plugin rebuilding its layers) is now its plugin's top layer.
298
+ const at = running.layers.indexOf(full);
299
+ if (at >= 0)
300
+ running.layers.splice(at, 1);
301
+ running.layers.push(full);
302
+ return full;
303
+ };
304
+ return {
305
+ gl,
306
+ id,
307
+ metadata: providers.metadata,
308
+ messages: providers.messages,
309
+ format: providers.format,
310
+ addSource(local, spec) {
311
+ const full = id(local);
312
+ if (running.dead)
313
+ return full;
314
+ gl.addSource(full, spec);
315
+ running.sources.push(full);
316
+ return full;
317
+ },
318
+ addLayer(local, spec) {
319
+ return addTracked(id(local), { ...spec, id: id(local) });
320
+ },
321
+ addCustomLayer(local, create) {
322
+ const full = id(local);
323
+ if (running.dead)
324
+ return full;
325
+ const layer = create(full);
326
+ if (layer.id !== full) {
327
+ throw new Error(`map-sdk: custom layer "${local}" must use the id it is given (${full})`);
328
+ }
329
+ return addTracked(full, layer);
330
+ },
331
+ addImage(local, image) {
332
+ if (running.dead)
333
+ return;
334
+ const full = id(local);
335
+ gl.addImage(full, image);
336
+ running.images.push(full);
337
+ },
338
+ interactive(layerIds, toHit) {
339
+ if (running.dead)
340
+ return;
341
+ running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0));
342
+ },
343
+ blocking(layerIds, toHover, onClick) {
344
+ if (running.dead)
345
+ return;
346
+ running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0));
347
+ },
348
+ on(type, layerId, listener) {
349
+ if (running.dead)
350
+ return;
351
+ gl.on(type, layerId, listener);
352
+ running.listeners.push({ type, layerId, listener });
353
+ },
354
+ get selectedKeys() {
355
+ return selectedKeys(pluginId);
356
+ },
357
+ effect(fn) {
358
+ if (running.dead)
359
+ return;
360
+ running.effects.push($effect.root(() => {
361
+ $effect(fn);
362
+ }));
363
+ }
364
+ };
365
+ }
366
+ }