@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +713 -2
- package/dist/core/basemap.svelte.d.ts +38 -0
- package/dist/core/basemap.svelte.js +163 -0
- package/dist/core/context.d.ts +5 -0
- package/dist/core/context.js +13 -0
- package/dist/core/create-map.svelte.d.ts +274 -0
- package/dist/core/create-map.svelte.js +591 -0
- package/dist/core/gl.d.ts +97 -0
- package/dist/core/gl.js +1 -0
- package/dist/core/interactions.svelte.d.ts +50 -0
- package/dist/core/interactions.svelte.js +330 -0
- package/dist/core/plugin-host.svelte.d.ts +71 -0
- package/dist/core/plugin-host.svelte.js +445 -0
- package/dist/core/plugin.d.ts +190 -0
- package/dist/core/plugin.js +1 -0
- package/dist/core/scoped-context.d.ts +7 -0
- package/dist/core/scoped-context.js +34 -0
- package/dist/core/shape-view.d.ts +27 -0
- package/dist/core/shape-view.js +323 -0
- package/dist/data/format.d.ts +10 -0
- package/dist/data/format.js +37 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +9 -0
- package/dist/data/messages/en.json +61 -0
- package/dist/data/messages.d.ts +10 -0
- package/dist/data/messages.js +17 -0
- package/dist/data/metadata.d.ts +22 -0
- package/dist/data/metadata.js +110 -0
- package/dist/data/refs.d.ts +18 -0
- package/dist/data/refs.js +61 -0
- package/dist/data/scale-types.d.ts +7 -0
- package/dist/data/scale-types.js +1 -0
- package/dist/data/scale.d.ts +13 -0
- package/dist/data/scale.js +27 -0
- package/dist/data/selection.d.ts +13 -0
- package/dist/data/selection.js +52 -0
- package/dist/data/static-source.d.ts +4 -0
- package/dist/data/static-source.js +3 -0
- package/dist/data/types.d.ts +74 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +42 -0
- package/dist/plugins/icons.d.ts +21 -0
- package/dist/plugins/icons.js +73 -0
- package/dist/plugins/points-clusters.d.ts +60 -0
- package/dist/plugins/points-clusters.js +93 -0
- package/dist/plugins/points.svelte.d.ts +105 -0
- package/dist/plugins/points.svelte.js +502 -0
- package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
- package/dist/plugins/scale-resolution.svelte.js +111 -0
- package/dist/plugins/shapes.svelte.d.ts +34 -0
- package/dist/plugins/shapes.svelte.js +325 -0
- package/dist/plugins/shared.d.ts +6 -0
- package/dist/plugins/shared.js +18 -0
- package/dist/plugins/style.d.ts +57 -0
- package/dist/plugins/style.js +193 -0
- package/dist/presets/CollocationTooltip.svelte +132 -0
- package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
- package/dist/presets/FireTooltip.svelte +70 -0
- package/dist/presets/FireTooltip.svelte.d.ts +13 -0
- package/dist/presets/MonitorClusterTooltip.svelte +168 -0
- package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
- package/dist/presets/MonitorReadingBox.svelte +58 -0
- package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
- package/dist/presets/MonitorTooltip.svelte +88 -0
- package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
- package/dist/presets/calheatscore.d.ts +24 -0
- package/dist/presets/calheatscore.js +22 -0
- package/dist/presets/cluster-area.d.ts +13 -0
- package/dist/presets/cluster-area.js +34 -0
- package/dist/presets/collocation-sites.d.ts +18 -0
- package/dist/presets/collocation-sites.js +67 -0
- package/dist/presets/fire-groups.d.ts +26 -0
- package/dist/presets/fire-groups.js +51 -0
- package/dist/presets/forecast-zones.d.ts +25 -0
- package/dist/presets/forecast-zones.js +23 -0
- package/dist/presets/hms-fire.d.ts +30 -0
- package/dist/presets/hms-fire.js +86 -0
- package/dist/presets/hms-smoke.d.ts +20 -0
- package/dist/presets/hms-smoke.js +22 -0
- package/dist/presets/monitors.d.ts +48 -0
- package/dist/presets/monitors.js +243 -0
- package/dist/presets/reactive-props.svelte.d.ts +4 -0
- package/dist/presets/reactive-props.svelte.js +7 -0
- package/dist/presets/regions.d.ts +24 -0
- package/dist/presets/regions.js +22 -0
- package/dist/stores/abort.d.ts +16 -0
- package/dist/stores/abort.js +52 -0
- package/dist/stores/collocation-store.svelte.d.ts +25 -0
- package/dist/stores/collocation-store.svelte.js +72 -0
- package/dist/stores/hms-stores.svelte.d.ts +38 -0
- package/dist/stores/hms-stores.svelte.js +97 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.js +4 -0
- package/dist/stores/load-once.svelte.d.ts +25 -0
- package/dist/stores/load-once.svelte.js +93 -0
- package/dist/stores/monitors-store.svelte.d.ts +85 -0
- package/dist/stores/monitors-store.svelte.js +340 -0
- package/dist/theme.css +34 -0
- package/dist/ui/FeatureList.svelte +501 -0
- package/dist/ui/FeatureList.svelte.d.ts +14 -0
- package/dist/ui/Legend.svelte +214 -0
- package/dist/ui/Legend.svelte.d.ts +22 -0
- package/dist/ui/LegendBar.svelte +161 -0
- package/dist/ui/LegendBar.svelte.d.ts +11 -0
- package/dist/ui/MapControls.svelte +135 -0
- package/dist/ui/MapControls.svelte.d.ts +11 -0
- package/dist/ui/MapTooltip.svelte +397 -0
- package/dist/ui/MapTooltip.svelte.d.ts +37 -0
- package/dist/ui/PickNotice.svelte +56 -0
- package/dist/ui/PickNotice.svelte.d.ts +7 -0
- package/dist/ui/SelectionBar.svelte +123 -0
- package/dist/ui/SelectionBar.svelte.d.ts +10 -0
- package/dist/ui/TooltipItem.svelte +66 -0
- package/dist/ui/TooltipItem.svelte.d.ts +11 -0
- package/dist/ui/ViewSummary.svelte +97 -0
- package/dist/ui/ViewSummary.svelte.d.ts +7 -0
- package/dist/ui/card.d.ts +29 -0
- package/dist/ui/card.js +1 -0
- package/dist/ui/controls.css +45 -0
- package/dist/ui/data-credits.svelte.d.ts +9 -0
- package/dist/ui/data-credits.svelte.js +52 -0
- package/dist/ui/feature-list.d.ts +46 -0
- package/dist/ui/feature-list.js +115 -0
- package/dist/ui/legend-data.d.ts +41 -0
- package/dist/ui/legend-data.js +40 -0
- package/dist/ui/level-chip.d.ts +11 -0
- package/dist/ui/level-chip.js +15 -0
- package/dist/ui/value-format.d.ts +8 -0
- package/dist/ui/value-format.js +20 -0
- package/dist/ui/view-summary.d.ts +11 -0
- package/dist/ui/view-summary.js +33 -0
- package/dist/view/MapView.svelte +443 -0
- package/dist/view/MapView.svelte.d.ts +51 -0
- package/dist/view/create-gl-map.d.ts +34 -0
- package/dist/view/create-gl-map.js +186 -0
- package/dist/view/list-placement.d.ts +21 -0
- package/dist/view/list-placement.js +13 -0
- package/package.json +104 -5
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { GeoJSONSourceSpecification } from "@maptiler/sdk";
|
|
2
|
+
import { type Snippet } from "svelte";
|
|
3
|
+
import type { FeatureListItem, MapPlugin, Providers } from "../core/plugin.js";
|
|
4
|
+
import type { Scale } from "../data/scale-types.js";
|
|
5
|
+
import type { DataSource, MapFeatureProperties } from "../data/types.js";
|
|
6
|
+
import { type ClusterInfo, type ClusterLayerArgs, type ClusterLayers } from "./points-clusters.js";
|
|
7
|
+
import { type ThemeColors } from "./style.js";
|
|
8
|
+
export type { ClusterInfo, ClusterLayerArgs } from "./points-clusters.js";
|
|
9
|
+
/** What tooltip content gets: the map's providers and the plugin's resolved scale. */
|
|
10
|
+
export interface PointsTooltipEnv extends Providers {
|
|
11
|
+
/** Null while it resolves or when unavailable. */
|
|
12
|
+
scale: Scale | null;
|
|
13
|
+
/** Decimal places for values, as the list items carry it. */
|
|
14
|
+
precision: number;
|
|
15
|
+
}
|
|
16
|
+
export interface PointsOptions<P extends MapFeatureProperties> {
|
|
17
|
+
id: string;
|
|
18
|
+
source: DataSource<P>;
|
|
19
|
+
/** Already-translated layer name for the list and summary. */
|
|
20
|
+
layerLabel: string;
|
|
21
|
+
selectable?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* How a selected feature shows. "ring" (default): a highlight ring and check mark over it.
|
|
24
|
+
* "enlarge": the icon draws at 1.3x with no ring (icons only; circles are unchanged).
|
|
25
|
+
*/
|
|
26
|
+
selectedStyle?: "ring" | "enlarge";
|
|
27
|
+
icon?: {
|
|
28
|
+
category(props: P): string;
|
|
29
|
+
/**
|
|
30
|
+
* An SVG `size` pixels square, filled with `color`; `theme` for outlines. `levelIndex` is the
|
|
31
|
+
* level's position in the scale, or -1 for the neutral image.
|
|
32
|
+
*/
|
|
33
|
+
draw(category: string, color: string, theme: ThemeColors, levelIndex?: number): string;
|
|
34
|
+
size: number;
|
|
35
|
+
/** Draws in `mutedColor` whatever the value (e.g. an inactive monitor). */
|
|
36
|
+
muted?(props: P): boolean;
|
|
37
|
+
/** The color of muted images. Default: the theme's `--muted-foreground`. */
|
|
38
|
+
mutedColor?: string;
|
|
39
|
+
};
|
|
40
|
+
/** Used when there is no `icon`. */
|
|
41
|
+
circle?: {
|
|
42
|
+
radius: number;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* One clustered source per group. Cluster bubbles hide what is beneath them from clicks and
|
|
46
|
+
* hovers; clicking one zooms in to expand it.
|
|
47
|
+
*/
|
|
48
|
+
cluster?: {
|
|
49
|
+
radius: number;
|
|
50
|
+
maxZoom: number;
|
|
51
|
+
groupBy?(props: P): string;
|
|
52
|
+
/** MapLibre `clusterProperties`: aggregates the layers and tooltip can read. */
|
|
53
|
+
properties?: GeoJSONSourceSpecification["clusterProperties"];
|
|
54
|
+
/** Replaces the neutral count bubbles. Local ids get the group appended. */
|
|
55
|
+
layers?(args: ClusterLayerArgs): ClusterLayers;
|
|
56
|
+
/** Tooltip content for a hovered cluster. Without it, hovering a bubble shows nothing. */
|
|
57
|
+
tooltip?(cluster: ClusterInfo<P>, env: PointsTooltipEnv): Snippet | undefined;
|
|
58
|
+
};
|
|
59
|
+
/** Higher draws on top (and, clustered, a group with a higher key draws above the others). */
|
|
60
|
+
sortKey?(props: P): number;
|
|
61
|
+
/**
|
|
62
|
+
* Limits every layer the plugin draws (points, clusters, selection) to a zoom range: visible
|
|
63
|
+
* from `min` (inclusive) up to `max` (exclusive) of the camera zoom, switching live while
|
|
64
|
+
* zooming (layer visibility, not layer zoom ranges, so ranges that meet leave no gap on a
|
|
65
|
+
* globe). Default: all zooms.
|
|
66
|
+
*/
|
|
67
|
+
zoomRange?: {
|
|
68
|
+
min?: number;
|
|
69
|
+
max?: number;
|
|
70
|
+
};
|
|
71
|
+
/** How the legend shows a range scale: a gradient (default) or labeled blocks. */
|
|
72
|
+
legendDisplay?: "gradient" | "blocks";
|
|
73
|
+
/**
|
|
74
|
+
* What titles the legend section: `layerLabel` (default) or, for a source with a `ScaleRef`,
|
|
75
|
+
* the entry type's label from the metadata provider (e.g. "PM2.5"), with `layerLabel` until
|
|
76
|
+
* it loads.
|
|
77
|
+
*/
|
|
78
|
+
legendTitle?: "layer" | "entry-type";
|
|
79
|
+
/**
|
|
80
|
+
* A short text for a feature with no level (e.g. "Level 2 charger"), shown in the feature list,
|
|
81
|
+
* its accessible name, and the generic tooltip where "No level" would be. Ignored for a
|
|
82
|
+
* feature with a level.
|
|
83
|
+
*/
|
|
84
|
+
detail?(props: P): string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Cut values off at the display precision instead of rounding them, in the list, its
|
|
87
|
+
* accessible names, and the generic tooltip (`FeatureListItem.truncate`). Default false.
|
|
88
|
+
*/
|
|
89
|
+
truncateValues?: boolean;
|
|
90
|
+
/** Whether `features()` lists this plugin's features. Default true. */
|
|
91
|
+
listed?: boolean;
|
|
92
|
+
/** Tooltip content for a feature, below its name; `props` are its source properties. */
|
|
93
|
+
tooltip?(item: FeatureListItem, props: P, env: PointsTooltipEnv): Snippet | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* The `tooltip` content shows the feature's name itself, so no name header is drawn above
|
|
96
|
+
* it.
|
|
97
|
+
*/
|
|
98
|
+
tooltipShowsName?: boolean;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Points with value-based color: anything with coordinates. Draws circles, or icons by
|
|
102
|
+
* category (one image per category and level color), optionally clustered per group, with a
|
|
103
|
+
* highlight ring and check mark over selected features.
|
|
104
|
+
*/
|
|
105
|
+
export declare function pointsPlugin<P extends MapFeatureProperties>(options: PointsOptions<P>): MapPlugin;
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { untrack } from "svelte";
|
|
2
|
+
import { decodeRef, selectionId } from "../data/refs.js";
|
|
3
|
+
import { levelFor, NO_LEVEL } from "../data/scale.js";
|
|
4
|
+
import { asDataURI, SELECTED_OVERLAY_PADDING, selectedOverlay } from "./icons.js";
|
|
5
|
+
import { clusterHit, clusterLeaves, defaultClusterLayers, parseClusterHit, zoomIntoCluster } from "./points-clusters.js";
|
|
6
|
+
import { hitFromProps, SELECTED_OPACITY } from "./shared.js";
|
|
7
|
+
import { createScaleResolver, datasetOf, scaleKey } from "./scale-resolution.svelte.js";
|
|
8
|
+
import { colorExpression, levelKeyExpression, opacityExpression, themeColors } from "./style.js";
|
|
9
|
+
/** Properties the plugin adds to each feature it hands MapLibre. */
|
|
10
|
+
const CATEGORY_PROP = "mapsdk:category";
|
|
11
|
+
const SORT_KEY_PROP = "mapsdk:sortKey";
|
|
12
|
+
const MUTED_PROP = "mapsdk:muted";
|
|
13
|
+
/** The icon image suffix for muted features; no scale level key starts with "mapsdk:". */
|
|
14
|
+
const MUTED_IMAGE = "mapsdk:muted";
|
|
15
|
+
/** Marker images are drawn at this multiple of their size and scaled down, for sharp HiDPI. */
|
|
16
|
+
const IMAGE_RATIO = 2;
|
|
17
|
+
/** The icon scale of a selected feature with `selectedStyle: "enlarge"`. */
|
|
18
|
+
const ENLARGE = 1.3;
|
|
19
|
+
const DEFAULT_RADIUS = 6;
|
|
20
|
+
const UNCLUSTERED = ["!", ["has", "point_count"]];
|
|
21
|
+
/**
|
|
22
|
+
* Points with value-based color: anything with coordinates. Draws circles, or icons by
|
|
23
|
+
* category (one image per category and level color), optionally clustered per group, with a
|
|
24
|
+
* highlight ring and check mark over selected features.
|
|
25
|
+
*/
|
|
26
|
+
export function pointsPlugin(options) {
|
|
27
|
+
const { id: pluginId, source, layerLabel, icon, cluster } = options;
|
|
28
|
+
const selectable = options.selectable ?? false;
|
|
29
|
+
const enlarge = options.selectedStyle === "enlarge";
|
|
30
|
+
const groupOf = cluster?.groupBy ?? (() => "");
|
|
31
|
+
const scales = createScaleResolver(pluginId, layerLabel, source, options.legendTitle === "entry-type");
|
|
32
|
+
const currentScale = scales.current;
|
|
33
|
+
const legend = () => {
|
|
34
|
+
const data = scales.legend();
|
|
35
|
+
return data && "scale" in data && options.legendDisplay
|
|
36
|
+
? { ...data, display: options.legendDisplay }
|
|
37
|
+
: data;
|
|
38
|
+
};
|
|
39
|
+
/** The providers of the running setup, for tooltips; null while not set up. */
|
|
40
|
+
let providers = null;
|
|
41
|
+
/** The running setup's map and cluster sources, for cluster tooltips; null while not set up. */
|
|
42
|
+
let live = null;
|
|
43
|
+
/** Bumped on every data render: cluster ids from older data name nothing. Reactive. */
|
|
44
|
+
let dataGeneration = $state(0);
|
|
45
|
+
function tooltipEnv() {
|
|
46
|
+
if (!providers)
|
|
47
|
+
return null;
|
|
48
|
+
const resolved = currentScale();
|
|
49
|
+
const scale = resolved === null || resolved === "unavailable" ? null : resolved;
|
|
50
|
+
return { ...providers, scale, precision: scales.precision() };
|
|
51
|
+
}
|
|
52
|
+
function featureTooltip(item) {
|
|
53
|
+
const env = tooltipEnv();
|
|
54
|
+
const props = source.features?.features.find((f) => f.properties?.refKey === item.refKey)?.properties;
|
|
55
|
+
if (!env || !props)
|
|
56
|
+
return undefined;
|
|
57
|
+
return options.tooltip?.(item, props, env);
|
|
58
|
+
}
|
|
59
|
+
/** A hovered bubble's content, from the hit alone; none once its data was replaced. */
|
|
60
|
+
function clusterTooltip(hit) {
|
|
61
|
+
const named = parseClusterHit(hit);
|
|
62
|
+
if (!named || named.generation !== dataGeneration)
|
|
63
|
+
return undefined;
|
|
64
|
+
const env = tooltipEnv();
|
|
65
|
+
const running = live;
|
|
66
|
+
const sourceId = running?.sourceOf(named.group);
|
|
67
|
+
if (!env || !running || !sourceId || !cluster?.tooltip)
|
|
68
|
+
return undefined;
|
|
69
|
+
const properties = hit.properties ?? {};
|
|
70
|
+
const pointCount = typeof properties.point_count === "number" ? properties.point_count : 0;
|
|
71
|
+
const info = {
|
|
72
|
+
group: named.group,
|
|
73
|
+
pointCount,
|
|
74
|
+
properties,
|
|
75
|
+
leaves: () => clusterLeaves(running.gl, sourceId, named.clusterId, pointCount, running.dead)
|
|
76
|
+
};
|
|
77
|
+
return cluster.tooltip(info, env);
|
|
78
|
+
}
|
|
79
|
+
function features() {
|
|
80
|
+
const collection = source.features;
|
|
81
|
+
if (!collection)
|
|
82
|
+
return [];
|
|
83
|
+
const resolved = currentScale();
|
|
84
|
+
const scale = resolved === null || resolved === "unavailable" ? null : resolved;
|
|
85
|
+
const precision = scales.precision();
|
|
86
|
+
const items = [];
|
|
87
|
+
for (const feature of collection.features) {
|
|
88
|
+
const props = feature.properties;
|
|
89
|
+
const geometry = feature.geometry;
|
|
90
|
+
if (!props || geometry?.type !== "Point")
|
|
91
|
+
continue;
|
|
92
|
+
const ref = decodeRef(props.refKey);
|
|
93
|
+
if (!ref)
|
|
94
|
+
continue;
|
|
95
|
+
const [lng, lat] = geometry.coordinates;
|
|
96
|
+
const detail = options.detail?.(props)?.trim();
|
|
97
|
+
items.push({
|
|
98
|
+
refKey: props.refKey,
|
|
99
|
+
ref,
|
|
100
|
+
name: props.name,
|
|
101
|
+
value: props.value,
|
|
102
|
+
level: scale ? levelFor(props.value, scale) : NO_LEVEL,
|
|
103
|
+
units: scale?.units ?? "",
|
|
104
|
+
precision,
|
|
105
|
+
lngLat: [lng, lat],
|
|
106
|
+
selectable: selectable && selectionId(ref) !== null,
|
|
107
|
+
...(detail ? { detail } : {}),
|
|
108
|
+
...(options.truncateValues ? { truncate: true } : {})
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return items;
|
|
112
|
+
}
|
|
113
|
+
function setup(ctx) {
|
|
114
|
+
let dead = false;
|
|
115
|
+
const { gl } = ctx;
|
|
116
|
+
providers = { metadata: ctx.metadata, messages: ctx.messages, format: ctx.format };
|
|
117
|
+
live = { gl, sourceOf: (group) => sources.get(group), dead: () => dead };
|
|
118
|
+
const container = gl.getCanvas().closest?.("[data-mapsdk-map]") ?? null;
|
|
119
|
+
const theme = themeColors(container);
|
|
120
|
+
const grouped = cluster?.groupBy !== undefined;
|
|
121
|
+
const sourceLocal = (group) => (grouped ? `points:${group}` : "points");
|
|
122
|
+
const layerLocal = (base, group) => (grouped ? `${base}:${group}` : base);
|
|
123
|
+
// Bookkeeping for this setup only; nothing reads these reactively.
|
|
124
|
+
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
125
|
+
/** Group → namespaced source id. Sources stay until teardown (emptied when unused). */
|
|
126
|
+
const sources = new Map();
|
|
127
|
+
/** Namespaced ids of the layers currently on the map, in draw order. */
|
|
128
|
+
let layers = [];
|
|
129
|
+
/** The subset of `layers` that draw the points themselves. */
|
|
130
|
+
let pointLayerIds = [];
|
|
131
|
+
const interactiveLayers = new Set();
|
|
132
|
+
const clusterLayerIds = new Set();
|
|
133
|
+
const images = new Set();
|
|
134
|
+
/** Group → highest sort key seen, for drawing groups in order. */
|
|
135
|
+
const groupRank = new Map();
|
|
136
|
+
/** Source id → refKeys whose `selected` state is set there. */
|
|
137
|
+
const applied = new Map();
|
|
138
|
+
/** refKey → group, from the latest data. */
|
|
139
|
+
let groupByKey = new Map();
|
|
140
|
+
/* eslint-enable svelte/prefer-svelte-reactivity */
|
|
141
|
+
/** What the layers on the map were built for (the scale by `scaleKey`). */
|
|
142
|
+
let built = null;
|
|
143
|
+
/** Bumped per distinct scale, so a new scale's icon images never collide with the old. */
|
|
144
|
+
let scaleGeneration = -1;
|
|
145
|
+
let generationKey = null;
|
|
146
|
+
const markerSize = icon ? icon.size : (options.circle?.radius ?? DEFAULT_RADIUS) * 2;
|
|
147
|
+
function loadImage(local, svg, size) {
|
|
148
|
+
if (images.has(local) || typeof Image === "undefined")
|
|
149
|
+
return;
|
|
150
|
+
images.add(local);
|
|
151
|
+
const image = new Image(size * IMAGE_RATIO, size * IMAGE_RATIO);
|
|
152
|
+
image.onload = () => {
|
|
153
|
+
if (!dead)
|
|
154
|
+
ctx.addImage(local, image);
|
|
155
|
+
};
|
|
156
|
+
image.src = asDataURI(svg);
|
|
157
|
+
}
|
|
158
|
+
function iconPrefix() {
|
|
159
|
+
return `icon:${scaleGeneration}:`;
|
|
160
|
+
}
|
|
161
|
+
function ensureIcons(scale, categories) {
|
|
162
|
+
if (!icon)
|
|
163
|
+
return;
|
|
164
|
+
for (const category of categories) {
|
|
165
|
+
// Every level of the scale, plus the neutral image for values no level covers.
|
|
166
|
+
for (const [index, level] of [...scale.levels, null].entries()) {
|
|
167
|
+
const local = `${iconPrefix()}${category}:${level?.key ?? ""}`;
|
|
168
|
+
if (images.has(local))
|
|
169
|
+
continue;
|
|
170
|
+
const color = level?.color || theme.neutral;
|
|
171
|
+
loadImage(local, icon.draw(category, color, theme, level ? index : -1), icon.size);
|
|
172
|
+
}
|
|
173
|
+
const muted = `${iconPrefix()}${category}:${MUTED_IMAGE}`;
|
|
174
|
+
if (icon.muted && !images.has(muted)) {
|
|
175
|
+
loadImage(muted, icon.draw(category, icon.mutedColor ?? theme.mutedForeground, theme), icon.size);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function iconImage(category, levelKey) {
|
|
180
|
+
return ["concat", ctx.id(iconPrefix()), category, ":", levelKey];
|
|
181
|
+
}
|
|
182
|
+
/** The plain feature collection for one group, with the properties layers read. */
|
|
183
|
+
function groupData(collection, group) {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const feature of collection?.features ?? []) {
|
|
186
|
+
const props = feature.properties;
|
|
187
|
+
if (!props || groupOf(props) !== group)
|
|
188
|
+
continue;
|
|
189
|
+
const extra = {};
|
|
190
|
+
if (icon)
|
|
191
|
+
extra[CATEGORY_PROP] = icon.category(props);
|
|
192
|
+
if (icon?.muted)
|
|
193
|
+
extra[MUTED_PROP] = icon.muted(props);
|
|
194
|
+
if (options.sortKey)
|
|
195
|
+
extra[SORT_KEY_PROP] = options.sortKey(props);
|
|
196
|
+
out.push({
|
|
197
|
+
type: "Feature",
|
|
198
|
+
geometry: feature.geometry,
|
|
199
|
+
properties: { ...props, ...extra }
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return { type: "FeatureCollection", features: out };
|
|
203
|
+
}
|
|
204
|
+
function sourceSpec(data) {
|
|
205
|
+
const spec = { type: "geojson", data, promoteId: "refKey" };
|
|
206
|
+
if (cluster) {
|
|
207
|
+
spec.cluster = true;
|
|
208
|
+
spec.clusterRadius = cluster.radius;
|
|
209
|
+
spec.clusterMaxZoom = cluster.maxZoom;
|
|
210
|
+
if (cluster.properties)
|
|
211
|
+
spec.clusterProperties = cluster.properties;
|
|
212
|
+
}
|
|
213
|
+
return spec;
|
|
214
|
+
}
|
|
215
|
+
// The zoom range shows and hides the layers by the camera zoom, not with layer
|
|
216
|
+
// minzoom/maxzoom: on a globe MapLibre uses the next zoom's tiles a little before the camera
|
|
217
|
+
// gets there and leaves a layer out of tiles at or above its maxzoom, so two plugins whose
|
|
218
|
+
// ranges meet (fire groups below 10, hotspots from 10) would leave a gap where neither drew.
|
|
219
|
+
const { min, max } = options.zoomRange ?? {};
|
|
220
|
+
const ranged = min !== undefined || max !== undefined;
|
|
221
|
+
const inRange = () => {
|
|
222
|
+
const zoom = gl.getZoom();
|
|
223
|
+
return (min === undefined || zoom >= min) && (max === undefined || zoom < max);
|
|
224
|
+
};
|
|
225
|
+
let shown = ranged ? inRange() : true;
|
|
226
|
+
/** The spec, hidden while the camera is outside the plugin's zoom range. */
|
|
227
|
+
function zoomed(spec) {
|
|
228
|
+
if (!ranged)
|
|
229
|
+
return spec;
|
|
230
|
+
const layout = spec.layout;
|
|
231
|
+
return {
|
|
232
|
+
...spec,
|
|
233
|
+
layout: { ...layout, visibility: shown ? "visible" : "none" }
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const onZoom = () => {
|
|
237
|
+
if (dead || inRange() === shown)
|
|
238
|
+
return;
|
|
239
|
+
shown = !shown;
|
|
240
|
+
for (const layerId of layers) {
|
|
241
|
+
if (gl.getLayer(layerId)) {
|
|
242
|
+
gl.setLayoutProperty(layerId, "visibility", shown ? "visible" : "none");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
if (ranged)
|
|
247
|
+
gl.on("zoom", onZoom);
|
|
248
|
+
function removeLayers() {
|
|
249
|
+
for (const layerId of layers.toReversed()) {
|
|
250
|
+
if (gl.getLayer(layerId))
|
|
251
|
+
gl.removeLayer(layerId);
|
|
252
|
+
}
|
|
253
|
+
layers = [];
|
|
254
|
+
pointLayerIds = [];
|
|
255
|
+
built = null;
|
|
256
|
+
}
|
|
257
|
+
function pointLayer(scale, sourceId) {
|
|
258
|
+
const filter = cluster ? { filter: UNCLUSTERED } : {};
|
|
259
|
+
const sortKey = options.sortKey ? ["get", SORT_KEY_PROP] : undefined;
|
|
260
|
+
if (icon) {
|
|
261
|
+
const levelKey = levelKeyExpression(scale, "value");
|
|
262
|
+
return {
|
|
263
|
+
type: "symbol",
|
|
264
|
+
source: sourceId,
|
|
265
|
+
...filter,
|
|
266
|
+
layout: {
|
|
267
|
+
"icon-image": iconImage(["get", CATEGORY_PROP], icon.muted
|
|
268
|
+
? ["case", ["==", ["get", MUTED_PROP], true], MUTED_IMAGE, levelKey]
|
|
269
|
+
: levelKey),
|
|
270
|
+
"icon-size": 1 / IMAGE_RATIO,
|
|
271
|
+
"icon-allow-overlap": true,
|
|
272
|
+
"icon-ignore-placement": true,
|
|
273
|
+
...(sortKey ? { "symbol-sort-key": sortKey } : {})
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
type: "circle",
|
|
279
|
+
source: sourceId,
|
|
280
|
+
...filter,
|
|
281
|
+
layout: sortKey ? { "circle-sort-key": sortKey } : {},
|
|
282
|
+
paint: {
|
|
283
|
+
"circle-radius": options.circle?.radius ?? DEFAULT_RADIUS,
|
|
284
|
+
"circle-color": colorExpression(scale, "value", theme.neutral),
|
|
285
|
+
"circle-opacity": opacityExpression(scale, "value", 1),
|
|
286
|
+
"circle-stroke-color": theme.foreground,
|
|
287
|
+
"circle-stroke-width": 1
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function overlayLayer(sourceId) {
|
|
292
|
+
const sortKey = options.sortKey ? ["get", SORT_KEY_PROP] : undefined;
|
|
293
|
+
return {
|
|
294
|
+
type: "symbol",
|
|
295
|
+
source: sourceId,
|
|
296
|
+
...(cluster ? { filter: UNCLUSTERED } : {}),
|
|
297
|
+
layout: {
|
|
298
|
+
"icon-image": ctx.id("selected"),
|
|
299
|
+
"icon-size": 1 / IMAGE_RATIO,
|
|
300
|
+
"icon-allow-overlap": true,
|
|
301
|
+
"icon-ignore-placement": true,
|
|
302
|
+
...(sortKey ? { "symbol-sort-key": sortKey } : {})
|
|
303
|
+
},
|
|
304
|
+
paint: { "icon-opacity": SELECTED_OPACITY }
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/** A group's cluster layers; they block what is beneath them and zoom in on click. */
|
|
308
|
+
function clusterLayers(scale, group, sourceId) {
|
|
309
|
+
const args = {
|
|
310
|
+
group,
|
|
311
|
+
sourceId,
|
|
312
|
+
scale,
|
|
313
|
+
theme,
|
|
314
|
+
iconSizeScale: 1 / IMAGE_RATIO,
|
|
315
|
+
iconImage(category, levelKey) {
|
|
316
|
+
ensureIcons(scale, [category]);
|
|
317
|
+
return iconImage(category, levelKey);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const added = [];
|
|
321
|
+
for (const { local, spec } of (cluster?.layers ?? defaultClusterLayers)(args)) {
|
|
322
|
+
added.push(ctx.addLayer(layerLocal(local, group), zoomed(spec)));
|
|
323
|
+
}
|
|
324
|
+
layers.push(...added);
|
|
325
|
+
const fresh = added.filter((layerId) => !clusterLayerIds.has(layerId));
|
|
326
|
+
if (fresh.length === 0)
|
|
327
|
+
return;
|
|
328
|
+
for (const layerId of fresh)
|
|
329
|
+
clusterLayerIds.add(layerId);
|
|
330
|
+
const isDead = () => dead;
|
|
331
|
+
ctx.blocking(fresh,
|
|
332
|
+
// A hover hit only when there is content to show for it.
|
|
333
|
+
cluster?.tooltip
|
|
334
|
+
? (props) => clusterHit(pluginId, untrack(() => dataGeneration), group, props)
|
|
335
|
+
: undefined, (feature) => zoomIntoCluster(gl, sourceId, feature, isDead, pluginId));
|
|
336
|
+
}
|
|
337
|
+
/** Adds every layer for `groups` in draw order: clusters, then points, then selection. */
|
|
338
|
+
function buildLayers(scale, groups) {
|
|
339
|
+
removeLayers();
|
|
340
|
+
if (cluster)
|
|
341
|
+
for (const group of groups)
|
|
342
|
+
clusterLayers(scale, group, sources.get(group));
|
|
343
|
+
const pointIds = [];
|
|
344
|
+
for (const group of groups) {
|
|
345
|
+
const layerId = ctx.addLayer(layerLocal("points", group), zoomed(pointLayer(scale, sources.get(group))));
|
|
346
|
+
layers.push(layerId);
|
|
347
|
+
pointIds.push(layerId);
|
|
348
|
+
}
|
|
349
|
+
pointLayerIds = pointIds;
|
|
350
|
+
if (!enlarge) {
|
|
351
|
+
for (const group of groups) {
|
|
352
|
+
layers.push(ctx.addLayer(layerLocal("selected", group), zoomed(overlayLayer(sources.get(group)))));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const fresh = pointIds.filter((layerId) => !interactiveLayers.has(layerId));
|
|
356
|
+
if (fresh.length) {
|
|
357
|
+
for (const layerId of fresh)
|
|
358
|
+
interactiveLayers.add(layerId);
|
|
359
|
+
ctx.interactive(fresh, toHit);
|
|
360
|
+
}
|
|
361
|
+
built = { scaleKey: scaleKey(scale), groups };
|
|
362
|
+
}
|
|
363
|
+
const toHit = (props) => hitFromProps(pluginId, props);
|
|
364
|
+
/** Sets `selected` feature-state for `keys` and clears it for everything else. */
|
|
365
|
+
function applySelection(keys) {
|
|
366
|
+
for (const [group, sourceId] of sources) {
|
|
367
|
+
// Ungrouped, a key not in the data is set anyway: it shows when the feature appears.
|
|
368
|
+
/* eslint-disable svelte/prefer-svelte-reactivity -- bookkeeping, not reactive */
|
|
369
|
+
const want = new Set([...keys].filter((key) => !grouped || groupByKey.get(key) === group));
|
|
370
|
+
const had = applied.get(sourceId) ?? new Set();
|
|
371
|
+
/* eslint-enable svelte/prefer-svelte-reactivity */
|
|
372
|
+
for (const key of had) {
|
|
373
|
+
if (!want.has(key))
|
|
374
|
+
gl.removeFeatureState({ source: sourceId, id: key }, "selected");
|
|
375
|
+
}
|
|
376
|
+
for (const key of want) {
|
|
377
|
+
if (!had.has(key))
|
|
378
|
+
gl.setFeatureState({ source: sourceId, id: key }, { selected: true });
|
|
379
|
+
}
|
|
380
|
+
applied.set(sourceId, want);
|
|
381
|
+
}
|
|
382
|
+
if (enlarge && icon)
|
|
383
|
+
enlargeSelected(keys);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Sizes the selected keys' icons up. `icon-size` is a layout property: no feature-state.
|
|
387
|
+
*/
|
|
388
|
+
function enlargeSelected(keys) {
|
|
389
|
+
const size = keys.size === 0
|
|
390
|
+
? 1 / IMAGE_RATIO
|
|
391
|
+
: [
|
|
392
|
+
"case",
|
|
393
|
+
["in", ["get", "refKey"], ["literal", [...keys]]],
|
|
394
|
+
ENLARGE / IMAGE_RATIO,
|
|
395
|
+
1 / IMAGE_RATIO
|
|
396
|
+
];
|
|
397
|
+
for (const layerId of pointLayerIds)
|
|
398
|
+
gl.setLayoutProperty(layerId, "icon-size", size);
|
|
399
|
+
}
|
|
400
|
+
function render(reactive, scale) {
|
|
401
|
+
// Plain objects: MapLibre structured-clones the data to its worker, which a proxy fails.
|
|
402
|
+
const collection = $state.snapshot(reactive);
|
|
403
|
+
// Sources: one per group present, kept (and emptied) when a group disappears.
|
|
404
|
+
/* eslint-disable svelte/prefer-svelte-reactivity -- locals for one render */
|
|
405
|
+
const present = new Set(grouped ? [] : [""]);
|
|
406
|
+
const categories = new Set();
|
|
407
|
+
const byKey = new Map();
|
|
408
|
+
const rank = new Map();
|
|
409
|
+
/* eslint-enable svelte/prefer-svelte-reactivity */
|
|
410
|
+
for (const feature of collection?.features ?? []) {
|
|
411
|
+
const props = feature.properties;
|
|
412
|
+
if (!props)
|
|
413
|
+
continue;
|
|
414
|
+
const group = groupOf(props);
|
|
415
|
+
present.add(group);
|
|
416
|
+
byKey.set(props.refKey, group);
|
|
417
|
+
if (icon)
|
|
418
|
+
categories.add(icon.category(props));
|
|
419
|
+
if (options.sortKey) {
|
|
420
|
+
rank.set(group, Math.max(rank.get(group) ?? -Infinity, options.sortKey(props)));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
for (const [group, max] of rank)
|
|
424
|
+
groupRank.set(group, max);
|
|
425
|
+
groupByKey = byKey;
|
|
426
|
+
dataGeneration++;
|
|
427
|
+
for (const group of present) {
|
|
428
|
+
const data = groupData(collection, group);
|
|
429
|
+
const existing = sources.get(group);
|
|
430
|
+
if (existing) {
|
|
431
|
+
gl.getSource(existing).setData(data);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
sources.set(group, ctx.addSource(sourceLocal(group), sourceSpec(data)));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
for (const [group, sourceId] of sources) {
|
|
438
|
+
if (present.has(group))
|
|
439
|
+
continue;
|
|
440
|
+
gl.getSource(sourceId).setData(groupData(null, group));
|
|
441
|
+
}
|
|
442
|
+
// Layers: only with a usable scale; rebuilt only for a new scale or a new group.
|
|
443
|
+
if (scale === null || scale === "unavailable") {
|
|
444
|
+
removeLayers();
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// By value: a host may hand over an equal fresh Scale on every poll.
|
|
448
|
+
const key = scaleKey(scale);
|
|
449
|
+
if (key !== generationKey) {
|
|
450
|
+
generationKey = key;
|
|
451
|
+
scaleGeneration++;
|
|
452
|
+
}
|
|
453
|
+
ensureIcons(scale, categories);
|
|
454
|
+
// Lowest rank first, so the group with the highest sort key draws on top.
|
|
455
|
+
const groups = [...sources.keys()].sort((a, b) => (groupRank.get(a) ?? -Infinity) - (groupRank.get(b) ?? -Infinity));
|
|
456
|
+
if (!built || built.scaleKey !== key || built.groups.join("\n") !== groups.join("\n")) {
|
|
457
|
+
buildLayers(scale, groups);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
applySelection(untrack(() => ctx.selectedKeys));
|
|
461
|
+
}
|
|
462
|
+
if (!enlarge) {
|
|
463
|
+
loadImage("selected", selectedOverlay(theme.highlight, theme.highlightForeground, markerSize), markerSize + 2 * SELECTED_OVERLAY_PADDING);
|
|
464
|
+
}
|
|
465
|
+
scales.track(ctx, () => dead);
|
|
466
|
+
ctx.effect(() => {
|
|
467
|
+
const collection = source.features;
|
|
468
|
+
const scale = currentScale();
|
|
469
|
+
untrack(() => render(collection, scale));
|
|
470
|
+
});
|
|
471
|
+
ctx.effect(() => {
|
|
472
|
+
const keys = ctx.selectedKeys;
|
|
473
|
+
untrack(() => applySelection(keys));
|
|
474
|
+
});
|
|
475
|
+
return () => {
|
|
476
|
+
dead = true;
|
|
477
|
+
if (ranged)
|
|
478
|
+
gl.off("zoom", onZoom);
|
|
479
|
+
providers = null;
|
|
480
|
+
live = null;
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const plugin = {
|
|
484
|
+
id: pluginId,
|
|
485
|
+
label: layerLabel,
|
|
486
|
+
get dataset() {
|
|
487
|
+
return datasetOf(source);
|
|
488
|
+
},
|
|
489
|
+
setup,
|
|
490
|
+
legend,
|
|
491
|
+
selectable
|
|
492
|
+
};
|
|
493
|
+
if (options.listed ?? true)
|
|
494
|
+
plugin.features = features;
|
|
495
|
+
if (options.tooltip)
|
|
496
|
+
plugin.tooltip = featureTooltip;
|
|
497
|
+
if (options.tooltipShowsName)
|
|
498
|
+
plugin.tooltipShowsName = true;
|
|
499
|
+
if (cluster?.tooltip)
|
|
500
|
+
plugin.hoverTooltip = clusterTooltip;
|
|
501
|
+
return plugin;
|
|
502
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LegendData, PluginContext } from "../core/plugin.js";
|
|
2
|
+
import type { Scale } from "../data/scale-types.js";
|
|
3
|
+
import type { DataSource, MapFeatureProperties } from "../data/types.js";
|
|
4
|
+
/** For a `null` source scale: features still draw, all neutral, with no legend. */
|
|
5
|
+
export declare const NO_SCALE: Scale;
|
|
6
|
+
export type Resolved = Scale | "unavailable";
|
|
7
|
+
/** The dataset of a source whose scale is a `ScaleRef`; `undefined` for a literal scale or none. */
|
|
8
|
+
export declare function datasetOf(source: DataSource): string | undefined;
|
|
9
|
+
/** Equal for scales equal by value. */
|
|
10
|
+
export declare function scaleKey(scale: Scale): string;
|
|
11
|
+
export interface ScaleResolver {
|
|
12
|
+
/** The scale for the source's current input, or null while it resolves. */
|
|
13
|
+
current(): Resolved | null;
|
|
14
|
+
/** The legend entry for the source's status and scale. */
|
|
15
|
+
legend(): LegendData | null;
|
|
16
|
+
/**
|
|
17
|
+
* Decimal places for the source's values: the metadata's display precision for a `ScaleRef`
|
|
18
|
+
* once known, else 0 on a range scale and 1 otherwise.
|
|
19
|
+
*/
|
|
20
|
+
precision(): number;
|
|
21
|
+
/** Resolves a `ScaleRef` through the metadata provider whenever the input changes. */
|
|
22
|
+
track(ctx: PluginContext, isDead: () => boolean): void;
|
|
23
|
+
}
|
|
24
|
+
/** Scale resolution shared by the plugins: abort on change, ignore late results. */
|
|
25
|
+
export declare function createScaleResolver<P extends MapFeatureProperties>(pluginId: string, layerLabel: string, source: DataSource<P>,
|
|
26
|
+
/** Title the legend with the entry type's label from the metadata once known. */
|
|
27
|
+
entryTypeTitle?: boolean): ScaleResolver;
|