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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +243 -17
  2. package/dist/core/create-map.svelte.d.ts +72 -5
  3. package/dist/core/create-map.svelte.js +229 -14
  4. package/dist/core/gl.d.ts +5 -0
  5. package/dist/core/interactions.svelte.d.ts +11 -3
  6. package/dist/core/interactions.svelte.js +128 -11
  7. package/dist/core/plugin-host.svelte.d.ts +30 -5
  8. package/dist/core/plugin-host.svelte.js +93 -14
  9. package/dist/core/plugin.d.ts +44 -2
  10. package/dist/core/shape-view.d.ts +27 -0
  11. package/dist/core/shape-view.js +323 -0
  12. package/dist/data/format.d.ts +1 -1
  13. package/dist/data/format.js +16 -3
  14. package/dist/data/messages/en.json +12 -2
  15. package/dist/data/selection.d.ts +6 -0
  16. package/dist/data/selection.js +21 -0
  17. package/dist/data/types.d.ts +5 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +1 -0
  20. package/dist/plugins/points.svelte.d.ts +14 -2
  21. package/dist/plugins/points.svelte.js +34 -5
  22. package/dist/plugins/shapes.svelte.d.ts +5 -0
  23. package/dist/plugins/shapes.svelte.js +20 -2
  24. package/dist/presets/FireTooltip.svelte +70 -0
  25. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  26. package/dist/presets/MonitorClusterTooltip.svelte +21 -2
  27. package/dist/presets/cluster-area.d.ts +13 -0
  28. package/dist/presets/cluster-area.js +34 -0
  29. package/dist/presets/hms-fire.d.ts +11 -7
  30. package/dist/presets/hms-fire.js +16 -17
  31. package/dist/presets/hms-smoke.d.ts +2 -1
  32. package/dist/presets/hms-smoke.js +4 -2
  33. package/dist/stores/hms-stores.svelte.d.ts +6 -3
  34. package/dist/stores/hms-stores.svelte.js +4 -4
  35. package/dist/ui/FeatureList.svelte +17 -17
  36. package/dist/ui/Legend.svelte +58 -16
  37. package/dist/ui/Legend.svelte.d.ts +9 -0
  38. package/dist/ui/MapTooltip.svelte +253 -14
  39. package/dist/ui/MapTooltip.svelte.d.ts +9 -0
  40. package/dist/ui/PickNotice.svelte +56 -0
  41. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  42. package/dist/ui/TooltipItem.svelte +12 -12
  43. package/dist/ui/card.d.ts +29 -0
  44. package/dist/ui/card.js +1 -0
  45. package/dist/ui/feature-list.d.ts +17 -2
  46. package/dist/ui/feature-list.js +45 -10
  47. package/dist/ui/value-format.d.ts +3 -2
  48. package/dist/ui/value-format.js +9 -2
  49. package/dist/view/MapView.svelte +72 -2
  50. package/dist/view/MapView.svelte.d.ts +8 -0
  51. package/dist/view/create-gl-map.js +4 -0
  52. package/package.json +2 -1
@@ -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 { map, project, width, height, mode = "pinned", gl = null }: Props = $props();
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
- if (mode !== "pointer" || !gl) return;
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
- gl.on("zoomstart", onZoomStart);
37
- return () => gl.off("zoomstart", onZoomStart);
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 over the pointer.
49
- const key = $derived(map.focusedKey ?? map.hovered?.hit.refKey ?? null);
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(map.focusedKey ? null : (map.hovered?.hit.pluginId ?? null));
52
- const hitName = $derived(map.focusedKey ? null : (map.hovered?.hit.name ?? null));
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
- const anchor = $derived(
74
- mode === "pointer" && !map.focusedKey
75
- ? (map.hovered?.lngLat ?? null)
76
- : (item?.lngLat ?? map.hovered?.lngLat ?? null)
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(() => map.hovered?.hit);
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 && !(zoomHidden && !map.focusedKey)}
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>
@@ -0,0 +1,7 @@
1
+ import type { MapContext } from "../core/create-map.svelte.js";
2
+ interface Props {
3
+ map: MapContext;
4
+ }
5
+ declare const PickNotice: import("svelte").Component<Props, {}, "">;
6
+ type PickNotice = ReturnType<typeof PickNotice>;
7
+ export default PickNotice;
@@ -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
- const levelLabel = $derived(level ? level.label : messages("mapsdk_no_level"));
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
- const valueText = $derived(
28
- item.value == null
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}&nbsp;{item.units}{/if}
40
36
  </div>
41
37
  {/if}
42
- <span class="mapsdk-tooltip-chip text-xs" class:neutral={!chipColors} style={chipColors}>
43
- {levelLabel}
44
- </span>
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
+ }
@@ -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
- /** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
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
- /** A row's accessible name: name, value, units, and level label. */
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;
@@ -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
- /** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
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(({ lngLat: [lng, lat] }) => {
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
- /** A row's accessible name: name, value, units, and level label. */
69
- export function rowLabel(item, messages, format) {
70
- if (item.value === null)
71
- return messages("mapsdk_list_row_no_value", { name: item.name });
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: typeof item.value === "number" ? formatValue(item.value, item.precision, format) : item.value,
110
+ value,
76
111
  units: item.units,
77
- level: level ? level.label : messages("mapsdk_no_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;
@@ -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