@sjvair/map-sdk 0.1.0-beta.1 → 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.
Files changed (52) hide show
  1. package/README.md +243 -17
  2. package/dist/core/create-map.svelte.d.ts +72 -5
  3. package/dist/core/create-map.svelte.js +229 -14
  4. package/dist/core/gl.d.ts +5 -0
  5. package/dist/core/interactions.svelte.d.ts +11 -3
  6. package/dist/core/interactions.svelte.js +128 -11
  7. package/dist/core/plugin-host.svelte.d.ts +30 -5
  8. package/dist/core/plugin-host.svelte.js +93 -14
  9. package/dist/core/plugin.d.ts +44 -2
  10. package/dist/core/shape-view.d.ts +27 -0
  11. package/dist/core/shape-view.js +323 -0
  12. package/dist/data/format.d.ts +1 -1
  13. package/dist/data/format.js +16 -3
  14. package/dist/data/messages/en.json +12 -2
  15. package/dist/data/selection.d.ts +6 -0
  16. package/dist/data/selection.js +21 -0
  17. package/dist/data/types.d.ts +5 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +1 -0
  20. package/dist/plugins/points.svelte.d.ts +14 -2
  21. package/dist/plugins/points.svelte.js +34 -5
  22. package/dist/plugins/shapes.svelte.d.ts +5 -0
  23. package/dist/plugins/shapes.svelte.js +20 -2
  24. package/dist/presets/FireTooltip.svelte +70 -0
  25. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  26. package/dist/presets/MonitorClusterTooltip.svelte +21 -2
  27. package/dist/presets/cluster-area.d.ts +13 -0
  28. package/dist/presets/cluster-area.js +34 -0
  29. package/dist/presets/hms-fire.d.ts +11 -7
  30. package/dist/presets/hms-fire.js +16 -17
  31. package/dist/presets/hms-smoke.d.ts +2 -1
  32. package/dist/presets/hms-smoke.js +4 -2
  33. package/dist/stores/hms-stores.svelte.d.ts +6 -3
  34. package/dist/stores/hms-stores.svelte.js +4 -4
  35. package/dist/ui/FeatureList.svelte +17 -17
  36. package/dist/ui/Legend.svelte +58 -16
  37. package/dist/ui/Legend.svelte.d.ts +9 -0
  38. package/dist/ui/MapTooltip.svelte +253 -14
  39. package/dist/ui/MapTooltip.svelte.d.ts +9 -0
  40. package/dist/ui/PickNotice.svelte +56 -0
  41. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  42. package/dist/ui/TooltipItem.svelte +12 -12
  43. package/dist/ui/card.d.ts +29 -0
  44. package/dist/ui/card.js +1 -0
  45. package/dist/ui/feature-list.d.ts +17 -2
  46. package/dist/ui/feature-list.js +45 -10
  47. package/dist/ui/value-format.d.ts +3 -2
  48. package/dist/ui/value-format.js +9 -2
  49. package/dist/view/MapView.svelte +72 -2
  50. package/dist/view/MapView.svelte.d.ts +8 -0
  51. package/dist/view/create-gl-map.js +4 -0
  52. package/package.json +2 -1
@@ -7,12 +7,23 @@ export interface PluginHostOptions {
7
7
  router: InteractionRouter;
8
8
  /** Reactive: read inside a plugin's effects. */
9
9
  selectedKeys(pluginId: string): ReadonlySet<string>;
10
+ /**
11
+ * Called after each queued setup run finishes (also when it had nothing to do), never after a
12
+ * synchronous one.
13
+ */
14
+ onSettled?: () => void;
10
15
  }
11
16
  /**
12
17
  * Runs plugins against a GL map. Plugins are set up only when the map's style is loaded, and
13
18
  * everything they add through their `PluginContext` is removed on teardown, whatever their own
14
19
  * teardown does. The plugin list outlives any one GL map.
15
20
  *
21
+ * Setup never runs inside the caller's code. When `add` or `attach` would set plugins up, they
22
+ * are queued and set up in one microtask, where no effect is active: Svelte cuts loose the
23
+ * `$effect.root`s created inside an effect when that effect re-runs, and their effects can stop
24
+ * (see docs/superpowers/specs/2026-11-07-plugin-setup-outside-effects-design.md). Setup on a
25
+ * style load or `restyle` comes from map events, not host code, and stays synchronous.
26
+ *
16
27
  * Plugins draw in list order, later on top: each plugin's layers stay together, below the first
17
28
  * layer of the next plugin in the list that has any.
18
29
  */
