@sjvair/map-sdk 0.0.0-stage → 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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +713 -2
  3. package/dist/core/basemap.svelte.d.ts +38 -0
  4. package/dist/core/basemap.svelte.js +163 -0
  5. package/dist/core/context.d.ts +5 -0
  6. package/dist/core/context.js +13 -0
  7. package/dist/core/create-map.svelte.d.ts +274 -0
  8. package/dist/core/create-map.svelte.js +591 -0
  9. package/dist/core/gl.d.ts +97 -0
  10. package/dist/core/gl.js +1 -0
  11. package/dist/core/interactions.svelte.d.ts +50 -0
  12. package/dist/core/interactions.svelte.js +330 -0
  13. package/dist/core/plugin-host.svelte.d.ts +71 -0
  14. package/dist/core/plugin-host.svelte.js +445 -0
  15. package/dist/core/plugin.d.ts +190 -0
  16. package/dist/core/plugin.js +1 -0
  17. package/dist/core/scoped-context.d.ts +7 -0
  18. package/dist/core/scoped-context.js +34 -0
  19. package/dist/core/shape-view.d.ts +27 -0
  20. package/dist/core/shape-view.js +323 -0
  21. package/dist/data/format.d.ts +10 -0
  22. package/dist/data/format.js +37 -0
  23. package/dist/data/index.d.ts +9 -0
  24. package/dist/data/index.js +9 -0
  25. package/dist/data/messages/en.json +61 -0
  26. package/dist/data/messages.d.ts +10 -0
  27. package/dist/data/messages.js +17 -0
  28. package/dist/data/metadata.d.ts +22 -0
  29. package/dist/data/metadata.js +110 -0
  30. package/dist/data/refs.d.ts +18 -0
  31. package/dist/data/refs.js +61 -0
  32. package/dist/data/scale-types.d.ts +7 -0
  33. package/dist/data/scale-types.js +1 -0
  34. package/dist/data/scale.d.ts +13 -0
  35. package/dist/data/scale.js +27 -0
  36. package/dist/data/selection.d.ts +13 -0
  37. package/dist/data/selection.js +52 -0
  38. package/dist/data/static-source.d.ts +4 -0
  39. package/dist/data/static-source.js +3 -0
  40. package/dist/data/types.d.ts +74 -0
  41. package/dist/data/types.js +1 -0
  42. package/dist/index.d.ts +59 -0
  43. package/dist/index.js +42 -0
  44. package/dist/plugins/icons.d.ts +21 -0
  45. package/dist/plugins/icons.js +73 -0
  46. package/dist/plugins/points-clusters.d.ts +60 -0
  47. package/dist/plugins/points-clusters.js +93 -0
  48. package/dist/plugins/points.svelte.d.ts +105 -0
  49. package/dist/plugins/points.svelte.js +502 -0
  50. package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
  51. package/dist/plugins/scale-resolution.svelte.js +111 -0
  52. package/dist/plugins/shapes.svelte.d.ts +34 -0
  53. package/dist/plugins/shapes.svelte.js +325 -0
  54. package/dist/plugins/shared.d.ts +6 -0
  55. package/dist/plugins/shared.js +18 -0
  56. package/dist/plugins/style.d.ts +57 -0
  57. package/dist/plugins/style.js +193 -0
  58. package/dist/presets/CollocationTooltip.svelte +132 -0
  59. package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
  60. package/dist/presets/FireTooltip.svelte +70 -0
  61. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  62. package/dist/presets/MonitorClusterTooltip.svelte +168 -0
  63. package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
  64. package/dist/presets/MonitorReadingBox.svelte +58 -0
  65. package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
  66. package/dist/presets/MonitorTooltip.svelte +88 -0
  67. package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
  68. package/dist/presets/calheatscore.d.ts +24 -0
  69. package/dist/presets/calheatscore.js +22 -0
  70. package/dist/presets/cluster-area.d.ts +13 -0
  71. package/dist/presets/cluster-area.js +34 -0
  72. package/dist/presets/collocation-sites.d.ts +18 -0
  73. package/dist/presets/collocation-sites.js +67 -0
  74. package/dist/presets/fire-groups.d.ts +26 -0
  75. package/dist/presets/fire-groups.js +51 -0
  76. package/dist/presets/forecast-zones.d.ts +25 -0
  77. package/dist/presets/forecast-zones.js +23 -0
  78. package/dist/presets/hms-fire.d.ts +30 -0
  79. package/dist/presets/hms-fire.js +86 -0
  80. package/dist/presets/hms-smoke.d.ts +20 -0
  81. package/dist/presets/hms-smoke.js +22 -0
  82. package/dist/presets/monitors.d.ts +48 -0
  83. package/dist/presets/monitors.js +243 -0
  84. package/dist/presets/reactive-props.svelte.d.ts +4 -0
  85. package/dist/presets/reactive-props.svelte.js +7 -0
  86. package/dist/presets/regions.d.ts +24 -0
  87. package/dist/presets/regions.js +22 -0
  88. package/dist/stores/abort.d.ts +16 -0
  89. package/dist/stores/abort.js +52 -0
  90. package/dist/stores/collocation-store.svelte.d.ts +25 -0
  91. package/dist/stores/collocation-store.svelte.js +72 -0
  92. package/dist/stores/hms-stores.svelte.d.ts +38 -0
  93. package/dist/stores/hms-stores.svelte.js +97 -0
  94. package/dist/stores/index.d.ts +4 -0
  95. package/dist/stores/index.js +4 -0
  96. package/dist/stores/load-once.svelte.d.ts +25 -0
  97. package/dist/stores/load-once.svelte.js +93 -0
  98. package/dist/stores/monitors-store.svelte.d.ts +85 -0
  99. package/dist/stores/monitors-store.svelte.js +340 -0
  100. package/dist/theme.css +34 -0
  101. package/dist/ui/FeatureList.svelte +501 -0
  102. package/dist/ui/FeatureList.svelte.d.ts +14 -0
  103. package/dist/ui/Legend.svelte +214 -0
  104. package/dist/ui/Legend.svelte.d.ts +22 -0
  105. package/dist/ui/LegendBar.svelte +161 -0
  106. package/dist/ui/LegendBar.svelte.d.ts +11 -0
  107. package/dist/ui/MapControls.svelte +135 -0
  108. package/dist/ui/MapControls.svelte.d.ts +11 -0
  109. package/dist/ui/MapTooltip.svelte +397 -0
  110. package/dist/ui/MapTooltip.svelte.d.ts +37 -0
  111. package/dist/ui/PickNotice.svelte +56 -0
  112. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  113. package/dist/ui/SelectionBar.svelte +123 -0
  114. package/dist/ui/SelectionBar.svelte.d.ts +10 -0
  115. package/dist/ui/TooltipItem.svelte +66 -0
  116. package/dist/ui/TooltipItem.svelte.d.ts +11 -0
  117. package/dist/ui/ViewSummary.svelte +97 -0
  118. package/dist/ui/ViewSummary.svelte.d.ts +7 -0
  119. package/dist/ui/card.d.ts +29 -0
  120. package/dist/ui/card.js +1 -0
  121. package/dist/ui/controls.css +45 -0
  122. package/dist/ui/data-credits.svelte.d.ts +9 -0
  123. package/dist/ui/data-credits.svelte.js +52 -0
  124. package/dist/ui/feature-list.d.ts +46 -0
  125. package/dist/ui/feature-list.js +115 -0
  126. package/dist/ui/legend-data.d.ts +41 -0
  127. package/dist/ui/legend-data.js +40 -0
  128. package/dist/ui/level-chip.d.ts +11 -0
  129. package/dist/ui/level-chip.js +15 -0
  130. package/dist/ui/value-format.d.ts +8 -0
  131. package/dist/ui/value-format.js +20 -0
  132. package/dist/ui/view-summary.d.ts +11 -0
  133. package/dist/ui/view-summary.js +33 -0
  134. package/dist/view/MapView.svelte +443 -0
  135. package/dist/view/MapView.svelte.d.ts +51 -0
  136. package/dist/view/create-gl-map.d.ts +34 -0
  137. package/dist/view/create-gl-map.js +186 -0
  138. package/dist/view/list-placement.d.ts +21 -0
  139. package/dist/view/list-placement.js +13 -0
  140. package/package.json +104 -5
