@loidolt/theme-maps 0.7.0

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/dist/components/BasemapSwitcher.svelte +53 -0
  4. package/dist/components/BasemapSwitcher.svelte.d.ts +20 -0
  5. package/dist/components/CircleLayer.svelte +22 -0
  6. package/dist/components/CircleLayer.svelte.d.ts +4 -0
  7. package/dist/components/CoordinateDisplay.svelte +68 -0
  8. package/dist/components/CoordinateDisplay.svelte.d.ts +13 -0
  9. package/dist/components/DeckOverlay.svelte +58 -0
  10. package/dist/components/DeckOverlay.svelte.d.ts +18 -0
  11. package/dist/components/FillExtrusionLayer.svelte +17 -0
  12. package/dist/components/FillExtrusionLayer.svelte.d.ts +4 -0
  13. package/dist/components/FillLayer.svelte +21 -0
  14. package/dist/components/FillLayer.svelte.d.ts +4 -0
  15. package/dist/components/HeatmapLayer.svelte +36 -0
  16. package/dist/components/HeatmapLayer.svelte.d.ts +4 -0
  17. package/dist/components/LayerManager.svelte +234 -0
  18. package/dist/components/LayerManager.svelte.d.ts +24 -0
  19. package/dist/components/LineLayer.svelte +18 -0
  20. package/dist/components/LineLayer.svelte.d.ts +4 -0
  21. package/dist/components/MapControl.svelte +47 -0
  22. package/dist/components/MapControl.svelte.d.ts +13 -0
  23. package/dist/components/MapFeatureList.svelte +67 -0
  24. package/dist/components/MapFeatureList.svelte.d.ts +20 -0
  25. package/dist/components/MapLegend.svelte +68 -0
  26. package/dist/components/MapLegend.svelte.d.ts +19 -0
  27. package/dist/components/MapLegendGroup.svelte +72 -0
  28. package/dist/components/MapLegendGroup.svelte.d.ts +19 -0
  29. package/dist/components/MapMarker.svelte +130 -0
  30. package/dist/components/MapMarker.svelte.d.ts +32 -0
  31. package/dist/components/MapPopup.svelte +123 -0
  32. package/dist/components/MapPopup.svelte.d.ts +21 -0
  33. package/dist/components/MapSource.svelte +109 -0
  34. package/dist/components/MapSource.svelte.d.ts +31 -0
  35. package/dist/components/MapView.svelte +454 -0
  36. package/dist/components/MapView.svelte.d.ts +76 -0
  37. package/dist/components/RasterLayer.svelte +14 -0
  38. package/dist/components/RasterLayer.svelte.d.ts +4 -0
  39. package/dist/components/SymbolLayer.svelte +22 -0
  40. package/dist/components/SymbolLayer.svelte.d.ts +4 -0
  41. package/dist/core/accessibility.d.ts +24 -0
  42. package/dist/core/accessibility.js +33 -0
  43. package/dist/core/bounds.d.ts +18 -0
  44. package/dist/core/bounds.js +91 -0
  45. package/dist/core/cluster.d.ts +35 -0
  46. package/dist/core/cluster.js +44 -0
  47. package/dist/core/data.d.ts +33 -0
  48. package/dist/core/data.js +157 -0
  49. package/dist/core/equality.d.ts +7 -0
  50. package/dist/core/equality.js +33 -0
  51. package/dist/core/format.d.ts +20 -0
  52. package/dist/core/format.js +47 -0
  53. package/dist/core/index.d.ts +16 -0
  54. package/dist/core/index.js +18 -0
  55. package/dist/core/lod.d.ts +27 -0
  56. package/dist/core/lod.js +49 -0
  57. package/dist/core/route.d.ts +58 -0
  58. package/dist/core/route.js +182 -0
  59. package/dist/core/simplify.d.ts +9 -0
  60. package/dist/core/simplify.js +102 -0
  61. package/dist/core/spatial.d.ts +21 -0
  62. package/dist/core/spatial.js +88 -0
  63. package/dist/core/style.d.ts +43 -0
  64. package/dist/core/style.js +205 -0
  65. package/dist/core/types.d.ts +110 -0
  66. package/dist/core/types.js +5 -0
  67. package/dist/core/workers/client.d.ts +26 -0
  68. package/dist/core/workers/client.js +88 -0
  69. package/dist/core/workers/handler.d.ts +6 -0
  70. package/dist/core/workers/handler.js +53 -0
  71. package/dist/core/workers/protocol.d.ts +32 -0
  72. package/dist/core/workers/protocol.js +1 -0
  73. package/dist/core/workers/spatial.worker.d.ts +1 -0
  74. package/dist/core/workers/spatial.worker.js +12 -0
  75. package/dist/deck.d.ts +18 -0
  76. package/dist/deck.js +27 -0
  77. package/dist/index.d.ts +27 -0
  78. package/dist/index.js +23 -0
  79. package/dist/internal/context.d.ts +39 -0
  80. package/dist/internal/context.js +15 -0
  81. package/dist/internal/safely.d.ts +5 -0
  82. package/dist/internal/safely.js +12 -0
  83. package/dist/internal/use-layer.svelte.d.ts +45 -0
  84. package/dist/internal/use-layer.svelte.js +179 -0
  85. package/dist/maplibre.d.ts +19 -0
  86. package/dist/maplibre.js +47 -0
  87. package/dist/styles.css +10 -0
  88. package/package.json +76 -0