@@ -21,9 +32,12 @@ export declare class PluginHost {
21
32
  constructor(opts: PluginHostOptions);
22
33
  /** In draw order, bottom first. Reactive. */
23
34
  get plugins(): readonly MapPlugin[];
35
+ /** True while a setup run is queued and has not started. Not reactive. */
36
+ get pending(): boolean;
24
37
  /**
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).
38
+ * Adds `plugin` on top, or just below the plugin `before` names. It is in `plugins` at once;
39
+ * on a live map its setup runs in a microtask. Throws when the same plugin object is set up on
40
+ * another map at the moment (one plugin object per map).
27
41
  */
28
42
  add(plugin: MapPlugin, before?: string): void;
29
43
  /**
@@ -32,15 +46,26 @@ export declare class PluginHost {
32
46
  */
33
47
  move(id: string, before?: string): void;
34
48
  remove(id: string): void;
35
- /** Uses `gl` from now on, setting up every plugin once its style is loaded. */
49
+ /**
50
+ * Uses `gl` from now on, setting up every plugin once its style is loaded (in a microtask when
51
+ * it already is).
52
+ */
36
53
  attach(gl: GlMapLike): void;
37
54
  /**
38
55
  * Tears every plugin down and stops waiting for a style, before the map's style is replaced:
39
56
  * nothing may touch a style on its way out. `restyle` sets them up again.
40
57
  */
41
58
  suspend(): void;
42
- /** Tears down and sets up every plugin again (after a basemap change), once the style loads. */
43
- restyle(): void;
59
+ /**
60
+ * Tears down and sets up every plugin again (after a basemap or theme change), once the style
61
+ * loads. Pass `styleLoaded` from a `style.load` listener: `isStyleLoaded()` stays false there
62
+ * until every tile has loaded, and `style.load` has already fired, so waiting would last until
63
+ * `idle`. A live host's style is loaded too (`suspend` comes before every `setStyle`), so a theme
64
+ * change sets up at once as well. On a loaded style the setup is synchronous, so only map events
65
+ * and theme watching call this, never code inside an effect: roots created there can stop (see
66
+ * the class comment).
67
+ */
68
+ restyle(styleLoaded?: boolean): void;
44
69
  /** Tears down every plugin. The gl is not touched after this returns. */
45
70
  detach(): void;
46
71
  }