@@ -0,0 +1,11 @@
1
+ import type { FormatProvider } from "../data/format.js";
2
+ import type { MessageProvider } from "../data/messages.js";
3
+ import type { FeatureListItem } from "../core/plugin.js";
4
+ interface Props {
5
+ item: FeatureListItem;
6
+ messages: MessageProvider;
7
+ format: FormatProvider;
8
+ }
9
+ declare const TooltipItem: import("svelte").Component<Props, {}, "">;
10
+ type TooltipItem = ReturnType<typeof TooltipItem>;
11
+ export default TooltipItem;
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import type { MapContext } from "../core/create-map.svelte.js";
4
+ import { inView } from "./feature-list.js";
5
+ import { SUMMARY_DEBOUNCE_MS, summaryText } from "./view-summary.js";
6
+
7
+ let { map }: { map: MapContext } = $props();
8
+
9
+ interface Line {
10
+ /** Stable per plugin and kind, so an unchanged line keeps its element and isn't re-announced. */
11
+ key: string;
12
+ text: string;
13
+ }
14
+
15
+ // The map's one live region: what is in view per layer, and how many are selected. Spoken text
16
+ // only; the visible pieces are the legend, the list, and the selection bar. Lines are keyed and
17
+ // the region is not atomic, so only a line that changed is announced.
18
+ /** Each plugin's selection count as of the last update, to tell when one drops to zero. */
19
+ let previous: Record<string, number> = {};
20
+
21
+ // Selection counts of every plugin, so a change reschedules the update.
22
+ const selectionCounts = $derived(
23
+ map.plugins.map((p) => `${p.id}:${map.getSelection(p.id)?.ids.length ?? 0}`).join(",")
24
+ );
25
+
26
+ function compute(): Line[] {
27
+ const bounds = map.bounds;
28
+ const out: Line[] = [];
29
+ const counts: Record<string, number> = {};
30
+ for (const plugin of map.plugins) {
31
+ const layer = plugin.label ?? plugin.id;
32
+ if (bounds && plugin.features) {
33
+ const data = plugin.legend?.();
34
+ const scale = data && "scale" in data ? data.scale : null;
35
+ out.push({
36
+ key: `view:${plugin.id}`,
37
+ text: summaryText(layer, inView(plugin.features(), bounds), scale, map.messages)
38
+ });
39
+ }
40
+ const count = map.getSelection(plugin.id)?.ids.length ?? 0;
41
+ counts[plugin.id] = count;
42
+ // A drop to zero is said once; the line is gone at the next update.
43
+ const selected =
44
+ count > 0
45
+ ? map.messages("mapsdk_selected_count", { count })
46
+ : (previous[plugin.id] ?? 0) > 0
47
+ ? map.messages("mapsdk_selection_cleared")
48
+ : null;
49
+ if (selected !== null) {
50
+ out.push({
51
+ key: `selection:${plugin.id}`,
52
+ text: map.messages("mapsdk_selected_in_layer", { layer, selected })
53
+ });
54
+ }
55
+ }
56
+ previous = counts;
57
+ return out;
58
+ }
59
+
60
+ // What is there at mount (the host's view and selection) is the baseline, not news.
61
+ let lines = $state<Line[]>(untrack(compute));
62
+
63
+ let first = true;
64
+ $effect(() => {
65
+ void map.bounds;
66
+ void selectionCounts;
67
+ if (first) {
68
+ first = false;
69
+ return;
70
+ }
71
+ const timer = setTimeout(() => {
72
+ lines = untrack(compute);
73
+ }, SUMMARY_DEBOUNCE_MS);
74
+ return () => clearTimeout(timer);
75
+ });
76
+ </script>
77
+
78
+ <div class="mapsdk-summary" aria-live="polite" aria-atomic="false">
79
+ {#each lines as line (line.key)}
80
+ <p>{line.text}</p>
81
+ {/each}
82
+ </div>
83
+
84
+ <style>
85
+ .mapsdk-summary {
86
+ position: absolute;
87
+ width: 1px;
88
+ height: 1px;
89
+ overflow: hidden;
90
+ clip-path: inset(50%);
91
+ white-space: nowrap;
92
+ }
93
+
94
+ p {
95
+ margin: 0;
96
+ }
97
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { MapContext } from "../core/create-map.svelte.js";
2
+ type $$ComponentProps = {
3
+ map: MapContext;
4
+ };
5
+ declare const ViewSummary: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ViewSummary = ReturnType<typeof ViewSummary>;
7
+ export default ViewSummary;
@@ -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 {};
@@ -0,0 +1,45 @@
1
+ /*
2
+ * Shared by map-sdk's controls (plain CSS, not scoped: the classes are set by several components).
3
+ *
4
+ * Focus ring, only on :focus-visible: a 2px --ring outline with a --ring-edge halo, so the ring
5
+ * reads against any background. A host without --ring-edge gets the page background.
6
+ */
7
+ /* Doubled classes raise specificity above any component rule that sets box-shadow (a card
8
+ shadow, a selected row's ring), so the result never depends on bundle order. */
9
+ .mapsdk-focus.mapsdk-focus:focus-visible {
10
+ outline: 2px solid var(--ring);
11
+ outline-offset: 1px;
12
+ box-shadow: 0 0 0 4px var(--ring-edge, var(--background));
13
+ }
14
+
15
+ /* For controls and rows inside a container that clips: the ring is drawn inside. */
16
+ .mapsdk-focus-inset.mapsdk-focus-inset:focus-visible,
17
+ .mapsdk-focus-inset-active:focus-visible .mapsdk-active-option.mapsdk-active-option {
18
+ outline: 2px solid var(--ring);
19
+ outline-offset: -3px;
20
+ box-shadow: inset 0 0 0 4px var(--ring-edge, var(--background));
21
+ }
22
+
23
+ /* Elements only script focuses (tabindex="-1"): the outline alone. */
24
+ .mapsdk-focus-outline:focus-visible {
25
+ outline: 2px solid var(--ring);
26
+ outline-offset: -2px;
27
+ }
28
+
29
+ /* Motion goes through the host's tokens; the fallbacks apply when it defines none. */
30
+ .mapsdk-motion {
31
+ transition-property: background-color, border-color, color, opacity;
32
+ transition-duration: var(--motion-duration-fast, 120ms);
33
+ transition-timing-function: var(--motion-ease-standard, ease);
34
+ }
35
+
36
+ @media (prefers-reduced-motion: reduce) {
37
+ .mapsdk-motion {
38
+ transition-duration: 0s;
39
+ }
40
+ }
41
+
42
+ /* The engine's credits keep its colors; links get an underline so color isn't the only cue. */
43
+ .mapsdk-map .maplibregl-ctrl-attrib a {
44
+ text-decoration: underline;
45
+ }
@@ -0,0 +1,9 @@
1
+ import type { MapContext } from "../core/create-map.svelte.js";
2
+ /**
3
+ * The credit text of every dataset the map's plugins declare, each once, as it arrives from the
4
+ * metadata provider. Call during a component's initialization: the fetching runs in an effect
5
+ * and stops with the component.
6
+ */
7
+ export declare function createDataCredits(map: MapContext): {
8
+ readonly list: string[];
9
+ };
@@ -0,0 +1,52 @@
1
+ /* eslint-disable svelte/prefer-svelte-reactivity -- derived sets only dedupe; never mutated */
2
+ import { onDestroy, untrack } from "svelte";
3
+ /** An unavailable attribution is tried again on a later moveend, at most once a minute. */
4
+ const RETRY_MS = 60_000;
5
+ /**
6
+ * The credit text of every dataset the map's plugins declare, each once, as it arrives from the
7
+ * metadata provider. Call during a component's initialization: the fetching runs in an effect
8
+ * and stops with the component.
9
+ */
10
+ export function createDataCredits(map) {
11
+ const datasets = $derived([
12
+ ...new Set(map.plugins.flatMap((plugin) => (plugin.dataset ? [plugin.dataset] : [])))
13
+ ]);
14
+ let fetched = $state.raw({});
15
+ const list = $derived([...new Set(datasets.flatMap((d) => (fetched[d] ? [fetched[d]] : [])))]);
16
+ const controller = new AbortController();
17
+ onDestroy(() => controller.abort());
18
+ // Plain collections: bookkeeping, never rendered.
19
+ const inflight = new Set();
20
+ const failedAt = new Map();
21
+ $effect(() => {
22
+ void map.bounds;
23
+ const wanted = datasets;
24
+ untrack(() => {
25
+ for (const dataset of wanted) {
26
+ if (fetched[dataset] || inflight.has(dataset))
27
+ continue;
28
+ const failed = failedAt.get(dataset);
29
+ if (failed !== undefined && Date.now() - failed < RETRY_MS)
30
+ continue;
31
+ inflight.add(dataset);
32
+ map.metadata.attribution(dataset, controller.signal).then((text) => {
33
+ inflight.delete(dataset);
34
+ if (controller.signal.aborted)
35
+ return;
36
+ if (text === "unavailable")
37
+ failedAt.set(dataset, Date.now());
38
+ else
39
+ fetched = { ...fetched, [dataset]: text };
40
+ }, () => {
41
+ inflight.delete(dataset);
42
+ failedAt.set(dataset, Date.now());
43
+ });
44
+ }
45
+ });
46
+ });
47
+ return {
48
+ get list() {
49
+ return list;
50
+ }
51
+ };
52
+ }
@@ -0,0 +1,46 @@
1
+ import type { Bounds } from "../core/create-map.svelte.js";
2
+ import type { FeatureListItem } from "../core/plugin.js";
3
+ import type { FormatProvider } from "../data/format.js";
4
+ import type { MessageProvider } from "../data/messages.js";
5
+ import type { Level } from "../data/scale-types.js";
6
+ export type { Bounds };
7
+ export type SortBy = "name" | "value";
8
+ /**
9
+ * How long after Shift+F10 or the Menu key the browser's own keyboard `contextmenu` event (sent on
10
+ * keydown or keyup, depending on the platform) is treated as a duplicate of the one sent to the row.
11
+ */
12
+ export declare const MENU_EVENT_WINDOW_MS = 1000;
13
+ /**
14
+ * A valid, stable id fragment for a row, from its plugin and refKey: anything but letters and digits
15
+ * is escaped as `_<hex>_` (`_` and `-` included), so the `--` between the parts can only be the
16
+ * separator and different rows never share a fragment.
17
+ */
18
+ export declare function optionIdPart(pluginId: string, refKey: string): string;
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
+ */
23
+ export declare function inView(items: FeatureListItem[], bounds: Bounds): FeatureListItem[];
24
+ /**
25
+ * The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
26
+ * label on a neutral swatch.
27
+ */
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;
34
+ /** A sorted copy. `value`: highest first, null values and no level last; ties and `name` by name. */
35
+ export declare function sortItems(items: FeatureListItem[], by: SortBy): FeatureListItem[];
36
+ /** The first index at or after `from` (wrapping) whose name starts with `query`, any case; -1 if none. */
37
+ export declare function typeaheadIndex(items: FeatureListItem[], query: string, from: number): number;
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). */
46
+ export declare function rowLabel(item: FeatureListItem, messages: MessageProvider, format: FormatProvider): string;
@@ -0,0 +1,115 @@
1
+ import { intersectsBounds } from "../core/shape-view.js";
2
+ import { NO_LEVEL } from "../data/scale.js";
3
+ import { formatValue } from "./value-format.js";
4
+ /**
5
+ * How long after Shift+F10 or the Menu key the browser's own keyboard `contextmenu` event (sent on
6
+ * keydown or keyup, depending on the platform) is treated as a duplicate of the one sent to the row.
7
+ */
8
+ export const MENU_EVENT_WINDOW_MS = 1000;
9
+ /**
10
+ * A valid, stable id fragment for a row, from its plugin and refKey: anything but letters and digits
11
+ * is escaped as `_<hex>_` (`_` and `-` included), so the `--` between the parts can only be the
12
+ * separator and different rows never share a fragment.
13
+ */
14
+ export function optionIdPart(pluginId, refKey) {
15
+ const escape = (text) => text.replace(/[^A-Za-z0-9]/g, (c) => `_${c.codePointAt(0).toString(16)}_`);
16
+ return `${escape(pluginId)}--${escape(refKey)}`;
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
+ */
22
+ export function inView(items, bounds) {
23
+ return items.filter((item) => intersectsBounds(item, bounds));
24
+ }
25
+ /**
26
+ * The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
27
+ * label on a neutral swatch.
28
+ */
29
+ export function shownLevel(item) {
30
+ return item.level !== NO_LEVEL ? item.level : null;
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
+ }
46
+ const collator = new Intl.Collator("en-US");
47
+ /** A value's sort weight; `null` sorts last. Category values sort by their level's tier. */
48
+ function weight(item) {
49
+ if (item.value === null || item.level === NO_LEVEL)
50
+ return null;
51
+ if (typeof item.value === "number")
52
+ return item.value;
53
+ return item.level.tier ?? null;
54
+ }
55
+ /** A sorted copy. `value`: highest first, null values and no level last; ties and `name` by name. */
56
+ export function sortItems(items, by) {
57
+ const byName = (a, b) => collator.compare(a.name, b.name);
58
+ if (by === "name")
59
+ return items.toSorted(byName);
60
+ const weighted = items.map((item) => ({ item, w: weight(item) }));
61
+ weighted.sort((a, b) => {
62
+ if (a.w === null || b.w === null) {
63
+ if (a.w !== b.w)
64
+ return a.w === null ? 1 : -1;
65
+ }
66
+ else if (a.w !== b.w) {
67
+ return b.w - a.w;
68
+ }
69
+ return byName(a.item, b.item);
70
+ });
71
+ return weighted.map(({ item }) => item);
72
+ }
73
+ /** The first index at or after `from` (wrapping) whose name starts with `query`, any case; -1 if none. */
74
+ export function typeaheadIndex(items, query, from) {
75
+ const q = query.toLocaleLowerCase("en-US");
76
+ const n = items.length;
77
+ for (let step = 0; step < n; step++) {
78
+ const i = (((from + step) % n) + n) % n;
79
+ if (items[i].name.toLocaleLowerCase("en-US").startsWith(q))
80
+ return i;
81
+ }
82
+ return -1;
83
+ }
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) {
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
+ }
108
+ const params = {
109
+ name: item.name,
110
+ value,
111
+ units: item.units,
112
+ level: level.text
113
+ };
114
+ return messages(item.units ? "mapsdk_list_row" : "mapsdk_list_row_no_units", params);
115
+ }
@@ -0,0 +1,41 @@
1
+ import type { LegendData } from "../core/plugin.js";
2
+ import type { FormatProvider } from "../data/format.js";
3
+ import type { DataStatus } from "../data/types.js";
4
+ export type LegendBar = {
5
+ kind: "gradient";
6
+ stops: Array<{
7
+ color: string;
8
+ label: string;
9
+ tick: string;
10
+ }>;
11
+ } | {
12
+ kind: "blocks";
13
+ blocks: Array<{
14
+ color: string;
15
+ opacity: number | null;
16
+ label: string;
17
+ tick: string;
18
+ }>;
19
+ };
20
+ export interface LegendSection {
21
+ pluginId: string;
22
+ title: string;
23
+ units: string;
24
+ bar: LegendBar;
25
+ }
26
+ export type LegendEntry = {
27
+ kind: "scale";
28
+ section: LegendSection;
29
+ } | {
30
+ kind: "status";
31
+ pluginId: string;
32
+ layer: string;
33
+ status: DataStatus;
34
+ };
35
+ /** One legend section from a plugin's legend data; null for status-only data or no levels. */
36
+ export declare function legendSection(data: LegendData, format: FormatProvider): LegendSection | null;
37
+ /**
38
+ * How a translucent fill looks over the page background (`background` is the computed
39
+ * `--background`, in a form color2k parses). An empty color or no opacity returns the color.
40
+ */
41
+ export declare function swatchOverBackground(color: string, opacity: number | null, background: string): string;
@@ -0,0 +1,40 @@
1
+ import { mix } from "color2k";
2
+ /** One legend section from a plugin's legend data; null for status-only data or no levels. */
3
+ export function legendSection(data, format) {
4
+ if (!("scale" in data))
5
+ return null;
6
+ const { scale } = data;
7
+ if (scale.levels.length === 0)
8
+ return null;
9
+ const bar = scale.kind === "range" && data.display !== "blocks"
10
+ ? {
11
+ kind: "gradient",
12
+ stops: scale.levels.map((level) => ({
13
+ color: level.color ?? "",
14
+ label: level.label,
15
+ tick: level.min === null ? "" : format.number(Math.floor(level.min))
16
+ }))
17
+ }
18
+ : {
19
+ kind: "blocks",
20
+ blocks: scale.levels.map((level) => ({
21
+ color: level.color ?? "",
22
+ opacity: level.opacity,
23
+ label: level.label,
24
+ // A range scale's lower bound, for screen readers; categories have none.
25
+ tick: scale.kind === "range" && level.min !== null
26
+ ? format.number(Math.floor(level.min))
27
+ : ""
28
+ }))
29
+ };
30
+ return { pluginId: data.pluginId, title: data.title, units: data.units, bar };
31
+ }
32
+ /**
33
+ * How a translucent fill looks over the page background (`background` is the computed
34
+ * `--background`, in a form color2k parses). An empty color or no opacity returns the color.
35
+ */
36
+ export function swatchOverBackground(color, opacity, background) {
37
+ if (!color || opacity === null)
38
+ return color;
39
+ return mix(background, color, opacity);
40
+ }
@@ -0,0 +1,11 @@
1
+ /** Colors for something filled with a level's color: the fill, a darker border, readable text. */
2
+ export interface LevelChipColors {
3
+ background: string;
4
+ border: string;
5
+ text: string;
6
+ }
7
+ /**
8
+ * The colors for a level color, or null when it is empty or unparseable (color2k throws there,
9
+ * as monitor-map's `darken("")` did), so callers fall back to their neutral look.
10
+ */
11
+ export declare function levelChip(color: string | null | undefined): LevelChipColors | null;
@@ -0,0 +1,15 @@
1
+ import { darken, readableColor } from "color2k";
2
+ /**
3
+ * The colors for a level color, or null when it is empty or unparseable (color2k throws there,
4
+ * as monitor-map's `darken("")` did), so callers fall back to their neutral look.
5
+ */
6
+ export function levelChip(color) {
7
+ if (!color)
8
+ return null;
9
+ try {
10
+ return { background: color, border: darken(color, 0.1), text: readableColor(color) };
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { FormatProvider } from "../data/format.js";
2
+ /**
3
+ * A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
4
+ * there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
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.
7
+ */
8
+ export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider, truncate?: boolean): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
3
+ * there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
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.
6
+ */
7
+ export function formatValue(value, precision, format, truncate = false) {
8
+ if (precision === undefined)
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
+ }
16
+ return format.number(value, {
17
+ minimumFractionDigits: precision,
18
+ maximumFractionDigits: precision
19
+ });
20
+ }
@@ -0,0 +1,11 @@
1
+ import type { FeatureListItem } from "../core/plugin.js";
2
+ import type { MessageProvider } from "../data/messages.js";
3
+ import type { Scale } from "../data/scale-types.js";
4
+ /**
5
+ * One line describing the listed features: the layer, then how many are at each level, in the
6
+ * scale's order, zero counts left out (items with no level to show come last). With no scale, or
7
+ * nothing counted, just the count and layer.
8
+ */
9
+ export declare function summaryText(layerLabel: string, items: FeatureListItem[], scale: Scale | null, messages: MessageProvider): string;
10
+ /** How long after the last camera move or selection change the live region is updated. */
11
+ export declare const SUMMARY_DEBOUNCE_MS = 750;
@@ -0,0 +1,33 @@
1
+ import { shownLevel } from "./feature-list.js";
2
+ const list = new Intl.ListFormat("en-US", { type: "unit" });
3
+ /**
4
+ * One line describing the listed features: the layer, then how many are at each level, in the
5
+ * scale's order, zero counts left out (items with no level to show come last). With no scale, or
6
+ * nothing counted, just the count and layer.
7
+ */
8
+ export function summaryText(layerLabel, items, scale, messages) {
9
+ const plain = () => messages("mapsdk_summary", { count: items.length, layer: layerLabel });
10
+ if (!scale || items.length === 0)
11
+ return plain();
12
+ const counts = new Map();
13
+ let none = 0;
14
+ for (const item of items) {
15
+ const level = shownLevel(item);
16
+ if (level && scale.levels.some((l) => l.key === level.key)) {
17
+ counts.set(level.key, (counts.get(level.key) ?? 0) + 1);
18
+ }
19
+ else {
20
+ none++;
21
+ }
22
+ }
23
+ const parts = scale.levels.flatMap((level) => {
24
+ const count = counts.get(level.key);
25
+ return count ? [messages("mapsdk_summary_level", { count, level: level.label })] : [];
26
+ });
27
+ if (none > 0) {
28
+ parts.push(messages("mapsdk_summary_level", { count: none, level: messages("mapsdk_no_level") }));
29
+ }
30
+ return messages("mapsdk_summary_with_levels", { layer: layerLabel, levels: list.format(parts) });
31
+ }
32
+ /** How long after the last camera move or selection change the live region is updated. */
33
+ export const SUMMARY_DEBOUNCE_MS = 750;