@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
@@ -3,10 +3,11 @@ import { SvelteMap } from "svelte/reactivity";
3
3
  import { createDefaultFormat } from "../data/format.js";
4
4
  import { defaultMessages } from "../data/messages.js";
5
5
  import { createDefaultMetadata } from "../data/metadata.js";
6
- import { applyPick, selectionKeys } from "../data/selection.js";
6
+ import { applyPick, selectionKeys, toggleInSelection } from "../data/selection.js";
7
7
  import { BasemapState } from "./basemap.svelte.js";
8
8
  import { InteractionRouter } from "./interactions.svelte.js";
9
9
  import { PluginHost } from "./plugin-host.svelte.js";
10
+ import { intersectsBounds, shapeAnchor } from "./shape-view.js";
10
11
  /** Monitor-map's start view. */
11
12
  const DEFAULT_VIEW = {
12
13
  center: [-119.7987626619462, 36.76272050981146],
@@ -19,6 +20,8 @@ class PluginSelection {
19
20
  mode = $state("none");
20
21
  selection = $state.raw(null);
21
22
  onselectionchange;
23
+ pickWith = $state("click");
24
+ canSelect;
22
25
  }
23
26
  function copyView(view) {
24
27
  return { ...view, center: [view.center[0], view.center[1]] };
@@ -36,6 +39,7 @@ function sameSelection(a, b) {
36
39
  return a === b;
37
40
  return (a.kind === b.kind && a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]));
38
41
  }
42
+ const inRect = (p, b) => p[0] >= b.west && p[0] <= b.east && p[1] >= b.south && p[1] <= b.north;
39
43
  function prefersReducedMotion() {
40
44
  return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
41
45
  }
@@ -50,9 +54,16 @@ class MapContextImpl {
50
54
  #bounds = $state.raw(null);
51
55
  #attached = $state(false);
52
56
  #ready = $state(false);
57
+ /** `markReady` came while plugin setups were queued; the queued run sets `ready`. */
58
+ #readyRequested = false;
53
59
  #glState = $state.raw(null);
54
60
  #focusedKey = $state(null);
55
61
  #focusedPluginId = $state(null);
62
+ #focusedAnchor = $state.raw(null);
63
+ #pinned = $state.raw(null);
64
+ #notice = $state.raw(null);
65
+ #noticeCount = 0;
66
+ #cardEnabled = false;
56
67
  #selections = new SvelteMap();
57
68
  #featureClickHandlers = new Set();
58
69
  #router = new InteractionRouter();
@@ -61,6 +72,11 @@ class MapContextImpl {
61
72
  #destroyed = false;
62
73
  #listInset = 0;
63
74
  #listInsetSide = "left";
75
+ /** An animated ease that `focusFeature` started is still running (cleared on `moveend`). */
76
+ #focusEasing = false;
77
+ #onMoveEnd = () => {
78
+ this.#focusEasing = false;
79
+ };
64
80
  constructor(options) {
65
81
  const initialView = copyView(options.config.initialView ?? DEFAULT_VIEW);
66
82
  this.config = {
@@ -77,9 +93,18 @@ class MapContextImpl {
77
93
  mapUid: this.uid,
78
94
  providers: { metadata: this.metadata, messages: this.messages, format: this.format },
79
95
  router: this.#router,
80
- selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId))
96
+ selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId)),
97
+ onSettled: () => {
98
+ // A setup that added a plugin queued another run; that run settles it.
99
+ if (this.#host.pending)
100
+ return;
101
+ if (this.#readyRequested && this.#gl)
102
+ this.#ready = true;
103
+ this.#readyRequested = false;
104
+ }
81
105
  });
82
106
  this.#router.onPick((e) => this.#pick(e));
107
+ this.#router.onMiss(() => this.unpin());
83
108
  }
