@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
|
@@ -1,9 +1,14 @@
|
|
|
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";
|
|
6
7
|
import TooltipItem from "./TooltipItem.svelte";
|
|
8
|
+
import type { Snippet } from "svelte";
|
|
9
|
+
import type { CardContext } from "./card.js";
|
|
10
|
+
import { selectionKeys } from "../data/selection.js";
|
|
11
|
+
import { selectionId } from "../data/refs.js";
|
|
7
12
|
|
|
8
13
|
interface Props {
|
|
9
14
|
map: MapContext;
|
|
@@ -24,17 +29,38 @@
|
|
|
24
29
|
mode?: "pinned" | "pointer";
|
|
25
30
|
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
26
31
|
gl?: Pick<GlMapLike, "on" | "off"> | null;
|
|
32
|
+
/**
|
|
33
|
+
* The pinned reading card: `map.pinned` and a focused list item show as an interactive card
|
|
34
|
+
* (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
|
|
35
|
+
*/
|
|
36
|
+
card?: boolean;
|
|
37
|
+
/** The host's buttons on the card (`MapView`'s `card.actions`). */
|
|
38
|
+
actions?: Snippet<[CardContext]>;
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
let {
|
|
41
|
+
let {
|
|
42
|
+
map,
|
|
43
|
+
project,
|
|
44
|
+
width,
|
|
45
|
+
height,
|
|
46
|
+
mode = "pinned",
|
|
47
|
+
gl = null,
|
|
48
|
+
card = false,
|
|
49
|
+
actions
|
|
50
|
+
}: Props = $props();
|
|
51
|
+
|
|
52
|
+
// Ignored without the card, so a host's map never pins.
|
|
53
|
+
const pinned = $derived(card ? map.pinned : null);
|
|
30
54
|
|
|
31
55
|
// Pointer mode only: set by a zoom, cleared by the next pointer move over a hit.
|
|
32
56
|
let zoomHidden = $state(false);
|
|
33
57
|
$effect(() => {
|
|
34
|
-
|
|
58
|
+
// Captured: props are live, and the cleanup runs after MapView has set `gl` to null.
|
|
59
|
+
const g = gl;
|
|
60
|
+
if (mode !== "pointer" || !g) return;
|
|
35
61
|
const onZoomStart = () => (zoomHidden = true);
|
|
36
|
-
|
|
37
|
-
return () =>
|
|
62
|
+
g.on("zoomstart", onZoomStart);
|
|
63
|
+
return () => g.off("zoomstart", onZoomStart);
|
|
38
64
|
});
|
|
39
65
|
$effect(() => {
|
|
40
66
|
// A new hovered object comes with every pointer move over a hit.
|
|
@@ -45,11 +71,15 @@
|
|
|
45
71
|
let tipHeight = $state(0);
|
|
46
72
|
const GAP = 12;
|
|
47
73
|
|
|
48
|
-
// The keyboard-focused list item wins
|
|
49
|
-
|
|
74
|
+
// The keyboard-focused list item wins, then the pinned card, then the pointer (which shows
|
|
75
|
+
// nothing while a card is pinned).
|
|
76
|
+
const shownHit = $derived(map.focusedKey ? null : (pinned ?? map.hovered));
|
|
77
|
+
const key = $derived(map.focusedKey ?? shownHit?.hit.refKey ?? null);
|
|
50
78
|
// Primitives, so moving the pointer within one feature doesn't recompute the content.
|
|
51
|
-
const hitPluginId = $derived(
|
|
52
|
-
const hitName = $derived(
|
|
79
|
+
const hitPluginId = $derived(shownHit?.hit.pluginId ?? null);
|
|
80
|
+
const hitName = $derived(shownHit?.hit.name ?? null);
|
|
81
|
+
// The card shows (interactive) for a focused item or a pinned feature.
|
|
82
|
+
const interactive = $derived(card && (map.focusedKey !== null || pinned !== null));
|
|
53
83
|
|
|
54
84
|
// Reads only the shown feature's plugin (or all plugins for a focused key), and only while
|
|
55
85
|
// something is shown, so an idle map doesn't subscribe to any plugin's data.
|
|
@@ -70,12 +100,33 @@
|
|
|
70
100
|
// A found item anchors the tooltip at its point (stable while the pointer moves over it).
|
|
71
101
|
// In pointer mode a hovered hit is anchored at the pointer instead (a focused item stays
|
|
72
102
|
// pinned).
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
103
|
+
// A hovered shape whose point is off-screen pins at a point of it in view, found once per
|
|
104
|
+
// hovered feature (keyed on primitives), so pointer moves within it don't move the tooltip.
|
|
105
|
+
// It's found again when the map settles (`bounds`), so a pan or zoom under a resting pointer
|
|
106
|
+
// doesn't leave it off-screen.
|
|
107
|
+
const hoverKey = $derived(
|
|
108
|
+
!map.focusedKey && item && hitPluginId ? `${hitPluginId}\n${item.refKey}` : null
|
|
77
109
|
);
|
|
110
|
+
const hoverAnchor = $derived.by(() => {
|
|
111
|
+
if (hoverKey === null) return null;
|
|
112
|
+
void map.bounds;
|
|
113
|
+
return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
|
|
114
|
+
});
|
|
115
|
+
const anchor = $derived.by((): [number, number] | null => {
|
|
116
|
+
if (mode === "pointer" && !map.focusedKey && !pinned) return map.hovered?.lngLat ?? null;
|
|
117
|
+
// A focused feature that left the data has no item, so no tooltip.
|
|
118
|
+
if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
|
|
119
|
+
return hoverAnchor ?? item?.lngLat ?? shownHit?.lngLat ?? null;
|
|
120
|
+
});
|
|
78
121
|
const projected = $derived(anchor ? project(anchor) : null);
|
|
122
|
+
// A card whose anchor is out of the map's box hides until it's back.
|
|
123
|
+
const offScreen = $derived(
|
|
124
|
+
interactive &&
|
|
125
|
+
projected !== null &&
|
|
126
|
+
width !== undefined &&
|
|
127
|
+
height !== undefined &&
|
|
128
|
+
(projected.x < 0 || projected.y < 0 || projected.x > width || projected.y > height)
|
|
129
|
+
);
|
|
79
130
|
// Centered above the point; flipped below it near the top edge; inside the container.
|
|
80
131
|
const position = $derived.by(() => {
|
|
81
132
|
if (!projected) return null;
|
|
@@ -92,7 +143,7 @@
|
|
|
92
143
|
// A hovered hit no plugin lists (e.g. a cluster bubble): its plugin's hover content.
|
|
93
144
|
if (key === null || hitPluginId === null) return undefined;
|
|
94
145
|
const plugin = map.plugins.find((p) => p.id === hitPluginId);
|
|
95
|
-
const hit = untrack(() =>
|
|
146
|
+
const hit = untrack(() => shownHit?.hit);
|
|
96
147
|
return hit && plugin?.hoverTooltip?.(hit);
|
|
97
148
|
});
|
|
98
149
|
|
|
@@ -103,10 +154,159 @@
|
|
|
103
154
|
const plugin = found?.plugin ?? map.plugins.find((p) => p.id === hitPluginId);
|
|
104
155
|
return !plugin?.tooltipShowsName;
|
|
105
156
|
});
|
|
157
|
+
|
|
158
|
+
// The card's feature for the host's actions: the focused item, or the pinned hit.
|
|
159
|
+
const cardContext = $derived.by((): CardContext | null => {
|
|
160
|
+
if (!interactive || !actions) return null;
|
|
161
|
+
const pluginId = found?.plugin.id ?? hitPluginId;
|
|
162
|
+
const ref = item?.ref ?? shownHit?.hit.ref;
|
|
163
|
+
const refKey = key;
|
|
164
|
+
if (!pluginId || !ref || refKey === null) return null;
|
|
165
|
+
return {
|
|
166
|
+
pluginId,
|
|
167
|
+
ref,
|
|
168
|
+
refKey,
|
|
169
|
+
item,
|
|
170
|
+
get selectable() {
|
|
171
|
+
return (
|
|
172
|
+
map.getSelectionMode(pluginId) !== "none" &&
|
|
173
|
+
selectionId(ref) !== null &&
|
|
174
|
+
(item?.selectable ?? true)
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
get selected() {
|
|
178
|
+
return selectionKeys(map.getSelection(pluginId)).has(refKey);
|
|
179
|
+
},
|
|
180
|
+
toggle() {
|
|
181
|
+
if (this.selectable) map.togglePick(pluginId, ref);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
let cardEl: HTMLDivElement | undefined = $state();
|
|
187
|
+
/** Where focus came from when it moved into the card, to return it on close. */
|
|
188
|
+
let origin: HTMLElement | null = null;
|
|
189
|
+
|
|
190
|
+
function onfocusin(e: FocusEvent) {
|
|
191
|
+
const from = e.relatedTarget;
|
|
192
|
+
focusInside = true;
|
|
193
|
+
region = cardEl?.closest<HTMLElement>("[data-mapsdk-map]") ?? region;
|
|
194
|
+
// Focus from outside sets the origin afresh, even from nowhere, so an earlier card's
|
|
195
|
+
// origin is never reused.
|
|
196
|
+
if (!(from instanceof Node && cardEl?.contains(from))) {
|
|
197
|
+
origin = from instanceof HTMLElement ? from : null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Focus is inside the card (kept by focusin/focusout, since a removed card no longer says). */
|
|
202
|
+
let focusInside = false;
|
|
203
|
+
/** The map region the card is in, found while it's in the document. */
|
|
204
|
+
let region: HTMLElement | null = null;
|
|
205
|
+
|
|
206
|
+
function onfocusout(e: FocusEvent) {
|
|
207
|
+
const to = e.relatedTarget;
|
|
208
|
+
if (to instanceof Node && cardEl?.contains(to)) return;
|
|
209
|
+
// Later, not now: removing the focused card fires focusout too, and its teardown (which
|
|
210
|
+
// runs first) still has to see that focus was inside.
|
|
211
|
+
queueMicrotask(() => {
|
|
212
|
+
if (!cardEl?.contains(document.activeElement)) focusInside = false;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Focus inside the card goes back where it came from, else to the map region. */
|
|
217
|
+
function handBack() {
|
|
218
|
+
if (!focusInside) return;
|
|
219
|
+
focusInside = false;
|
|
220
|
+
const back = origin;
|
|
221
|
+
origin = null;
|
|
222
|
+
if (back?.isConnected) back.focus();
|
|
223
|
+
else if (region?.isConnected) {
|
|
224
|
+
if (!region.hasAttribute("tabindex")) region.setAttribute("tabindex", "-1");
|
|
225
|
+
region.focus();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// However the card goes (Close, a host's unpin, its plugin removed, its anchor off-screen),
|
|
230
|
+
// focus inside it isn't left on a removed node.
|
|
231
|
+
const handBackOnRemove: Attachment<HTMLElement> = () => () => handBack();
|
|
232
|
+
|
|
233
|
+
/** Unpins, handing focus back first. */
|
|
234
|
+
function close() {
|
|
235
|
+
handBack();
|
|
236
|
+
map.unpin();
|
|
237
|
+
// Focus back on a list row focuses (and so shows) that row again; the card stays closed
|
|
238
|
+
// until the next row move, as with Esc in the list.
|
|
239
|
+
if (map.focusedKey !== null) map.focusFeature(null);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function onkeydown(e: KeyboardEvent) {
|
|
243
|
+
if (e.key !== "Escape") return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
close();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A pinned hit with nothing to show (no name, listed item, or hover content) would be an
|
|
250
|
+
// invisible card that still hides the hover and takes Esc: unpin it.
|
|
251
|
+
$effect(() => {
|
|
252
|
+
if (pinned && !map.focusedKey && !(name || item || custom)) untrack(() => map.unpin());
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// A pinned feature that left its plugin's data (a poll dropped it) closes its card. Only one
|
|
256
|
+
// its plugin listed while pinned: some hits are never listed (HMS fire hotspots), and keep it.
|
|
257
|
+
let listedKey: string | null = null;
|
|
258
|
+
$effect(() => {
|
|
259
|
+
if (!pinned || map.focusedKey) return;
|
|
260
|
+
const { pluginId, refKey } = pinned.hit;
|
|
261
|
+
const plugin = map.plugins.find((p) => p.id === pluginId);
|
|
262
|
+
if (!plugin?.features) return;
|
|
263
|
+
const id = `${pluginId}\n${refKey}`;
|
|
264
|
+
if (plugin.features().some((f) => f.refKey === refKey)) listedKey = id;
|
|
265
|
+
else if (listedKey === id) {
|
|
266
|
+
listedKey = null;
|
|
267
|
+
untrack(close);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
106
270
|
</script>
|
|
107
271
|
|
|
108
272
|
<!-- Only with something to show: a hit with no name, item, or content gets no empty box. -->
|
|
109
|
-
{#if (name || item || custom) && position && !
|
|
273
|
+
{#if interactive && (name || item || custom) && position && !offScreen}
|
|
274
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
275
|
+
<div
|
|
276
|
+
bind:this={cardEl}
|
|
277
|
+
{@attach handBackOnRemove}
|
|
278
|
+
class="mapsdk-tooltip mapsdk-card text-sm"
|
|
279
|
+
role="group"
|
|
280
|
+
aria-label={name ?? undefined}
|
|
281
|
+
bind:offsetWidth={tipWidth}
|
|
282
|
+
bind:offsetHeight={tipHeight}
|
|
283
|
+
style:left="{position.left}px"
|
|
284
|
+
style:top="{position.top}px"
|
|
285
|
+
{onfocusin}
|
|
286
|
+
{onfocusout}
|
|
287
|
+
{onkeydown}
|
|
288
|
+
>
|
|
289
|
+
{#if showName}
|
|
290
|
+
<div class="mapsdk-tooltip-name">{name}</div>
|
|
291
|
+
{/if}
|
|
292
|
+
{#if custom}
|
|
293
|
+
{@render custom()}
|
|
294
|
+
{:else if item}
|
|
295
|
+
<TooltipItem {item} messages={map.messages} format={map.format} />
|
|
296
|
+
{/if}
|
|
297
|
+
{#if actions && cardContext}
|
|
298
|
+
<div class="mapsdk-card-actions">{@render actions(cardContext)}</div>
|
|
299
|
+
{/if}
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
class="mapsdk-card-close mapsdk-focus"
|
|
303
|
+
aria-label={map.messages("mapsdk_card_close")}
|
|
304
|
+
onclick={close}
|
|
305
|
+
>
|
|
306
|
+
<span aria-hidden="true">×</span>
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
{:else if !interactive && (name || item || custom) && position && !(zoomHidden && !map.focusedKey)}
|
|
110
310
|
<div
|
|
111
311
|
class="mapsdk-tooltip text-sm"
|
|
112
312
|
role="tooltip"
|
|
@@ -155,4 +355,43 @@
|
|
|
155
355
|
.mapsdk-tooltip-name {
|
|
156
356
|
font-weight: 600;
|
|
157
357
|
}
|
|
358
|
+
|
|
359
|
+
/* The pinned card: takes the pointer, and sits above the feature list panel. Room on the
|
|
360
|
+
right for the Close button. */
|
|
361
|
+
.mapsdk-card {
|
|
362
|
+
z-index: 3;
|
|
363
|
+
padding-right: 2rem;
|
|
364
|
+
pointer-events: auto;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.mapsdk-card-actions {
|
|
368
|
+
display: flex;
|
|
369
|
+
flex-wrap: wrap;
|
|
370
|
+
gap: 0.5rem;
|
|
371
|
+
margin-top: 0.5rem;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.mapsdk-card-close {
|
|
375
|
+
position: absolute;
|
|
376
|
+
top: 0.25rem;
|
|
377
|
+
right: 0.25rem;
|
|
378
|
+
display: flex;
|
|
379
|
+
align-items: center;
|
|
380
|
+
justify-content: center;
|
|
381
|
+
width: 1.5rem;
|
|
382
|
+
height: 1.5rem;
|
|
383
|
+
padding: 0;
|
|
384
|
+
border: 0;
|
|
385
|
+
border-radius: var(--radius-control, var(--radius));
|
|
386
|
+
background: transparent;
|
|
387
|
+
color: inherit;
|
|
388
|
+
font: inherit;
|
|
389
|
+
font-size: 1.125rem;
|
|
390
|
+
line-height: 1;
|
|
391
|
+
cursor: pointer;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.mapsdk-card-close:hover {
|
|
395
|
+
background: var(--accent, var(--muted));
|
|
396
|
+
}
|
|
158
397
|
</style>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { MapContext } from "../core/create-map.svelte.js";
|
|
2
2
|
import type { GlMapLike } from "../core/gl.js";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
import type { CardContext } from "./card.js";
|
|
3
5
|
interface Props {
|
|
4
6
|
map: MapContext;
|
|
5
7
|
/**
|
|
@@ -22,6 +24,13 @@ interface Props {
|
|
|
22
24
|
mode?: "pinned" | "pointer";
|
|
23
25
|
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
24
26
|
gl?: Pick<GlMapLike, "on" | "off"> | null;
|
|
27
|
+
/**
|
|
28
|
+
* The pinned reading card: `map.pinned` and a focused list item show as an interactive card
|
|
29
|
+
* (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
|
|
30
|
+
*/
|
|
31
|
+
card?: boolean;
|
|
32
|
+
/** The host's buttons on the card (`MapView`'s `card.actions`). */
|
|
33
|
+
actions?: Snippet<[CardContext]>;
|
|
25
34
|
}
|
|
26
35
|
declare const MapTooltip: import("svelte").Component<Props, {}, "">;
|
|
27
36
|
type MapTooltip = ReturnType<typeof MapTooltip>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { MapContext } from "../core/create-map.svelte.js";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
map: MapContext;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let { map }: Props = $props();
|
|
9
|
+
|
|
10
|
+
/** How long a refused pick's message stays. */
|
|
11
|
+
const SHOW_MS = 4000;
|
|
12
|
+
|
|
13
|
+
// Each refusal (a new id) shows for SHOW_MS, then clears.
|
|
14
|
+
$effect(() => {
|
|
15
|
+
const id = map.notice?.id;
|
|
16
|
+
if (id === undefined) return;
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
if (map.notice?.id === id) map.clearNotice();
|
|
19
|
+
}, SHOW_MS);
|
|
20
|
+
return () => clearTimeout(timer);
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<!-- Always present: a live region must exist before its text changes to be announced. -->
|
|
25
|
+
<div class="mapsdk-pick-notice" role="status" aria-live="polite" aria-atomic="true">
|
|
26
|
+
{#if map.notice?.message}
|
|
27
|
+
{#key map.notice.id}
|
|
28
|
+
<span class="mapsdk-pick-notice-text text-sm">{map.notice.message}</span>
|
|
29
|
+
{/key}
|
|
30
|
+
{/if}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
/* Spans the map less 1rem a side, centering its text box, so a long message wraps at the
|
|
35
|
+
map's width rather than at half of it. */
|
|
36
|
+
.mapsdk-pick-notice {
|
|
37
|
+
position: absolute;
|
|
38
|
+
right: 1rem;
|
|
39
|
+
bottom: 2.5rem;
|
|
40
|
+
left: 1rem;
|
|
41
|
+
z-index: 3;
|
|
42
|
+
display: flex;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.mapsdk-pick-notice-text {
|
|
48
|
+
display: block;
|
|
49
|
+
padding: 0.375rem 0.75rem;
|
|
50
|
+
border: 1px solid var(--card-border, var(--border));
|
|
51
|
+
border-radius: var(--radius-control, var(--radius));
|
|
52
|
+
background: var(--background);
|
|
53
|
+
color: var(--foreground);
|
|
54
|
+
box-shadow: var(--shadow-card, none);
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import type { MessageProvider } from "../data/messages.js";
|
|
4
4
|
import type { FeatureListItem } from "../core/plugin.js";
|
|
5
5
|
import { NO_LEVEL } from "../data/scale.js";
|
|
6
|
+
import { levelText, shownValue } from "./feature-list.js";
|
|
6
7
|
import { levelChip } from "./level-chip.js";
|
|
7
|
-
import { formatValue } from "./value-format.js";
|
|
8
8
|
|
|
9
9
|
// The generic tooltip body under a feature's name: its value and a chip with its level. Shared
|
|
10
10
|
// by MapTooltip and by plugins that show it for features they don't list.
|
|
@@ -19,18 +19,14 @@
|
|
|
19
19
|
|
|
20
20
|
// A level with an empty color shows its label on the neutral chip.
|
|
21
21
|
const level = $derived(item.level !== NO_LEVEL ? item.level : null);
|
|
22
|
-
|
|
22
|
+
// The level's label, else the item's detail (plain text, no chip), else "No level".
|
|
23
|
+
const levelPlace = $derived(levelText(item, messages));
|
|
23
24
|
const chipColors = $derived.by(() => {
|
|
24
25
|
const chip = levelChip(level?.color);
|
|
25
26
|
return chip ? `background:${chip.background};color:${chip.text}` : "";
|
|
26
27
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
? null
|
|
30
|
-
: typeof item.value === "number"
|
|
31
|
-
? formatValue(item.value, item.precision, format)
|
|
32
|
-
: item.value
|
|
33
|
-
);
|
|
28
|
+
// A category reading shows only its level chip: its value is the level's code.
|
|
29
|
+
const valueText = $derived(shownValue(item, format));
|
|
34
30
|
</script>
|
|
35
31
|
|
|
36
32
|
<div class="mapsdk-tooltip-item">
|
|
@@ -39,9 +35,13 @@
|
|
|
39
35
|
{valueText}{#if item.units} {item.units}{/if}
|
|
40
36
|
</div>
|
|
41
37
|
{/if}
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
|
|
38
|
+
{#if levelPlace.kind === "detail"}
|
|
39
|
+
<span class="mapsdk-tooltip-detail text-xs">{levelPlace.text}</span>
|
|
40
|
+
{:else}
|
|
41
|
+
<span class="mapsdk-tooltip-chip text-xs" class:neutral={!chipColors} style={chipColors}>
|
|
42
|
+
{levelPlace.text}
|
|
43
|
+
</span>
|
|
44
|
+
{/if}
|
|
45
45
|
</div>
|
|
46
46
|
|
|
47
47
|
<style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { FeatureListItem } from "../core/plugin.js";
|
|
3
|
+
import type { FeatureRef } from "../data/types.js";
|
|
4
|
+
/** What a host's card actions get: the card's feature and its selection state. */
|
|
5
|
+
export interface CardContext {
|
|
6
|
+
readonly pluginId: string;
|
|
7
|
+
readonly ref: FeatureRef;
|
|
8
|
+
readonly refKey: string;
|
|
9
|
+
/** The listed item, or null for a hit its plugin doesn't list (an HMS fire hotspot). */
|
|
10
|
+
readonly item: FeatureListItem | null;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the feature can be selected: its plugin's mode isn't `"none"`, it's a monitor or a
|
|
13
|
+
* region (not a `feature` ref), and its listed item, if any, is `selectable`. Reactive. Show
|
|
14
|
+
* Select/Remove only when it's true.
|
|
15
|
+
*/
|
|
16
|
+
readonly selectable: boolean;
|
|
17
|
+
/** Whether the feature is in its plugin's selection. Reactive. */
|
|
18
|
+
readonly selected: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* A user's Select/Remove: toggles the feature, with the plugin's `canSelect`. Does nothing
|
|
21
|
+
* when it isn't `selectable`.
|
|
22
|
+
*/
|
|
23
|
+
toggle(): void;
|
|
24
|
+
}
|
|
25
|
+
/** `MapView`'s `card` options. */
|
|
26
|
+
export interface CardOptions {
|
|
27
|
+
/** The host's buttons, in a row at the card's foot. */
|
|
28
|
+
actions?: Snippet<[CardContext]>;
|
|
29
|
+
}
|
package/dist/ui/card.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -16,16 +16,31 @@ export declare const MENU_EVENT_WINDOW_MS = 1000;
|
|
|
16
16
|
* separator and different rows never share a fragment.
|
|
17
17
|
*/
|
|
18
18
|
export declare function optionIdPart(pluginId: string, refKey: string): string;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
|
|
21
|
+
* its point is inside. No antimeridian handling (the Valley).
|
|
22
|
+
*/
|
|
20
23
|
export declare function inView(items: FeatureListItem[], bounds: Bounds): FeatureListItem[];
|
|
21
24
|
/**
|
|
22
25
|
* The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
|
|
23
26
|
* label on a neutral swatch.
|
|
24
27
|
*/
|
|
25
28
|
export declare function shownLevel(item: FeatureListItem): Level | null;
|
|
29
|
+
/**
|
|
30
|
+
* The value as shown, or `null` for none. A category reading, whose value is its level's code
|
|
31
|
+
* (smoke's "light", the heat score's 2), shows no value: its level label already says it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shownValue(item: FeatureListItem, format: FormatProvider): string | null;
|
|
26
34
|
/** A sorted copy. `value`: highest first, null values and no level last; ties and `name` by name. */
|
|
27
35
|
export declare function sortItems(items: FeatureListItem[], by: SortBy): FeatureListItem[];
|
|
28
36
|
/** The first index at or after `from` (wrapping) whose name starts with `query`, any case; -1 if none. */
|
|
29
37
|
export declare function typeaheadIndex(items: FeatureListItem[], query: string, from: number): number;
|
|
30
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* What stands in an item's level place: its level's label, else its `detail`, else "No level".
|
|
40
|
+
*/
|
|
41
|
+
export declare function levelText(item: FeatureListItem, messages: MessageProvider): {
|
|
42
|
+
kind: "level" | "detail" | "none";
|
|
43
|
+
text: string;
|
|
44
|
+
};
|
|
45
|
+
/** A row's accessible name: name, value, units, and level label (or detail). */
|
|
31
46
|
export declare function rowLabel(item: FeatureListItem, messages: MessageProvider, format: FormatProvider): string;
|
package/dist/ui/feature-list.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { intersectsBounds } from "../core/shape-view.js";
|
|
1
2
|
import { NO_LEVEL } from "../data/scale.js";
|
|
2
3
|
import { formatValue } from "./value-format.js";
|
|
3
4
|
/**
|
|
@@ -14,11 +15,12 @@ export function optionIdPart(pluginId, refKey) {
|
|
|
14
15
|
const escape = (text) => text.replace(/[^A-Za-z0-9]/g, (c) => `_${c.codePointAt(0).toString(16)}_`);
|
|
15
16
|
return `${escape(pluginId)}--${escape(refKey)}`;
|
|
16
17
|
}
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
|
|
20
|
+
* its point is inside. No antimeridian handling (the Valley).
|
|
21
|
+
*/
|
|
18
22
|
export function inView(items, bounds) {
|
|
19
|
-
return items.filter((
|
|
20
|
-
return lng >= bounds.west && lng <= bounds.east && lat >= bounds.south && lat <= bounds.north;
|
|
21
|
-
});
|
|
23
|
+
return items.filter((item) => intersectsBounds(item, bounds));
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
|
|
@@ -27,6 +29,20 @@ export function inView(items, bounds) {
|
|
|
27
29
|
export function shownLevel(item) {
|
|
28
30
|
return item.level !== NO_LEVEL ? item.level : null;
|
|
29
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The value as shown, or `null` for none. A category reading, whose value is its level's code
|
|
34
|
+
* (smoke's "light", the heat score's 2), shows no value: its level label already says it.
|
|
35
|
+
*/
|
|
36
|
+
export function shownValue(item, format) {
|
|
37
|
+
if (item.value === null)
|
|
38
|
+
return null;
|
|
39
|
+
const level = shownLevel(item);
|
|
40
|
+
if (level && level.value !== null && level.value === item.value)
|
|
41
|
+
return null;
|
|
42
|
+
return typeof item.value === "number"
|
|
43
|
+
? formatValue(item.value, item.precision, format, item.truncate)
|
|
44
|
+
: item.value;
|
|
45
|
+
}
|
|
30
46
|
const collator = new Intl.Collator("en-US");
|
|
31
47
|
/** A value's sort weight; `null` sorts last. Category values sort by their level's tier. */
|
|
32
48
|
function weight(item) {
|
|
@@ -65,16 +81,35 @@ export function typeaheadIndex(items, query, from) {
|
|
|
65
81
|
}
|
|
66
82
|
return -1;
|
|
67
83
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
/**
|
|
85
|
+
* What stands in an item's level place: its level's label, else its `detail`, else "No level".
|
|
86
|
+
*/
|
|
87
|
+
export function levelText(item, messages) {
|
|
72
88
|
const level = shownLevel(item);
|
|
89
|
+
if (level)
|
|
90
|
+
return { kind: "level", text: level.label };
|
|
91
|
+
const detail = item.detail?.trim();
|
|
92
|
+
if (detail)
|
|
93
|
+
return { kind: "detail", text: detail };
|
|
94
|
+
return { kind: "none", text: messages("mapsdk_no_level") };
|
|
95
|
+
}
|
|
96
|
+
/** A row's accessible name: name, value, units, and level label (or detail). */
|
|
97
|
+
export function rowLabel(item, messages, format) {
|
|
98
|
+
const level = levelText(item, messages);
|
|
99
|
+
const value = shownValue(item, format);
|
|
100
|
+
if (value === null) {
|
|
101
|
+
// A category reading: its level is its value.
|
|
102
|
+
if (item.value !== null && level.kind === "level")
|
|
103
|
+
return messages("mapsdk_list_row_level", { name: item.name, level: level.text });
|
|
104
|
+
return level.kind === "detail"
|
|
105
|
+
? messages("mapsdk_list_row_detail", { name: item.name, detail: level.text })
|
|
106
|
+
: messages("mapsdk_list_row_no_value", { name: item.name });
|
|
107
|
+
}
|
|
73
108
|
const params = {
|
|
74
109
|
name: item.name,
|
|
75
|
-
value
|
|
110
|
+
value,
|
|
76
111
|
units: item.units,
|
|
77
|
-
level: level
|
|
112
|
+
level: level.text
|
|
78
113
|
};
|
|
79
114
|
return messages(item.units ? "mapsdk_list_row" : "mapsdk_list_row_no_units", params);
|
|
80
115
|
}
|
|
@@ -2,6 +2,7 @@ import type { FormatProvider } from "../data/format.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
|
|
4
4
|
* there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
|
|
5
|
-
* so they all agree.
|
|
5
|
+
* so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
|
|
6
|
+
* precision 1 is "9.9"), so the number never reads as the next level up.
|
|
6
7
|
*/
|
|
7
|
-
export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider): string;
|
|
8
|
+
export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider, truncate?: boolean): string;
|
package/dist/ui/value-format.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
|
|
3
3
|
* there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
|
|
4
|
-
* so they all agree.
|
|
4
|
+
* so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
|
|
5
|
+
* precision 1 is "9.9"), so the number never reads as the next level up.
|
|
5
6
|
*/
|
|
6
|
-
export function formatValue(value, precision, format) {
|
|
7
|
+
export function formatValue(value, precision, format, truncate = false) {
|
|
7
8
|
if (precision === undefined)
|
|
8
9
|
return format.number(value);
|
|
10
|
+
if (truncate) {
|
|
11
|
+
const factor = 10 ** precision;
|
|
12
|
+
// toPrecision drops float noise first (4.35 * 100 is 434.99999999999994); 15 digits keep
|
|
13
|
+
// any real value from rounding up.
|
|
14
|
+
value = Math.trunc(Number((value * factor).toPrecision(15))) / factor;
|
|
15
|
+
}
|
|
9
16
|
return format.number(value, {
|
|
10
17
|
minimumFractionDigits: precision,
|
|
11
18
|
maximumFractionDigits: precision
|