@sjvair/map-sdk 0.1.0-beta.7 → 0.1.0-beta.9
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 +26 -1
- package/dist/core/create-map.svelte.d.ts +11 -0
- package/dist/core/create-map.svelte.js +35 -2
- package/dist/core/interactions.svelte.d.ts +5 -0
- package/dist/core/interactions.svelte.js +25 -1
- package/dist/data/messages/en.json +2 -1
- package/dist/ui/MapTooltip.svelte +185 -12
- package/dist/ui/MapTooltip.svelte.d.ts +5 -0
- package/dist/view/MapView.svelte +54 -2
- package/dist/view/MapView.svelte.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,7 +129,13 @@ buttons, `"engine"` the MapTiler SDK's default zoom/compass and locate-me contro
|
|
|
129
129
|
monitor-map uses today), and `"none"` none; the credits show in every case. Choose `controls` up
|
|
130
130
|
front: changing it after mount recreates the map. `tooltip` is `"pinned"` (default: anchored to the
|
|
131
131
|
feature) or `"pointer"` (shown at the pointer, as sjvair.com's map does, and hidden on zoom until
|
|
132
|
-
the pointer moves again); a feature focused from the list is always pinned.
|
|
132
|
+
the pointer moves again); a feature focused from the list is always pinned. `card` (default
|
|
133
|
+
`false`) pins the reading card on a click or tap: the card takes the pointer, has a Close button,
|
|
134
|
+
and closes with Esc, Close, or a click elsewhere on the map; focusing a feature-list row shows the
|
|
135
|
+
same card, and Tab from the row reaches it. Esc closes the card of the map that has focus; with
|
|
136
|
+
focus nowhere, it closes the page's most recently pinned card. One card shows at a time, and no hover card shows while
|
|
137
|
+
one is pinned. Selection and `featureclick` are unchanged: a click still selects as before and
|
|
138
|
+
also pins. `map.pinned` is the pinned feature (reactive), and `map.unpin()` closes it. Anything else goes in
|
|
133
139
|
the `overlays` snippet, where `getMapContext()` returns the map. `config.basemaps` replaces the
|
|
134
140
|
default MapTiler Streets v4 variants (build on `defaultBasemaps()`, which `createMap` uses: today's
|
|
135
141
|
sjvair.com setup is `{ ...defaultBasemaps(), excludedLayers: [] }`); `config.initialView` and
|
|
@@ -558,6 +564,25 @@ local sjvair.com stack at `http://localhost:8000`.
|
|
|
558
564
|
|
|
559
565
|
## Changes
|
|
560
566
|
|
|
567
|
+
### 0.1.0-beta.9
|
|
568
|
+
|
|
569
|
+
- **A map with `tooltip="pointer"` comes back after releasing WebGL.** Setting `MapView`'s
|
|
570
|
+
`active` to `false` threw "Cannot read properties of null (reading 'off')" from the tooltip's
|
|
571
|
+
zoom listener, and the map never drew again until a reload. It now releases and rebuilds cleanly.
|
|
572
|
+
In 0.1.0-beta.7 and 0.1.0-beta.8; maps with the default `tooltip="pinned"` weren't affected.
|
|
573
|
+
|
|
574
|
+
### 0.1.0-beta.8
|
|
575
|
+
|
|
576
|
+
- **The pinned reading card.** `MapView`'s new `card` prop: a click or tap on a feature pins its
|
|
577
|
+
card, which takes the pointer, has a Close button, and closes with Esc, Close, or a click
|
|
578
|
+
elsewhere on the map. Focusing a feature-list row shows the same card, and Tab reaches it; closing
|
|
579
|
+
it returns focus where it came from. With `card`, a tap always shows the reading: without
|
|
580
|
+
it, a tap's emulated `mouseout` (Chromium, WebKit) still hides the hover card right after the
|
|
581
|
+
tap, as before. Nothing changes without `card`, and with it, selection and `featureclick` behave
|
|
582
|
+
as before.
|
|
583
|
+
- **New API:** `MapView`'s `card`, `MapContext.pinned` and `unpin()`. New message key for hosts
|
|
584
|
+
with their own catalog: `mapsdk_card_close` ("Close").
|
|
585
|
+
|
|
561
586
|
### 0.1.0-beta.7
|
|
562
587
|
|
|
563
588
|
- **Plugins come back at once after a basemap switch.** After picking another basemap, monitors,
|
|
@@ -170,6 +170,17 @@ export interface MapContext {
|
|
|
170
170
|
* lists, with the bubble's `properties`, never a feature beneath it.
|
|
171
171
|
*/
|
|
172
172
|
readonly hovered: Hovered | null;
|
|
173
|
+
/**
|
|
174
|
+
* Reactive. The feature whose reading card is pinned, and where, while `MapView`'s `card` is
|
|
175
|
+
* on; `null` otherwise. A click on a feature pins it (selection and `featureclick` are
|
|
176
|
+
* unchanged), focusing a feature-list row pins that row's feature, and a click on nothing (or
|
|
177
|
+
* on a cluster bubble or a feature without a hover card) unpins.
|
|
178
|
+
*/
|
|
179
|
+
readonly pinned: Hovered | null;
|
|
180
|
+
/** Closes the pinned card. Does nothing when nothing is pinned. */
|
|
181
|
+
unpin(): void;
|
|
182
|
+
/** @internal For `MapView` only: turns pinning on or off. Off also unpins. */
|
|
183
|
+
setCardEnabled(on: boolean): void;
|
|
173
184
|
/**
|
|
174
185
|
* Marks the feature focused and pans to it (no animation under reduced motion): the item in
|
|
175
186
|
* `pluginId`'s features when given, else the first plugin listing `refKey`. `null` clears the
|
|
@@ -58,6 +58,8 @@ class MapContextImpl {
|
|
|
58
58
|
#focusedKey = $state(null);
|
|
59
59
|
#focusedPluginId = $state(null);
|
|
60
60
|
#focusedAnchor = $state.raw(null);
|
|
61
|
+
#pinned = $state.raw(null);
|
|
62
|
+
#cardEnabled = false;
|
|
61
63
|
#selections = new SvelteMap();
|
|
62
64
|
#featureClickHandlers = new Set();
|
|
63
65
|
#router = new InteractionRouter();
|
|
@@ -98,6 +100,7 @@ class MapContextImpl {
|
|
|
98
100
|
}
|
|
99
101
|
});
|
|
100
102
|
this.#router.onPick((e) => this.#pick(e));
|
|
103
|
+
this.#router.onMiss(() => this.unpin());
|
|
101
104
|
}
|
|
102
105
|
get camera() {
|
|
103
106
|
return this.#camera;
|
|
@@ -137,6 +140,18 @@ class MapContextImpl {
|
|
|
137
140
|
get hovered() {
|
|
138
141
|
return this.#router.hovered;
|
|
139
142
|
}
|
|
143
|
+
get pinned() {
|
|
144
|
+
return this.#pinned;
|
|
145
|
+
}
|
|
146
|
+
unpin() {
|
|
147
|
+
if (untrack(() => this.#pinned) !== null)
|
|
148
|
+
this.#pinned = null;
|
|
149
|
+
}
|
|
150
|
+
setCardEnabled(on) {
|
|
151
|
+
this.#cardEnabled = on;
|
|
152
|
+
if (!on)
|
|
153
|
+
this.unpin();
|
|
154
|
+
}
|
|
140
155
|
addPlugin(plugin, options = {}) {
|
|
141
156
|
this.#assertAlive();
|
|
142
157
|
const previous = untrack(() => this.#selections.get(plugin.id));
|
|
@@ -171,6 +186,8 @@ class MapContextImpl {
|
|
|
171
186
|
removePlugin(id) {
|
|
172
187
|
this.#host.remove(id);
|
|
173
188
|
this.#selections.delete(id);
|
|
189
|
+
if (untrack(() => this.#pinned)?.hit.pluginId === id)
|
|
190
|
+
this.unpin();
|
|
174
191
|
}
|
|
175
192
|
setSelection(pluginId, selection) {
|
|
176
193
|
if (this.#destroyed)
|
|
@@ -249,9 +266,15 @@ class MapContextImpl {
|
|
|
249
266
|
const found = untrack(() => this.#findItem(refKey, pluginId));
|
|
250
267
|
if (!found)
|
|
251
268
|
return;
|
|
252
|
-
const { item } = found;
|
|
269
|
+
const { plugin, item } = found;
|
|
253
270
|
const place = this.#placeFor(item, gl);
|
|
254
271
|
this.#focusedAnchor = place.anchor;
|
|
272
|
+
if (this.#cardEnabled) {
|
|
273
|
+
this.#pinned = {
|
|
274
|
+
hit: { pluginId: plugin.id, ref: item.ref, refKey: item.refKey, name: item.name },
|
|
275
|
+
lngLat: place.anchor
|
|
276
|
+
};
|
|
277
|
+
}
|
|
255
278
|
if (!place.pan) {
|
|
256
279
|
// Stays where it is now: an earlier focus's ease that is still running stops here.
|
|
257
280
|
// With nothing running, no move at all.
|
|
@@ -395,6 +418,7 @@ class MapContextImpl {
|
|
|
395
418
|
return;
|
|
396
419
|
this.#gl.off("moveend", this.#onMoveEnd);
|
|
397
420
|
this.#focusEasing = false;
|
|
421
|
+
this.unpin();
|
|
398
422
|
this.#router.detach();
|
|
399
423
|
this.basemap.detach();
|
|
400
424
|
this.#host.detach();
|
|
@@ -437,9 +461,18 @@ class MapContextImpl {
|
|
|
437
461
|
#pick(e) {
|
|
438
462
|
if (this.#destroyed)
|
|
439
463
|
return;
|
|
440
|
-
const [
|
|
464
|
+
const [hit] = e.hits;
|
|
465
|
+
const { pluginId, ref } = hit;
|
|
441
466
|
this.#emitClick(pluginId, ref, e.lngLat);
|
|
442
467
|
this.#applyPick(pluginId, ref, e.additive);
|
|
468
|
+
if (!this.#cardEnabled || this.#destroyed)
|
|
469
|
+
return;
|
|
470
|
+
// A feature with no hover card (e.g. a smoke plume) gets no pinned card either.
|
|
471
|
+
const plugin = untrack(() => this.plugins).find((p) => p.id === pluginId);
|
|
472
|
+
if (!plugin || plugin.hoverable === false)
|
|
473
|
+
this.unpin();
|
|
474
|
+
else
|
|
475
|
+
this.#pinned = { hit, lngLat: e.lngLat };
|
|
443
476
|
}
|
|
444
477
|
#emitClick(pluginId, ref, lngLat) {
|
|
445
478
|
for (const handler of [...this.#featureClickHandlers]) {
|
|
@@ -42,4 +42,9 @@ export declare class InteractionRouter {
|
|
|
42
42
|
* click, or a touch tap; selection rules decide what it means for the plugin's mode.
|
|
43
43
|
*/
|
|
44
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;
|
|
45
50
|
}
|
|
@@ -14,6 +14,7 @@ export class InteractionRouter {
|
|
|
14
14
|
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
15
15
|
#registrations = new Set();
|
|
16
16
|
#pickHandlers = new Set();
|
|
17
|
+
#missHandlers = new Set();
|
|
17
18
|
/* eslint-enable svelte/prefer-svelte-reactivity */
|
|
18
19
|
#gl = null;
|
|
19
20
|
#canvas = null;
|
|
@@ -186,6 +187,26 @@ export class InteractionRouter {
|
|
|
186
187
|
this.#pickHandlers.delete(handler);
|
|
187
188
|
};
|
|
188
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
|
|
192
|
+
* that layer's own click handler). Returns an unsubscribe function.
|
|
193
|
+
*/
|
|
194
|
+
onMiss(handler) {
|
|
195
|
+
this.#missHandlers.add(handler);
|
|
196
|
+
return () => {
|
|
197
|
+
this.#missHandlers.delete(handler);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
#emitMiss() {
|
|
201
|
+
for (const handler of [...this.#missHandlers]) {
|
|
202
|
+
try {
|
|
203
|
+
handler();
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
console.error("map-sdk: a miss handler failed", error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
189
210
|
#onPointerDown = (e) => {
|
|
190
211
|
this.#lastPointerType = e.pointerType || null;
|
|
191
212
|
};
|
|
@@ -201,11 +222,14 @@ export class InteractionRouter {
|
|
|
201
222
|
catch (error) {
|
|
202
223
|
console.error("map-sdk: a blocking layer's click handler failed", error);
|
|
203
224
|
}
|
|
225
|
+
this.#emitMiss();
|
|
204
226
|
return;
|
|
205
227
|
}
|
|
206
228
|
const { hits } = found;
|
|
207
|
-
if (hits.length === 0)
|
|
229
|
+
if (hits.length === 0) {
|
|
230
|
+
this.#emitMiss();
|
|
208
231
|
return;
|
|
232
|
+
}
|
|
209
233
|
const o = e.originalEvent;
|
|
210
234
|
const pointerType = o?.pointerType || recorded;
|
|
211
235
|
const event = {
|
|
@@ -56,5 +56,6 @@
|
|
|
56
56
|
"mapsdk_collocation_reference": "Reference monitor",
|
|
57
57
|
"mapsdk_collocation_colocated": "Collocated monitor",
|
|
58
58
|
"mapsdk_collocation_interval_avg": "({interval} avg)",
|
|
59
|
-
"mapsdk_collocation_updated": "Last updated {when}"
|
|
59
|
+
"mapsdk_collocation_updated": "Last updated {when}",
|
|
60
|
+
"mapsdk_card_close": "Close"
|
|
60
61
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from "svelte";
|
|
3
|
+
import type { Attachment } from "svelte/attachments";
|
|
3
4
|
import type { MapContext } from "../core/create-map.svelte.js";
|
|
4
5
|
import type { GlMapLike } from "../core/gl.js";
|
|
5
6
|
import type { FeatureListItem, MapPlugin } from "../core/plugin.js";
|
|
@@ -24,17 +25,27 @@
|
|
|
24
25
|
mode?: "pinned" | "pointer";
|
|
25
26
|
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
26
27
|
gl?: Pick<GlMapLike, "on" | "off"> | null;
|
|
28
|
+
/**
|
|
29
|
+
* The pinned reading card: `map.pinned` and a focused list item show as an interactive card
|
|
30
|
+
* (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
|
|
31
|
+
*/
|
|
32
|
+
card?: boolean;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
let { map, project, width, height, mode = "pinned", gl = null }: Props = $props();
|
|
35
|
+
let { map, project, width, height, mode = "pinned", gl = null, card = false }: Props = $props();
|
|
36
|
+
|
|
37
|
+
// Ignored without the card, so a host's map never pins.
|
|
38
|
+
const pinned = $derived(card ? map.pinned : null);
|
|
30
39
|
|
|
31
40
|
// Pointer mode only: set by a zoom, cleared by the next pointer move over a hit.
|
|
32
41
|
let zoomHidden = $state(false);
|
|
33
42
|
$effect(() => {
|
|
34
|
-
|
|
43
|
+
// Captured: props are live, and the cleanup runs after MapView has set `gl` to null.
|
|
44
|
+
const g = gl;
|
|
45
|
+
if (mode !== "pointer" || !g) return;
|
|
35
46
|
const onZoomStart = () => (zoomHidden = true);
|
|
36
|
-
|
|
37
|
-
return () =>
|
|
47
|
+
g.on("zoomstart", onZoomStart);
|
|
48
|
+
return () => g.off("zoomstart", onZoomStart);
|
|
38
49
|
});
|
|
39
50
|
$effect(() => {
|
|
40
51
|
// A new hovered object comes with every pointer move over a hit.
|
|
@@ -45,11 +56,15 @@
|
|
|
45
56
|
let tipHeight = $state(0);
|
|
46
57
|
const GAP = 12;
|
|
47
58
|
|
|
48
|
-
// The keyboard-focused list item wins
|
|
49
|
-
|
|
59
|
+
// The keyboard-focused list item wins, then the pinned card, then the pointer (which shows
|
|
60
|
+
// nothing while a card is pinned).
|
|
61
|
+
const shownHit = $derived(map.focusedKey ? null : (pinned ?? map.hovered));
|
|
62
|
+
const key = $derived(map.focusedKey ?? shownHit?.hit.refKey ?? null);
|
|
50
63
|
// Primitives, so moving the pointer within one feature doesn't recompute the content.
|
|
51
|
-
const hitPluginId = $derived(
|
|
52
|
-
const hitName = $derived(
|
|
64
|
+
const hitPluginId = $derived(shownHit?.hit.pluginId ?? null);
|
|
65
|
+
const hitName = $derived(shownHit?.hit.name ?? null);
|
|
66
|
+
// The card shows (interactive) for a focused item or a pinned feature.
|
|
67
|
+
const interactive = $derived(card && (map.focusedKey !== null || pinned !== null));
|
|
53
68
|
|
|
54
69
|
// Reads only the shown feature's plugin (or all plugins for a focused key), and only while
|
|
55
70
|
// something is shown, so an idle map doesn't subscribe to any plugin's data.
|
|
@@ -83,12 +98,20 @@
|
|
|
83
98
|
return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
|
|
84
99
|
});
|
|
85
100
|
const anchor = $derived.by((): [number, number] | null => {
|
|
86
|
-
if (mode === "pointer" && !map.focusedKey) return map.hovered?.lngLat ?? null;
|
|
101
|
+
if (mode === "pointer" && !map.focusedKey && !pinned) return map.hovered?.lngLat ?? null;
|
|
87
102
|
// A focused feature that left the data has no item, so no tooltip.
|
|
88
103
|
if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
|
|
89
|
-
return hoverAnchor ?? item?.lngLat ??
|
|
104
|
+
return hoverAnchor ?? item?.lngLat ?? shownHit?.lngLat ?? null;
|
|
90
105
|
});
|
|
91
106
|
const projected = $derived(anchor ? project(anchor) : null);
|
|
107
|
+
// A card whose anchor is out of the map's box hides until it's back.
|
|
108
|
+
const offScreen = $derived(
|
|
109
|
+
interactive &&
|
|
110
|
+
projected !== null &&
|
|
111
|
+
width !== undefined &&
|
|
112
|
+
height !== undefined &&
|
|
113
|
+
(projected.x < 0 || projected.y < 0 || projected.x > width || projected.y > height)
|
|
114
|
+
);
|
|
92
115
|
// Centered above the point; flipped below it near the top edge; inside the container.
|
|
93
116
|
const position = $derived.by(() => {
|
|
94
117
|
if (!projected) return null;
|
|
@@ -105,7 +128,7 @@
|
|
|
105
128
|
// A hovered hit no plugin lists (e.g. a cluster bubble): its plugin's hover content.
|
|
106
129
|
if (key === null || hitPluginId === null) return undefined;
|
|
107
130
|
const plugin = map.plugins.find((p) => p.id === hitPluginId);
|
|
108
|
-
const hit = untrack(() =>
|
|
131
|
+
const hit = untrack(() => shownHit?.hit);
|
|
109
132
|
return hit && plugin?.hoverTooltip?.(hit);
|
|
110
133
|
});
|
|
111
134
|
|
|
@@ -116,10 +139,128 @@
|
|
|
116
139
|
const plugin = found?.plugin ?? map.plugins.find((p) => p.id === hitPluginId);
|
|
117
140
|
return !plugin?.tooltipShowsName;
|
|
118
141
|
});
|
|
142
|
+
|
|
143
|
+
let cardEl: HTMLDivElement | undefined = $state();
|
|
144
|
+
/** Where focus came from when it moved into the card, to return it on close. */
|
|
145
|
+
let origin: HTMLElement | null = null;
|
|
146
|
+
|
|
147
|
+
function onfocusin(e: FocusEvent) {
|
|
148
|
+
const from = e.relatedTarget;
|
|
149
|
+
focusInside = true;
|
|
150
|
+
region = cardEl?.closest<HTMLElement>("[data-mapsdk-map]") ?? region;
|
|
151
|
+
// Focus from outside sets the origin afresh, even from nowhere, so an earlier card's
|
|
152
|
+
// origin is never reused.
|
|
153
|
+
if (!(from instanceof Node && cardEl?.contains(from))) {
|
|
154
|
+
origin = from instanceof HTMLElement ? from : null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Focus is inside the card (kept by focusin/focusout, since a removed card no longer says). */
|
|
159
|
+
let focusInside = false;
|
|
160
|
+
/** The map region the card is in, found while it's in the document. */
|
|
161
|
+
let region: HTMLElement | null = null;
|
|
162
|
+
|
|
163
|
+
function onfocusout(e: FocusEvent) {
|
|
164
|
+
const to = e.relatedTarget;
|
|
165
|
+
if (to instanceof Node && cardEl?.contains(to)) return;
|
|
166
|
+
// Later, not now: removing the focused card fires focusout too, and its teardown (which
|
|
167
|
+
// runs first) still has to see that focus was inside.
|
|
168
|
+
queueMicrotask(() => {
|
|
169
|
+
if (!cardEl?.contains(document.activeElement)) focusInside = false;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Focus inside the card goes back where it came from, else to the map region. */
|
|
174
|
+
function handBack() {
|
|
175
|
+
if (!focusInside) return;
|
|
176
|
+
focusInside = false;
|
|
177
|
+
const back = origin;
|
|
178
|
+
origin = null;
|
|
179
|
+
if (back?.isConnected) back.focus();
|
|
180
|
+
else if (region?.isConnected) {
|
|
181
|
+
if (!region.hasAttribute("tabindex")) region.setAttribute("tabindex", "-1");
|
|
182
|
+
region.focus();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// However the card goes (Close, a host's unpin, its plugin removed, its anchor off-screen),
|
|
187
|
+
// focus inside it isn't left on a removed node.
|
|
188
|
+
const handBackOnRemove: Attachment<HTMLElement> = () => () => handBack();
|
|
189
|
+
|
|
190
|
+
/** Unpins, handing focus back first. */
|
|
191
|
+
function close() {
|
|
192
|
+
handBack();
|
|
193
|
+
map.unpin();
|
|
194
|
+
// Focus back on a list row focuses (and so shows) that row again; the card stays closed
|
|
195
|
+
// until the next row move, as with Esc in the list.
|
|
196
|
+
if (map.focusedKey !== null) map.focusFeature(null);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function onkeydown(e: KeyboardEvent) {
|
|
200
|
+
if (e.key !== "Escape") return;
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
e.stopPropagation();
|
|
203
|
+
close();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A pinned hit with nothing to show (no name, listed item, or hover content) would be an
|
|
207
|
+
// invisible card that still hides the hover and takes Esc: unpin it.
|
|
208
|
+
$effect(() => {
|
|
209
|
+
if (pinned && !map.focusedKey && !(name || item || custom)) untrack(() => map.unpin());
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// A pinned feature that left its plugin's data (a poll dropped it) closes its card. Only one
|
|
213
|
+
// its plugin listed while pinned: some hits are never listed (HMS fire hotspots), and keep it.
|
|
214
|
+
let listedKey: string | null = null;
|
|
215
|
+
$effect(() => {
|
|
216
|
+
if (!pinned || map.focusedKey) return;
|
|
217
|
+
const { pluginId, refKey } = pinned.hit;
|
|
218
|
+
const plugin = map.plugins.find((p) => p.id === pluginId);
|
|
219
|
+
if (!plugin?.features) return;
|
|
220
|
+
const id = `${pluginId}\n${refKey}`;
|
|
221
|
+
if (plugin.features().some((f) => f.refKey === refKey)) listedKey = id;
|
|
222
|
+
else if (listedKey === id) {
|
|
223
|
+
listedKey = null;
|
|
224
|
+
untrack(close);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
119
227
|
</script>
|
|
120
228
|
|
|
121
229
|
<!-- Only with something to show: a hit with no name, item, or content gets no empty box. -->
|
|
122
|
-
{#if (name || item || custom) && position && !
|
|
230
|
+
{#if interactive && (name || item || custom) && position && !offScreen}
|
|
231
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
232
|
+
<div
|
|
233
|
+
bind:this={cardEl}
|
|
234
|
+
{@attach handBackOnRemove}
|
|
235
|
+
class="mapsdk-tooltip mapsdk-card text-sm"
|
|
236
|
+
role="group"
|
|
237
|
+
aria-label={name ?? undefined}
|
|
238
|
+
bind:offsetWidth={tipWidth}
|
|
239
|
+
bind:offsetHeight={tipHeight}
|
|
240
|
+
style:left="{position.left}px"
|
|
241
|
+
style:top="{position.top}px"
|
|
242
|
+
{onfocusin}
|
|
243
|
+
{onfocusout}
|
|
244
|
+
{onkeydown}
|
|
245
|
+
>
|
|
246
|
+
{#if showName}
|
|
247
|
+
<div class="mapsdk-tooltip-name">{name}</div>
|
|
248
|
+
{/if}
|
|
249
|
+
{#if custom}
|
|
250
|
+
{@render custom()}
|
|
251
|
+
{:else if item}
|
|
252
|
+
<TooltipItem {item} messages={map.messages} format={map.format} />
|
|
253
|
+
{/if}
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
class="mapsdk-card-close mapsdk-focus"
|
|
257
|
+
aria-label={map.messages("mapsdk_card_close")}
|
|
258
|
+
onclick={close}
|
|
259
|
+
>
|
|
260
|
+
<span aria-hidden="true">×</span>
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
{:else if !interactive && (name || item || custom) && position && !(zoomHidden && !map.focusedKey)}
|
|
123
264
|
<div
|
|
124
265
|
class="mapsdk-tooltip text-sm"
|
|
125
266
|
role="tooltip"
|
|
@@ -168,4 +309,36 @@
|
|
|
168
309
|
.mapsdk-tooltip-name {
|
|
169
310
|
font-weight: 600;
|
|
170
311
|
}
|
|
312
|
+
|
|
313
|
+
/* The pinned card: takes the pointer, and sits above the feature list panel. Room on the
|
|
314
|
+
right for the Close button. */
|
|
315
|
+
.mapsdk-card {
|
|
316
|
+
z-index: 3;
|
|
317
|
+
padding-right: 2rem;
|
|
318
|
+
pointer-events: auto;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.mapsdk-card-close {
|
|
322
|
+
position: absolute;
|
|
323
|
+
top: 0.25rem;
|
|
324
|
+
right: 0.25rem;
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
justify-content: center;
|
|
328
|
+
width: 1.5rem;
|
|
329
|
+
height: 1.5rem;
|
|
330
|
+
padding: 0;
|
|
331
|
+
border: 0;
|
|
332
|
+
border-radius: var(--radius-control, var(--radius));
|
|
333
|
+
background: transparent;
|
|
334
|
+
color: inherit;
|
|
335
|
+
font: inherit;
|
|
336
|
+
font-size: 1.125rem;
|
|
337
|
+
line-height: 1;
|
|
338
|
+
cursor: pointer;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.mapsdk-card-close:hover {
|
|
342
|
+
background: var(--accent, var(--muted));
|
|
343
|
+
}
|
|
171
344
|
</style>
|
|
@@ -22,6 +22,11 @@ interface Props {
|
|
|
22
22
|
mode?: "pinned" | "pointer";
|
|
23
23
|
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
24
24
|
gl?: Pick<GlMapLike, "on" | "off"> | null;
|
|
25
|
+
/**
|
|
26
|
+
* The pinned reading card: `map.pinned` and a focused list item show as an interactive card
|
|
27
|
+
* (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
|
|
28
|
+
*/
|
|
29
|
+
card?: boolean;
|
|
25
30
|
}
|
|
26
31
|
declare const MapTooltip: import("svelte").Component<Props, {}, "">;
|
|
27
32
|
type MapTooltip = ReturnType<typeof MapTooltip>;
|
package/dist/view/MapView.svelte
CHANGED
|
@@ -56,6 +56,12 @@
|
|
|
56
56
|
* Keyboard focus from the feature list always shows it pinned.
|
|
57
57
|
*/
|
|
58
58
|
tooltip?: "pinned" | "pointer";
|
|
59
|
+
/**
|
|
60
|
+
* `true`: a click or tap on a feature pins its reading card, which takes the pointer and
|
|
61
|
+
* closes with Esc, its Close button, or a click elsewhere on the map; focusing a
|
|
62
|
+
* feature-list row shows the same card. Selection and `featureclick` are unchanged.
|
|
63
|
+
*/
|
|
64
|
+
card?: boolean;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
let {
|
|
@@ -67,9 +73,46 @@
|
|
|
67
73
|
featureList = true,
|
|
68
74
|
selectionBar = true,
|
|
69
75
|
controls = "mapsdk",
|
|
70
|
-
tooltip = "pinned"
|
|
76
|
+
tooltip = "pinned",
|
|
77
|
+
card = false
|
|
71
78
|
}: Props = $props();
|
|
72
79
|
|
|
80
|
+
// Pinning only, no plugin setup, so it's safe in an effect.
|
|
81
|
+
$effect(() => {
|
|
82
|
+
map.setCardEnabled(card);
|
|
83
|
+
return () => map.setCardEnabled(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Esc outside the card closes it when focus is in this map or nowhere (the body), so a key
|
|
88
|
+
* pressed in another map, or elsewhere on the page, leaves this card alone.
|
|
89
|
+
*/
|
|
90
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
91
|
+
if (e.key !== "Escape" || e.defaultPrevented || !map.pinned) return;
|
|
92
|
+
const active = document.activeElement;
|
|
93
|
+
if (active && active !== document.body && !root.contains(active)) return;
|
|
94
|
+
// Focus nowhere: only the most recently pinned card on the page closes.
|
|
95
|
+
if (!active || active === document.body) {
|
|
96
|
+
let latest: HTMLElement | null = null;
|
|
97
|
+
for (const el of document.querySelectorAll<HTMLElement>("[data-mapsdk-pinned-at]")) {
|
|
98
|
+
if (!latest || Number(el.dataset.mapsdkPinnedAt) > Number(latest.dataset.mapsdkPinnedAt)) {
|
|
99
|
+
latest = el;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (latest && latest !== root) return;
|
|
103
|
+
}
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
map.unpin();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// When this map's card was pinned, so Esc with focus nowhere closes the latest card only.
|
|
109
|
+
$effect(() => {
|
|
110
|
+
const pinned = map.pinned;
|
|
111
|
+
if (!root) return;
|
|
112
|
+
if (pinned) root.dataset.mapsdkPinnedAt = String(performance.now());
|
|
113
|
+
else delete root.dataset.mapsdkPinnedAt;
|
|
114
|
+
});
|
|
115
|
+
|
|
73
116
|
const list = $derived(resolveFeatureList(featureList));
|
|
74
117
|
const sides = $derived(list ? cornerSides(list.position) : null);
|
|
75
118
|
|
|
@@ -148,6 +191,12 @@
|
|
|
148
191
|
function onListKeydown(e: KeyboardEvent) {
|
|
149
192
|
if (e.key !== "Escape") return;
|
|
150
193
|
e.preventDefault();
|
|
194
|
+
// The card closes first (and the focused row's card with it); the next Esc closes the panel.
|
|
195
|
+
if (card && (map.pinned || map.focusedKey)) {
|
|
196
|
+
map.unpin();
|
|
197
|
+
map.focusFeature(null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
151
200
|
listOpen = false;
|
|
152
201
|
listButton?.focus();
|
|
153
202
|
}
|
|
@@ -242,6 +291,8 @@
|
|
|
242
291
|
});
|
|
243
292
|
</script>
|
|
244
293
|
|
|
294
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
295
|
+
|
|
245
296
|
<div
|
|
246
297
|
bind:this={root}
|
|
247
298
|
bind:clientWidth={width}
|
|
@@ -253,7 +304,6 @@
|
|
|
253
304
|
style:--mapsdk-map-width={width > 0 ? `${width}px` : undefined}
|
|
254
305
|
>
|
|
255
306
|
<div class="mapsdk-gl" bind:this={glContainer}></div>
|
|
256
|
-
<MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} />
|
|
257
307
|
{#if list && sides}
|
|
258
308
|
<div
|
|
259
309
|
class="mapsdk-list-panel"
|
|
@@ -283,6 +333,8 @@
|
|
|
283
333
|
{/if}
|
|
284
334
|
</div>
|
|
285
335
|
{/if}
|
|
336
|
+
<!-- After the list, so Tab from a list row reaches the pinned card's Close button. -->
|
|
337
|
+
<MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} {card} />
|
|
286
338
|
<!-- After the list, so Tab reaches the List button first. -->
|
|
287
339
|
{#if selectionBar}
|
|
288
340
|
<SelectionBar {map} clearRight={list?.position === "top-right" && controls === "none"} />
|
|
@@ -37,6 +37,12 @@ interface Props {
|
|
|
37
37
|
* Keyboard focus from the feature list always shows it pinned.
|
|
38
38
|
*/
|
|
39
39
|
tooltip?: "pinned" | "pointer";
|
|
40
|
+
/**
|
|
41
|
+
* `true`: a click or tap on a feature pins its reading card, which takes the pointer and
|
|
42
|
+
* closes with Esc, its Close button, or a click elsewhere on the map; focusing a
|
|
43
|
+
* feature-list row shows the same card. Selection and `featureclick` are unchanged.
|
|
44
|
+
*/
|
|
45
|
+
card?: boolean;
|
|
40
46
|
}
|
|
41
47
|
declare const MapView: import("svelte").Component<Props, {}, "">;
|
|
42
48
|
type MapView = ReturnType<typeof MapView>;
|