@@ -0,0 +1,234 @@
1
+ <script lang="ts">
2
+ import { tick, untrack } from 'svelte';
3
+ import {
4
+ IconButton,
5
+ LiveRegion,
6
+ Slider,
7
+ Switch,
8
+ createAnnouncer,
9
+ cx,
10
+ } from '@loidolt/theme-svelte';
11
+ import type { LayerState } from '../core/types.js';
12
+ import { requireMapContext, type LayerEntry } from '../internal/context.js';
13
+ import MapControl from './MapControl.svelte';
14
+
15
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
16
+
17
+ interface Props {
18
+ /** The layers to offer, by id. Defaults to every layer placed with a layer component. */
19
+ layers?: string[];
20
+ title?: string;
21
+ position?: Corner;
22
+ /** Offer an opacity slider per layer. */
23
+ showOpacity?: boolean;
24
+ /** Offer moving layers up and down (Alt + ↑ and ↓ on a row, too). */
25
+ allowReorder?: boolean;
26
+ /** Show the list. Bindable, and toggled by the heading. */
27
+ open?: boolean;
28
+ /** Every change: visibility, opacity or order. Top of the list first. */
29
+ onChange?: (layers: LayerState[]) => void;
30
+ opacityLabel?: (label: string) => string;
31
+ moveUpLabel?: (label: string) => string;
32
+ moveDownLabel?: (label: string) => string;
33
+ movedMessage?: (label: string, position: number, total: number) => string;
34
+ class?: string;
35
+ }
36
+
37
+ let {
38
+ layers: only,
39
+ title = 'Layers',
40
+ position = 'top-right',
41
+ showOpacity = true,
42
+ allowReorder = true,
43
+ open = $bindable(true),
44
+ onChange,
45
+ opacityLabel = (label) => `${label} opacity`,
46
+ moveUpLabel = (label) => `Move ${label} up`,
47
+ moveDownLabel = (label) => `Move ${label} down`,
48
+ movedMessage = (label, place, total) => `${label} moved to position ${place} of ${total}.`,
49
+ class: className,
50
+ }: Props = $props();
51
+
52
+ const context = requireMapContext('LayerManager');
53
+ const announcer = createAnnouncer();
54
+ const id = $props.id();
55
+ let list = $state<HTMLUListElement | null>(null);
56
+ let orderVersion = $state(0);
57
+ let states = $state<Record<string, { visible: boolean; opacity: number }>>({});
58
+
59
+ const OPACITY: Record<string, string[]> = {
60
+ fill: ['fill-opacity'],
61
+ line: ['line-opacity'],
62
+ circle: ['circle-opacity', 'circle-stroke-opacity'],
63
+ symbol: ['text-opacity', 'icon-opacity'],
64
+ 'fill-extrusion': ['fill-extrusion-opacity'],
65
+ heatmap: ['heatmap-opacity'],
66
+ raster: ['raster-opacity'],
67
+ background: ['background-opacity'],
68
+ };
69
+
70
+ /** Managed layers in draw order, topmost first — the order the list shows. */
71
+ const rows = $derived.by((): LayerEntry[] => {
72
+ void orderVersion;
73
+ void context.styleVersion;
74
+ const map = context.map;
75
+ const entries = context.layers.filter((entry) => !only || only.includes(entry.id));
76
+ if (!map) return entries;
77
+ const drawn = (map.getStyle()?.layers ?? []).map((layer) => layer.id);
78
+ return entries
79
+ .filter((entry) => drawn.includes(entry.id))
80
+ .sort((a, b) => drawn.indexOf(b.id) - drawn.indexOf(a.id));
81
+ });
82
+
83
+ // Read each layer's current state off the map as it arrives.
84
+ $effect(() => {
85
+ const map = context.map;
86
+ const current = rows;
87
+ if (!map) return;
88
+ untrack(() => {
89
+ for (const entry of current) {
90
+ if (states[entry.id]) continue;
91
+ const property = OPACITY[entry.type]?.[0];
92
+ const opacity = property ? map.getPaintProperty(entry.id, property as never) : undefined;
93
+ states[entry.id] = {
94
+ visible: map.getLayoutProperty(entry.id, 'visibility') !== 'none',
95
+ opacity: typeof opacity === 'number' ? opacity : 1,
96
+ };
97
+ }
98
+ });
99
+ });
100
+
101
+ const snapshot = (): LayerState[] =>
102
+ rows.map((entry) => ({
103
+ id: entry.id,
104
+ label: entry.label,
105
+ visible: states[entry.id]?.visible ?? true,
106
+ opacity: states[entry.id]?.opacity ?? 1,
107
+ }));
108
+
109
+ function setVisible(entry: LayerEntry, visible: boolean) {
110
+ context.map?.setLayoutProperty(entry.id, 'visibility', visible ? 'visible' : 'none');
111
+ states[entry.id] = { ...states[entry.id], visible };
112
+ onChange?.(snapshot());
113
+ }
114
+
115
+ function setOpacity(entry: LayerEntry, opacity: number) {
116
+ for (const property of OPACITY[entry.type] ?? []) {
117
+ context.map?.setPaintProperty(entry.id, property as never, opacity as never);
118
+ }
119
+ states[entry.id] = { ...states[entry.id], opacity };
120
+ onChange?.(snapshot());
121
+ }
122
+
123
+ /** Moves a layer one place up (drawn above its neighbour) or down, keeping focus with it. */
124
+ async function move(entry: LayerEntry, direction: 'up' | 'down') {
125
+ const map = context.map;
126
+ const index = rows.indexOf(entry);
127
+ const neighbour = rows[direction === 'up' ? index - 1 : index + 1];
128
+ if (!map || !neighbour) return;
129
+ if (direction === 'down') {
130
+ map.moveLayer(entry.id, neighbour.id);
131
+ } else {
132
+ // Above the neighbour means before whatever is drawn right after it, or on top.
133
+ const drawn = (map.getStyle()?.layers ?? []).map((layer) => layer.id);
134
+ map.moveLayer(entry.id, drawn[drawn.indexOf(neighbour.id) + 1]);
135
+ }
136
+ orderVersion += 1;
137
+ const place = rows.indexOf(entry) + 1;
138
+ announcer.announce(movedMessage(entry.label, place, rows.length));
139
+ onChange?.(snapshot());
140
+ // Reordering moves the row's DOM, which drops focus; put it back on the same control.
141
+ await tick();
142
+ const row = list?.querySelector<HTMLElement>(`[data-layer="${CSS.escape(entry.id)}"]`);
143
+ const preferred = row?.querySelector<HTMLButtonElement>(
144
+ `[data-move="${direction}"]:not(:disabled)`
145
+ );
146
+ (preferred ?? row?.querySelector<HTMLButtonElement>('[data-move]:not(:disabled)'))?.focus();
147
+ }
148
+
149
+ function onRowKeydown(event: KeyboardEvent, entry: LayerEntry) {
150
+ if (!allowReorder || !event.altKey) return;
151
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
152
+ event.preventDefault();
153
+ void move(entry, event.key === 'ArrowUp' ? 'up' : 'down');
154
+ }
155
+ </script>
156
+
157
+ <MapControl {position} class={cx('ldt-layer-manager', className)}>
158
+ <section aria-labelledby={`${id}-title`}>
159
+ <h3 class="ldt-layer-manager__title" id={`${id}-title`}>
160
+ <button
161
+ type="button"
162
+ class="ldt-layer-manager__toggle"
163
+ aria-expanded={open}
164
+ aria-controls={`${id}-list`}
165
+ onclick={() => (open = !open)}>{title}</button
166
+ >
167
+ </h3>
168
+ <ul bind:this={list} class="ldt-layer-manager__list" id={`${id}-list`} hidden={!open}>
169
+ {#each rows as entry, index (entry.id)}
170
+ {@const state = states[entry.id] ?? { visible: true, opacity: 1 }}
171
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
172
+ <li
173
+ class="ldt-layer-manager__row"
174
+ data-layer={entry.id}
175
+ onkeydown={(event) => onRowKeydown(event, entry)}
176
+ >
177
+ <Switch checked={state.visible} onCheckedChange={(visible) => setVisible(entry, visible)}
178
+ >{entry.label}</Switch
179
+ >
180
+ {#if allowReorder}
181
+ <span class="ldt-layer-manager__move">
182
+ <IconButton
183
+ size="sm"
184
+ label={moveUpLabel(entry.label)}
185
+ data-move="up"
186
+ disabled={index === 0}
187
+ onclick={() => move(entry, 'up')}
188
+ >
189
+ <svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true" focusable="false"
190
+ ><path
191
+ d="M5 8V2M2 5l3-3 3 3"
192
+ fill="none"
193
+ stroke="currentColor"
194
+ stroke-width="1.4"
195
+ /></svg
196
+ >
197
+ </IconButton>
198
+ <IconButton
199
+ size="sm"
200
+ label={moveDownLabel(entry.label)}
201
+ data-move="down"
202
+ disabled={index === rows.length - 1}
203
+ onclick={() => move(entry, 'down')}
204
+ >
205
+ <svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true" focusable="false"
206
+ ><path
207
+ d="M5 2v6M2 5l3 3 3-3"
208
+ fill="none"
209
+ stroke="currentColor"
210
+ stroke-width="1.4"
211
+ /></svg
212
+ >
213
+ </IconButton>
214
+ </span>
215
+ {/if}
216
+ {#if showOpacity && OPACITY[entry.type]}
217
+ <Slider
218
+ class="ldt-layer-manager__opacity"
219
+ label={opacityLabel(entry.label)}
220
+ min={0}
221
+ max={1}
222
+ step={0.05}
223
+ value={state.opacity}
224
+ disabled={!state.visible}
225
+ formatValue={(value) => `${Math.round(value * 100)}%`}
226
+ onValueChange={(value) => setOpacity(entry, value as number)}
227
+ />
228
+ {/if}
229
+ </li>
230
+ {/each}
231
+ </ul>
232
+ <LiveRegion message={announcer.message} politeness={announcer.politeness} />
233
+ </section>
234
+ </MapControl>
@@ -0,0 +1,24 @@
1
+ import type { LayerState } from '../core/types.js';
2
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
3
+ interface Props {
4
+ /** The layers to offer, by id. Defaults to every layer placed with a layer component. */
5
+ layers?: string[];
6
+ title?: string;
7
+ position?: Corner;
8
+ /** Offer an opacity slider per layer. */
9
+ showOpacity?: boolean;
10
+ /** Offer moving layers up and down (Alt + ↑ and ↓ on a row, too). */
11
+ allowReorder?: boolean;
12
+ /** Show the list. Bindable, and toggled by the heading. */
13
+ open?: boolean;
14
+ /** Every change: visibility, opacity or order. Top of the list first. */
15
+ onChange?: (layers: LayerState[]) => void;
16
+ opacityLabel?: (label: string) => string;
17
+ moveUpLabel?: (label: string) => string;
18
+ moveDownLabel?: (label: string) => string;
19
+ movedMessage?: (label: string, position: number, total: number) => string;
20
+ class?: string;
21
+ }
22
+ declare const LayerManager: import("svelte").Component<Props, {}, "open">;
23
+ type LayerManager = ReturnType<typeof LayerManager>;
24
+ export default LayerManager;
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import type { MapColors } from '@loidolt/theme-tokens';
3
+ import { useLayer, type LayerProps } from '../internal/use-layer.svelte.js';
4
+
5
+ /* Lines — routes, boundaries, outlines. Defaults to the accent colour. */
6
+ type Props = LayerProps;
7
+
8
+ let props: Props = $props();
9
+
10
+ useLayer(
11
+ 'line',
12
+ () => props,
13
+ (colors: MapColors) => ({
14
+ paint: { 'line-color': colors.accent, 'line-width': 2.5 },
15
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
16
+ })
17
+ );
18
+ </script>
@@ -0,0 +1,4 @@
1
+ import { type LayerProps } from '../internal/use-layer.svelte.js';
2
+ declare const LineLayer: import("svelte").Component<LayerProps, {}, "">;
3
+ type LineLayer = ReturnType<typeof LineLayer>;
4
+ export default LineLayer;
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { IControl } from 'maplibre-gl';
5
+ import { cx } from '@loidolt/theme-svelte';
6
+ import { requireMapContext } from '../internal/context.js';
7
+ import { safely } from '../internal/safely.js';
8
+
9
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
10
+
11
+ interface Props extends HTMLAttributes<HTMLDivElement> {
12
+ /** The corner it sits in, stacked with MapLibre's own controls there. */
13
+ position?: Corner;
14
+ children?: Snippet;
15
+ class?: string;
16
+ ref?: HTMLDivElement | null;
17
+ }
18
+
19
+ let {
20
+ position = 'top-left',
21
+ children,
22
+ class: className,
23
+ ref = $bindable(null),
24
+ ...rest
25
+ }: Props = $props();
26
+
27
+ const context = requireMapContext('MapControl');
28
+
29
+ $effect(() => {
30
+ const target = context.map;
31
+ const node = ref;
32
+ const corner = position;
33
+ if (!target || !node) return;
34
+ const control: IControl = {
35
+ onAdd: () => node,
36
+ onRemove: () => node.remove(),
37
+ };
38
+ target.addControl(control, corner);
39
+ return () => safely(() => target.removeControl(control));
40
+ });
41
+ </script>
42
+
43
+ <div hidden>
44
+ <div bind:this={ref} class={cx('maplibregl-ctrl ldt-map-control', className)} {...rest}>
45
+ {@render children?.()}
46
+ </div>
47
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
4
+ interface Props extends HTMLAttributes<HTMLDivElement> {
5
+ /** The corner it sits in, stacked with MapLibre's own controls there. */
6
+ position?: Corner;
7
+ children?: Snippet;
8
+ class?: string;
9
+ ref?: HTMLDivElement | null;
10
+ }
11
+ declare const MapControl: import("svelte").Component<Props, {}, "ref">;
12
+ type MapControl = ReturnType<typeof MapControl>;
13
+ export default MapControl;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { Map as MapLibreMap } from 'maplibre-gl';
4
+ import { cx } from '@loidolt/theme-svelte';
5
+ import type { MapFeatureItem } from '../core/types.js';
6
+ import { getMapContext } from '../internal/context.js';
7
+
8
+ interface Props extends HTMLAttributes<HTMLElement> {
9
+ /** The places on the map, in reading order. */
10
+ items: MapFeatureItem[];
11
+ /** Names the list, e.g. "Stops on this route". */
12
+ label: string;
13
+ /** The map to move. Found automatically inside a `MapView`; pass it when the list sits beside one. */
14
+ map?: MapLibreMap | null;
15
+ /** Zoom level to fly to. */
16
+ zoom?: number;
17
+ /** The chosen item, highlighted. Bindable. */
18
+ selected?: string | number | null;
19
+ onSelect?: (item: MapFeatureItem) => void;
20
+ class?: string;
21
+ }
22
+
23
+ let {
24
+ items,
25
+ label,
26
+ map = null,
27
+ zoom = 14,
28
+ selected = $bindable(null),
29
+ onSelect,
30
+ class: className,
31
+ ...rest
32
+ }: Props = $props();
33
+
34
+ const context = getMapContext();
35
+ const reducedMotion =
36
+ typeof window !== 'undefined' &&
37
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
38
+
39
+ function choose(item: MapFeatureItem) {
40
+ selected = item.id;
41
+ const target = map ?? context?.map;
42
+ const move = { center: item.lngLat, zoom: Math.max(zoom, target?.getZoom() ?? 0) };
43
+ if (context?.reducedMotion ?? reducedMotion) target?.jumpTo(move);
44
+ else target?.flyTo(move);
45
+ onSelect?.(item);
46
+ }
47
+ </script>
48
+
49
+ <!-- Every place on the map, as a list: the non-visual way to find and go to one. -->
50
+ <nav class={cx('ldt-map-features', className)} aria-label={label} {...rest}>
51
+ <ul class="ldt-map-features__list">
52
+ {#each items as item (item.id)}
53
+ <li>
54
+ <button
55
+ type="button"
56
+ class="ldt-map-features__item"
57
+ aria-current={selected === item.id ? 'true' : undefined}
58
+ onclick={() => choose(item)}
59
+ >
60
+ <span class="ldt-map-features__label">{item.label}</span>
61
+ {#if item.description}<span class="ldt-map-features__description">{item.description}</span
62
+ >{/if}
63
+ </button>
64
+ </li>
65
+ {/each}
66
+ </ul>
67
+ </nav>
@@ -0,0 +1,20 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { Map as MapLibreMap } from 'maplibre-gl';
3
+ import type { MapFeatureItem } from '../core/types.js';
4
+ interface Props extends HTMLAttributes<HTMLElement> {
5
+ /** The places on the map, in reading order. */
6
+ items: MapFeatureItem[];
7
+ /** Names the list, e.g. "Stops on this route". */
8
+ label: string;
9
+ /** The map to move. Found automatically inside a `MapView`; pass it when the list sits beside one. */
10
+ map?: MapLibreMap | null;
11
+ /** Zoom level to fly to. */
12
+ zoom?: number;
13
+ /** The chosen item, highlighted. Bindable. */
14
+ selected?: string | number | null;
15
+ onSelect?: (item: MapFeatureItem) => void;
16
+ class?: string;
17
+ }
18
+ declare const MapFeatureList: import("svelte").Component<Props, {}, "selected">;
19
+ type MapFeatureList = ReturnType<typeof MapFeatureList>;
20
+ export default MapFeatureList;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { cx } from '@loidolt/theme-svelte';
4
+ import type { LegendGradient, LegendItem } from '../core/types.js';
5
+ import { getMapContext } from '../internal/context.js';
6
+ import MapControl from './MapControl.svelte';
7
+
8
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
9
+
10
+ interface Props extends HTMLAttributes<HTMLElement> {
11
+ title: string;
12
+ /** Categories, each with its colour. */
13
+ items?: LegendItem[];
14
+ /** A continuous scale instead: its colours from least to most, and the two end labels. */
15
+ gradient?: LegendGradient;
16
+ /**
17
+ * Where it sits. Inside a `MapView` it becomes a map control in this corner; outside one it
18
+ * renders in the page flow, beside the map.
19
+ */
20
+ position?: Corner;
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ title,
26
+ items = [],
27
+ gradient,
28
+ position = 'bottom-left',
29
+ class: className,
30
+ ...rest
31
+ }: Props = $props();
32
+
33
+ const inMap = getMapContext() !== null;
34
+ </script>
35
+
36
+ {#snippet legend()}
37
+ <section class={cx('ldt-map-legend', className)} aria-label={title} {...rest}>
38
+ <h3 class="ldt-map-legend__title">{title}</h3>
39
+ {#if gradient}
40
+ <div
41
+ class="ldt-map-legend__ramp"
42
+ style:background-image={`linear-gradient(to right, ${gradient.colors.join(', ')})`}
43
+ aria-hidden="true"
44
+ ></div>
45
+ <p class="ldt-map-legend__ends"><span>{gradient.min}</span><span>{gradient.max}</span></p>
46
+ {/if}
47
+ {#if items.length}
48
+ <ul class="ldt-map-legend__items">
49
+ {#each items as item (item.label)}
50
+ <li class="ldt-map-legend__item">
51
+ <span
52
+ class={cx('ldt-map-legend__key', `ldt-map-legend__key--${item.shape ?? 'fill'}`)}
53
+ style:--ldt-legend-color={item.color}
54
+ aria-hidden="true"
55
+ ></span>
56
+ {item.label}
57
+ </li>
58
+ {/each}
59
+ </ul>
60
+ {/if}
61
+ </section>
62
+ {/snippet}
63
+
64
+ {#if inMap}
65
+ <MapControl {position}>{@render legend()}</MapControl>
66
+ {:else}
67
+ {@render legend()}
68
+ {/if}
@@ -0,0 +1,19 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { LegendGradient, LegendItem } from '../core/types.js';
3
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
4
+ interface Props extends HTMLAttributes<HTMLElement> {
5
+ title: string;
6
+ /** Categories, each with its colour. */
7
+ items?: LegendItem[];
8
+ /** A continuous scale instead: its colours from least to most, and the two end labels. */
9
+ gradient?: LegendGradient;
10
+ /**
11
+ * Where it sits. Inside a `MapView` it becomes a map control in this corner; outside one it
12
+ * renders in the page flow, beside the map.
13
+ */
14
+ position?: Corner;
15
+ class?: string;
16
+ }
17
+ declare const MapLegend: import("svelte").Component<Props, {}, "">;
18
+ type MapLegend = ReturnType<typeof MapLegend>;
19
+ export default MapLegend;
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { cx } from '@loidolt/theme-svelte';
4
+ import type { LegendGradient, LegendItem } from '../core/types.js';
5
+ import { getMapContext } from '../internal/context.js';
6
+ import MapControl from './MapControl.svelte';
7
+
8
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
9
+
10
+ interface Props extends HTMLAttributes<HTMLElement> {
11
+ /** Names the group, e.g. "Layers shown". */
12
+ title: string;
13
+ /** One collapsible legend per layer. */
14
+ legends: Array<{
15
+ title: string;
16
+ items?: LegendItem[];
17
+ gradient?: LegendGradient;
18
+ open?: boolean;
19
+ }>;
20
+ position?: Corner;
21
+ class?: string;
22
+ }
23
+
24
+ let { title, legends, position = 'bottom-left', class: className, ...rest }: Props = $props();
25
+
26
+ const inMap = getMapContext() !== null;
27
+ </script>
28
+
29
+ {#snippet group()}
30
+ <section
31
+ class={cx('ldt-map-legend ldt-map-legend--group', className)}
32
+ aria-label={title}
33
+ {...rest}
34
+ >
35
+ <h3 class="ldt-map-legend__title">{title}</h3>
36
+ {#each legends as legend (legend.title)}
37
+ <details class="ldt-map-legend__section" open={legend.open ?? true}>
38
+ <summary class="ldt-map-legend__summary">{legend.title}</summary>
39
+ {#if legend.gradient}
40
+ <div
41
+ class="ldt-map-legend__ramp"
42
+ style:background-image={`linear-gradient(to right, ${legend.gradient.colors.join(', ')})`}
43
+ aria-hidden="true"
44
+ ></div>
45
+ <p class="ldt-map-legend__ends">
46
+ <span>{legend.gradient.min}</span><span>{legend.gradient.max}</span>
47
+ </p>
48
+ {/if}
49
+ {#if legend.items?.length}
50
+ <ul class="ldt-map-legend__items">
51
+ {#each legend.items as item (item.label)}
52
+ <li class="ldt-map-legend__item">
53
+ <span
54
+ class={cx('ldt-map-legend__key', `ldt-map-legend__key--${item.shape ?? 'fill'}`)}
55
+ style:--ldt-legend-color={item.color}
56
+ aria-hidden="true"
57
+ ></span>
58
+ {item.label}
59
+ </li>
60
+ {/each}
61
+ </ul>
62
+ {/if}
63
+ </details>
64
+ {/each}
65
+ </section>
66
+ {/snippet}
67
+
68
+ {#if inMap}
69
+ <MapControl {position}>{@render group()}</MapControl>
70
+ {:else}
71
+ {@render group()}
72
+ {/if}
@@ -0,0 +1,19 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { LegendGradient, LegendItem } from '../core/types.js';
3
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
4
+ interface Props extends HTMLAttributes<HTMLElement> {
5
+ /** Names the group, e.g. "Layers shown". */
6
+ title: string;
7
+ /** One collapsible legend per layer. */
8
+ legends: Array<{
9
+ title: string;
10
+ items?: LegendItem[];
11
+ gradient?: LegendGradient;
12
+ open?: boolean;
13
+ }>;
14
+ position?: Corner;
15
+ class?: string;
16
+ }
17
+ declare const MapLegendGroup: import("svelte").Component<Props, {}, "">;
18
+ type MapLegendGroup = ReturnType<typeof MapLegendGroup>;
19
+ export default MapLegendGroup;