@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.1

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +487 -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 +207 -0
  8. package/dist/core/create-map.svelte.js +376 -0
  9. package/dist/core/gl.d.ts +92 -0
  10. package/dist/core/gl.js +1 -0
  11. package/dist/core/interactions.svelte.d.ts +42 -0
  12. package/dist/core/interactions.svelte.js +213 -0
  13. package/dist/core/plugin-host.svelte.d.ts +46 -0
  14. package/dist/core/plugin-host.svelte.js +366 -0
  15. package/dist/core/plugin.d.ts +148 -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/data/format.d.ts +10 -0
  20. package/dist/data/format.js +24 -0
  21. package/dist/data/index.d.ts +9 -0
  22. package/dist/data/index.js +9 -0
  23. package/dist/data/messages/en.json +51 -0
  24. package/dist/data/messages.d.ts +10 -0
  25. package/dist/data/messages.js +17 -0
  26. package/dist/data/metadata.d.ts +22 -0
  27. package/dist/data/metadata.js +110 -0
  28. package/dist/data/refs.d.ts +18 -0
  29. package/dist/data/refs.js +61 -0
  30. package/dist/data/scale-types.d.ts +7 -0
  31. package/dist/data/scale-types.js +1 -0
  32. package/dist/data/scale.d.ts +13 -0
  33. package/dist/data/scale.js +27 -0
  34. package/dist/data/selection.d.ts +7 -0
  35. package/dist/data/selection.js +31 -0
  36. package/dist/data/static-source.d.ts +4 -0
  37. package/dist/data/static-source.js +3 -0
  38. package/dist/data/types.d.ts +69 -0
  39. package/dist/data/types.js +1 -0
  40. package/dist/index.d.ts +56 -0
  41. package/dist/index.js +41 -0
  42. package/dist/plugins/icons.d.ts +21 -0
  43. package/dist/plugins/icons.js +73 -0
  44. package/dist/plugins/points-clusters.d.ts +60 -0
  45. package/dist/plugins/points-clusters.js +93 -0
  46. package/dist/plugins/points.svelte.d.ts +93 -0
  47. package/dist/plugins/points.svelte.js +473 -0
  48. package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
  49. package/dist/plugins/scale-resolution.svelte.js +111 -0
  50. package/dist/plugins/shapes.svelte.d.ts +29 -0
  51. package/dist/plugins/shapes.svelte.js +307 -0
  52. package/dist/plugins/shared.d.ts +6 -0
  53. package/dist/plugins/shared.js +18 -0
  54. package/dist/plugins/style.d.ts +57 -0
  55. package/dist/plugins/style.js +193 -0
  56. package/dist/presets/CollocationTooltip.svelte +132 -0
  57. package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
  58. package/dist/presets/MonitorClusterTooltip.svelte +149 -0
  59. package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
  60. package/dist/presets/MonitorReadingBox.svelte +58 -0
  61. package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
  62. package/dist/presets/MonitorTooltip.svelte +88 -0
  63. package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
  64. package/dist/presets/calheatscore.d.ts +24 -0
  65. package/dist/presets/calheatscore.js +22 -0
  66. package/dist/presets/collocation-sites.d.ts +18 -0
  67. package/dist/presets/collocation-sites.js +67 -0
  68. package/dist/presets/fire-groups.d.ts +26 -0
  69. package/dist/presets/fire-groups.js +51 -0
  70. package/dist/presets/forecast-zones.d.ts +25 -0
  71. package/dist/presets/forecast-zones.js +23 -0
  72. package/dist/presets/hms-fire.d.ts +26 -0
  73. package/dist/presets/hms-fire.js +87 -0
  74. package/dist/presets/hms-smoke.d.ts +19 -0
  75. package/dist/presets/hms-smoke.js +20 -0
  76. package/dist/presets/monitors.d.ts +48 -0
  77. package/dist/presets/monitors.js +243 -0
  78. package/dist/presets/reactive-props.svelte.d.ts +4 -0
  79. package/dist/presets/reactive-props.svelte.js +7 -0
  80. package/dist/presets/regions.d.ts +24 -0
  81. package/dist/presets/regions.js +22 -0
  82. package/dist/stores/abort.d.ts +16 -0
  83. package/dist/stores/abort.js +52 -0
  84. package/dist/stores/collocation-store.svelte.d.ts +25 -0
  85. package/dist/stores/collocation-store.svelte.js +72 -0
  86. package/dist/stores/hms-stores.svelte.d.ts +35 -0
  87. package/dist/stores/hms-stores.svelte.js +97 -0
  88. package/dist/stores/index.d.ts +4 -0
  89. package/dist/stores/index.js +4 -0
  90. package/dist/stores/load-once.svelte.d.ts +25 -0
  91. package/dist/stores/load-once.svelte.js +93 -0
  92. package/dist/stores/monitors-store.svelte.d.ts +85 -0
  93. package/dist/stores/monitors-store.svelte.js +340 -0
  94. package/dist/theme.css +34 -0
  95. package/dist/ui/FeatureList.svelte +501 -0
  96. package/dist/ui/FeatureList.svelte.d.ts +14 -0
  97. package/dist/ui/Legend.svelte +172 -0
  98. package/dist/ui/Legend.svelte.d.ts +13 -0
  99. package/dist/ui/LegendBar.svelte +161 -0
  100. package/dist/ui/LegendBar.svelte.d.ts +11 -0
  101. package/dist/ui/MapControls.svelte +135 -0
  102. package/dist/ui/MapControls.svelte.d.ts +11 -0
  103. package/dist/ui/MapTooltip.svelte +158 -0
  104. package/dist/ui/MapTooltip.svelte.d.ts +28 -0
  105. package/dist/ui/SelectionBar.svelte +123 -0
  106. package/dist/ui/SelectionBar.svelte.d.ts +10 -0
  107. package/dist/ui/TooltipItem.svelte +66 -0
  108. package/dist/ui/TooltipItem.svelte.d.ts +11 -0
  109. package/dist/ui/ViewSummary.svelte +97 -0
  110. package/dist/ui/ViewSummary.svelte.d.ts +7 -0
  111. package/dist/ui/controls.css +45 -0
  112. package/dist/ui/data-credits.svelte.d.ts +9 -0
  113. package/dist/ui/data-credits.svelte.js +52 -0
  114. package/dist/ui/feature-list.d.ts +31 -0
  115. package/dist/ui/feature-list.js +80 -0
  116. package/dist/ui/legend-data.d.ts +41 -0
  117. package/dist/ui/legend-data.js +40 -0
  118. package/dist/ui/level-chip.d.ts +11 -0
  119. package/dist/ui/level-chip.js +15 -0
  120. package/dist/ui/value-format.d.ts +7 -0
  121. package/dist/ui/value-format.js +13 -0
  122. package/dist/ui/view-summary.d.ts +11 -0
  123. package/dist/ui/view-summary.js +33 -0
  124. package/dist/view/MapView.svelte +373 -0
  125. package/dist/view/MapView.svelte.d.ts +43 -0
  126. package/dist/view/create-gl-map.d.ts +34 -0
  127. package/dist/view/create-gl-map.js +182 -0
  128. package/dist/view/list-placement.d.ts +21 -0
  129. package/dist/view/list-placement.js +13 -0
  130. package/package.json +103 -5