84
109
  get camera() {
85
110
  return this.#camera;
@@ -100,7 +125,11 @@ class MapContextImpl {
100
125
  return this.#glState;
101
126
  }
102
127
  markReady() {
103
- if (this.#gl)
128
+ if (!this.#gl)
129
+ return;
130
+ if (this.#host.pending)
131
+ this.#readyRequested = true;
132
+ else
104
133
  this.#ready = true;
105
134
  }
106
135
  get focusedKey() {
@@ -109,9 +138,39 @@ class MapContextImpl {
109
138
  get focusedPluginId() {
110
139
  return this.#focusedPluginId;
111
140
  }
141
+ get focusedAnchor() {
142
+ return this.#focusedAnchor;
143
+ }
112
144
  get hovered() {
113
145
  return this.#router.hovered;
114
146
  }
147
+ get pinned() {
148
+ return this.#pinned;
149
+ }
150
+ get notice() {
151
+ return this.#notice;
152
+ }
153
+ clearNotice() {
154
+ if (untrack(() => this.#notice) !== null)
155
+ this.#notice = null;
156
+ }
157
+ getPickWith(pluginId) {
158
+ return this.#selections.get(pluginId)?.pickWith ?? "click";
159
+ }
160
+ togglePick(pluginId, ref) {
161
+ if (this.#destroyed)
162
+ return;
163
+ this.#applyPick(pluginId, ref, { toggle: true });
164
+ }
165
+ unpin() {
166
+ if (untrack(() => this.#pinned) !== null)
167
+ this.#pinned = null;
168
+ }
169
+ setCardEnabled(on) {
170
+ this.#cardEnabled = on;
171
+ if (!on)
172
+ this.unpin();
173
+ }
115
174
  addPlugin(plugin, options = {}) {
116
175
  this.#assertAlive();
117
176
  const previous = untrack(() => this.#selections.get(plugin.id));
@@ -125,6 +184,10 @@ class MapContextImpl {
125
184
  : (previous?.selection ?? null);
126
185
  });
127
186
  state.onselectionchange = options.onselectionchange ?? previous?.onselectionchange;
187
+ untrack(() => {
188
+ state.pickWith = options.pickWith ?? previous?.pickWith ?? "click";
189
+ });
190
+ state.canSelect = options.canSelect ?? previous?.canSelect;
128
191
  // Stored before the host adds it, so a setup that runs at once sees its selection.
129
192
  this.#selections.set(plugin.id, state);
130
193
  try {
@@ -146,6 +209,8 @@ class MapContextImpl {
146
209
  removePlugin(id) {
147
210
  this.#host.remove(id);
148
211
  this.#selections.delete(id);
212
+ if (untrack(() => this.#pinned)?.hit.pluginId === id)
213
+ this.unpin();
149
214
  }
150
215
  setSelection(pluginId, selection) {
151
216
  if (this.#destroyed)
@@ -164,9 +229,15 @@ class MapContextImpl {
164
229
  const found = untrack(() => this.#findItem(refKey, pluginId));
165
230
  if (!found)
166
231
  return;
167
- this.#emitClick(pluginId, found.item.ref, found.item.lngLat);
168
- if (found.item.selectable)
169
- this.#applyPick(pluginId, found.item.ref, additive);
232
+ const { item } = found;
233
+ // Where the row's tooltip pins: the focused anchor for the focused row, else a point of a
234
+ // shape in view (a point's own position for points and on-screen label points).
235
+ const focused = untrack(() => this.#focusedKey) === refKey &&
236
+ (untrack(() => this.#focusedPluginId) ?? pluginId) === pluginId;
237
+ const anchor = (focused && untrack(() => this.#focusedAnchor)) || this.anchorFor(item);
238
+ this.#emitClick(pluginId, item.ref, anchor);
239
+ if (item.selectable)
240
+ this.#applyPick(pluginId, item.ref, { additive, modifier: additive });
170
241
  }
171
242
  clearSelection(pluginId) {
172
243
  if (this.#destroyed)
@@ -211,19 +282,107 @@ class MapContextImpl {
211
282
  return;
212
283
  this.#focusedKey = refKey;
213
284
  this.#focusedPluginId = refKey === null ? null : (pluginId ?? null);
285
+ this.#focusedAnchor = null;
214
286
  const gl = this.#gl;
215
287
  if (!gl || refKey === null)
216
288
  return;
217
289
  const found = untrack(() => this.#findItem(refKey, pluginId));
218
290
  if (!found)
219
291
  return;
292
+ const { plugin, item } = found;
293
+ const place = this.#placeFor(item, gl);
294
+ this.#focusedAnchor = place.anchor;
295
+ if (this.#cardEnabled) {
296
+ this.#pinned = {
297
+ hit: { pluginId: plugin.id, ref: item.ref, refKey: item.refKey, name: item.name },
298
+ lngLat: place.anchor
299
+ };
300
+ }
301
+ if (!place.pan) {
302
+ // Stays where it is now: an earlier focus's ease that is still running stops here.
303
+ // With nothing running, no move at all.
304
+ if (this.#focusEasing) {
305
+ const { lng, lat } = gl.getCenter();
306
+ gl.easeTo({ center: [lng, lat], duration: 0 });
307
+ this.#focusEasing = false;
308
+ }
309
+ return;
310
+ }
311
+ const reduce = prefersReducedMotion();
220
312
  gl.easeTo({
221
- center: found.item.lngLat,
313
+ center: place.anchor,
222
314
  // The middle of the part the panel leaves clear. An offset, not padding: MapLibre keeps
223
315
  // padding for later moves (zooms, the reported center), an offset applies to this one.
224
316
  ...(this.#listInset > 0 && { offset: [this.#listOffsetX(), 0] }),
225
- ...(prefersReducedMotion() && { duration: 0 })
317
+ ...(reduce && { duration: 0 })
226
318
  });
319
+ // Set after the call: stopping an earlier move fires its `moveend` inside `easeTo`.
320
+ this.#focusEasing = !reduce;
321
+ }
322
+ anchorFor(item) {
323
+ const gl = this.#gl;
324
+ if (!gl || !item.geometry)
325
+ return item.lngLat;
326
+ const { live, clear, liveCenter, clearCenter } = this.#views(gl);
327
+ if (inRect(item.lngLat, live) || !intersectsBounds(item, live))
328
+ return item.lngLat;
329
+ return (shapeAnchor(item.geometry, clear, clearCenter) ??
330
+ shapeAnchor(item.geometry, live, liveCenter) ??
331
+ item.lngLat);
332
+ }
333
+ /** The live view, and the part of it the list panel leaves clear, with their centers. */
334
+ #views(gl) {
335
+ const b = gl.getBounds();
336
+ const live = {
337
+ west: b.getWest(),
338
+ south: b.getSouth(),
339
+ east: b.getEast(),
340
+ north: b.getNorth()
341
+ };
342
+ const center = gl.getCenter();
343
+ const width = untrack(() => gl.getCanvas().clientWidth) || 0;
344
+ const share = width > 0 ? Math.min(Math.max(this.#listInset / width, 0), 0.99) : 0;
345
+ const cut = share * (live.east - live.west);
346
+ const clear = this.#listInsetSide === "left"
347
+ ? { ...live, west: live.west + cut }
348
+ : { ...live, east: live.east - cut };
349
+ return {
350
+ live,
351
+ clear,
352
+ liveCenter: [center.lng, center.lat],
353
+ clearCenter: [(clear.west + clear.east) / 2, center.lat]
354
+ };
355
+ }
356
+ /**
357
+ * Where focus puts `item`: `pan: false` keeps the camera (a shape in view whose `lngLat` is
358
+ * off-screen, with a point in the clear part); otherwise ease to center `anchor`.
359
+ */
360
+ #placeFor(item, gl) {
361
+ const { geometry, lngLat } = item;
362
+ if (!geometry)
363
+ return { anchor: lngLat, pan: true };
364
+ const { live, clear, liveCenter, clearCenter } = this.#views(gl);
365
+ if (inRect(lngLat, live))
366
+ return { anchor: lngLat, pan: true };
367
+ if (intersectsBounds(item, live)) {
368
+ const inClear = shapeAnchor(geometry, clear, clearCenter);
369
+ if (inClear)
370
+ return { anchor: inClear, pan: false };
371
+ const inLive = shapeAnchor(geometry, live, liveCenter);
372
+ if (inLive)
373
+ return { anchor: inLive, pan: true };
374
+ }
375
+ const settled = untrack(() => this.#bounds);
376
+ if (settled && intersectsBounds(item, settled)) {
377
+ const middle = [
378
+ (settled.west + settled.east) / 2,
379
+ (settled.south + settled.north) / 2
380
+ ];
381
+ const inSettled = shapeAnchor(geometry, settled, middle);
382
+ if (inSettled)
383
+ return { anchor: inSettled, pan: true };
384
+ }
385
+ return { anchor: lngLat, pan: true };
227
386
  }
228
387
  #cameraOptions(target, gl) {
229
388
  const zoom = target.zoom ??
@@ -267,8 +426,12 @@ class MapContextImpl {
267
426
  this.#glState = gl;
268
427
  this.#host.attach(gl);
269
428
  this.#router.attach(gl);
270
- this.basemap.attach(gl, () => this.#host.restyle(), () => {
429
+ gl.on("moveend", this.#onMoveEnd);
430
+ this.basemap.attach(gl,
431
+ // Called from style.load: the style is loaded, though its tiles may not be.
432
+ () => this.#host.restyle(true), () => {
271
433
  this.#ready = false;
434
+ this.#readyRequested = false;
272
435
  this.#host.suspend();
273
436
  });
274
437
  this.#attached = true;
@@ -276,6 +439,10 @@ class MapContextImpl {
276
439
  detach() {
277
440
  if (!this.#gl)
278
441
  return;
442
+ this.#gl.off("moveend", this.#onMoveEnd);
443
+ this.#focusEasing = false;
444
+ this.unpin();
445
+ this.clearNotice();
279
446
  this.#router.detach();
280
447
  this.basemap.detach();
281
448
  this.#host.detach();
@@ -283,6 +450,7 @@ class MapContextImpl {
283
450
  this.#glState = null;
284
451
  this.#attached = false;
285
452
  this.#ready = false;
453
+ this.#readyRequested = false;
286
454
  }
287
455
  themeChanged() {
288
456
  if (this.#gl)
@@ -310,15 +478,29 @@ class MapContextImpl {
310
478
  this.#featureClickHandlers.clear();
311
479
  this.#focusedKey = null;
312
480
  this.#focusedPluginId = null;
481
+ this.#focusedAnchor = null;
313
482
  this.#destroyed = true;
314
483
  }
315
484
  /** A click on a feature: report it, then apply it to the top hit's plugin's selection. */
316
485
  #pick(e) {
317
486
  if (this.#destroyed)
318
487
  return;
319
- const [{ pluginId, ref }] = e.hits;
488
+ const [hit] = e.hits;
489
+ const { pluginId, ref } = hit;
490
+ // A box-zoom Shift-click only counts for a plugin that asked for modifier picks.
491
+ if (e.boxZoom && untrack(() => this.#selections.get(pluginId)?.pickWith) !== "modifier") {
492
+ return;
493
+ }
320
494
  this.#emitClick(pluginId, ref, e.lngLat);
321
- this.#applyPick(pluginId, ref, e.additive);
495
+ this.#applyPick(pluginId, ref, { additive: e.additive, modifier: e.modifier });
496
+ if (!this.#cardEnabled || this.#destroyed)
497
+ return;
498
+ // A feature with no hover card (e.g. a smoke plume) gets no pinned card either.
499
+ const plugin = untrack(() => this.plugins).find((p) => p.id === pluginId);
500
+ if (!plugin || plugin.hoverable === false)
501
+ this.unpin();
502
+ else
503
+ this.#pinned = { hit, lngLat: e.lngLat };
322
504
  }
323
505
  #emitClick(pluginId, ref, lngLat) {
324
506
  for (const handler of [...this.#featureClickHandlers]) {
@@ -331,15 +513,48 @@ class MapContextImpl {
331
513
  }
332
514
  }
333
515
  /** The one place a user's pick (map click or list key) changes a plugin's selection. */
334
- #applyPick(pluginId, ref, additive) {
516
+ /**
517
+ * The one place a user's pick (map click, list key, or the card's toggle) changes a plugin's
518
+ * selection: the plugin's `pickWith` rule, then its `canSelect`.
519
+ */
520
+ #applyPick(pluginId, ref, gesture) {
335
521
  // A featureclick handler may have removed the plugin.
336
522
  const state = untrack(() => this.#selections.get(pluginId));
337
523
  if (!state)
338
524
  return;
339
525
  const current = untrack(() => state.selection);
340
- const next = applyPick(current, ref, untrack(() => state.mode), { additive });
341
- if (sameSelection(current, next))
526
+ const mode = untrack(() => state.mode);
527
+ let next;
528
+ if (gesture.toggle)
529
+ next = toggleInSelection(current, ref, mode);
530
+ else if (untrack(() => state.pickWith) === "modifier") {
531
+ // A plain click or tap only pins the card.
532
+ next = gesture.modifier ? toggleInSelection(current, ref, mode) : current;
533
+ }
534
+ else
535
+ next = applyPick(current, ref, mode, { additive: gesture.additive ?? false });
536
+ if (sameSelection(current, next)) {
537
+ this.clearNotice();
342
538
  return;
539
+ }
540
+ if (state.canSelect) {
541
+ let verdict;
542
+ try {
543
+ verdict = state.canSelect(copySelection(next), ref);
544
+ }
545
+ catch (error) {
546
+ console.error("map-sdk: canSelect failed", error);
547
+ return;
548
+ }
549
+ if (verdict !== true) {
550
+ // An empty (or, from untyped JS, false) verdict refuses quietly.
551
+ if (typeof verdict === "string" && verdict !== "") {
552
+ this.#notice = { message: verdict, id: ++this.#noticeCount };
553
+ }
554
+ return;
555
+ }
556
+ }
557
+ this.clearNotice();
343
558
  state.selection = next;
344
559
  state.onselectionchange?.(copySelection(next));
345
560
  }
package/dist/core/gl.d.ts CHANGED
@@ -87,6 +87,11 @@ export interface GlMapLike {
87
87
  x: number;
88
88
  y: number;
89
89
  };
90
+ /** Position at a canvas pixel. */
91
+ unproject(point: [number, number]): {
92
+ lng: number;
93
+ lat: number;
94
+ };
90
95
  getCanvas(): HTMLCanvasElement;
91
96
  remove(): void;
92
97
  }
@@ -10,21 +10,24 @@ 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. A non-hoverable plugin's features (e.g.
15
+ * smoke plumes) are left out of the hover and cursor as if they weren't rendered, but are still
16
+ * clicked and returned by lookups.
14
17
  */
15
18
  export declare class InteractionRouter {
16
19
  #private;
17
20
  /** The top hit under the pointer and where it is. Reactive, for the tooltip. */
18
21
  get hovered(): Hovered | null;
19
22
  /** Records the layers whose features `toHit` turns into hits. Returns an unregister function. */
20
- register(pluginId: string, layerIds: string[], toHit: (props: Record<string, unknown>) => FeatureHit | null, priority?: number): () => void;
23
+ register(pluginId: string, layerIds: string[], toHit: (props: Record<string, unknown>) => FeatureHit | null, priority?: number, hoverable?: boolean): () => void;
21
24
  /**
22
25
  * Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
23
26
  * nothing (calling `onClick` with that feature instead) and the hover is `toHover`'s hit for
24
27
  * it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
25
28
  * unregister function.
26
29
  */
27
- registerBlocking(pluginId: string, layerIds: string[], toHover?: (props: Record<string, unknown>) => FeatureHit | null, onClick?: (feature: BlockingClick) => void, priority?: number): () => void;
30
+ registerBlocking(pluginId: string, layerIds: string[], toHover?: (props: Record<string, unknown>) => FeatureHit | null, onClick?: (feature: BlockingClick) => void, priority?: number, hoverable?: boolean): () => void;
28
31
  /** Listens to `gl` from now on. */
29
32
  attach(gl: GlMapLike): void;
30
33
  /** Stops listening and clears `hovered`. */
@@ -39,4 +42,9 @@ export declare class InteractionRouter {
39
42
  * click, or a touch tap; selection rules decide what it means for the plugin's mode.
40
43
  */
41
44
  onPick(handler: (e: ClickEvent) => void): () => void;
45
+ /**
46
+ * Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
47
+ * that layer's own click handler). Returns an unsubscribe function.
48
+ */
49
+ onMiss(handler: () => void): () => void;
42
50
  }
@@ -4,13 +4,17 @@
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. A non-hoverable plugin's features (e.g.
9
+ * smoke plumes) are left out of the hover and cursor as if they weren't rendered, but are still
10
+ * clicked and returned by lookups.
8
11
  */
9
12
  export class InteractionRouter {
10
13
  // Bookkeeping only; nothing reads these reactively.
11
14
  /* eslint-disable svelte/prefer-svelte-reactivity */
12
15
  #registrations = new Set();
13
16
  #pickHandlers = new Set();
17
+ #missHandlers = new Set();
14
18
  /* eslint-enable svelte/prefer-svelte-reactivity */
15
19
  #gl = null;
16
20
  #canvas = null;
@@ -27,8 +31,15 @@ export class InteractionRouter {
27
31
  return this.#hovered;
28
32
  }
29
33
  /** 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 });
34
+ register(pluginId, layerIds, toHit, priority = 0, hoverable = true) {
35
+ return this.#add({
36
+ pluginId,
37
+ layerIds: [...layerIds],
38
+ toHit,
39
+ blocking: false,
40
+ priority,
41
+ hoverable
42
+ });
32
43
  }
33
44
  /**
34
45
  * Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
@@ -36,14 +47,15 @@ export class InteractionRouter {
36
47
  * it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
37
48
  * unregister function.
38
49
  */
39
- registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0) {
50
+ registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0, hoverable = true) {
40
51
  return this.#add({
41
52
  pluginId,
42
53
  layerIds: [...layerIds],
43
54
  toHit: toHover ?? (() => null),
44
55
  blocking: true,
45
56
  onClick,
46
- priority
57
+ priority,
58
+ hoverable
47
59
  });
48
60
  }
49
61
  #add(registration) {
@@ -67,6 +79,8 @@ export class InteractionRouter {
67
79
  gl.on("click", this.#onClick);
68
80
  gl.on("mousemove", this.#onMove);
69
81
  gl.on("mouseout", this.#onOut);
82
+ gl.on("boxzoomstart", this.#onBoxZoomStart);
83
+ gl.on("boxzoomcancel", this.#onBoxZoomCancel);
70
84
  this.#canvas = gl.getCanvas();
71
85
  this.#canvas.addEventListener("pointerdown", this.#onPointerDown);
72
86
  }
@@ -78,6 +92,9 @@ export class InteractionRouter {
78
92
  gl.off("click", this.#onClick);
79
93
  gl.off("mousemove", this.#onMove);
80
94
  gl.off("mouseout", this.#onOut);
95
+ gl.off("boxzoomstart", this.#onBoxZoomStart);
96
+ gl.off("boxzoomcancel", this.#onBoxZoomCancel);
97
+ this.#boxDrawn = false;
81
98
  this.#canvas?.removeEventListener("pointerdown", this.#onPointerDown);
82
99
  this.#setPointer(false);
83
100
  this.#canvas = null;
@@ -93,7 +110,8 @@ export class InteractionRouter {
93
110
  const found = this.#lookup(point);
94
111
  return found.blocked ? [] : found.hits;
95
112
  }
96
- #lookup(point) {
113
+ /** What is under one point; with `forHover`, non-hoverable features are left out first. */
114
+ #lookup(point, forHover = false) {
97
115
  const none = { hits: [], hover: null, blocked: false, blocker: null, blockedBy: null };
98
116
  const gl = this.#gl;
99
117
  if (!gl)
@@ -102,6 +120,8 @@ export class InteractionRouter {
102
120
  /* eslint-disable svelte/prefer-svelte-reactivity */
103
121
  const byLayer = new Map();
104
122
  for (const registration of this.#registrations) {
123
+ if (forHover && !registration.hoverable)
124
+ continue;
105
125
  for (const id of registration.layerIds) {
106
126
  // MapLibre errors on querying a missing layer (e.g. mid basemap change).
107
127
  if (!byLayer.has(id) && gl.getLayer(id))
@@ -120,9 +140,25 @@ export class InteractionRouter {
120
140
  if (top?.blocking) {
121
141
  const properties = features[0].properties ?? {};
122
142
  const hit = top.toHit(properties);
143
+ // Clicks stay blocked, but the hover goes to the highest-priority feature beneath that
144
+ // outranks the blocking plugin.
145
+ let beneath = null;
146
+ let beneathPriority = top.priority;
147
+ for (const feature of features.slice(1)) {
148
+ const registration = byLayer.get(feature.layer.id);
149
+ if (!registration || registration.blocking)
150
+ continue;
151
+ if (registration.priority <= beneathPriority)
152
+ continue;
153
+ const below = registration.toHit(feature.properties ?? {});
154
+ if (!below)
155
+ continue;
156
+ beneath = { ...below, pluginId: registration.pluginId };
157
+ beneathPriority = registration.priority;
158
+ }
123
159
  return {
124
160
  hits: [],
125
- hover: null,
161
+ hover: beneath,
126
162
  blocked: true,
127
163
  blocker: hit && { ...hit, pluginId: top.pluginId },
128
164
  blockedBy: { registration: top, feature: { properties, geometry: features[0].geometry } }
@@ -156,6 +192,26 @@ export class InteractionRouter {
156
192
  this.#pickHandlers.delete(handler);
157
193
  };
158
194
  }
195
+ /**
196
+ * Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
197
+ * that layer's own click handler). Returns an unsubscribe function.
198
+ */
199
+ onMiss(handler) {
200
+ this.#missHandlers.add(handler);
201
+ return () => {
202
+ this.#missHandlers.delete(handler);
203
+ };
204
+ }
205
+ #emitMiss() {
206
+ for (const handler of [...this.#missHandlers]) {
207
+ try {
208
+ handler();
209
+ }
210
+ catch (error) {
211
+ console.error("map-sdk: a miss handler failed", error);
212
+ }
213
+ }
214
+ }
159
215
  #onPointerDown = (e) => {
160
216
  this.#lastPointerType = e.pointerType || null;
161
217
  };
@@ -171,16 +227,23 @@ export class InteractionRouter {
171
227
  catch (error) {
172
228
  console.error("map-sdk: a blocking layer's click handler failed", error);
173
229
  }
230
+ this.#emitMiss();
174
231
  return;
175
232
  }
176
233
  const { hits } = found;
177
- if (hits.length === 0)
234
+ if (hits.length === 0) {
235
+ this.#emitMiss();
178
236
  return;
237
+ }
179
238
  const o = e.originalEvent;
180
239
  const pointerType = o?.pointerType || recorded;
240
+ const modifier = Boolean(o?.shiftKey || o?.metaKey || o?.ctrlKey);
241
+ const touch = pointerType === "touch";
181
242
  const event = {
182
243
  hits,
183
- additive: Boolean(o?.shiftKey || o?.metaKey || o?.ctrlKey || pointerType === "touch"),
244
+ additive: modifier || touch,
245
+ modifier,
246
+ touch,
184
247
  lngLat: [e.lngLat.lng, e.lngLat.lat]
185
248
  };
186
249
  for (const handler of [...this.#pickHandlers]) {
@@ -192,9 +255,63 @@ export class InteractionRouter {
192
255
  }
193
256
  }
194
257
  };
258
+ /**
259
+ * MapLibre's box zoom starts on a Shift press and, released in place, suppresses the click and
260
+ * fires `boxzoomcancel`. A release with a pointer position is reported as a Shift-click
261
+ * (`boxZoom: true`) to the pick handlers only: no miss, no blocking layer's click.
262
+ */
263
+ /** A box was drawn since the Shift press: its cancel is a drag back to the start, not a click. */
264
+ #boxDrawn = false;
265
+ #onBoxZoomStart = () => {
266
+ this.#boxDrawn = true;
267
+ };
268
+ #onBoxZoomCancel = (e) => {
269
+ const drawn = this.#boxDrawn;
270
+ this.#boxDrawn = false;
271
+ if (drawn)
272
+ return;
273
+ const o = e.originalEvent;
274
+ const gl = this.#gl;
275
+ if (!gl || typeof o?.clientX !== "number" || typeof o.clientY !== "number")
276
+ return;
277
+ const point = this.#canvasPoint(o.clientX, o.clientY);
278
+ const found = this.#lookup(point);
279
+ if (found.blockedBy || found.hits.length === 0)
280
+ return;
281
+ const { lng, lat } = gl.unproject(point);
282
+ const event = {
283
+ hits: found.hits,
284
+ additive: true,
285
+ modifier: true,
286
+ touch: false,
287
+ boxZoom: true,
288
+ lngLat: [lng, lat]
289
+ };
290
+ for (const handler of [...this.#pickHandlers]) {
291
+ try {
292
+ handler(event);
293
+ }
294
+ catch (error) {
295
+ console.error("map-sdk: a pick handler failed", error);
296
+ }
297
+ }
298
+ };
299
+ /** A page position as a canvas pixel, as MapLibre's `DOM.mousePos` does (CSS scale included). */
300
+ #canvasPoint(clientX, clientY) {
301
+ const canvas = this.#canvas;
302
+ const rect = canvas?.getBoundingClientRect?.();
303
+ if (!canvas || !rect)
304
+ return [clientX, clientY];
305
+ const scaleX = canvas.offsetWidth ? rect.width / canvas.offsetWidth : 1;
306
+ const scaleY = canvas.offsetHeight ? rect.height / canvas.offsetHeight : 1;
307
+ return [
308
+ (clientX - rect.left) / scaleX - (canvas.clientLeft || 0),
309
+ (clientY - rect.top) / scaleY - (canvas.clientTop || 0)
310
+ ];
311
+ }
195
312
  #onMove = (e) => {
196
- const found = this.#lookup([e.point.x, e.point.y]);
197
- const hit = found.blocked ? found.blocker : found.hover;
313
+ const found = this.#lookup([e.point.x, e.point.y], true);
314
+ const hit = found.blocked ? (found.hover ?? found.blocker) : found.hover;
198
315
  this.#hovered = hit ? { hit, lngLat: [e.lngLat.lng, e.lngLat.lat] } : null;
199
316
  this.#setPointer(found.blocked || hit !== null);
200
317
  };