@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.2
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 +506 -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 +43 -0
- package/dist/core/interactions.svelte.js +230 -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 +155 -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 +52 -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 +100 -0
- package/dist/plugins/points.svelte.js +501 -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 +505 -0
- package/dist/ui/FeatureList.svelte.d.ts +14 -0
- package/dist/ui/Legend.svelte +213 -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 +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 +72 -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 +38 -0
- package/dist/ui/feature-list.js +95 -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,95 @@
|
|
|
1
|
+
import { NO_LEVEL } from "../data/scale.js";
|
|
2
|
+
import { formatValue } from "./value-format.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long after Shift+F10 or the Menu key the browser's own keyboard `contextmenu` event (sent on
|
|
5
|
+
* keydown or keyup, depending on the platform) is treated as a duplicate of the one sent to the row.
|
|
6
|
+
*/
|
|
7
|
+
export const MENU_EVENT_WINDOW_MS = 1000;
|
|
8
|
+
/**
|
|
9
|
+
* A valid, stable id fragment for a row, from its plugin and refKey: anything but letters and digits
|
|
10
|
+
* is escaped as `_<hex>_` (`_` and `-` included), so the `--` between the parts can only be the
|
|
11
|
+
* separator and different rows never share a fragment.
|
|
12
|
+
*/
|
|
13
|
+
export function optionIdPart(pluginId, refKey) {
|
|
14
|
+
const escape = (text) => text.replace(/[^A-Za-z0-9]/g, (c) => `_${c.codePointAt(0).toString(16)}_`);
|
|
15
|
+
return `${escape(pluginId)}--${escape(refKey)}`;
|
|
16
|
+
}
|
|
17
|
+
/** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
|
|
18
|
+
export function inView(items, bounds) {
|
|
19
|
+
return items.filter(({ lngLat: [lng, lat] }) => {
|
|
20
|
+
return lng >= bounds.west && lng <= bounds.east && lat >= bounds.south && lat <= bounds.north;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
|
|
25
|
+
* label on a neutral swatch.
|
|
26
|
+
*/
|
|
27
|
+
export function shownLevel(item) {
|
|
28
|
+
return item.level !== NO_LEVEL ? item.level : null;
|
|
29
|
+
}
|
|
30
|
+
const collator = new Intl.Collator("en-US");
|
|
31
|
+
/** A value's sort weight; `null` sorts last. Category values sort by their level's tier. */
|
|
32
|
+
function weight(item) {
|
|
33
|
+
if (item.value === null || item.level === NO_LEVEL)
|
|
34
|
+
return null;
|
|
35
|
+
if (typeof item.value === "number")
|
|
36
|
+
return item.value;
|
|
37
|
+
return item.level.tier ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** A sorted copy. `value`: highest first, null values and no level last; ties and `name` by name. */
|
|
40
|
+
export function sortItems(items, by) {
|
|
41
|
+
const byName = (a, b) => collator.compare(a.name, b.name);
|
|
42
|
+
if (by === "name")
|
|
43
|
+
return items.toSorted(byName);
|
|
44
|
+
const weighted = items.map((item) => ({ item, w: weight(item) }));
|
|
45
|
+
weighted.sort((a, b) => {
|
|
46
|
+
if (a.w === null || b.w === null) {
|
|
47
|
+
if (a.w !== b.w)
|
|
48
|
+
return a.w === null ? 1 : -1;
|
|
49
|
+
}
|
|
50
|
+
else if (a.w !== b.w) {
|
|
51
|
+
return b.w - a.w;
|
|
52
|
+
}
|
|
53
|
+
return byName(a.item, b.item);
|
|
54
|
+
});
|
|
55
|
+
return weighted.map(({ item }) => item);
|
|
56
|
+
}
|
|
57
|
+
/** The first index at or after `from` (wrapping) whose name starts with `query`, any case; -1 if none. */
|
|
58
|
+
export function typeaheadIndex(items, query, from) {
|
|
59
|
+
const q = query.toLocaleLowerCase("en-US");
|
|
60
|
+
const n = items.length;
|
|
61
|
+
for (let step = 0; step < n; step++) {
|
|
62
|
+
const i = (((from + step) % n) + n) % n;
|
|
63
|
+
if (items[i].name.toLocaleLowerCase("en-US").startsWith(q))
|
|
64
|
+
return i;
|
|
65
|
+
}
|
|
66
|
+
return -1;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What stands in an item's level place: its level's label, else its `detail`, else "No level".
|
|
70
|
+
*/
|
|
71
|
+
export function levelText(item, messages) {
|
|
72
|
+
const level = shownLevel(item);
|
|
73
|
+
if (level)
|
|
74
|
+
return { kind: "level", text: level.label };
|
|
75
|
+
const detail = item.detail?.trim();
|
|
76
|
+
if (detail)
|
|
77
|
+
return { kind: "detail", text: detail };
|
|
78
|
+
return { kind: "none", text: messages("mapsdk_no_level") };
|
|
79
|
+
}
|
|
80
|
+
/** A row's accessible name: name, value, units, and level label (or detail). */
|
|
81
|
+
export function rowLabel(item, messages, format) {
|
|
82
|
+
const level = levelText(item, messages);
|
|
83
|
+
if (item.value === null) {
|
|
84
|
+
return level.kind === "detail"
|
|
85
|
+
? messages("mapsdk_list_row_detail", { name: item.name, detail: level.text })
|
|
86
|
+
: messages("mapsdk_list_row_no_value", { name: item.name });
|
|
87
|
+
}
|
|
88
|
+
const params = {
|
|
89
|
+
name: item.name,
|
|
90
|
+
value: typeof item.value === "number" ? formatValue(item.value, item.precision, format) : item.value,
|
|
91
|
+
units: item.units,
|
|
92
|
+
level: level.text
|
|
93
|
+
};
|
|
94
|
+
return messages(item.units ? "mapsdk_list_row" : "mapsdk_list_row_no_units", params);
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LegendData } from "../core/plugin.js";
|
|
2
|
+
import type { FormatProvider } from "../data/format.js";
|
|
3
|
+
import type { DataStatus } from "../data/types.js";
|
|
4
|
+
export type LegendBar = {
|
|
5
|
+
kind: "gradient";
|
|
6
|
+
stops: Array<{
|
|
7
|
+
color: string;
|
|
8
|
+
label: string;
|
|
9
|
+
tick: string;
|
|
10
|
+
}>;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "blocks";
|
|
13
|
+
blocks: Array<{
|
|
14
|
+
color: string;
|
|
15
|
+
opacity: number | null;
|
|
16
|
+
label: string;
|
|
17
|
+
tick: string;
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
20
|
+
export interface LegendSection {
|
|
21
|
+
pluginId: string;
|
|
22
|
+
title: string;
|
|
23
|
+
units: string;
|
|
24
|
+
bar: LegendBar;
|
|
25
|
+
}
|
|
26
|
+
export type LegendEntry = {
|
|
27
|
+
kind: "scale";
|
|
28
|
+
section: LegendSection;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "status";
|
|
31
|
+
pluginId: string;
|
|
32
|
+
layer: string;
|
|
33
|
+
status: DataStatus;
|
|
34
|
+
};
|
|
35
|
+
/** One legend section from a plugin's legend data; null for status-only data or no levels. */
|
|
36
|
+
export declare function legendSection(data: LegendData, format: FormatProvider): LegendSection | null;
|
|
37
|
+
/**
|
|
38
|
+
* How a translucent fill looks over the page background (`background` is the computed
|
|
39
|
+
* `--background`, in a form color2k parses). An empty color or no opacity returns the color.
|
|
40
|
+
*/
|
|
41
|
+
export declare function swatchOverBackground(color: string, opacity: number | null, background: string): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mix } from "color2k";
|
|
2
|
+
/** One legend section from a plugin's legend data; null for status-only data or no levels. */
|
|
3
|
+
export function legendSection(data, format) {
|
|
4
|
+
if (!("scale" in data))
|
|
5
|
+
return null;
|
|
6
|
+
const { scale } = data;
|
|
7
|
+
if (scale.levels.length === 0)
|
|
8
|
+
return null;
|
|
9
|
+
const bar = scale.kind === "range" && data.display !== "blocks"
|
|
10
|
+
? {
|
|
11
|
+
kind: "gradient",
|
|
12
|
+
stops: scale.levels.map((level) => ({
|
|
13
|
+
color: level.color ?? "",
|
|
14
|
+
label: level.label,
|
|
15
|
+
tick: level.min === null ? "" : format.number(Math.floor(level.min))
|
|
16
|
+
}))
|
|
17
|
+
}
|
|
18
|
+
: {
|
|
19
|
+
kind: "blocks",
|
|
20
|
+
blocks: scale.levels.map((level) => ({
|
|
21
|
+
color: level.color ?? "",
|
|
22
|
+
opacity: level.opacity,
|
|
23
|
+
label: level.label,
|
|
24
|
+
// A range scale's lower bound, for screen readers; categories have none.
|
|
25
|
+
tick: scale.kind === "range" && level.min !== null
|
|
26
|
+
? format.number(Math.floor(level.min))
|
|
27
|
+
: ""
|
|
28
|
+
}))
|
|
29
|
+
};
|
|
30
|
+
return { pluginId: data.pluginId, title: data.title, units: data.units, bar };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* How a translucent fill looks over the page background (`background` is the computed
|
|
34
|
+
* `--background`, in a form color2k parses). An empty color or no opacity returns the color.
|
|
35
|
+
*/
|
|
36
|
+
export function swatchOverBackground(color, opacity, background) {
|
|
37
|
+
if (!color || opacity === null)
|
|
38
|
+
return color;
|
|
39
|
+
return mix(background, color, opacity);
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Colors for something filled with a level's color: the fill, a darker border, readable text. */
|
|
2
|
+
export interface LevelChipColors {
|
|
3
|
+
background: string;
|
|
4
|
+
border: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The colors for a level color, or null when it is empty or unparseable (color2k throws there,
|
|
9
|
+
* as monitor-map's `darken("")` did), so callers fall back to their neutral look.
|
|
10
|
+
*/
|
|
11
|
+
export declare function levelChip(color: string | null | undefined): LevelChipColors | null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { darken, readableColor } from "color2k";
|
|
2
|
+
/**
|
|
3
|
+
* The colors for a level color, or null when it is empty or unparseable (color2k throws there,
|
|
4
|
+
* as monitor-map's `darken("")` did), so callers fall back to their neutral look.
|
|
5
|
+
*/
|
|
6
|
+
export function levelChip(color) {
|
|
7
|
+
if (!color)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
return { background: color, border: darken(color, 0.1), text: readableColor(color) };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FormatProvider } from "../data/format.js";
|
|
2
|
+
/**
|
|
3
|
+
* A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
|
|
4
|
+
* there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
|
|
5
|
+
* so they all agree.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
|
|
3
|
+
* there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
|
|
4
|
+
* so they all agree.
|
|
5
|
+
*/
|
|
6
|
+
export function formatValue(value, precision, format) {
|
|
7
|
+
if (precision === undefined)
|
|
8
|
+
return format.number(value);
|
|
9
|
+
return format.number(value, {
|
|
10
|
+
minimumFractionDigits: precision,
|
|
11
|
+
maximumFractionDigits: precision
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FeatureListItem } from "../core/plugin.js";
|
|
2
|
+
import type { MessageProvider } from "../data/messages.js";
|
|
3
|
+
import type { Scale } from "../data/scale-types.js";
|
|
4
|
+
/**
|
|
5
|
+
* One line describing the listed features: the layer, then how many are at each level, in the
|
|
6
|
+
* scale's order, zero counts left out (items with no level to show come last). With no scale, or
|
|
7
|
+
* nothing counted, just the count and layer.
|
|
8
|
+
*/
|
|
9
|
+
export declare function summaryText(layerLabel: string, items: FeatureListItem[], scale: Scale | null, messages: MessageProvider): string;
|
|
10
|
+
/** How long after the last camera move or selection change the live region is updated. */
|
|
11
|
+
export declare const SUMMARY_DEBOUNCE_MS = 750;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { shownLevel } from "./feature-list.js";
|
|
2
|
+
const list = new Intl.ListFormat("en-US", { type: "unit" });
|
|
3
|
+
/**
|
|
4
|
+
* One line describing the listed features: the layer, then how many are at each level, in the
|
|
5
|
+
* scale's order, zero counts left out (items with no level to show come last). With no scale, or
|
|
6
|
+
* nothing counted, just the count and layer.
|
|
7
|
+
*/
|
|
8
|
+
export function summaryText(layerLabel, items, scale, messages) {
|
|
9
|
+
const plain = () => messages("mapsdk_summary", { count: items.length, layer: layerLabel });
|
|
10
|
+
if (!scale || items.length === 0)
|
|
11
|
+
return plain();
|
|
12
|
+
const counts = new Map();
|
|
13
|
+
let none = 0;
|
|
14
|
+
for (const item of items) {
|
|
15
|
+
const level = shownLevel(item);
|
|
16
|
+
if (level && scale.levels.some((l) => l.key === level.key)) {
|
|
17
|
+
counts.set(level.key, (counts.get(level.key) ?? 0) + 1);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
none++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const parts = scale.levels.flatMap((level) => {
|
|
24
|
+
const count = counts.get(level.key);
|
|
25
|
+
return count ? [messages("mapsdk_summary_level", { count, level: level.label })] : [];
|
|
26
|
+
});
|
|
27
|
+
if (none > 0) {
|
|
28
|
+
parts.push(messages("mapsdk_summary_level", { count: none, level: messages("mapsdk_no_level") }));
|
|
29
|
+
}
|
|
30
|
+
return messages("mapsdk_summary_with_levels", { layer: layerLabel, levels: list.format(parts) });
|
|
31
|
+
}
|
|
32
|
+
/** How long after the last camera move or selection change the live region is updated. */
|
|
33
|
+
export const SUMMARY_DEBOUNCE_MS = 750;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import "@maptiler/sdk/dist/maptiler-sdk.css";
|
|
3
|
+
import "../ui/controls.css";
|
|
4
|
+
import { DEV } from "esm-env";
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import { untrack } from "svelte";
|
|
7
|
+
import { setMapContext } from "../core/context.js";
|
|
8
|
+
import type { MapContext } from "../core/create-map.svelte.js";
|
|
9
|
+
import type { GlMapLike } from "../core/gl.js";
|
|
10
|
+
import FeatureList from "../ui/FeatureList.svelte";
|
|
11
|
+
import type { SortBy } from "../ui/feature-list.js";
|
|
12
|
+
import MapControls from "../ui/MapControls.svelte";
|
|
13
|
+
import MapTooltip from "../ui/MapTooltip.svelte";
|
|
14
|
+
import SelectionBar from "../ui/SelectionBar.svelte";
|
|
15
|
+
import ViewSummary from "../ui/ViewSummary.svelte";
|
|
16
|
+
import { createDataCredits } from "../ui/data-credits.svelte.js";
|
|
17
|
+
import { createGlMap, setDataCredits, watchTheme } from "./create-gl-map.js";
|
|
18
|
+
import {
|
|
19
|
+
cornerSides,
|
|
20
|
+
resolveFeatureList,
|
|
21
|
+
type FeatureListOptions,
|
|
22
|
+
type ListPosition
|
|
23
|
+
} from "./list-placement.js";
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** The map to show. Fixed for the component's life. */
|
|
27
|
+
map: MapContext;
|
|
28
|
+
/** `false` releases the WebGL context; `true` rebuilds it with the same camera. */
|
|
29
|
+
active?: boolean;
|
|
30
|
+
class?: string;
|
|
31
|
+
/** The region's accessible name. Defaults to the `mapsdk_map_label` message. */
|
|
32
|
+
label?: string;
|
|
33
|
+
/** On-screen pieces drawn over the map; they get the map through `getMapContext()`. */
|
|
34
|
+
overlays?: Snippet;
|
|
35
|
+
/**
|
|
36
|
+
* The List button and its feature list panel: `false` leaves them out; `true` (default) or
|
|
37
|
+
* options place them. `position` picks the corner (default `"top-left"`); the button stacks
|
|
38
|
+
* below (or, in a bottom corner, above) the controls in that corner, and the open panel stops
|
|
39
|
+
* short of the controls and credits at the far end of its side. `reveal: "focus"` shows the
|
|
40
|
+
* button only while it or its open panel has keyboard focus (and the whole time the panel is
|
|
41
|
+
* open); otherwise it's visually hidden but stays in the Tab order, like a skip link.
|
|
42
|
+
*/
|
|
43
|
+
featureList?: boolean | FeatureListOptions;
|
|
44
|
+
/** `false` leaves out the selection bar. */
|
|
45
|
+
selectionBar?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* The zoom controls: `"mapsdk"` (default) draws map-sdk's zoom buttons, `"engine"` the
|
|
48
|
+
* MapTiler SDK's default zoom/compass and locate-me controls, `"none"` none. The credits
|
|
49
|
+
* always show. Choose it up front: changing it after mount recreates the map (the engine
|
|
50
|
+
* controls are part of how the map is built), like `active`.
|
|
51
|
+
*/
|
|
52
|
+
controls?: "mapsdk" | "engine" | "none";
|
|
53
|
+
/**
|
|
54
|
+
* `"pinned"` (default) anchors the tooltip to its feature. `"pointer"` shows it at the
|
|
55
|
+
* pointer, like sjvair.com's map, and hides it on zoom until the pointer moves again.
|
|
56
|
+
* Keyboard focus from the feature list always shows it pinned.
|
|
57
|
+
*/
|
|
58
|
+
tooltip?: "pinned" | "pointer";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let {
|
|
62
|
+
map,
|
|
63
|
+
active = true,
|
|
64
|
+
class: className,
|
|
65
|
+
label,
|
|
66
|
+
overlays,
|
|
67
|
+
featureList = true,
|
|
68
|
+
selectionBar = true,
|
|
69
|
+
controls = "mapsdk",
|
|
70
|
+
tooltip = "pinned"
|
|
71
|
+
}: Props = $props();
|
|
72
|
+
|
|
73
|
+
const list = $derived(resolveFeatureList(featureList));
|
|
74
|
+
const sides = $derived(list ? cornerSides(list.position) : null);
|
|
75
|
+
|
|
76
|
+
const uid = $props.id();
|
|
77
|
+
const listId = `${uid}-list`;
|
|
78
|
+
let listOpen = $state(false);
|
|
79
|
+
// Kept here: the list is unmounted while closed, so it doesn't sort every feature on each move.
|
|
80
|
+
let listSort = $state<SortBy>("name");
|
|
81
|
+
let listButton: HTMLButtonElement | undefined = $state();
|
|
82
|
+
let listBox: HTMLDivElement | undefined = $state();
|
|
83
|
+
let listWidth = $state(0);
|
|
84
|
+
|
|
85
|
+
// The open panel's right edge, so focusing a feature pans it clear of the list.
|
|
86
|
+
$effect(() => {
|
|
87
|
+
const box = listOpen ? listBox : undefined;
|
|
88
|
+
void listWidth;
|
|
89
|
+
if (!box) {
|
|
90
|
+
map.setListInset(0);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const panel = box.getBoundingClientRect();
|
|
94
|
+
const mapBox = root.getBoundingClientRect();
|
|
95
|
+
if (sides?.horizontal === "right") map.setListInset(mapBox.right - panel.left, "right");
|
|
96
|
+
else map.setListInset(panel.right - mapBox.left, "left");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// How far the controls in the button's corner, and in the other corner on its side, reach in
|
|
100
|
+
// from the map's top or bottom edge (px, 0 when there are none), so the button stacks clear of
|
|
101
|
+
// the first and the open panel stops short of the second. Measured, since the engine's
|
|
102
|
+
// controls change size (the credits switch between compact and full; MapTiler's logo).
|
|
103
|
+
let nearExtent = $state(0);
|
|
104
|
+
let farExtent = $state(0);
|
|
105
|
+
|
|
106
|
+
function extent(corner: ListPosition): number {
|
|
107
|
+
const mapBox = root.getBoundingClientRect();
|
|
108
|
+
const top = corner.startsWith("top");
|
|
109
|
+
const sources: Element[] = [];
|
|
110
|
+
const engine = glContainer.querySelector(`.maplibregl-ctrl-${corner}`);
|
|
111
|
+
if (engine) sources.push(engine);
|
|
112
|
+
if (corner === "top-right" && controls === "mapsdk") {
|
|
113
|
+
const zoom = root.querySelector(".mapsdk-zoom");
|
|
114
|
+
if (zoom) sources.push(zoom);
|
|
115
|
+
}
|
|
116
|
+
let reach = 0;
|
|
117
|
+
for (const el of sources) {
|
|
118
|
+
const r = el.getBoundingClientRect();
|
|
119
|
+
if (r.height === 0) continue;
|
|
120
|
+
reach = Math.max(reach, top ? r.bottom - mapBox.top : mapBox.bottom - r.top);
|
|
121
|
+
}
|
|
122
|
+
return Math.max(0, reach);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
$effect(() => {
|
|
126
|
+
const s = sides;
|
|
127
|
+
void projector;
|
|
128
|
+
void controls;
|
|
129
|
+
if (!s) return;
|
|
130
|
+
const measure = () => {
|
|
131
|
+
nearExtent = extent(`${s.vertical}-${s.horizontal}`);
|
|
132
|
+
farExtent = extent(s.far);
|
|
133
|
+
};
|
|
134
|
+
const observer = new ResizeObserver(measure);
|
|
135
|
+
observer.observe(root);
|
|
136
|
+
const corners = [`${s.vertical}-${s.horizontal}`, s.far].map((c) =>
|
|
137
|
+
glContainer.querySelector(`.maplibregl-ctrl-${c}`)
|
|
138
|
+
);
|
|
139
|
+
for (const el of [...corners, root.querySelector(".mapsdk-zoom")]) {
|
|
140
|
+
if (el) observer.observe(el);
|
|
141
|
+
}
|
|
142
|
+
measure();
|
|
143
|
+
return () => observer.disconnect();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const inset = (reach: number) => (reach > 0 ? `calc(${reach}px + 0.5rem)` : undefined);
|
|
147
|
+
|
|
148
|
+
function onListKeydown(e: KeyboardEvent) {
|
|
149
|
+
if (e.key !== "Escape") return;
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
listOpen = false;
|
|
152
|
+
listButton?.focus();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The map is fixed for the component's life, so its first value is the one to provide.
|
|
156
|
+
// svelte-ignore state_referenced_locally
|
|
157
|
+
setMapContext(map);
|
|
158
|
+
// svelte-ignore state_referenced_locally
|
|
159
|
+
const firstMap = map;
|
|
160
|
+
if (DEV) {
|
|
161
|
+
$effect(() => {
|
|
162
|
+
if (map !== firstMap) {
|
|
163
|
+
console.warn(
|
|
164
|
+
"map-sdk: MapView's `map` prop changed after mount. It is fixed for the component's life (overlays keep the first map); wrap MapView in {#key map} to show another map."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const dataCredits = createDataCredits(firstMap);
|
|
171
|
+
// The dataset credits go in the engine's attribution control; it is replaced when they change.
|
|
172
|
+
$effect(() => {
|
|
173
|
+
const credits = dataCredits.list;
|
|
174
|
+
if (projector) untrack(() => setDataCredits(projector!, credits));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let root: HTMLDivElement;
|
|
178
|
+
let glContainer: HTMLDivElement;
|
|
179
|
+
// The live GL map and a counter bumped on every camera move or resize: the tooltip's `project` reads
|
|
180
|
+
// both, so it re-runs (and follows its feature) as the map moves.
|
|
181
|
+
let projector = $state.raw<GlMapLike | null>(null);
|
|
182
|
+
let moves = $state(0);
|
|
183
|
+
let width = $state(0);
|
|
184
|
+
let height = $state(0);
|
|
185
|
+
|
|
186
|
+
function project(lngLat: [number, number]) {
|
|
187
|
+
void moves;
|
|
188
|
+
return projector ? projector.project(lngLat) : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
$effect(() => {
|
|
192
|
+
if (!active) return;
|
|
193
|
+
const ctx = map;
|
|
194
|
+
const engine = controls === "engine";
|
|
195
|
+
const gl = untrack(() => {
|
|
196
|
+
const gl = createGlMap(glContainer, ctx, engine);
|
|
197
|
+
try {
|
|
198
|
+
ctx.attach(gl);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
gl.remove();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
return gl;
|
|
204
|
+
});
|
|
205
|
+
const onMove = () => moves++;
|
|
206
|
+
gl.on("move", onMove);
|
|
207
|
+
gl.on("resize", onMove);
|
|
208
|
+
projector = gl;
|
|
209
|
+
// Ready is the first idle after each style load (the first style, and every basemap
|
|
210
|
+
// switch). A wait still pending when the style is replaced or the map detached is dropped,
|
|
211
|
+
// so a stale idle never marks the context ready.
|
|
212
|
+
let stopWaiting: (() => void) | null = null;
|
|
213
|
+
const waitForIdle = () => {
|
|
214
|
+
stopWaiting?.();
|
|
215
|
+
const onIdle = () => {
|
|
216
|
+
stopWaiting?.();
|
|
217
|
+
if (ctx.gl === gl) ctx.markReady();
|
|
218
|
+
};
|
|
219
|
+
stopWaiting = () => {
|
|
220
|
+
gl.off("idle", onIdle);
|
|
221
|
+
stopWaiting = null;
|
|
222
|
+
};
|
|
223
|
+
gl.on("idle", onIdle);
|
|
224
|
+
};
|
|
225
|
+
gl.on("style.load", waitForIdle);
|
|
226
|
+
waitForIdle();
|
|
227
|
+
// Plugins bake theme colors into paint and images, so they set up again on a change.
|
|
228
|
+
const stopTheme = watchTheme(root, () => ctx.themeChanged());
|
|
229
|
+
return () => {
|
|
230
|
+
stopTheme();
|
|
231
|
+
gl.off("style.load", waitForIdle);
|
|
232
|
+
stopWaiting?.();
|
|
233
|
+
projector = null;
|
|
234
|
+
gl.off("move", onMove);
|
|
235
|
+
gl.off("resize", onMove);
|
|
236
|
+
try {
|
|
237
|
+
ctx.detach();
|
|
238
|
+
} finally {
|
|
239
|
+
gl.remove();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
</script>
|
|
244
|
+
|
|
245
|
+
<div
|
|
246
|
+
bind:this={root}
|
|
247
|
+
bind:clientWidth={width}
|
|
248
|
+
bind:clientHeight={height}
|
|
249
|
+
class={["mapsdk-map mapsdk-focus-outline", className]}
|
|
250
|
+
role="region"
|
|
251
|
+
aria-label={label ?? map.messages("mapsdk_map_label")}
|
|
252
|
+
data-mapsdk-map={map.uid}
|
|
253
|
+
>
|
|
254
|
+
<div class="mapsdk-gl" bind:this={glContainer}></div>
|
|
255
|
+
<MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} />
|
|
256
|
+
{#if list && sides}
|
|
257
|
+
<div
|
|
258
|
+
class="mapsdk-list-panel"
|
|
259
|
+
data-position={list.position}
|
|
260
|
+
data-reveal={list.reveal}
|
|
261
|
+
data-open={listOpen}
|
|
262
|
+
style:--mapsdk-list-near={inset(nearExtent)}
|
|
263
|
+
style:--mapsdk-list-far={inset(farExtent)}
|
|
264
|
+
>
|
|
265
|
+
<button
|
|
266
|
+
bind:this={listButton}
|
|
267
|
+
type="button"
|
|
268
|
+
class="mapsdk-list-button mapsdk-focus mapsdk-motion text-sm"
|
|
269
|
+
aria-label={map.messages("mapsdk_list_button_label")}
|
|
270
|
+
aria-expanded={listOpen}
|
|
271
|
+
aria-controls={listOpen ? listId : undefined}
|
|
272
|
+
onclick={() => (listOpen = !listOpen)}
|
|
273
|
+
>
|
|
274
|
+
{map.messages("mapsdk_list_button")}
|
|
275
|
+
</button>
|
|
276
|
+
<!-- Escape anywhere in the panel closes it. -->
|
|
277
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
278
|
+
{#if listOpen}
|
|
279
|
+
<div bind:this={listBox} bind:clientWidth={listWidth} onkeydown={onListKeydown}>
|
|
280
|
+
<FeatureList {map} id={listId} bind:sortBy={listSort} />
|
|
281
|
+
</div>
|
|
282
|
+
{/if}
|
|
283
|
+
</div>
|
|
284
|
+
{/if}
|
|
285
|
+
<!-- After the list, so Tab reaches the List button first. -->
|
|
286
|
+
{#if selectionBar}
|
|
287
|
+
<SelectionBar {map} clearRight={list?.position === "top-right" && controls === "none"} />
|
|
288
|
+
{/if}
|
|
289
|
+
<ViewSummary {map} />
|
|
290
|
+
{#if controls === "mapsdk"}
|
|
291
|
+
<MapControls {map} gl={projector} />
|
|
292
|
+
{/if}
|
|
293
|
+
{@render overlays?.()}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<style>
|
|
297
|
+
.mapsdk-map {
|
|
298
|
+
position: relative;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* The region takes focus only from script (the selection bar's Clear), never from Tab: the
|
|
302
|
+
outline alone, from .mapsdk-focus-outline. */
|
|
303
|
+
.mapsdk-gl {
|
|
304
|
+
position: absolute;
|
|
305
|
+
inset: 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* In its corner: 0.75rem from the edges, or 0.5rem past the controls already there. The open
|
|
309
|
+
panel also stops 0.5rem short of the controls at the far end of the side. */
|
|
310
|
+
.mapsdk-list-panel {
|
|
311
|
+
position: absolute;
|
|
312
|
+
z-index: 2;
|
|
313
|
+
display: flex;
|
|
314
|
+
flex-direction: column;
|
|
315
|
+
align-items: flex-start;
|
|
316
|
+
gap: 0.5rem;
|
|
317
|
+
max-height: calc(100% - var(--mapsdk-list-near, 0.75rem) - var(--mapsdk-list-far, 0.75rem));
|
|
318
|
+
pointer-events: none;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.mapsdk-list-panel[data-position^="top"] {
|
|
322
|
+
top: var(--mapsdk-list-near, 0.75rem);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.mapsdk-list-panel[data-position^="bottom"] {
|
|
326
|
+
bottom: var(--mapsdk-list-near, 0.75rem);
|
|
327
|
+
flex-direction: column-reverse;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.mapsdk-list-panel[data-position$="left"] {
|
|
331
|
+
left: 0.75rem;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.mapsdk-list-panel[data-position$="right"] {
|
|
335
|
+
right: 0.75rem;
|
|
336
|
+
align-items: flex-end;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* reveal: "focus": hidden like a skip link (still in the Tab order and the accessibility tree)
|
|
340
|
+
until it or its panel has focus, and shown the whole time the panel is open. No transition. */
|
|
341
|
+
.mapsdk-list-panel[data-reveal="focus"][data-open="false"]:not(:focus-within)
|
|
342
|
+
.mapsdk-list-button {
|
|
343
|
+
position: absolute;
|
|
344
|
+
width: 1px;
|
|
345
|
+
height: 1px;
|
|
346
|
+
padding: 0;
|
|
347
|
+
border: 0;
|
|
348
|
+
overflow: hidden;
|
|
349
|
+
clip-path: inset(50%);
|
|
350
|
+
white-space: nowrap;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.mapsdk-list-panel > div {
|
|
354
|
+
display: flex;
|
|
355
|
+
min-height: 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.mapsdk-list-button {
|
|
359
|
+
padding: 0.375rem 0.75rem;
|
|
360
|
+
border: 1px solid var(--card-border, var(--border));
|
|
361
|
+
border-radius: var(--radius-control, var(--radius));
|
|
362
|
+
background: var(--background);
|
|
363
|
+
color: var(--foreground);
|
|
364
|
+
box-shadow: var(--shadow-card, none);
|
|
365
|
+
font-family: inherit;
|
|
366
|
+
cursor: pointer;
|
|
367
|
+
pointer-events: auto;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.mapsdk-list-button:hover {
|
|
371
|
+
background: var(--accent, var(--muted));
|
|
372
|
+
}
|
|
373
|
+
</style>
|