@@ -0,0 +1,161 @@
1
+ <script lang="ts">
2
+ import type { MessageProvider } from "../data/messages.js";
3
+ import { swatchOverBackground, type LegendBar } from "./legend-data.js";
4
+
5
+ interface Props {
6
+ bar: LegendBar;
7
+ /** The page background as a color color2k can parse. */
8
+ background: string;
9
+ messages: MessageProvider;
10
+ }
11
+
12
+ let { bar, background, messages }: Props = $props();
13
+
14
+ // An empty color (a level with none) gets a neutral swatch rather than nothing.
15
+ const NEUTRAL = "var(--muted-foreground)";
16
+
17
+ const items = $derived(
18
+ bar.kind === "gradient"
19
+ ? bar.stops.map((stop) => ({
20
+ color: stop.color || NEUTRAL,
21
+ label: stop.label,
22
+ // The ticks are aria-hidden, so their numbers reach screen readers here.
23
+ threshold: stop.tick ? messages("mapsdk_legend_threshold", { value: stop.tick }) : ""
24
+ }))
25
+ : bar.blocks.map((block) => ({
26
+ color: block.color
27
+ ? swatchOverBackground(block.color, block.opacity, background)
28
+ : NEUTRAL,
29
+ label: block.label,
30
+ threshold: block.tick ? messages("mapsdk_legend_threshold", { value: block.tick }) : ""
31
+ }))
32
+ );
33
+
34
+ const gradient = $derived(
35
+ bar.kind === "gradient" && bar.stops.length > 0
36
+ ? bar.stops.length === 1
37
+ ? items[0].color
38
+ : `linear-gradient(to right, ${items.map((item) => item.color).join(", ")})`
39
+ : null
40
+ );
41
+ </script>
42
+
43
+ {#if bar.kind === "gradient" && gradient}
44
+ <div class="mapsdk-legend-gradient" style:background={gradient} aria-hidden="true"></div>
45
+ <div class="mapsdk-legend-ticks text-xs" aria-hidden="true">
46
+ {#each bar.stops as stop, i (i)}
47
+ <span>{stop.tick}</span>
48
+ {/each}
49
+ </div>
50
+ {/if}
51
+ {#if bar.kind === "blocks"}
52
+ <!-- One contiguous bar, each level's label under its segment (a list for screen readers). -->
53
+ <div class="mapsdk-legend-blocks" aria-hidden="true">
54
+ {#each items as item, i (i)}
55
+ <span class="mapsdk-legend-block" style:background={item.color}></span>
56
+ {/each}
57
+ </div>
58
+ <ul class="mapsdk-legend-block-labels text-xs" style:--blocks={items.length}>
59
+ {#each items as item, i (i)}
60
+ <li>
61
+ {item.label}
62
+ {#if item.threshold}<span class="mapsdk-legend-threshold">{item.threshold}</span>{/if}
63
+ </li>
64
+ {/each}
65
+ </ul>
66
+ {:else}
67
+ <ul class="mapsdk-legend-items text-xs">
68
+ {#each items as item, i (i)}
69
+ <li>
70
+ <span class="mapsdk-legend-swatch" style:background={item.color}></span>
71
+ <span>{item.label}</span>
72
+ {#if item.threshold}<span class="mapsdk-legend-threshold">{item.threshold}</span>{/if}
73
+ </li>
74
+ {/each}
75
+ </ul>
76
+ {/if}
77
+
78
+ <style>
79
+ .mapsdk-legend-gradient {
80
+ height: 0.75rem;
81
+ border-radius: 0.25rem;
82
+ outline: var(--swatch-outline-width, 1px) solid
83
+ var(--swatch-outline-color, color-mix(in srgb, var(--foreground) 75%, var(--background)));
84
+ outline-offset: 1px;
85
+ }
86
+
87
+ .mapsdk-legend-blocks {
88
+ display: flex;
89
+ height: 0.75rem;
90
+ border-radius: 0.25rem;
91
+ overflow: hidden;
92
+ outline: var(--swatch-outline-width, 1px) solid
93
+ var(--swatch-outline-color, color-mix(in srgb, var(--foreground) 75%, var(--background)));
94
+ outline-offset: 1px;
95
+ }
96
+
97
+ .mapsdk-legend-block {
98
+ flex: 1 1 0;
99
+ }
100
+
101
+ /* Equal columns under the equal segments; long labels wrap inside their column. */
102
+ .mapsdk-legend-block-labels {
103
+ display: grid;
104
+ grid-template-columns: repeat(var(--blocks), minmax(0, 1fr));
105
+ margin: 0.375rem 0 0;
106
+ padding: 0;
107
+ list-style: none;
108
+ text-align: center;
109
+ }
110
+
111
+ .mapsdk-legend-block-labels li {
112
+ position: relative;
113
+ padding: 0 0.125rem;
114
+ overflow-wrap: anywhere;
115
+ }
116
+
117
+ .mapsdk-legend-ticks {
118
+ display: flex;
119
+ justify-content: space-between;
120
+ margin-top: 0.375rem;
121
+ font-variant-numeric: tabular-nums;
122
+ }
123
+
124
+ .mapsdk-legend-items {
125
+ display: flex;
126
+ flex-wrap: wrap;
127
+ gap: 0.25rem 0.75rem;
128
+ margin: 0.5rem 0 0;
129
+ padding: 0;
130
+ list-style: none;
131
+ }
132
+
133
+ /* Visually hidden, read after the label. */
134
+ .mapsdk-legend-threshold {
135
+ position: absolute;
136
+ width: 1px;
137
+ height: 1px;
138
+ overflow: hidden;
139
+ clip-path: inset(50%);
140
+ white-space: nowrap;
141
+ }
142
+
143
+ .mapsdk-legend-items li {
144
+ position: relative;
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 0.375rem;
148
+ }
149
+
150
+ /* The outline sits 1px off the swatch, over the background, so it needs 3:1 against the
151
+ background only; the gap keeps a swatch near the outline's own color readable. */
152
+ .mapsdk-legend-swatch {
153
+ width: 0.875rem;
154
+ height: 0.875rem;
155
+ flex: none;
156
+ border-radius: 0.125rem;
157
+ outline: var(--swatch-outline-width, 1px) solid
158
+ var(--swatch-outline-color, color-mix(in srgb, var(--foreground) 75%, var(--background)));
159
+ outline-offset: 1px;
160
+ }
161
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { MessageProvider } from "../data/messages.js";
2
+ import { type LegendBar } from "./legend-data.js";
3
+ interface Props {
4
+ bar: LegendBar;
5
+ /** The page background as a color color2k can parse. */
6
+ background: string;
7
+ messages: MessageProvider;
8
+ }
9
+ declare const LegendBar: import("svelte").Component<Props, {}, "">;
10
+ type LegendBar = ReturnType<typeof LegendBar>;
11
+ export default LegendBar;
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ import "./controls.css";
3
+ import type { MapContext } from "../core/create-map.svelte.js";
4
+ import type { GlMapLike } from "../core/gl.js";
5
+
6
+ interface Props {
7
+ map: MapContext;
8
+ /** The live GL map; the zoom buttons do nothing while it is `null`. */
9
+ gl: GlMapLike | null;
10
+ }
11
+
12
+ let { map, gl }: Props = $props();
13
+
14
+ function zoomBy(delta: number) {
15
+ if (!gl) return;
16
+ const reduced = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
17
+ gl.easeTo({ zoom: gl.getZoom() + delta, ...(reduced && { duration: 0 }) });
18
+ }
19
+ </script>
20
+
21
+ <div class="mapsdk-zoom" role="group" aria-label={map.messages("mapsdk_controls_label")}>
22
+ <button type="button" class="mapsdk-focus mapsdk-motion" disabled={!gl} onclick={() => zoomBy(1)}>
23
+ <span class="mapsdk-zoom-icon" aria-hidden="true">+</span>
24
+ <span class="mapsdk-sr">{map.messages("mapsdk_zoom_in")}</span>
25
+ <span class="mapsdk-zoom-tip mapsdk-motion text-xs" aria-hidden="true"
26
+ >{map.messages("mapsdk_zoom_in")}</span
27
+ >
28
+ </button>
29
+ <button
30
+ type="button"
31
+ class="mapsdk-focus mapsdk-motion"
32
+ disabled={!gl}
33
+ onclick={() => zoomBy(-1)}
34
+ >
35
+ <span class="mapsdk-zoom-icon" aria-hidden="true">&minus;</span>
36
+ <span class="mapsdk-sr">{map.messages("mapsdk_zoom_out")}</span>
37
+ <span class="mapsdk-zoom-tip mapsdk-motion text-xs" aria-hidden="true"
38
+ >{map.messages("mapsdk_zoom_out")}</span
39
+ >
40
+ </button>
41
+ </div>
42
+
43
+ <style>
44
+ .mapsdk-zoom {
45
+ position: absolute;
46
+ top: 0.75rem;
47
+ right: 0.75rem;
48
+ z-index: 2;
49
+ display: flex;
50
+ flex-direction: column;
51
+ border: 1px solid var(--card-border, var(--border));
52
+ border-radius: var(--radius-control, var(--radius));
53
+ background: var(--background);
54
+ box-shadow: var(--shadow-card, none);
55
+ pointer-events: auto;
56
+ }
57
+
58
+ .mapsdk-zoom button {
59
+ width: 2.25rem;
60
+ height: 2.25rem;
61
+ padding: 0;
62
+ border: 0;
63
+ background: transparent;
64
+ color: var(--foreground);
65
+ font: inherit;
66
+ cursor: pointer;
67
+ }
68
+
69
+ .mapsdk-zoom button + button {
70
+ border-top: 1px solid var(--card-border, var(--border));
71
+ }
72
+
73
+ .mapsdk-zoom button:disabled {
74
+ color: var(--muted-foreground);
75
+ cursor: default;
76
+ }
77
+
78
+ .mapsdk-zoom button {
79
+ position: relative;
80
+ }
81
+
82
+ /* The hover fill follows the group's rounded corners. */
83
+ .mapsdk-zoom button:first-child {
84
+ border-radius: var(--radius-control, var(--radius)) var(--radius-control, var(--radius)) 0 0;
85
+ }
86
+
87
+ .mapsdk-zoom button:last-child {
88
+ border-radius: 0 0 var(--radius-control, var(--radius)) var(--radius-control, var(--radius));
89
+ }
90
+
91
+ .mapsdk-zoom button:hover:not(:disabled) {
92
+ background: var(--accent, var(--muted));
93
+ }
94
+
95
+ /* Above the next button, so the ring isn't cut by it. */
96
+ .mapsdk-zoom button:focus-visible {
97
+ z-index: 1;
98
+ }
99
+
100
+ /* The visible name: on hover and focus, left of the button; the screen-reader text stays. */
101
+ .mapsdk-zoom-tip {
102
+ position: absolute;
103
+ top: 50%;
104
+ right: calc(100% + 0.5rem);
105
+ padding: 0.125rem 0.5rem;
106
+ border: 1px solid var(--card-border, var(--border));
107
+ border-radius: var(--radius-control, var(--radius));
108
+ background: var(--popover, var(--background));
109
+ color: var(--popover-foreground, var(--foreground));
110
+ box-shadow: var(--shadow-lifted, var(--shadow-card, none));
111
+ white-space: nowrap;
112
+ opacity: 0;
113
+ pointer-events: none;
114
+ transform: translateY(-50%);
115
+ }
116
+
117
+ .mapsdk-zoom button:hover .mapsdk-zoom-tip,
118
+ .mapsdk-zoom button:focus-visible .mapsdk-zoom-tip {
119
+ opacity: 1;
120
+ }
121
+
122
+ .mapsdk-zoom-icon {
123
+ font-size: 1.25rem;
124
+ line-height: 1;
125
+ }
126
+
127
+ .mapsdk-sr {
128
+ position: absolute;
129
+ width: 1px;
130
+ height: 1px;
131
+ overflow: hidden;
132
+ clip-path: inset(50%);
133
+ white-space: nowrap;
134
+ }
135
+ </style>
@@ -0,0 +1,11 @@
1
+ import "./controls.css";
2
+ import type { MapContext } from "../core/create-map.svelte.js";
3
+ import type { GlMapLike } from "../core/gl.js";
4
+ interface Props {
5
+ map: MapContext;
6
+ /** The live GL map; the zoom buttons do nothing while it is `null`. */
7
+ gl: GlMapLike | null;
8
+ }
9
+ declare const MapControls: import("svelte").Component<Props, {}, "">;
10
+ type MapControls = ReturnType<typeof MapControls>;
11
+ export default MapControls;
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import type { MapContext } from "../core/create-map.svelte.js";
4
+ import type { GlMapLike } from "../core/gl.js";
5
+ import type { FeatureListItem, MapPlugin } from "../core/plugin.js";
6
+ import TooltipItem from "./TooltipItem.svelte";
7
+
8
+ interface Props {
9
+ map: MapContext;
10
+ /**
11
+ * Canvas pixel for a position, or `null` while there is no GL map. Reactive: it should
12
+ * read something that changes when the camera moves, so the tooltip follows the map.
13
+ */
14
+ project: (lngLat: [number, number]) => { x: number; y: number } | null;
15
+ /** The map container's size; the tooltip stays inside it. Unset: no clamping. */
16
+ width?: number;
17
+ height?: number;
18
+ /**
19
+ * `"pinned"` (default) anchors the tooltip to its feature; `"pointer"` shows it at the last
20
+ * pointer position and hides it on `gl`'s `zoomstart` until the next pointer move over a
21
+ * hit.
22
+ * A keyboard-focused list item is always pinned.
23
+ */
24
+ mode?: "pinned" | "pointer";
25
+ /** The GL map, for `zoomstart` in `"pointer"` mode. */
26
+ gl?: Pick<GlMapLike, "on" | "off"> | null;
27
+ }
28
+
29
+ let { map, project, width, height, mode = "pinned", gl = null }: Props = $props();
30
+
31
+ // Pointer mode only: set by a zoom, cleared by the next pointer move over a hit.
32
+ let zoomHidden = $state(false);
33
+ $effect(() => {
34
+ if (mode !== "pointer" || !gl) return;
35
+ const onZoomStart = () => (zoomHidden = true);
36
+ gl.on("zoomstart", onZoomStart);
37
+ return () => gl.off("zoomstart", onZoomStart);
38
+ });
39
+ $effect(() => {
40
+ // A new hovered object comes with every pointer move over a hit.
41
+ if (map.hovered) zoomHidden = false;
42
+ });
43
+
44
+ let tipWidth = $state(0);
45
+ let tipHeight = $state(0);
46
+ const GAP = 12;
47
+
48
+ // The keyboard-focused list item wins over the pointer.
49
+ const key = $derived(map.focusedKey ?? map.hovered?.hit.refKey ?? null);
50
+ // 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));
53
+
54
+ // Reads only the shown feature's plugin (or all plugins for a focused key), and only while
55
+ // something is shown, so an idle map doesn't subscribe to any plugin's data.
56
+ const found = $derived.by((): { item: FeatureListItem; plugin: MapPlugin } | null => {
57
+ if (key === null) return null;
58
+ // The hovered hit's plugin, or the focused one; without either, the first plugin listing the key.
59
+ const pluginId = hitPluginId ?? map.focusedPluginId;
60
+ const plugins = pluginId ? map.plugins.filter((p) => p.id === pluginId) : map.plugins;
61
+ for (const plugin of plugins) {
62
+ const item = plugin.features?.().find((f) => f.refKey === key);
63
+ if (item) return { item, plugin };
64
+ }
65
+ return null;
66
+ });
67
+
68
+ const item = $derived(found?.item ?? null);
69
+ const name = $derived(item?.name ?? hitName);
70
+ // A found item anchors the tooltip at its point (stable while the pointer moves over it).
71
+ // In pointer mode a hovered hit is anchored at the pointer instead (a focused item stays
72
+ // pinned).
73
+ const anchor = $derived(
74
+ mode === "pointer" && !map.focusedKey
75
+ ? (map.hovered?.lngLat ?? null)
76
+ : (item?.lngLat ?? map.hovered?.lngLat ?? null)
77
+ );
78
+ const projected = $derived(anchor ? project(anchor) : null);
79
+ // Centered above the point; flipped below it near the top edge; inside the container.
80
+ const position = $derived.by(() => {
81
+ if (!projected) return null;
82
+ let { x, y } = projected;
83
+ if (width)
84
+ x = Math.min(Math.max(x, tipWidth / 2), Math.max(width - tipWidth / 2, tipWidth / 2));
85
+ let top = y - tipHeight - GAP;
86
+ if (top < 0) top = y + GAP;
87
+ if (height) top = Math.min(top, Math.max(height - tipHeight, 0));
88
+ return { left: x - tipWidth / 2, top: Math.max(top, 0) };
89
+ });
90
+ const custom = $derived.by(() => {
91
+ if (found) return found.plugin.tooltip?.(found.item);
92
+ // A hovered hit no plugin lists (e.g. a cluster bubble): its plugin's hover content.
93
+ if (key === null || hitPluginId === null) return undefined;
94
+ const plugin = map.plugins.find((p) => p.id === hitPluginId);
95
+ const hit = untrack(() => map.hovered?.hit);
96
+ return hit && plugin?.hoverTooltip?.(hit);
97
+ });
98
+
99
+ // The name header is left out when the plugin's own tooltip content shows the name.
100
+ const showName = $derived.by(() => {
101
+ if (!name) return false;
102
+ if (!custom) return true;
103
+ const plugin = found?.plugin ?? map.plugins.find((p) => p.id === hitPluginId);
104
+ return !plugin?.tooltipShowsName;
105
+ });
106
+ </script>
107
+
108
+ <!-- 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)}
110
+ <div
111
+ class="mapsdk-tooltip text-sm"
112
+ role="tooltip"
113
+ bind:offsetWidth={tipWidth}
114
+ bind:offsetHeight={tipHeight}
115
+ style:left="{position.left}px"
116
+ style:top="{position.top}px"
117
+ >
118
+ {#if showName}
119
+ <div class="mapsdk-tooltip-name">{name}</div>
120
+ {/if}
121
+ {#if custom}
122
+ {@render custom()}
123
+ {:else if item}
124
+ <TooltipItem {item} messages={map.messages} format={map.format} />
125
+ {/if}
126
+ </div>
127
+ {/if}
128
+
129
+ <style>
130
+ .mapsdk-tooltip {
131
+ position: absolute;
132
+ z-index: 1;
133
+ display: flex;
134
+ flex-direction: column;
135
+ align-items: flex-start;
136
+ gap: 0.25rem;
137
+ max-width: 16rem;
138
+ box-sizing: border-box;
139
+ padding: 0.5rem 0.75rem;
140
+ border: 1px solid var(--card-border, var(--border));
141
+ border-radius: var(--radius-control, var(--radius));
142
+ background: var(--popover, var(--background));
143
+ color: var(--popover-foreground, var(--foreground));
144
+ box-shadow: var(--shadow-lifted, var(--shadow-card, none));
145
+ pointer-events: none;
146
+ }
147
+
148
+ /* The monitor, cluster, and collocation tooltips (today's ~420px cards) mark their content
149
+ `mapsdk-tooltip-wide`: wide enough for a reading box and name, never wider than the map. */
150
+ .mapsdk-tooltip:has(:global(.mapsdk-tooltip-wide)) {
151
+ width: max-content;
152
+ max-width: min(28rem, calc(100% - 1rem));
153
+ }
154
+
155
+ .mapsdk-tooltip-name {
156
+ font-weight: 600;
157
+ }
158
+ </style>
@@ -0,0 +1,28 @@
1
+ import type { MapContext } from "../core/create-map.svelte.js";
2
+ import type { GlMapLike } from "../core/gl.js";
3
+ interface Props {
4
+ map: MapContext;
5
+ /**
6
+ * Canvas pixel for a position, or `null` while there is no GL map. Reactive: it should
7
+ * read something that changes when the camera moves, so the tooltip follows the map.
8
+ */
9
+ project: (lngLat: [number, number]) => {
10
+ x: number;
11
+ y: number;
12
+ } | null;
13
+ /** The map container's size; the tooltip stays inside it. Unset: no clamping. */
14
+ width?: number;
15
+ height?: number;
16
+ /**
17
+ * `"pinned"` (default) anchors the tooltip to its feature; `"pointer"` shows it at the last
18
+ * pointer position and hides it on `gl`'s `zoomstart` until the next pointer move over a
19
+ * hit.
20
+ * A keyboard-focused list item is always pinned.
21
+ */
22
+ mode?: "pinned" | "pointer";
23
+ /** The GL map, for `zoomstart` in `"pointer"` mode. */
24
+ gl?: Pick<GlMapLike, "on" | "off"> | null;
25
+ }
26
+ declare const MapTooltip: import("svelte").Component<Props, {}, "">;
27
+ type MapTooltip = ReturnType<typeof MapTooltip>;
28
+ export default MapTooltip;
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import type { MapContext } from "../core/create-map.svelte.js";
3
+ import "./controls.css";
4
+
5
+ interface Props {
6
+ map: MapContext;
7
+ /** Leaves room at the right for a top-right List button, as the bar does at the left. */
8
+ clearRight?: boolean;
9
+ }
10
+
11
+ let { map, clearRight = false }: Props = $props();
12
+
13
+ let bar: HTMLElement | undefined = $state();
14
+
15
+ // Every id counts, whether or not it is in the data: a host may select features that load later.
16
+ const entries = $derived(
17
+ map.plugins.flatMap((plugin) => {
18
+ const count = map.getSelection(plugin.id)?.ids.length ?? 0;
19
+ return count > 0 ? [{ id: plugin.id, layer: plugin.label ?? plugin.id, count }] : [];
20
+ })
21
+ );
22
+
23
+ /**
24
+ * Clears one plugin's selection. The button is about to go (or its row is), so focus moves
25
+ * first: to another plugin's Clear button, else the List button, else the map region.
26
+ */
27
+ function clear(id: string, button: HTMLElement) {
28
+ const another = [...(bar?.querySelectorAll<HTMLElement>("button") ?? [])].find(
29
+ (b) => b !== button
30
+ );
31
+ const region = bar?.closest<HTMLElement>("[data-mapsdk-map]");
32
+ const target = another ?? region?.querySelector<HTMLElement>(".mapsdk-list-button") ?? region;
33
+ if (target === region && region && !region.hasAttribute("tabindex")) {
34
+ region.setAttribute("tabindex", "-1");
35
+ }
36
+ target?.focus();
37
+ map.clearSelection(id);
38
+ }
39
+ </script>
40
+
41
+ {#if entries.length > 0}
42
+ <div
43
+ bind:this={bar}
44
+ class={["mapsdk-selection text-sm", clearRight && "mapsdk-selection-clear-right"]}
45
+ role="group"
46
+ aria-label={map.messages("mapsdk_selection_label")}
47
+ >
48
+ {#each entries as entry (entry.id)}
49
+ {@const selected = map.messages("mapsdk_selected_count", { count: entry.count })}
50
+ <div class="mapsdk-selection-row">
51
+ <span>
52
+ {entries.length > 1
53
+ ? map.messages("mapsdk_selected_in_layer", { layer: entry.layer, selected })
54
+ : selected}
55
+ </span>
56
+ <button
57
+ type="button"
58
+ class="mapsdk-focus mapsdk-motion"
59
+ aria-label={map.messages("mapsdk_selection_clear_layer", { layer: entry.layer })}
60
+ onclick={(e) => clear(entry.id, e.currentTarget)}
61
+ >
62
+ {map.messages("mapsdk_selection_clear")}
63
+ </button>
64
+ </div>
65
+ {/each}
66
+ </div>
67
+ {/if}
68
+
69
+ <style>
70
+ .mapsdk-selection {
71
+ position: absolute;
72
+ top: 0.75rem;
73
+ /* Clear of the List button (left) and the zoom buttons (right); centered between them. */
74
+ right: 4rem;
75
+ left: 5rem;
76
+ z-index: 2;
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 0.25rem;
80
+ box-sizing: border-box;
81
+ width: fit-content;
82
+ margin-inline: auto;
83
+ padding: 0.375rem 0.5rem 0.375rem 0.75rem;
84
+ border: 1px solid var(--card-border, var(--border));
85
+ border-radius: var(--radius-control, var(--radius));
86
+ background: var(--background);
87
+ color: var(--foreground);
88
+ box-shadow: var(--shadow-card, none);
89
+ pointer-events: auto;
90
+ }
91
+
92
+ .mapsdk-selection-clear-right {
93
+ right: 5rem;
94
+ }
95
+
96
+ .mapsdk-selection-row {
97
+ display: flex;
98
+ flex-wrap: wrap;
99
+ align-items: center;
100
+ justify-content: space-between;
101
+ gap: 0.25rem 0.75rem;
102
+ }
103
+
104
+ .mapsdk-selection-row span {
105
+ min-width: 0;
106
+ overflow-wrap: anywhere;
107
+ }
108
+
109
+ button {
110
+ flex: none;
111
+ padding: 0.25rem 0.625rem;
112
+ border: 1px solid var(--border);
113
+ border-radius: var(--radius-control, var(--radius));
114
+ background: var(--background);
115
+ color: var(--foreground);
116
+ font: inherit;
117
+ cursor: pointer;
118
+ }
119
+
120
+ button:hover {
121
+ background: var(--accent, var(--muted));
122
+ }
123
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { MapContext } from "../core/create-map.svelte.js";
2
+ import "./controls.css";
3
+ interface Props {
4
+ map: MapContext;
5
+ /** Leaves room at the right for a top-right List button, as the bar does at the left. */
6
+ clearRight?: boolean;
7
+ }
8
+ declare const SelectionBar: import("svelte").Component<Props, {}, "">;
9
+ type SelectionBar = ReturnType<typeof SelectionBar>;
10
+ export default SelectionBar;