@sjvair/map-sdk 0.1.0-beta.1 → 0.1.0-beta.3

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/README.md CHANGED
@@ -9,10 +9,10 @@ Supported browsers: Chrome/Edge 111+, Safari 16.4+, Firefox 128+. MIT licensed.
9
9
 
10
10
  ## Install
11
11
 
12
- Once published:
12
+ map-sdk is in beta. Betas are published under the `next` tag:
13
13
 
14
14
  ```bash
15
- npm install @sjvair/map-sdk
15
+ npm install @sjvair/map-sdk@next
16
16
  ```
17
17
 
18
18
  Peer dependencies: `svelte` (^5.57) and `@sjvair/sdk`, which is published to
@@ -156,6 +156,11 @@ create one per map (adding an object that is set up on another map throws). The
156
156
  ones they are built on. Presets don't fetch: the host supplies a `DataSource`. `monitorsPlugin` is
157
157
  selectable by default, like the shape presets.
158
158
 
159
+ An added plugin is in `map.plugins` (and the legend and selection state) as soon as `addPlugin`
160
+ returns. On a map that is already showing, its `setup` runs just after the current code finishes,
161
+ so its layers aren't there yet on the next line. Code that must see them uses `await tick()`;
162
+ `flushSync()` does not run setup.
163
+
159
164
  Selected points show a highlight ring and check mark by default. `selectedStyle: "enlarge"` on
160
165
  `pointsPlugin` or `monitorsPlugin` instead draws the selected icon at 1.3x with no ring (icon
161
166
  points only), as sjvair.com's map does today.
@@ -165,7 +170,8 @@ in each level's color at the fill's opacity; choropleth only), or `"none"` (no o
165
170
  `hmsSmokePlugin` uses `"level"`.
166
171
 
167
172
  `zoomRange: { min?, max? }` on `pointsPlugin` limits every layer it draws (points, clusters,
168
- selection) to zooms from `min` (inclusive) up to `max` (exclusive), switching live while zooming.
173
+ selection) to camera zooms from `min` (inclusive) up to `max` (exclusive), switching live while
174
+ zooming. Two plugins whose ranges meet hand over with no gap, on the globe too.
169
175
 
170
176
  `hmsFirePlugin({ id?, source, hotspots?, layerLabel })` draws fires as flame icons sized and
171
177
  colored by FRP. With `hotspots`, it switches by itself while zooming, as sjvair.com's map does:
@@ -180,7 +186,23 @@ each level's label under its segment; long labels wrap. `legendTitle: "layer" |
180
186
  the legend section's title: `layerLabel` (default) or the entry type's label from the metadata
181
187
  provider (`monitorsPlugin` uses the latter, so its section reads "PM2.5"; `layerLabel` still names
182
188
  the layer in the list, selection bar and summary). `Legend` takes `order` (`"bottom-first"`,
183
- default, or `"top-first"`).
189
+ default, or `"top-first"`) and `framed` (`true`, default, draws its own card; `false` draws no
190
+ border, background, padding or fixed width, so a host's own card frames it). Without a frame,
191
+ translucent swatches blend over the nearest ancestor with an opaque background (a see-through
192
+ card is skipped), else `--background`; that color is read on mount, when `framed` changes, and on
193
+ a theme change, but not when the card changes color through its own class or style.
194
+
195
+ ```svelte
196
+ <div class="w-84 rounded-md bg-white shadow">
197
+ <h3>Legend</h3>
198
+ <Legend {map} framed={false} />
199
+ </div>
200
+ ```
201
+
202
+ `detail?(props)` on `pointsPlugin` gives a feature with no level a short text (such as "Level 2
203
+ charger") shown where "No level" would be: in the feature list (plain text, no swatch), the row's
204
+ accessible name ("Fresno Station, Level 2 charger"), and the tooltip. A feature with a level
205
+ always shows its level. A plugin of your own sets the same `detail` on its `FeatureListItem`s.
184
206
 
185
207
  `collocationSitesPlugin({ id?, source, layerLabel })` draws collocation sites as sjvair.com does:
186
208
  each at its reference monitor, in that monitor's shape and level color (inactive gray, inside
@@ -280,8 +302,10 @@ not selectable). `encodeRef`/`decodeRef` convert between refs and keys.
280
302
 
281
303
  A plugin is an object with an `id` and a `setup(ctx)` that adds sources, layers, and images
282
304
  through its `PluginContext` and returns a teardown. The host runs `setup` once the map's style is
283
- ready and again after a basemap or theme change; everything added through `ctx` is removed on
284
- teardown, and ids are namespaced per map. `features()` feeds the feature list, tooltip, and
305
+ ready and again after a basemap or theme change. For a plugin added to a ready map, `setup` runs
306
+ just after the current code finishes (a microtask), never inside the caller's code, so adding and
307
+ removing plugins from an `$effect` is safe. Everything added through `ctx` is removed on teardown,
308
+ and ids are namespaced per map. `features()` feeds the feature list, tooltip, and
285
309
  summary; `legend()` the legend. A plugin's `tooltip` content sits under the feature's name; set
286
310
  `tooltipShowsName` when the content shows the name itself (the monitor and collocation tooltips
287
311
  do), and the name header is left out.
@@ -329,8 +353,10 @@ time), and `movePlugin` moves it with the plugin's other layers.
329
353
 
