@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.
- package/LICENSE +21 -0
- package/README.md +713 -2
- package/dist/core/basemap.svelte.d.ts +38 -0
- package/dist/core/basemap.svelte.js +163 -0
- package/dist/core/context.d.ts +5 -0
- package/dist/core/context.js +13 -0
- package/dist/core/create-map.svelte.d.ts +274 -0
- package/dist/core/create-map.svelte.js +591 -0
- package/dist/core/gl.d.ts +97 -0
- package/dist/core/gl.js +1 -0
- package/dist/core/interactions.svelte.d.ts +50 -0
- package/dist/core/interactions.svelte.js +330 -0
- package/dist/core/plugin-host.svelte.d.ts +71 -0
- package/dist/core/plugin-host.svelte.js +445 -0
- package/dist/core/plugin.d.ts +190 -0
- package/dist/core/plugin.js +1 -0
- package/dist/core/scoped-context.d.ts +7 -0
- package/dist/core/scoped-context.js +34 -0
- package/dist/core/shape-view.d.ts +27 -0
- package/dist/core/shape-view.js +323 -0
- package/dist/data/format.d.ts +10 -0
- package/dist/data/format.js +37 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +9 -0
- package/dist/data/messages/en.json +61 -0
- package/dist/data/messages.d.ts +10 -0
- package/dist/data/messages.js +17 -0
- package/dist/data/metadata.d.ts +22 -0
- package/dist/data/metadata.js +110 -0
- package/dist/data/refs.d.ts +18 -0
- package/dist/data/refs.js +61 -0
- package/dist/data/scale-types.d.ts +7 -0
- package/dist/data/scale-types.js +1 -0
- package/dist/data/scale.d.ts +13 -0
- package/dist/data/scale.js +27 -0
- package/dist/data/selection.d.ts +13 -0
- package/dist/data/selection.js +52 -0
- package/dist/data/static-source.d.ts +4 -0
- package/dist/data/static-source.js +3 -0
- package/dist/data/types.d.ts +74 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +42 -0
- package/dist/plugins/icons.d.ts +21 -0
- package/dist/plugins/icons.js +73 -0
- package/dist/plugins/points-clusters.d.ts +60 -0
- package/dist/plugins/points-clusters.js +93 -0
- package/dist/plugins/points.svelte.d.ts +105 -0
- package/dist/plugins/points.svelte.js +502 -0
- package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
- package/dist/plugins/scale-resolution.svelte.js +111 -0
- package/dist/plugins/shapes.svelte.d.ts +34 -0
- package/dist/plugins/shapes.svelte.js +325 -0
- package/dist/plugins/shared.d.ts +6 -0
- package/dist/plugins/shared.js +18 -0
- package/dist/plugins/style.d.ts +57 -0
- package/dist/plugins/style.js +193 -0
- package/dist/presets/CollocationTooltip.svelte +132 -0
- package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
- package/dist/presets/FireTooltip.svelte +70 -0
- package/dist/presets/FireTooltip.svelte.d.ts +13 -0
- package/dist/presets/MonitorClusterTooltip.svelte +168 -0
- package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
- package/dist/presets/MonitorReadingBox.svelte +58 -0
- package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
- package/dist/presets/MonitorTooltip.svelte +88 -0
- package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
- package/dist/presets/calheatscore.d.ts +24 -0
- package/dist/presets/calheatscore.js +22 -0
- package/dist/presets/cluster-area.d.ts +13 -0
- package/dist/presets/cluster-area.js +34 -0
- package/dist/presets/collocation-sites.d.ts +18 -0
- package/dist/presets/collocation-sites.js +67 -0
- package/dist/presets/fire-groups.d.ts +26 -0
- package/dist/presets/fire-groups.js +51 -0
- package/dist/presets/forecast-zones.d.ts +25 -0
- package/dist/presets/forecast-zones.js +23 -0
- package/dist/presets/hms-fire.d.ts +30 -0
- package/dist/presets/hms-fire.js +86 -0
- package/dist/presets/hms-smoke.d.ts +20 -0
- package/dist/presets/hms-smoke.js +22 -0
- package/dist/presets/monitors.d.ts +48 -0
- package/dist/presets/monitors.js +243 -0
- package/dist/presets/reactive-props.svelte.d.ts +4 -0
- package/dist/presets/reactive-props.svelte.js +7 -0
- package/dist/presets/regions.d.ts +24 -0
- package/dist/presets/regions.js +22 -0
- package/dist/stores/abort.d.ts +16 -0
- package/dist/stores/abort.js +52 -0
- package/dist/stores/collocation-store.svelte.d.ts +25 -0
- package/dist/stores/collocation-store.svelte.js +72 -0
- package/dist/stores/hms-stores.svelte.d.ts +38 -0
- package/dist/stores/hms-stores.svelte.js +97 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.js +4 -0
- package/dist/stores/load-once.svelte.d.ts +25 -0
- package/dist/stores/load-once.svelte.js +93 -0
- package/dist/stores/monitors-store.svelte.d.ts +85 -0
- package/dist/stores/monitors-store.svelte.js +340 -0
- package/dist/theme.css +34 -0
- package/dist/ui/FeatureList.svelte +501 -0
- package/dist/ui/FeatureList.svelte.d.ts +14 -0
- package/dist/ui/Legend.svelte +214 -0
- package/dist/ui/Legend.svelte.d.ts +22 -0
- package/dist/ui/LegendBar.svelte +161 -0
- package/dist/ui/LegendBar.svelte.d.ts +11 -0
- package/dist/ui/MapControls.svelte +135 -0
- package/dist/ui/MapControls.svelte.d.ts +11 -0
- package/dist/ui/MapTooltip.svelte +397 -0
- package/dist/ui/MapTooltip.svelte.d.ts +37 -0
- package/dist/ui/PickNotice.svelte +56 -0
- package/dist/ui/PickNotice.svelte.d.ts +7 -0
- package/dist/ui/SelectionBar.svelte +123 -0
- package/dist/ui/SelectionBar.svelte.d.ts +10 -0
- package/dist/ui/TooltipItem.svelte +66 -0
- package/dist/ui/TooltipItem.svelte.d.ts +11 -0
- package/dist/ui/ViewSummary.svelte +97 -0
- package/dist/ui/ViewSummary.svelte.d.ts +7 -0
- package/dist/ui/card.d.ts +29 -0
- package/dist/ui/card.js +1 -0
- package/dist/ui/controls.css +45 -0
- package/dist/ui/data-credits.svelte.d.ts +9 -0
- package/dist/ui/data-credits.svelte.js +52 -0
- package/dist/ui/feature-list.d.ts +46 -0
- package/dist/ui/feature-list.js +115 -0
- package/dist/ui/legend-data.d.ts +41 -0
- package/dist/ui/legend-data.js +40 -0
- package/dist/ui/level-chip.d.ts +11 -0
- package/dist/ui/level-chip.js +15 -0
- package/dist/ui/value-format.d.ts +8 -0
- package/dist/ui/value-format.js +20 -0
- package/dist/ui/view-summary.d.ts +11 -0
- package/dist/ui/view-summary.js +33 -0
- package/dist/view/MapView.svelte +443 -0
- package/dist/view/MapView.svelte.d.ts +51 -0
- package/dist/view/create-gl-map.d.ts +34 -0
- package/dist/view/create-gl-map.js +186 -0
- package/dist/view/list-placement.d.ts +21 -0
- package/dist/view/list-placement.js +13 -0
- package/package.json +104 -5
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import type { Attachment } from "svelte/attachments";
|
|
4
|
+
import type { MapContext } from "../core/create-map.svelte.js";
|
|
5
|
+
import type { GlMapLike } from "../core/gl.js";
|
|
6
|
+
import type { FeatureListItem, MapPlugin } from "../core/plugin.js";
|
|
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";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
map: MapContext;
|
|
15
|
+
/**
|
|
16
|
+
* Canvas pixel for a position, or `null` while there is no GL map. Reactive: it should
|
|
17
|
+
* read something that changes when the camera moves, so the tooltip follows the map.
|
|
18
|
+
*/
|
|
19
|
+
project: (lngLat: [number, number]) => { x: number; y: number } | null;
|
|
20
|
+
/** The map container's size; the tooltip stays inside it. Unset: no clamping. */
|
|
21
|
+
width?: number;
|
|
22
|
+
height?: number;
|
|
23
|
+
/**
|
|
24
|
+
* `"pinned"` (default) anchors the tooltip to its feature; `"pointer"` shows it at the last
|
|
25
|
+
* pointer position and hides it on `gl`'s `zoomstart` until the next pointer move over a
|
|
26
|
+
* hit.
|
|
27
|
+
* A keyboard-focused list item is always pinned.
|
|
28
|
+
*/
|
|
29
|
+
mode?: "pinned" | "pointer";
|
|
30
|
+
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
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]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
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);
|
|
54
|
+
|
|
55
|
+
// Pointer mode only: set by a zoom, cleared by the next pointer move over a hit.
|
|
56
|
+
let zoomHidden = $state(false);
|
|
57
|
+
$effect(() => {
|
|
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;
|
|
61
|
+
const onZoomStart = () => (zoomHidden = true);
|
|
62
|
+
g.on("zoomstart", onZoomStart);
|
|
63
|
+
return () => g.off("zoomstart", onZoomStart);
|
|
64
|
+
});
|
|
65
|
+
$effect(() => {
|
|
66
|
+
// A new hovered object comes with every pointer move over a hit.
|
|
67
|
+
if (map.hovered) zoomHidden = false;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let tipWidth = $state(0);
|
|
71
|
+
let tipHeight = $state(0);
|
|
72
|
+
const GAP = 12;
|
|
73
|
+
|
|
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);
|
|
78
|
+
// Primitives, so moving the pointer within one feature doesn't recompute the content.
|
|
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));
|
|
83
|
+
|
|
84
|
+
// Reads only the shown feature's plugin (or all plugins for a focused key), and only while
|
|
85
|
+
// something is shown, so an idle map doesn't subscribe to any plugin's data.
|
|
86
|
+
const found = $derived.by((): { item: FeatureListItem; plugin: MapPlugin } | null => {
|
|
87
|
+
if (key === null) return null;
|
|
88
|
+
// The hovered hit's plugin, or the focused one; without either, the first plugin listing the key.
|
|
89
|
+
const pluginId = hitPluginId ?? map.focusedPluginId;
|
|
90
|
+
const plugins = pluginId ? map.plugins.filter((p) => p.id === pluginId) : map.plugins;
|
|
91
|
+
for (const plugin of plugins) {
|
|
92
|
+
const item = plugin.features?.().find((f) => f.refKey === key);
|
|
93
|
+
if (item) return { item, plugin };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const item = $derived(found?.item ?? null);
|
|
99
|
+
const name = $derived(item?.name ?? hitName);
|
|
100
|
+
// A found item anchors the tooltip at its point (stable while the pointer moves over it).
|
|
101
|
+
// In pointer mode a hovered hit is anchored at the pointer instead (a focused item stays
|
|
102
|
+
// pinned).
|
|
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
|
|
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
|
+
});
|
|
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
|
+
);
|
|
130
|
+
// Centered above the point; flipped below it near the top edge; inside the container.
|
|
131
|
+
const position = $derived.by(() => {
|
|
132
|
+
if (!projected) return null;
|
|
133
|
+
let { x, y } = projected;
|
|
134
|
+
if (width)
|
|
135
|
+
x = Math.min(Math.max(x, tipWidth / 2), Math.max(width - tipWidth / 2, tipWidth / 2));
|
|
136
|
+
let top = y - tipHeight - GAP;
|
|
137
|
+
if (top < 0) top = y + GAP;
|
|
138
|
+
if (height) top = Math.min(top, Math.max(height - tipHeight, 0));
|
|
139
|
+
return { left: x - tipWidth / 2, top: Math.max(top, 0) };
|
|
140
|
+
});
|
|
141
|
+
const custom = $derived.by(() => {
|
|
142
|
+
if (found) return found.plugin.tooltip?.(found.item);
|
|
143
|
+
// A hovered hit no plugin lists (e.g. a cluster bubble): its plugin's hover content.
|
|
144
|
+
if (key === null || hitPluginId === null) return undefined;
|
|
145
|
+
const plugin = map.plugins.find((p) => p.id === hitPluginId);
|
|
146
|
+
const hit = untrack(() => shownHit?.hit);
|
|
147
|
+
return hit && plugin?.hoverTooltip?.(hit);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// The name header is left out when the plugin's own tooltip content shows the name.
|
|
151
|
+
const showName = $derived.by(() => {
|
|
152
|
+
if (!name) return false;
|
|
153
|
+
if (!custom) return true;
|
|
154
|
+
const plugin = found?.plugin ?? map.plugins.find((p) => p.id === hitPluginId);
|
|
155
|
+
return !plugin?.tooltipShowsName;
|
|
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
|
+
});
|
|
270
|
+
</script>
|
|
271
|
+
|
|
272
|
+
<!-- Only with something to show: a hit with no name, item, or content gets no empty box. -->
|
|
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)}
|
|
310
|
+
<div
|
|
311
|
+
class="mapsdk-tooltip text-sm"
|
|
312
|
+
role="tooltip"
|
|
313
|
+
bind:offsetWidth={tipWidth}
|
|
314
|
+
bind:offsetHeight={tipHeight}
|
|
315
|
+
style:left="{position.left}px"
|
|
316
|
+
style:top="{position.top}px"
|
|
317
|
+
>
|
|
318
|
+
{#if showName}
|
|
319
|
+
<div class="mapsdk-tooltip-name">{name}</div>
|
|
320
|
+
{/if}
|
|
321
|
+
{#if custom}
|
|
322
|
+
{@render custom()}
|
|
323
|
+
{:else if item}
|
|
324
|
+
<TooltipItem {item} messages={map.messages} format={map.format} />
|
|
325
|
+
{/if}
|
|
326
|
+
</div>
|
|
327
|
+
{/if}
|
|
328
|
+
|
|
329
|
+
<style>
|
|
330
|
+
.mapsdk-tooltip {
|
|
331
|
+
position: absolute;
|
|
332
|
+
z-index: 1;
|
|
333
|
+
display: flex;
|
|
334
|
+
flex-direction: column;
|
|
335
|
+
align-items: flex-start;
|
|
336
|
+
gap: 0.25rem;
|
|
337
|
+
max-width: 16rem;
|
|
338
|
+
box-sizing: border-box;
|
|
339
|
+
padding: 0.5rem 0.75rem;
|
|
340
|
+
border: 1px solid var(--card-border, var(--border));
|
|
341
|
+
border-radius: var(--radius-control, var(--radius));
|
|
342
|
+
background: var(--popover, var(--background));
|
|
343
|
+
color: var(--popover-foreground, var(--foreground));
|
|
344
|
+
box-shadow: var(--shadow-lifted, var(--shadow-card, none));
|
|
345
|
+
pointer-events: none;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* The monitor, cluster, and collocation tooltips (today's ~420px cards) mark their content
|
|
349
|
+
`mapsdk-tooltip-wide`: wide enough for a reading box and name, never wider than the map. */
|
|
350
|
+
.mapsdk-tooltip:has(:global(.mapsdk-tooltip-wide)) {
|
|
351
|
+
width: max-content;
|
|
352
|
+
max-width: min(28rem, calc(100% - 1rem));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.mapsdk-tooltip-name {
|
|
356
|
+
font-weight: 600;
|
|
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
|
+
}
|
|
397
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MapContext } from "../core/create-map.svelte.js";
|
|
2
|
+
import type { GlMapLike } from "../core/gl.js";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
import type { CardContext } from "./card.js";
|
|
5
|
+
interface Props {
|
|
6
|
+
map: MapContext;
|
|
7
|
+
/**
|
|
8
|
+
* Canvas pixel for a position, or `null` while there is no GL map. Reactive: it should
|
|
9
|
+
* read something that changes when the camera moves, so the tooltip follows the map.
|
|
10
|
+
*/
|
|
11
|
+
project: (lngLat: [number, number]) => {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
} | 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
|
+
* 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]>;
|
|
34
|
+
}
|
|
35
|
+
declare const MapTooltip: import("svelte").Component<Props, {}, "">;
|
|
36
|
+
type MapTooltip = ReturnType<typeof MapTooltip>;
|
|
37
|
+
export default 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,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;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FormatProvider } from "../data/format.js";
|
|
3
|
+
import type { MessageProvider } from "../data/messages.js";
|
|
4
|
+
import type { FeatureListItem } from "../core/plugin.js";
|
|
5
|
+
import { NO_LEVEL } from "../data/scale.js";
|
|
6
|
+
import { levelText, shownValue } from "./feature-list.js";
|
|
7
|
+
import { levelChip } from "./level-chip.js";
|
|
8
|
+
|
|
9
|
+
// The generic tooltip body under a feature's name: its value and a chip with its level. Shared
|
|
10
|
+
// by MapTooltip and by plugins that show it for features they don't list.
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
item: FeatureListItem;
|
|
14
|
+
messages: MessageProvider;
|
|
15
|
+
format: FormatProvider;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { item, messages, format }: Props = $props();
|
|
19
|
+
|
|
20
|
+
// A level with an empty color shows its label on the neutral chip.
|
|
21
|
+
const level = $derived(item.level !== NO_LEVEL ? item.level : null);
|
|
22
|
+
// The level's label, else the item's detail (plain text, no chip), else "No level".
|
|
23
|
+
const levelPlace = $derived(levelText(item, messages));
|
|
24
|
+
const chipColors = $derived.by(() => {
|
|
25
|
+
const chip = levelChip(level?.color);
|
|
26
|
+
return chip ? `background:${chip.background};color:${chip.text}` : "";
|
|
27
|
+
});
|
|
28
|
+
// A category reading shows only its level chip: its value is the level's code.
|
|
29
|
+
const valueText = $derived(shownValue(item, format));
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="mapsdk-tooltip-item">
|
|
33
|
+
{#if valueText !== null}
|
|
34
|
+
<div class="mapsdk-tooltip-value">
|
|
35
|
+
{valueText}{#if item.units} {item.units}{/if}
|
|
36
|
+
</div>
|
|
37
|
+
{/if}
|
|
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
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.mapsdk-tooltip-item {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
align-items: flex-start;
|
|
52
|
+
gap: 0.25rem;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.mapsdk-tooltip-chip {
|
|
56
|
+
padding: 0.0625rem 0.5rem;
|
|
57
|
+
border: var(--swatch-outline-width, 1px) solid
|
|
58
|
+
var(--swatch-outline-color, color-mix(in srgb, var(--foreground) 75%, var(--background)));
|
|
59
|
+
border-radius: 999px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.mapsdk-tooltip-chip.neutral {
|
|
63
|
+
background: var(--muted);
|
|
64
|
+
color: var(--muted-foreground);
|
|
65
|
+
}
|
|
66
|
+
</style>
|