@@ -19,6 +19,12 @@ function ownershipError(plugin) {
19
19
  * everything they add through their `PluginContext` is removed on teardown, whatever their own
20
20
  * teardown does. The plugin list outlives any one GL map.
21
21
  *
22
+ * Setup never runs inside the caller's code. When `add` or `attach` would set plugins up, they
23
+ * are queued and set up in one microtask, where no effect is active: Svelte cuts loose the
24
+ * `$effect.root`s created inside an effect when that effect re-runs, and their effects can stop
25
+ * (see docs/superpowers/specs/2026-11-07-plugin-setup-outside-effects-design.md). Setup on a
26
+ * style load or `restyle` comes from map events, not host code, and stays synchronous.
27
+ *
22
28
  * Plugins draw in list order, later on top: each plugin's layers stay together, below the first
23
29
  * layer of the next plugin in the list that has any.
24
30
  */
@@ -36,6 +42,8 @@ export class PluginHost {
36
42
  * a style that already loaded while `isStyleLoaded()` is false because sources are loading.
37
43
  */
38
44
  #onStyleLoad = null;
45
+ /** True from scheduling a queued setup run until it starts. */
46
+ #setupQueued = false;
39
47
  constructor(opts) {
40
48
  this.#opts = opts;
41
49
  }
@@ -43,6 +51,10 @@ export class PluginHost {
43
51
  get plugins() {
44
52
  return this.#plugins;
45
53
  }
54
+ /** True while a setup run is queued and has not started. Not reactive. */
55
+ get pending() {
56
+ return this.#setupQueued;
57
+ }
46
58
  /**
47
59
  * The host's own reads are untracked, so calling `add`, `attach`, etc. from inside an effect
48
60
  * doesn't make that effect depend on the plugin list.
@@ -51,8 +63,9 @@ export class PluginHost {
51
63
  return untrack(() => this.#plugins);
52
64
  }
53
65
  /**
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).
66
+ * Adds `plugin` on top, or just below the plugin `before` names. It is in `plugins` at once;
67
+ * on a live map its setup runs in a microtask. Throws when the same plugin object is set up on
68
+ * another map at the moment (one plugin object per map).
56
69
  */
57
70
  add(plugin, before) {
58
71
  if (this.#list.some((p) => p.id === plugin.id)) {
@@ -62,7 +75,7 @@ export class PluginHost {
62
75
  throw ownershipError(plugin);
63
76
  this.#plugins = this.#inserted(this.#list, plugin, before);
64
77
  if (this.#live)
65
- this.#setup(plugin);
78
+ this.#queueSetup();
66
79
  }
67
80
  /**
68
81
  * Moves a plugin to the top, or just below the plugin `before` names, with its layers. Does
@@ -120,16 +133,22 @@ export class PluginHost {
120
133
  if (!this.#list.some((p) => p.id === id))
121
134
  return;
122
135
  this.#teardown(id);
136
+ const plugin = this.#list.find((p) => p.id === id);
137
+ if (plugin)
138
+ this.#release(plugin);
123
139
  this.#plugins = this.#list.filter((p) => p.id !== id);
124
140
  }
125
- /** Uses `gl` from now on, setting up every plugin once its style is loaded. */
141
+ /**
142
+ * Uses `gl` from now on, setting up every plugin once its style is loaded (in a microtask when
143
+ * it already is).
144
+ */
126
145
  attach(gl) {
127
146
  if (this.#gl === gl)
128
147
  return;
129
148
  if (this.#gl)
130
149
  this.detach();
131
150
  this.#gl = gl;
132
- this.#whenStyleLoaded();
151
+ this.#whenStyleLoaded(false);
133
152
  }
134
153
  /**
135
154
  * Tears every plugin down and stops waiting for a style, before the map's style is replaced:
@@ -141,12 +160,27 @@ export class PluginHost {
141
160
  this.#cancelStyleWait();
142
161
  this.#teardownAll();
143
162
  }
144
- /** Tears down and sets up every plugin again (after a basemap change), once the style loads. */
145
- restyle() {
163
+ /**
164
+ * Tears down and sets up every plugin again (after a basemap or theme change), once the style
165
+ * loads. Pass `styleLoaded` from a `style.load` listener: `isStyleLoaded()` stays false there
166
+ * until every tile has loaded, and `style.load` has already fired, so waiting would last until
167
+ * `idle`. A live host's style is loaded too (`suspend` comes before every `setStyle`), so a theme
168
+ * change sets up at once as well. On a loaded style the setup is synchronous, so only map events
169
+ * and theme watching call this, never code inside an effect: roots created there can stop (see
170
+ * the class comment).
171
+ */
172
+ restyle(styleLoaded = false) {
146
173
  if (!this.#gl)
147
174
  return;
175
+ const loaded = styleLoaded || this.#live;
148
176
  this.#teardownAll();
149
- this.#whenStyleLoaded();
177
+ if (loaded) {
178
+ this.#cancelStyleWait();
179
+ this.#setupAll();
180
+ }
181
+ else {
182
+ this.#whenStyleLoaded(true);
183
+ }
150
184
  }
151
185
  /** Tears down every plugin. The gl is not touched after this returns. */
152
186
  detach() {
@@ -157,11 +191,22 @@ export class PluginHost {
157
191
  this.#teardownAll();
158
192
  this.#gl = null;
159
193
  }
160
- #whenStyleLoaded() {
194
+ /**
195
+ * Sets every plugin up once the style is loaded. On a style that already is, `now` sets them up
196
+ * synchronously (only for callers that never run inside an effect); otherwise they are queued.
197
+ */
198
+ #whenStyleLoaded(now) {
161
199
  const gl = this.#gl;
162
200
  this.#cancelStyleWait();
163
201
  if (gl.isStyleLoaded()) {
164
- this.#setupAll();
202
+ if (now) {
203
+ this.#setupAll();
204
+ }
205
+ else {
206
+ // Live already, so an `add` before the microtask joins the queued run.
207
+ this.#live = true;
208
+ this.#queueSetup();
209
+ }
165
210
  return;
166
211
  }
167
212
  const onStyleLoad = () => {
@@ -175,6 +220,37 @@ export class PluginHost {
175
220
  for (const type of STYLE_READY_EVENTS)
176
221
  gl.on(type, onStyleLoad);
177
222
  }
223
+ /**
224
+ * Sets every plugin that isn't running up in a microtask, on whichever gl is live by then.
225
+ * Detach, suspend, and restyle clear `#live`, and only a loaded current gl sets it again, so
226
+ * the run never touches a gl that left. One run covers every request made before it starts.
227
+ */
228
+ #queueSetup() {
229
+ // Claim the queued plugins now, so another map can't take one before the run.
230
+ for (const plugin of this.#list)
231
+ this.#claim(plugin);
232
+ if (this.#setupQueued)
233
+ return;
234
+ this.#setupQueued = true;
235
+ queueMicrotask(() => {
236
+ this.#setupQueued = false;
237
+ if (this.#live)
238
+ this.#setupAll();
239
+ this.#opts.onSettled?.();
240
+ });
241
+ }
242
+ /** Marks `plugin` as this host's, unless another map holds it. */
243
+ #claim(plugin) {
244
+ if (!ownedElsewhere(plugin, this) && Object.isExtensible(plugin)) {
245
+ plugin[OWNER] = this;
246
+ }
247
+ }
248
+ /** Releases a plugin this host claimed but isn't running (queued, then removed or left). */
249
+ #release(plugin) {
250
+ const owned = plugin;
251
+ if (owned[OWNER] === this && !this.#running.has(plugin.id))
252
+ delete owned[OWNER];
253
+ }
178
254
  #cancelStyleWait() {
179
255
  const listener = this.#onStyleLoad;
180
256
  if (listener && this.#gl) {
@@ -202,8 +278,10 @@ export class PluginHost {
202
278
  }
203
279
  #teardownAll() {
204
280
  this.#live = false;
205
- for (const plugin of this.#list)
281
+ for (const plugin of this.#list) {
206
282
  this.#teardown(plugin.id);
283
+ this.#release(plugin);
284
+ }
207
285
  }
208
286
  #setup(plugin) {
209
287
  const gl = this.#gl;
@@ -225,7 +303,8 @@ export class PluginHost {
225
303
  const ctx = this.#context(plugin.id, running);
226
304
  let teardown;
227
305
  try {
228
- // Untracked: setup may be called from inside an effect (e.g. MapView's).
306
+ // Untracked, in case a plugin's setup reads state (setup runs from a microtask or a map
307
+ // event, never inside the caller's effect).
229
308
  teardown = untrack(() => plugin.setup(ctx));
230
309
  }
231
310
  catch (error) {
@@ -338,12 +417,12 @@ export class PluginHost {
338
417
  interactive(layerIds, toHit) {
339
418
  if (running.dead)
340
419
  return;
341
- running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0));
420
+ running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0, running.plugin.hoverable ?? true));
342
421
  },
343
422
  blocking(layerIds, toHover, onClick) {
344
423
  if (running.dead)
345
424
  return;
346
- running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0));
425
+ running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0, running.plugin.hoverable ?? true));
347
426
  },
348
427
  on(type, layerId, listener) {
349
428
  if (running.dead)
@@ -46,8 +46,33 @@ export interface FeatureListItem {
46
46
  * metadata's display hint). Without it the value is formatted as given.
47
47
  */
48
48
  precision?: number;
49
+ /**
50
+ * Cut a numeric `value` off at `precision` instead of rounding it, so the number never reads
51
+ * as the next level (e.g. fire power: 9.96 MW shows "9.9", not "10.0", beside "<10"). Default
52
+ * false.
53
+ */
54
+ truncate?: boolean;
49
55
  lngLat: [number, number];
56
+ /**
57
+ * The feature's shape. When given, the item is in view (the feature list, the view summary)
58
+ * when the shape overlaps the view, not only when `lngLat` is inside it, and focusing or
59
+ * hovering it while `lngLat` is off-screen pins its tooltip at a point of the shape in view
60
+ * (focus then leaves the camera where it is).
61
+ */
62
+ geometry?: Geometry;
63
+ /**
64
+ * `geometry`'s bounding box, `[west, south, east, north]` (`geometryBbox`). Optional; computed
65
+ * from `geometry` when missing. A plugin listing many shapes should measure it once per data
66
+ * load, since the list and summary test every item on every move.
67
+ */
68
+ bbox?: [number, number, number, number];
50
69
  selectable: boolean;
70
+ /**
71
+ * A short text about a feature with no level (e.g. "Level 2 charger"), shown where its level
72
+ * would be: in the feature list, its row's accessible name, and the generic tooltip. Ignored
73
+ * when the item has a level.
74
+ */
75
+ detail?: string;
51
76
  }
52
77
  export type LegendData = {
53
78
  pluginId: string;
@@ -65,7 +90,17 @@ export type LegendData = {
65
90
  };
66
91
  export interface ClickEvent {
67
92
  hits: FeatureHit[];
93
+ /** `modifier || touch`: today's "add to the selection" in multiple mode. */
68
94
  additive: boolean;
95
+ /** Shift, ⌘, or Ctrl was held. */
96
+ modifier: boolean;
97
+ /** A touch tap. */
98
+ touch: boolean;
99
+ /**
100
+ * A Shift-click that MapLibre's box zoom swallowed (it fires `boxzoomcancel` instead of
101
+ * `click`). Only `"modifier"` mode acts on it, so other maps behave as before.
102
+ */
103
+ boxZoom?: boolean;
69
104
  lngLat: [number, number];
70
105
  }
71
106
  /**
@@ -128,10 +163,17 @@ export interface MapPlugin {
128
163
  selectable?: boolean;
129
164
  /**
130
165
  * Which plugin's feature the hover tooltip shows where several are under the pointer: the
131
- * highest wins, the topmost on a tie. Default 0. A blocking layer on top still hides what is
132
- * beneath it, and clicks ignore this (topmost first).
166
+ * highest wins, the topmost on a tie. Default 0. Over a blocking layer (a cluster bubble), a
167
+ * feature beneath shows only if its plugin's priority is higher than the blocking plugin's.
168
+ * Clicks ignore this (topmost first), and a blocking layer on top still blocks them.
133
169
  */
134
170
  hoverPriority?: number;
171
+ /**
172
+ * False: the plugin's features never take the hover (so no hover tooltip and no pointer
173
+ * cursor) and never hide a feature beneath from it, as if they weren't rendered. Clicks,
174
+ * `featuresAt`, and keyboard focus from the list are unchanged. Default true.
175
+ */
176
+ hoverable?: boolean;
135
177
  displayOptions?: Snippet;
136
178
  /** Richer tooltip content for one of this plugin's features, below its name. */
137
179
  tooltip?(item: FeatureListItem): Snippet | undefined;
@@ -0,0 +1,27 @@
1
+ import type { Geometry } from "geojson";
2
+ import type { Bounds } from "./create-map.svelte.js";
3
+ /**
4
+ * Shapes against the view: a geometry's bounding box, whether it overlaps the view, and a point of
5
+ * it inside the view. Coordinates are planar lng/lat (as `labelPoint`); no antimeridian handling.
6
+ */
7
+ /** `[west, south, east, north]`. */
8
+ export type Bbox = [west: number, south: number, east: number, north: number];
9
+ /** The geometry's bounding box, or `null` when it has no coordinates. */
10
+ export declare function geometryBbox(geometry: Geometry): Bbox | null;
11
+ /** Inside an outer ring and outside its holes, for any polygon of the geometry. */
12
+ export declare function containsPoint(geometry: Geometry, p: [number, number]): boolean;
13
+ /**
14
+ * Whether the item is in view: a shape (`geometry`) when it intersects `bounds` (bbox pre-check,
15
+ * then the exact test; holes count), anything else when its `lngLat` is inside. Edges included.
16
+ */
17
+ export declare function intersectsBounds(item: {
18
+ lngLat: [number, number];
19
+ geometry?: Geometry;
20
+ bbox?: Bbox;
21
+ }, bounds: Bounds): boolean;
22
+ /**
23
+ * A point of the shape inside `view`: `center` when the shape contains it; otherwise a point well
24
+ * inside the shape's visible part (polygons: the largest clipped one, never in a hole; lines: the
25
+ * middle of the longest visible piece); `null` when nothing of it is visible.
26
+ */
27
+ export declare function shapeAnchor(geometry: Geometry, view: Bounds, center: [number, number]): [number, number] | null;