330
354
  The cursor is a pointer over any interactive feature or cluster bubble. Where several plugins'
331
355
  features are under the pointer, the hover tooltip shows the one whose plugin has the highest
332
- `hoverPriority` (default 0), the topmost on a tie; a cluster bubble on top still hides what is
333
- beneath it, and clicks always go to the topmost.
356
+ `hoverPriority` (default 0), the topmost on a tie. Over a cluster bubble, the bubble's tooltip
357
+ shows unless a feature beneath belongs to a plugin with a higher `hoverPriority` than the
358
+ bubble's (collocation sites under monitor clusters). Clicks always go to the topmost, so a click
359
+ on a bubble still zooms into it.
334
360
 
335
361
  ```ts
336
362
  import type { CustomLayerInterface, MapPlugin, PluginContext } from "@sjvair/map-sdk";
@@ -402,9 +428,10 @@ map.easeTo({ center: monitor.lngLat, minZoom: 12 });
402
428
 
403
429
  ## Ready, and reaching the GL map
404
430
 
405
- `map.ready` is reactive: true once the attached map's style has loaded and the map has gone idle
406
- for the first time, false again on detach and while a basemap switch reloads the style. Show a load
407
- screen until it is true. `map.gl` is the attached GL map (or `null`), reactive.
431
+ `map.ready` is reactive: true once the attached map's style has loaded, the map has gone idle
432
+ for the first time, and any queued plugin setups have run; false again on detach and while a
433
+ basemap switch reloads the style. Adding a plugin to a ready map leaves it true. Show a load screen
434
+ until it is true. `map.gl` is the attached GL map (or `null`), reactive.
408
435
 
409
436
  `maptilerMap(map)` returns it as a MapTiler `Map` (or `null` while detached). It is an escape hatch
410
437
  for markers and popups only: never add sources or layers through it, because plugins own those.
@@ -82,8 +82,9 @@ export interface MapContext {
82
82
  /** Reactive. */
83
83
  readonly attached: boolean;
84
84
  /**
85
- * Reactive. True once the attached map's style has loaded and the map has gone idle for the
86
- * first time; false again on `detach()` and while a basemap switch reloads the style. A load
85
+ * Reactive. True once the attached map's style has loaded, the map has gone idle for the
86
+ * first time, and any queued plugin setups have run; false again on `detach()` and while a
87
+ * basemap switch reloads the style. Adding a plugin to a ready map leaves it true. A load
87
88
  * screen can wait for it.
88
89
  */
89
90
  readonly ready: boolean;
@@ -197,7 +198,8 @@ export interface MapContext {
197
198
  updateView(camera: CameraState, bounds: Bounds): void;
198
199
  /**
199
200
  * For MapView only: the host's theme changed, so plugins set up again and re-read the theme
200
- * colors they bake into paint and images. Does nothing while detached.
201
+ * colors they bake into paint and images. Does nothing while detached. Sets plugins up
202
+ * synchronously, so never call it from inside an effect (see `PluginHost`).
201
203
  */
202
204
  themeChanged(): void;
203
205
  /** Detaches, removes every plugin, and drops handlers. The context can't be used after. */
@@ -50,6 +50,8 @@ class MapContextImpl {
50
50
  #bounds = $state.raw(null);
51
51
  #attached = $state(false);
52
52
  #ready = $state(false);
53
+ /** `markReady` came while plugin setups were queued; the queued run sets `ready`. */
54
+ #readyRequested = false;
53
55
  #glState = $state.raw(null);
54
56
  #focusedKey = $state(null);
55
57
  #focusedPluginId = $state(null);
@@ -77,7 +79,15 @@ class MapContextImpl {
77
79
  mapUid: this.uid,
78
80
  providers: { metadata: this.metadata, messages: this.messages, format: this.format },
79
81
  router: this.#router,
80
- selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId))
82
+ selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId)),
83
+ onSettled: () => {
84
+ // A setup that added a plugin queued another run; that run settles it.
85
+ if (this.#host.pending)
86
+ return;
87
+ if (this.#readyRequested && this.#gl)
88
+ this.#ready = true;
89
+ this.#readyRequested = false;
90
+ }
81
91
  });
82
92
  this.#router.onPick((e) => this.#pick(e));
83
93
  }
@@ -100,7 +110,11 @@ class MapContextImpl {
100
110
  return this.#glState;
101
111
  }
102
112
  markReady() {
103
- if (this.#gl)
113
+ if (!this.#gl)
114
+ return;
115
+ if (this.#host.pending)
116
+ this.#readyRequested = true;
117
+ else
104
118
  this.#ready = true;
105
119
  }
106
120
  get focusedKey() {
@@ -269,6 +283,7 @@ class MapContextImpl {
269
283
  this.#router.attach(gl);
270
284
  this.basemap.attach(gl, () => this.#host.restyle(), () => {
271
285
  this.#ready = false;
286
+ this.#readyRequested = false;
272
287
  this.#host.suspend();
273
288
  });
274
289
  this.#attached = true;
@@ -283,6 +298,7 @@ class MapContextImpl {
283
298
  this.#glState = null;
284
299
  this.#attached = false;
285
300
  this.#ready = false;
301
+ this.#readyRequested = false;
286
302
  }
287
303
  themeChanged() {
288
304
  if (this.#gl)
@@ -10,7 +10,8 @@ export interface Hovered {
10
10
  * Listens on the map itself (`click`, `mousemove`, `mouseout`) and queries only the registered
11
11
  * layers, so registrations can come and go without re-binding listeners. Layers nobody registered
12
12
  * are never hit. A blocking layer (e.g. a cluster bubble, which zooms instead) is never hit either,
13
- * but when it is topmost at a point nothing beneath it is hit or hovered.
13
+ * and when it is topmost at a point nothing beneath it is hit; only a feature whose plugin
14
+ * outranks it in `hoverPriority` is hovered through it.
14
15
  */
15
16
  export declare class InteractionRouter {
16
17
  #private;
@@ -4,7 +4,8 @@
4
4
  * Listens on the map itself (`click`, `mousemove`, `mouseout`) and queries only the registered
5
5
  * layers, so registrations can come and go without re-binding listeners. Layers nobody registered
6
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.
7
+ * and when it is topmost at a point nothing beneath it is hit; only a feature whose plugin
8
+ * outranks it in `hoverPriority` is hovered through it.
8
9
  */
9
10
  export class InteractionRouter {
10
11
  // Bookkeeping only; nothing reads these reactively.
@@ -120,9 +121,25 @@ export class InteractionRouter {
120
121
  if (top?.blocking) {
121
122
  const properties = features[0].properties ?? {};
122
123
  const hit = top.toHit(properties);
124
+ // Clicks stay blocked, but the hover goes to the highest-priority feature beneath that
125
+ // outranks the blocking plugin.
126
+ let beneath = null;
127
+ let beneathPriority = top.priority;
128
+ for (const feature of features.slice(1)) {
129
+ const registration = byLayer.get(feature.layer.id);
130
+ if (!registration || registration.blocking)
131
+ continue;
132
+ if (registration.priority <= beneathPriority)
133
+ continue;
134
+ const below = registration.toHit(feature.properties ?? {});
135
+ if (!below)
136
+ continue;
137
+ beneath = { ...below, pluginId: registration.pluginId };
138
+ beneathPriority = registration.priority;
139
+ }
123
140
  return {
124
141
  hits: [],
125
- hover: null,
142
+ hover: beneath,
126
143
  blocked: true,
127
144
  blocker: hit && { ...hit, pluginId: top.pluginId },
128
145
  blockedBy: { registration: top, feature: { properties, geometry: features[0].geometry } }
@@ -194,7 +211,7 @@ export class InteractionRouter {
194
211
  };
195
212
  #onMove = (e) => {
196
213
  const found = this.#lookup([e.point.x, e.point.y]);
197
- const hit = found.blocked ? found.blocker : found.hover;
214
+ const hit = found.blocked ? (found.hover ?? found.blocker) : found.hover;
198
215
  this.#hovered = hit ? { hit, lngLat: [e.lngLat.lng, e.lngLat.lat] } : null;
199
216
  this.#setPointer(found.blocked || hit !== null);
200
217
  };
@@ -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,14 +46,21 @@ 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. */
59
+ /**
60
+ * Tears down and sets up every plugin again (after a basemap change), once the style loads.
61
+ * On a loaded style the setup is synchronous, so only map events and theme watching call this,
62
+ * never code inside an effect: roots created there can stop (see the class comment).
63
+ */
43
64
  restyle(): void;
44
65
  /** Tears down every plugin. The gl is not touched after this returns. */
45
66
  detach(): void;
@@ -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,16 @@ 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. */
163
+ /**
164
+ * Tears down and sets up every plugin again (after a basemap change), once the style loads.
165
+ * On a loaded style the setup is synchronous, so only map events and theme watching call this,
166
+ * never code inside an effect: roots created there can stop (see the class comment).
167
+ */
145
168
  restyle() {
146
169
  if (!this.#gl)
147
170
  return;
148
171
  this.#teardownAll();
149
- this.#whenStyleLoaded();
172
+ this.#whenStyleLoaded(true);
150
173
  }
151
174
  /** Tears down every plugin. The gl is not touched after this returns. */
152
175
  detach() {
@@ -157,11 +180,22 @@ export class PluginHost {
157
180
  this.#teardownAll();
158
181
  this.#gl = null;
159
182
  }
160
- #whenStyleLoaded() {
183
+ /**
184
+ * Sets every plugin up once the style is loaded. On a style that already is, `now` sets them up
185
+ * synchronously (only for callers that never run inside an effect); otherwise they are queued.
186
+ */
187
+ #whenStyleLoaded(now) {
161
188
  const gl = this.#gl;
162
189
  this.#cancelStyleWait();
163
190
  if (gl.isStyleLoaded()) {
164
- this.#setupAll();
191
+ if (now) {
192
+ this.#setupAll();
193
+ }
194
+ else {
195
+ // Live already, so an `add` before the microtask joins the queued run.
196
+ this.#live = true;
197
+ this.#queueSetup();
198
+ }
165
199
  return;
166
200
  }
167
201
  const onStyleLoad = () => {
@@ -175,6 +209,37 @@ export class PluginHost {
175
209
  for (const type of STYLE_READY_EVENTS)
176
210
  gl.on(type, onStyleLoad);
177
211
  }
212
+ /**
213
+ * Sets every plugin that isn't running up in a microtask, on whichever gl is live by then.
214
+ * Detach, suspend, and restyle clear `#live`, and only a loaded current gl sets it again, so
215
+ * the run never touches a gl that left. One run covers every request made before it starts.
216
+ */
217
+ #queueSetup() {
218
+ // Claim the queued plugins now, so another map can't take one before the run.
219
+ for (const plugin of this.#list)
220
+ this.#claim(plugin);
221
+ if (this.#setupQueued)
222
+ return;
223
+ this.#setupQueued = true;
224
+ queueMicrotask(() => {
225
+ this.#setupQueued = false;
226
+ if (this.#live)
227
+ this.#setupAll();
228
+ this.#opts.onSettled?.();
229
+ });
230
+ }
231
+ /** Marks `plugin` as this host's, unless another map holds it. */
232
+ #claim(plugin) {
233
+ if (!ownedElsewhere(plugin, this) && Object.isExtensible(plugin)) {
234
+ plugin[OWNER] = this;
235
+ }
236
+ }
237
+ /** Releases a plugin this host claimed but isn't running (queued, then removed or left). */
238
+ #release(plugin) {
239
+ const owned = plugin;
240
+ if (owned[OWNER] === this && !this.#running.has(plugin.id))
241
+ delete owned[OWNER];
242
+ }
178
243
  #cancelStyleWait() {
179
244
  const listener = this.#onStyleLoad;
180
245
  if (listener && this.#gl) {
@@ -202,8 +267,10 @@ export class PluginHost {
202
267
  }
203
268
  #teardownAll() {
204
269
  this.#live = false;
205
- for (const plugin of this.#list)
270
+ for (const plugin of this.#list) {
206
271
  this.#teardown(plugin.id);
272
+ this.#release(plugin);
273
+ }
207
274
  }
208
275
  #setup(plugin) {
209
276
  const gl = this.#gl;
@@ -225,7 +292,8 @@ export class PluginHost {
225
292
  const ctx = this.#context(plugin.id, running);
226
293
  let teardown;
227
294
  try {
228
- // Untracked: setup may be called from inside an effect (e.g. MapView's).
295
+ // Untracked, in case a plugin's setup reads state (setup runs from a microtask or a map
296
+ // event, never inside the caller's effect).
229
297
  teardown = untrack(() => plugin.setup(ctx));
230
298
  }
231
299
  catch (error) {
@@ -48,6 +48,12 @@ export interface FeatureListItem {
48
48
  precision?: number;
49
49
  lngLat: [number, number];
50
50
  selectable: boolean;
51
+ /**
52
+ * A short text about a feature with no level (e.g. "Level 2 charger"), shown where its level
53
+ * would be: in the feature list, its row's accessible name, and the generic tooltip. Ignored
54
+ * when the item has a level.
55
+ */
56
+ detail?: string;
51
57
  }
52
58
  export type LegendData = {
53
59
  pluginId: string;
@@ -128,8 +134,9 @@ export interface MapPlugin {
128
134
  selectable?: boolean;
129
135
  /**
130
136
  * 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).
137
+ * highest wins, the topmost on a tie. Default 0. Over a blocking layer (a cluster bubble), a
138
+ * feature beneath shows only if its plugin's priority is higher than the blocking plugin's.
139
+ * Clicks ignore this (topmost first), and a blocking layer on top still blocks them.
133
140
  */
134
141
  hoverPriority?: number;
135
142
  displayOptions?: Snippet;
@@ -8,6 +8,7 @@
8
8
  "mapsdk_list_row": "{name}, {value} {units}, {level}",
9
9
  "mapsdk_list_row_no_units": "{name}, {value}, {level}",
10
10
  "mapsdk_list_row_no_value": "{name}, no reading",
11
+ "mapsdk_list_row_detail": "{name}, {detail}",
11
12
  "mapsdk_list_empty": "Nothing on the map in this view",
12
13
  "mapsdk_selected_count_one": "{count} selected",
13
14
  "mapsdk_selected_count_other": "{count} selected",
@@ -60,8 +60,9 @@ export interface PointsOptions<P extends MapFeatureProperties> {
60
60
  sortKey?(props: P): number;
61
61
  /**
62
62
  * Limits every layer the plugin draws (points, clusters, selection) to a zoom range: visible
63
- * from `min` (inclusive) up to `max` (exclusive). Set on the layers, so it switches live while
64
- * zooming. Default: all zooms.
63
+ * from `min` (inclusive) up to `max` (exclusive) of the camera zoom, switching live while
64
+ * zooming (layer visibility, not layer zoom ranges, so ranges that meet leave no gap on a
65
+ * globe). Default: all zooms.
65
66
  */
66
67
  zoomRange?: {
67
68
  min?: number;
@@ -75,6 +76,12 @@ export interface PointsOptions<P extends MapFeatureProperties> {
75
76
  * it loads.
76
77
  */
77
78
  legendTitle?: "layer" | "entry-type";
79
+ /**
80
+ * A short text for a feature with no level (e.g. "Level 2 charger"), shown in the feature list,
81
+ * its accessible name, and the generic tooltip where "No level" would be. Ignored for a
82
+ * feature with a level.
83
+ */
84
+ detail?(props: P): string | undefined;
78
85
  /** Whether `features()` lists this plugin's features. Default true. */
79
86
  listed?: boolean;
80
87
  /** Tooltip content for a feature, below its name; `props` are its source properties. */
@@ -93,6 +93,7 @@ export function pointsPlugin(options) {
93
93
  if (!ref)
94
94
  continue;
95
95
  const [lng, lat] = geometry.coordinates;
96
+ const detail = options.detail?.(props)?.trim();
96
97
  items.push({
97
98
  refKey: props.refKey,
98
99
  ref,
@@ -102,7 +103,8 @@ export function pointsPlugin(options) {
102
103
  units: scale?.units ?? "",
103
104
  precision,
104
105
  lngLat: [lng, lat],
105
- selectable: selectable && selectionId(ref) !== null
106
+ selectable: selectable && selectionId(ref) !== null,
107
+ ...(detail ? { detail } : {})
106
108
  });
107
109
  }
108
110
  return items;
@@ -209,15 +211,39 @@ export function pointsPlugin(options) {
209
211
  }
210
212
  return spec;
211
213
  }
212
- /** The spec limited to the plugin's zoom range. */
214
+ // The zoom range shows and hides the layers by the camera zoom, not with layer
215
+ // minzoom/maxzoom: on a globe MapLibre uses the next zoom's tiles a little before the camera
216
+ // gets there and leaves a layer out of tiles at or above its maxzoom, so two plugins whose
217
+ // ranges meet (fire groups below 10, hotspots from 10) would leave a gap where neither drew.
218
+ const { min, max } = options.zoomRange ?? {};
219
+ const ranged = min !== undefined || max !== undefined;
220
+ const inRange = () => {
221
+ const zoom = gl.getZoom();
222
+ return (min === undefined || zoom >= min) && (max === undefined || zoom < max);
223
+ };
224
+ let shown = ranged ? inRange() : true;
225
+ /** The spec, hidden while the camera is outside the plugin's zoom range. */
213
226
  function zoomed(spec) {
214
- const { min, max } = options.zoomRange ?? {};
227
+ if (!ranged)
228
+ return spec;
229
+ const layout = spec.layout;
215
230
  return {
216
231
  ...spec,
217
- ...(min !== undefined ? { minzoom: min } : {}),
218
- ...(max !== undefined ? { maxzoom: max } : {})
232
+ layout: { ...layout, visibility: shown ? "visible" : "none" }
219
233
  };
220
234
  }
235
+ const onZoom = () => {
236
+ if (dead || inRange() === shown)
237
+ return;
238
+ shown = !shown;
239
+ for (const layerId of layers) {
240
+ if (gl.getLayer(layerId)) {
241
+ gl.setLayoutProperty(layerId, "visibility", shown ? "visible" : "none");
242
+ }
243
+ }
244
+ };
245
+ if (ranged)
246
+ gl.on("zoom", onZoom);
221
247
  function removeLayers() {
222
248
  for (const layerId of layers.toReversed()) {
223
249
  if (gl.getLayer(layerId))
@@ -447,6 +473,8 @@ export function pointsPlugin(options) {
447
473
  });
448
474
  return () => {
449
475
  dead = true;
476
+ if (ranged)
477
+ gl.off("zoom", onZoom);
450
478
  providers = null;
451
479
  live = null;
452
480
  };
@@ -9,6 +9,7 @@
9
9
  inView,
10
10
  optionIdPart,
11
11
  rowLabel,
12
+ levelText,
12
13
  shownLevel,
13
14
  sortItems,
14
15
  typeaheadIndex,
@@ -330,6 +331,7 @@
330
331
  {#each windowRows as row, i (row.key)}
331
332
  {@const index = range.start + i}
332
333
  {@const level = shownLevel(row)}
334
+ {@const levelPlace = levelText(row, map.messages)}
333
335
  {@const isOn = isSelected(row)}
334
336
  <!-- Options aren't focused: the listbox holds focus and handles keys (aria-activedescendant). -->
335
337
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->
@@ -360,12 +362,14 @@
360
362
  {formatValue(row)}{#if row.units && row.value !== null}&nbsp;{row.units}{/if}
361
363
  </span>
362
364
  <span class="mapsdk-list-level text-xs">
363
- <span
364
- class="mapsdk-list-swatch"
365
- class:neutral={!level?.color}
366
- style:background={level?.color || undefined}
367
- ></span>
368
- {level ? level.label : map.messages("mapsdk_no_level")}
365
+ {#if levelPlace.kind !== "detail"}
366
+ <span
367
+ class="mapsdk-list-swatch"
368
+ class:neutral={!level?.color}
369
+ style:background={level?.color || undefined}
370
+ ></span>
371
+ {/if}
372
+ {levelPlace.text}
369
373
  </span>
370
374
  </div>
371
375
  {/each}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from "svelte";
2
+ import { onMount, untrack } from "svelte";
3
3
  import "./controls.css";
4
4
  import type { MapContext } from "../core/create-map.svelte.js";
5
5
  import { watchTheme } from "../view/create-gl-map.js";
@@ -8,9 +8,19 @@
8
8
 
9
9
  let {
10
10
  map,
11
- order = "bottom-first"
11
+ order = "bottom-first",
12
+ framed = true
12
13
  }: {
13
14
  map: MapContext;
15
+ /**
16
+ * `true` (default) draws the legend's own card. `false` draws no frame (no border,
17
+ * background, padding, or fixed width) so a host's card frames it. Translucent swatches
18
+ * then blend over the nearest ancestor with an opaque background (a see-through card is
19
+ * skipped), else the `--background` token. The color is read on mount, when `framed`
20
+ * changes, and on a theme change; a card that changes color through its own class or
21
+ * style isn't re-read.
22
+ */
23
+ framed?: boolean;
14
24
  /**
15
25
  * `"bottom-first"` (default) lists the bottom plugin's section first; `"top-first"`
16
26
  * last.
@@ -43,30 +53,50 @@
43
53
  )
44
54
  );
45
55
 
46
- /** Computed colors can be oklch() or color(); a 1x1 canvas turns any of them into rgb. */
47
- function resolveBackground(el: Element): string {
48
- const css = getComputedStyle(el).backgroundColor;
56
+ /**
57
+ * A computed color as rgb and whether it's opaque; computed colors can be oklch() or color(),
58
+ * and a 1x1 canvas turns any of them into rgb.
59
+ */
60
+ function toRgb(css: string): { rgb: string; opaque: boolean } {
49
61
  const ctx = document.createElement("canvas").getContext("2d");
50
- if (!ctx) return css;
62
+ if (!ctx) return { rgb: css, opaque: true };
51
63
  ctx.clearRect(0, 0, 1, 1);
52
64
  ctx.fillStyle = css;
53
65
  ctx.fillRect(0, 0, 1, 1);
54
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
55
- return `rgb(${r}, ${g}, ${b})`;
66
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
67
+ return { rgb: `rgb(${r}, ${g}, ${b})`, opaque: a === 255 };
56
68
  }
57
69
 
58
- onMount(() => {
59
- const update = () => {
60
- if (root) background = resolveBackground(root);
61
- };
62
- update();
63
- return watchTheme(root!, update);
70
+ /**
71
+ * What translucent swatches blend over: the legend's own card, or without a frame the nearest
72
+ * ancestor with an opaque background (the host's card), else the `--background` token.
73
+ */
74
+ function resolveBackground(el: HTMLElement, frameless: boolean): string {
75
+ if (!frameless) return toRgb(getComputedStyle(el).backgroundColor).rgb;
76
+ for (let at = el.parentElement; at; at = at.parentElement) {
77
+ const color = toRgb(getComputedStyle(at).backgroundColor);
78
+ if (color.opaque) return color.rgb;
79
+ }
80
+ // A computed custom property has its var() references already substituted.
81
+ return toRgb(getComputedStyle(el).getPropertyValue("--background").trim()).rgb;
82
+ }
83
+
84
+ function update() {
85
+ if (root) background = resolveBackground(root, !framed);
86
+ }
87
+
88
+ onMount(() => watchTheme(root!, update));
89
+
90
+ // On mount, and again when `framed` changes.
91
+ $effect(() => {
92
+ void framed;
93
+ untrack(update);
64
94
  });
65
95
  </script>
66
96
 
67
97
  <section
68
98
  bind:this={root}
69
- class="mapsdk-legend"
99
+ class={["mapsdk-legend", !framed && "mapsdk-legend-frameless"]}
70
100
  aria-label={map.messages("mapsdk_legend_label")}
71
101
  hidden={entries.length === 0}
72
102
  >
@@ -124,6 +154,17 @@
124
154
  pointer-events: auto;
125
155
  }
126
156
 
157
+ /* framed={false}: the host's card is the frame. */
158
+ .mapsdk-legend-frameless {
159
+ width: auto;
160
+ max-width: none;
161
+ padding: 0;
162
+ border: 0;
163
+ border-radius: 0;
164
+ background: none;
165
+ box-shadow: none;
166
+ }
167
+
127
168
  .mapsdk-legend[hidden] {
128
169
  display: none;
129
170
  }
@@ -2,6 +2,15 @@ import "./controls.css";
2
2
  import type { MapContext } from "../core/create-map.svelte.js";
3
3
  type $$ComponentProps = {
4
4
  map: MapContext;
5
+ /**
6
+ * `true` (default) draws the legend's own card. `false` draws no frame (no border,
7
+ * background, padding, or fixed width) so a host's card frames it. Translucent swatches
8
+ * then blend over the nearest ancestor with an opaque background (a see-through card is
9
+ * skipped), else the `--background` token. The color is read on mount, when `framed`
10
+ * changes, and on a theme change; a card that changes color through its own class or
11
+ * style isn't re-read.
12
+ */
13
+ framed?: boolean;
5
14
  /**
6
15
  * `"bottom-first"` (default) lists the bottom plugin's section first; `"top-first"`
7
16
  * last.
@@ -3,6 +3,7 @@
3
3
  import type { MessageProvider } from "../data/messages.js";
4
4
  import type { FeatureListItem } from "../core/plugin.js";
5
5
  import { NO_LEVEL } from "../data/scale.js";
6
+ import { levelText } from "./feature-list.js";
6
7
  import { levelChip } from "./level-chip.js";
7
8
  import { formatValue } from "./value-format.js";
8
9
 
@@ -19,7 +20,8 @@
19
20
 
20
21
  // A level with an empty color shows its label on the neutral chip.
21
22
  const level = $derived(item.level !== NO_LEVEL ? item.level : null);
22
- const levelLabel = $derived(level ? level.label : messages("mapsdk_no_level"));
23
+ // The level's label, else the item's detail (plain text, no chip), else "No level".
24
+ const levelPlace = $derived(levelText(item, messages));
23
25
  const chipColors = $derived.by(() => {
24
26
  const chip = levelChip(level?.color);
25
27
  return chip ? `background:${chip.background};color:${chip.text}` : "";
@@ -39,9 +41,13 @@
39
41
  {valueText}{#if item.units}&nbsp;{item.units}{/if}
40
42
  </div>
41
43
  {/if}
42
- <span class="mapsdk-tooltip-chip text-xs" class:neutral={!chipColors} style={chipColors}>
43
- {levelLabel}
44
- </span>
44
+ {#if levelPlace.kind === "detail"}
45
+ <span class="mapsdk-tooltip-detail text-xs">{levelPlace.text}</span>
46
+ {:else}
47
+ <span class="mapsdk-tooltip-chip text-xs" class:neutral={!chipColors} style={chipColors}>
48
+ {levelPlace.text}
49
+ </span>
50
+ {/if}
45
51
  </div>
46
52
 
47
53
  <style>
@@ -27,5 +27,12 @@ export declare function shownLevel(item: FeatureListItem): Level | null;
27
27
  export declare function sortItems(items: FeatureListItem[], by: SortBy): FeatureListItem[];
28
28
  /** The first index at or after `from` (wrapping) whose name starts with `query`, any case; -1 if none. */
29
29
  export declare function typeaheadIndex(items: FeatureListItem[], query: string, from: number): number;
30
- /** A row's accessible name: name, value, units, and level label. */
30
+ /**
31
+ * What stands in an item's level place: its level's label, else its `detail`, else "No level".
32
+ */
33
+ export declare function levelText(item: FeatureListItem, messages: MessageProvider): {
34
+ kind: "level" | "detail" | "none";
35
+ text: string;
36
+ };
37
+ /** A row's accessible name: name, value, units, and level label (or detail). */
31
38
  export declare function rowLabel(item: FeatureListItem, messages: MessageProvider, format: FormatProvider): string;
@@ -65,16 +65,31 @@ export function typeaheadIndex(items, query, from) {
65
65
  }
66
66
  return -1;
67
67
  }
68
- /** A row's accessible name: name, value, units, and level label. */
69
- export function rowLabel(item, messages, format) {
70
- if (item.value === null)
71
- return messages("mapsdk_list_row_no_value", { name: item.name });
68
+ /**
69
+ * What stands in an item's level place: its level's label, else its `detail`, else "No level".
70
+ */
71
+ export function levelText(item, messages) {
72
72
  const level = shownLevel(item);
73
+ if (level)
74
+ return { kind: "level", text: level.label };
75
+ const detail = item.detail?.trim();
76
+ if (detail)
77
+ return { kind: "detail", text: detail };
78
+ return { kind: "none", text: messages("mapsdk_no_level") };
79
+ }
80
+ /** A row's accessible name: name, value, units, and level label (or detail). */
81
+ export function rowLabel(item, messages, format) {
82
+ const level = levelText(item, messages);
83
+ if (item.value === null) {
84
+ return level.kind === "detail"
85
+ ? messages("mapsdk_list_row_detail", { name: item.name, detail: level.text })
86
+ : messages("mapsdk_list_row_no_value", { name: item.name });
87
+ }
73
88
  const params = {
74
89
  name: item.name,
75
90
  value: typeof item.value === "number" ? formatValue(item.value, item.precision, format) : item.value,
76
91
  units: item.units,
77
- level: level ? level.label : messages("mapsdk_no_level")
92
+ level: level.text
78
93
  };
79
94
  return messages(item.units ? "mapsdk_list_row" : "mapsdk_list_row_no_units", params);
80
95
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjvair/map-sdk",
3
3
  "description": "Svelte 5 map components for SJVAir air-quality data",
4
- "version": "0.1.0-beta.1",
4
+ "version": "0.1.0-beta.3",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",