@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.
- package/README.md +243 -17
- package/dist/core/create-map.svelte.d.ts +72 -5
- package/dist/core/create-map.svelte.js +229 -14
- package/dist/core/gl.d.ts +5 -0
- package/dist/core/interactions.svelte.d.ts +11 -3
- package/dist/core/interactions.svelte.js +128 -11
- package/dist/core/plugin-host.svelte.d.ts +30 -5
- package/dist/core/plugin-host.svelte.js +93 -14
- package/dist/core/plugin.d.ts +44 -2
- package/dist/core/shape-view.d.ts +27 -0
- package/dist/core/shape-view.js +323 -0
- package/dist/data/format.d.ts +1 -1
- package/dist/data/format.js +16 -3
- package/dist/data/messages/en.json +12 -2
- package/dist/data/selection.d.ts +6 -0
- package/dist/data/selection.js +21 -0
- package/dist/data/types.d.ts +5 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/plugins/points.svelte.d.ts +14 -2
- package/dist/plugins/points.svelte.js +34 -5
- package/dist/plugins/shapes.svelte.d.ts +5 -0
- package/dist/plugins/shapes.svelte.js +20 -2
- package/dist/presets/FireTooltip.svelte +70 -0
- package/dist/presets/FireTooltip.svelte.d.ts +13 -0
- package/dist/presets/MonitorClusterTooltip.svelte +21 -2
- package/dist/presets/cluster-area.d.ts +13 -0
- package/dist/presets/cluster-area.js +34 -0
- package/dist/presets/hms-fire.d.ts +11 -7
- package/dist/presets/hms-fire.js +16 -17
- package/dist/presets/hms-smoke.d.ts +2 -1
- package/dist/presets/hms-smoke.js +4 -2
- package/dist/stores/hms-stores.svelte.d.ts +6 -3
- package/dist/stores/hms-stores.svelte.js +4 -4
- package/dist/ui/FeatureList.svelte +17 -17
- package/dist/ui/Legend.svelte +58 -16
- package/dist/ui/Legend.svelte.d.ts +9 -0
- package/dist/ui/MapTooltip.svelte +253 -14
- package/dist/ui/MapTooltip.svelte.d.ts +9 -0
- package/dist/ui/PickNotice.svelte +56 -0
- package/dist/ui/PickNotice.svelte.d.ts +7 -0
- package/dist/ui/TooltipItem.svelte +12 -12
- package/dist/ui/card.d.ts +29 -0
- package/dist/ui/card.js +1 -0
- package/dist/ui/feature-list.d.ts +17 -2
- package/dist/ui/feature-list.js +45 -10
- package/dist/ui/value-format.d.ts +3 -2
- package/dist/ui/value-format.js +9 -2
- package/dist/view/MapView.svelte +72 -2
- package/dist/view/MapView.svelte.d.ts +8 -0
- package/dist/view/create-gl-map.js +4 -0
- 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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
341
|
-
|
|
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
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
};
|