@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.
- package/LICENSE +21 -0
- package/README.md +487 -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 +207 -0
- package/dist/core/create-map.svelte.js +376 -0
- package/dist/core/gl.d.ts +92 -0
- package/dist/core/gl.js +1 -0
- package/dist/core/interactions.svelte.d.ts +42 -0
- package/dist/core/interactions.svelte.js +213 -0
- package/dist/core/plugin-host.svelte.d.ts +46 -0
- package/dist/core/plugin-host.svelte.js +366 -0
- package/dist/core/plugin.d.ts +148 -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/data/format.d.ts +10 -0
- package/dist/data/format.js +24 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +9 -0
- package/dist/data/messages/en.json +51 -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 +7 -0
- package/dist/data/selection.js +31 -0
- package/dist/data/static-source.d.ts +4 -0
- package/dist/data/static-source.js +3 -0
- package/dist/data/types.d.ts +69 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +41 -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 +93 -0
- package/dist/plugins/points.svelte.js +473 -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 +29 -0
- package/dist/plugins/shapes.svelte.js +307 -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/MonitorClusterTooltip.svelte +149 -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/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 +26 -0
- package/dist/presets/hms-fire.js +87 -0
- package/dist/presets/hms-smoke.d.ts +19 -0
- package/dist/presets/hms-smoke.js +20 -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 +35 -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 +172 -0
- package/dist/ui/Legend.svelte.d.ts +13 -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 +158 -0
- package/dist/ui/MapTooltip.svelte.d.ts +28 -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/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 +31 -0
- package/dist/ui/feature-list.js +80 -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 +7 -0
- package/dist/ui/value-format.js +13 -0
- package/dist/ui/view-summary.d.ts +11 -0
- package/dist/ui/view-summary.js +33 -0
- package/dist/view/MapView.svelte +373 -0
- package/dist/view/MapView.svelte.d.ts +43 -0
- package/dist/view/create-gl-map.d.ts +34 -0
- package/dist/view/create-gl-map.js +182 -0
- package/dist/view/list-placement.d.ts +21 -0
- package/dist/view/list-placement.js +13 -0
- 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">−</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;
|