@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +487 -2
- package/dist/core/basemap.svelte.d.ts +38 -0
- package/dist/core/basemap.svelte.js +163 -0
- package/dist/core/context.d.ts +5 -0
- package/dist/core/context.js +13 -0
- package/dist/core/create-map.svelte.d.ts +207 -0
- package/dist/core/create-map.svelte.js +376 -0
- package/dist/core/gl.d.ts +92 -0
- package/dist/core/gl.js +1 -0
- package/dist/core/interactions.svelte.d.ts +42 -0
- package/dist/core/interactions.svelte.js +213 -0
- package/dist/core/plugin-host.svelte.d.ts +46 -0
- package/dist/core/plugin-host.svelte.js +366 -0
- package/dist/core/plugin.d.ts +148 -0
- package/dist/core/plugin.js +1 -0
- package/dist/core/scoped-context.d.ts +7 -0
- package/dist/core/scoped-context.js +34 -0
- package/dist/data/format.d.ts +10 -0
- package/dist/data/format.js +24 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +9 -0
- package/dist/data/messages/en.json +51 -0
- package/dist/data/messages.d.ts +10 -0
- package/dist/data/messages.js +17 -0
- package/dist/data/metadata.d.ts +22 -0
- package/dist/data/metadata.js +110 -0
- package/dist/data/refs.d.ts +18 -0
- package/dist/data/refs.js +61 -0
- package/dist/data/scale-types.d.ts +7 -0
- package/dist/data/scale-types.js +1 -0
- package/dist/data/scale.d.ts +13 -0
- package/dist/data/scale.js +27 -0
- package/dist/data/selection.d.ts +7 -0
- package/dist/data/selection.js +31 -0
- package/dist/data/static-source.d.ts +4 -0
- package/dist/data/static-source.js +3 -0
- package/dist/data/types.d.ts +69 -0
- package/dist/data/types.js +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +41 -0
- package/dist/plugins/icons.d.ts +21 -0
- package/dist/plugins/icons.js +73 -0
- package/dist/plugins/points-clusters.d.ts +60 -0
- package/dist/plugins/points-clusters.js +93 -0
- package/dist/plugins/points.svelte.d.ts +93 -0
- package/dist/plugins/points.svelte.js +473 -0
- package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
- package/dist/plugins/scale-resolution.svelte.js +111 -0
- package/dist/plugins/shapes.svelte.d.ts +29 -0
- package/dist/plugins/shapes.svelte.js +307 -0
- package/dist/plugins/shared.d.ts +6 -0
- package/dist/plugins/shared.js +18 -0
- package/dist/plugins/style.d.ts +57 -0
- package/dist/plugins/style.js +193 -0
- package/dist/presets/CollocationTooltip.svelte +132 -0
- package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
- package/dist/presets/MonitorClusterTooltip.svelte +149 -0
- package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
- package/dist/presets/MonitorReadingBox.svelte +58 -0
- package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
- package/dist/presets/MonitorTooltip.svelte +88 -0
- package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
- package/dist/presets/calheatscore.d.ts +24 -0
- package/dist/presets/calheatscore.js +22 -0
- package/dist/presets/collocation-sites.d.ts +18 -0
- package/dist/presets/collocation-sites.js +67 -0
- package/dist/presets/fire-groups.d.ts +26 -0
- package/dist/presets/fire-groups.js +51 -0
- package/dist/presets/forecast-zones.d.ts +25 -0
- package/dist/presets/forecast-zones.js +23 -0
- package/dist/presets/hms-fire.d.ts +26 -0
- package/dist/presets/hms-fire.js +87 -0
- package/dist/presets/hms-smoke.d.ts +19 -0
- package/dist/presets/hms-smoke.js +20 -0
- package/dist/presets/monitors.d.ts +48 -0
- package/dist/presets/monitors.js +243 -0
- package/dist/presets/reactive-props.svelte.d.ts +4 -0
- package/dist/presets/reactive-props.svelte.js +7 -0
- package/dist/presets/regions.d.ts +24 -0
- package/dist/presets/regions.js +22 -0
- package/dist/stores/abort.d.ts +16 -0
- package/dist/stores/abort.js +52 -0
- package/dist/stores/collocation-store.svelte.d.ts +25 -0
- package/dist/stores/collocation-store.svelte.js +72 -0
- package/dist/stores/hms-stores.svelte.d.ts +35 -0
- package/dist/stores/hms-stores.svelte.js +97 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.js +4 -0
- package/dist/stores/load-once.svelte.d.ts +25 -0
- package/dist/stores/load-once.svelte.js +93 -0
- package/dist/stores/monitors-store.svelte.d.ts +85 -0
- package/dist/stores/monitors-store.svelte.js +340 -0
- package/dist/theme.css +34 -0
- package/dist/ui/FeatureList.svelte +501 -0
- package/dist/ui/FeatureList.svelte.d.ts +14 -0
- package/dist/ui/Legend.svelte +172 -0
- package/dist/ui/Legend.svelte.d.ts +13 -0
- package/dist/ui/LegendBar.svelte +161 -0
- package/dist/ui/LegendBar.svelte.d.ts +11 -0
- package/dist/ui/MapControls.svelte +135 -0
- package/dist/ui/MapControls.svelte.d.ts +11 -0
- package/dist/ui/MapTooltip.svelte +158 -0
- package/dist/ui/MapTooltip.svelte.d.ts +28 -0
- package/dist/ui/SelectionBar.svelte +123 -0
- package/dist/ui/SelectionBar.svelte.d.ts +10 -0
- package/dist/ui/TooltipItem.svelte +66 -0
- package/dist/ui/TooltipItem.svelte.d.ts +11 -0
- package/dist/ui/ViewSummary.svelte +97 -0
- package/dist/ui/ViewSummary.svelte.d.ts +7 -0
- package/dist/ui/controls.css +45 -0
- package/dist/ui/data-credits.svelte.d.ts +9 -0
- package/dist/ui/data-credits.svelte.js +52 -0
- package/dist/ui/feature-list.d.ts +31 -0
- package/dist/ui/feature-list.js +80 -0
- package/dist/ui/legend-data.d.ts +41 -0
- package/dist/ui/legend-data.js +40 -0
- package/dist/ui/level-chip.d.ts +11 -0
- package/dist/ui/level-chip.js +15 -0
- package/dist/ui/value-format.d.ts +7 -0
- package/dist/ui/value-format.js +13 -0
- package/dist/ui/view-summary.d.ts +11 -0
- package/dist/ui/view-summary.js +33 -0
- package/dist/view/MapView.svelte +373 -0
- package/dist/view/MapView.svelte.d.ts +43 -0
- package/dist/view/create-gl-map.d.ts +34 -0
- package/dist/view/create-gl-map.js +182 -0
- package/dist/view/list-placement.d.ts +21 -0
- package/dist/view/list-placement.js +13 -0
- package/package.json +103 -5
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import clustersDbscan from "@turf/clusters-dbscan";
|
|
2
|
+
import { featureCollection, point } from "@turf/helpers";
|
|
3
|
+
// Ported from monitor-map 4.3.0 (src/lib/hms/hms.svelte.ts, computeFireGroups).
|
|
4
|
+
/** Fires within this distance (km) of each other share a group. */
|
|
5
|
+
const EPSILON_KM = 2;
|
|
6
|
+
const MIN_POINTS = 1;
|
|
7
|
+
const mean = (values) => values.reduce((s, v) => s + v, 0) / values.length;
|
|
8
|
+
/**
|
|
9
|
+
* Drops fires without a finite FRP, then keeps one fire per exact coordinates: the latest by
|
|
10
|
+
* timestamp, else the first listed. Dropping first means a newer fire with no FRP hides nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function cleanFires(fires) {
|
|
13
|
+
const latest = new Map();
|
|
14
|
+
for (const fire of fires) {
|
|
15
|
+
if (!Number.isFinite(fire.frp))
|
|
16
|
+
continue;
|
|
17
|
+
const key = `${fire.coordinates[0]},${fire.coordinates[1]}`;
|
|
18
|
+
const existing = latest.get(key);
|
|
19
|
+
if (!existing || (fire.timestamp ?? "") > (existing.timestamp ?? ""))
|
|
20
|
+
latest.set(key, fire);
|
|
21
|
+
}
|
|
22
|
+
return [...latest.values()];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Groups nearby fires (DBSCAN, 2 km, every fire its own seed) into one point per group, after
|
|
26
|
+
* skipping fires without a finite FRP and keeping one fire per coordinates, as monitor-map does.
|
|
27
|
+
*/
|
|
28
|
+
export function groupFires(fires) {
|
|
29
|
+
const usable = cleanFires(fires);
|
|
30
|
+
if (!usable.length)
|
|
31
|
+
return [];
|
|
32
|
+
const clustered = clustersDbscan(featureCollection(usable.map((f) => point(f.coordinates, { fireId: f.id, frp: f.frp }))), EPSILON_KM, { minPoints: MIN_POINTS, units: "kilometers" });
|
|
33
|
+
const groups = new Map();
|
|
34
|
+
for (const feature of clustered.features) {
|
|
35
|
+
const props = feature.properties;
|
|
36
|
+
// With minPoints 1 every fire is a core point, so "noise" never occurs.
|
|
37
|
+
const key = props.dbscan === "noise" ? `noise-${props.fireId}` : `cluster-${props.cluster}`;
|
|
38
|
+
const [lng, lat] = feature.geometry.coordinates;
|
|
39
|
+
const entry = groups.get(key) ?? { frps: [], lngs: [], lats: [] };
|
|
40
|
+
entry.frps.push(props.frp);
|
|
41
|
+
entry.lngs.push(lng);
|
|
42
|
+
entry.lats.push(lat);
|
|
43
|
+
groups.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
return [...groups].map(([key, { frps, lngs, lats }]) => ({
|
|
46
|
+
id: `hms-fire-group-${key}`,
|
|
47
|
+
coordinates: [mean(lngs), mean(lats)],
|
|
48
|
+
avgFrp: mean(frps),
|
|
49
|
+
count: frps.length
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MapPlugin } from "../core/plugin.js";
|
|
2
|
+
import type { DataSource } from "../data/types.js";
|
|
3
|
+
export interface ForecastZonesPluginOptions {
|
|
4
|
+
/** Default "forecast-zones". */
|
|
5
|
+
id?: string;
|
|
6
|
+
source: DataSource;
|
|
7
|
+
/** Already-translated layer name for the list and summary. */
|
|
8
|
+
layerLabel: string;
|
|
9
|
+
/** Default true. */
|
|
10
|
+
selectable?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Air quality forecast zones filled by the forecast's AQI level.
|
|
14
|
+
*
|
|
15
|
+
* The source's `scale` is expected to be the ScaleRef
|
|
16
|
+
* `{ dataset: "forecasts", entryType: "aqi-forecast" }`.
|
|
17
|
+
*
|
|
18
|
+
* This preset does not fetch. The host's data source supplies the features, and each feature's
|
|
19
|
+
* `refKey` must already be `region:<type>:<external_id>`: a county region
|
|
20
|
+
* (`region:county:<code>`) for most zones and a `custom` region for the rest (e.g.
|
|
21
|
+
* `region:custom:sjvapcd-kern-airbasin`). Use the region's `external_id` (its code), never the
|
|
22
|
+
* server's `id` (a sqid), which is not a ref. A picked feature selects
|
|
23
|
+
* `{ kind: "regions", ids: ["<type>:<code>"] }`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function forecastZonesPlugin(options: ForecastZonesPluginOptions): MapPlugin;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { shapesPlugin } from "../plugins/shapes.svelte.js";
|
|
2
|
+
/**
|
|
3
|
+
* Air quality forecast zones filled by the forecast's AQI level.
|
|
4
|
+
*
|
|
5
|
+
* The source's `scale` is expected to be the ScaleRef
|
|
6
|
+
* `{ dataset: "forecasts", entryType: "aqi-forecast" }`.
|
|
7
|
+
*
|
|
8
|
+
* This preset does not fetch. The host's data source supplies the features, and each feature's
|
|
9
|
+
* `refKey` must already be `region:<type>:<external_id>`: a county region
|
|
10
|
+
* (`region:county:<code>`) for most zones and a `custom` region for the rest (e.g.
|
|
11
|
+
* `region:custom:sjvapcd-kern-airbasin`). Use the region's `external_id` (its code), never the
|
|
12
|
+
* server's `id` (a sqid), which is not a ref. A picked feature selects
|
|
13
|
+
* `{ kind: "regions", ids: ["<type>:<code>"] }`.
|
|
14
|
+
*/
|
|
15
|
+
export function forecastZonesPlugin(options) {
|
|
16
|
+
return shapesPlugin({
|
|
17
|
+
id: options.id ?? "forecast-zones",
|
|
18
|
+
source: options.source,
|
|
19
|
+
layerLabel: options.layerLabel,
|
|
20
|
+
mode: "choropleth",
|
|
21
|
+
selectable: options.selectable ?? true
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MapPlugin } from "../core/plugin.js";
|
|
2
|
+
import type { DataSource } from "../data/types.js";
|
|
3
|
+
export interface HmsFirePluginOptions {
|
|
4
|
+
/** Default "hms-fire". */
|
|
5
|
+
id?: string;
|
|
6
|
+
/** The fires, or with `hotspots` the groups shown below zoom 10 (listed). */
|
|
7
|
+
source: DataSource;
|
|
8
|
+
/** Single detections shown from zoom 10 instead of `source` (not listed). Default: none. */
|
|
9
|
+
hotspots?: DataSource;
|
|
10
|
+
/** Already-translated layer name for the list and summary. */
|
|
11
|
+
layerLabel: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* NOAA HMS fire detections as flame icons colored by fire radiative power (MW) and growing with
|
|
15
|
+
* its level. Not selectable; `source`'s fires are listed.
|
|
16
|
+
*
|
|
17
|
+
* With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
|
|
18
|
+
* while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
|
|
19
|
+
* (each detection) from zoom 10. Both are hit-tested and show the same tooltip (name, value,
|
|
20
|
+
* level);
|
|
21
|
+
* only the groups are listed.
|
|
22
|
+
*
|
|
23
|
+
* The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
|
|
24
|
+
* "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
|
|
25
|
+
*/
|
|
26
|
+
export declare function hmsFirePlugin(options: HmsFirePluginOptions): MapPlugin;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { scopedContext } from "../core/scoped-context.js";
|
|
2
|
+
import { fireIcon } from "../plugins/icons.js";
|
|
3
|
+
import { pointsPlugin } from "../plugins/points.svelte.js";
|
|
4
|
+
import TooltipItem from "../ui/TooltipItem.svelte";
|
|
5
|
+
import { componentSnippet } from "./monitors.js";
|
|
6
|
+
/** The zoom where `hotspots` replace the groups (inclusive), as sjvair.com's map does. */
|
|
7
|
+
const HOTSPOT_ZOOM = 10;
|
|
8
|
+
/** Icon size in pixels by level index, smallest to largest (presentation). */
|
|
9
|
+
const SIZES = [20, 24, 28, 32, 36];
|
|
10
|
+
const CANVAS = SIZES[SIZES.length - 1];
|
|
11
|
+
/** The fire glyph at the size for a level index, centered on a fixed canvas. */
|
|
12
|
+
function drawFire(color, levelIndex = -1) {
|
|
13
|
+
const size = SIZES[Math.min(Math.max(levelIndex, 0), SIZES.length - 1)];
|
|
14
|
+
const offset = (CANVAS - size) / 2;
|
|
15
|
+
return `<svg width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" xmlns="http://www.w3.org/2000/svg"><g transform="translate(${offset} ${offset})">${fireIcon(color, size)}</g></svg>`;
|
|
16
|
+
}
|
|
17
|
+
function firePoints(id, source, layerLabel, zoomRange) {
|
|
18
|
+
return pointsPlugin({
|
|
19
|
+
id,
|
|
20
|
+
source,
|
|
21
|
+
layerLabel,
|
|
22
|
+
selectable: false,
|
|
23
|
+
zoomRange,
|
|
24
|
+
legendDisplay: "blocks",
|
|
25
|
+
icon: {
|
|
26
|
+
category: () => "fire",
|
|
27
|
+
draw: (_category, color, _theme, levelIndex) => drawFire(color, levelIndex),
|
|
28
|
+
size: CANVAS
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* NOAA HMS fire detections as flame icons colored by fire radiative power (MW) and growing with
|
|
34
|
+
* its level. Not selectable; `source`'s fires are listed.
|
|
35
|
+
*
|
|
36
|
+
* With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
|
|
37
|
+
* while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
|
|
38
|
+
* (each detection) from zoom 10. Both are hit-tested and show the same tooltip (name, value,
|
|
39
|
+
* level);
|
|
40
|
+
* only the groups are listed.
|
|
41
|
+
*
|
|
42
|
+
* The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
|
|
43
|
+
* "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
|
|
44
|
+
*/
|
|
45
|
+
export function hmsFirePlugin(options) {
|
|
46
|
+
const id = options.id ?? "hms-fire";
|
|
47
|
+
const { source, hotspots, layerLabel } = options;
|
|
48
|
+
if (!hotspots)
|
|
49
|
+
return firePoints(id, source, layerLabel);
|
|
50
|
+
// Both inner plugins share the outer id, so their hits are this plugin's.
|
|
51
|
+
const groups = firePoints(id, source, layerLabel, { max: HOTSPOT_ZOOM });
|
|
52
|
+
const singles = firePoints(id, hotspots, layerLabel, { min: HOTSPOT_ZOOM });
|
|
53
|
+
// The providers of the running setup, for the hotspots' tooltips.
|
|
54
|
+
let ctxNow = null;
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
label: layerLabel,
|
|
58
|
+
get dataset() {
|
|
59
|
+
return groups.dataset;
|
|
60
|
+
},
|
|
61
|
+
selectable: false,
|
|
62
|
+
setup(ctx) {
|
|
63
|
+
ctxNow = ctx;
|
|
64
|
+
const teardownGroups = groups.setup(scopedContext(ctx, "groups:"));
|
|
65
|
+
const teardownSingles = singles.setup(scopedContext(ctx, "hotspots:"));
|
|
66
|
+
return () => {
|
|
67
|
+
ctxNow = null;
|
|
68
|
+
teardownSingles();
|
|
69
|
+
teardownGroups();
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
legend: groups.legend,
|
|
73
|
+
// Only the groups are listed; a hovered hotspot shows what a group's hover does: its name
|
|
74
|
+
// (the generic header) with its value and level.
|
|
75
|
+
features: groups.features,
|
|
76
|
+
hoverTooltip(hit) {
|
|
77
|
+
const item = singles.features?.().find((f) => f.refKey === hit.refKey);
|
|
78
|
+
if (!item || !ctxNow)
|
|
79
|
+
return undefined;
|
|
80
|
+
return componentSnippet(TooltipItem, {
|
|
81
|
+
item,
|
|
82
|
+
messages: ctxNow.messages,
|
|
83
|
+
format: ctxNow.format
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MapPlugin } from "../core/plugin.js";
|
|
2
|
+
import type { DataSource } from "../data/types.js";
|
|
3
|
+
export interface HmsSmokePluginOptions {
|
|
4
|
+
/** Default "hms-smoke". */
|
|
5
|
+
id?: string;
|
|
6
|
+
source: DataSource;
|
|
7
|
+
/** Already-translated layer name for the legend and summary. */
|
|
8
|
+
layerLabel: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
|
|
12
|
+
* level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
|
|
13
|
+
* can be reached without a mouse.
|
|
14
|
+
*
|
|
15
|
+
* The source's `scale` is expected to be the ScaleRef
|
|
16
|
+
* `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
|
|
17
|
+
* key ("light", "medium", "heavy"). This preset does not fetch.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hmsSmokePlugin(options: HmsSmokePluginOptions): MapPlugin;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { shapesPlugin } from "../plugins/shapes.svelte.js";
|
|
2
|
+
/**
|
|
3
|
+
* NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
|
|
4
|
+
* level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
|
|
5
|
+
* can be reached without a mouse.
|
|
6
|
+
*
|
|
7
|
+
* The source's `scale` is expected to be the ScaleRef
|
|
8
|
+
* `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
|
|
9
|
+
* key ("light", "medium", "heavy"). This preset does not fetch.
|
|
10
|
+
*/
|
|
11
|
+
export function hmsSmokePlugin(options) {
|
|
12
|
+
return shapesPlugin({
|
|
13
|
+
id: options.id ?? "hms-smoke",
|
|
14
|
+
source: options.source,
|
|
15
|
+
layerLabel: options.layerLabel,
|
|
16
|
+
mode: "choropleth",
|
|
17
|
+
selectable: false,
|
|
18
|
+
outline: "level"
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Component, type Snippet } from "svelte";
|
|
2
|
+
import type { MapPlugin } from "../core/plugin.js";
|
|
3
|
+
import type { MetadataProvider } from "../data/metadata.js";
|
|
4
|
+
import type { DataSource, MonitorFeatureProperties } from "../data/types.js";
|
|
5
|
+
import { circle, square, triangle } from "../plugins/icons.js";
|
|
6
|
+
export declare const MONITOR_INACTIVE_COLOR = "#969696";
|
|
7
|
+
export declare const MONITOR_INSIDE_OUTLINE = "#000000";
|
|
8
|
+
export interface MonitorsPluginOptions {
|
|
9
|
+
/** Default "monitors". */
|
|
10
|
+
id?: string;
|
|
11
|
+
source: DataSource<MonitorFeatureProperties>;
|
|
12
|
+
/** Already-translated layer name for the list and summary. */
|
|
13
|
+
layerLabel: string;
|
|
14
|
+
/** Default true, like the shape presets. */
|
|
15
|
+
selectable?: boolean;
|
|
16
|
+
/** "ring" (default): ring and check mark. "enlarge": the selected icon draws at 1.3x. */
|
|
17
|
+
selectedStyle?: "ring" | "enlarge";
|
|
18
|
+
/** Cluster each monitor type below zoom 9. Default true. */
|
|
19
|
+
clustered?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export type MonitorShape = "circle" | "square" | "triangle";
|
|
22
|
+
/** The marker shape for a monitor type: reference monitors are triangles, sensors circles. */
|
|
23
|
+
export declare function monitorShape(type: string): MonitorShape;
|
|
24
|
+
/** The marker drawing for each shape. */
|
|
25
|
+
export declare const SHAPES: {
|
|
26
|
+
circle: typeof circle;
|
|
27
|
+
square: typeof square;
|
|
28
|
+
triangle: typeof triangle;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* A snippet that mounts `component` with `props` (a tooltip outside the map's component tree).
|
|
32
|
+
* It mounts at once; `late`, when given, updates the props once it resolves (e.g. a note that
|
|
33
|
+
* needs a lookup), so a slow lookup never leaves an empty card. Nothing is mounted or updated
|
|
34
|
+
* after the tooltip closes, and a failure to mount or update is logged, not thrown.
|
|
35
|
+
*/
|
|
36
|
+
export declare function componentSnippet<Props extends Record<string, any>>(component: Component<Props>, props: Props, late?: Promise<Partial<Props>>): Snippet;
|
|
37
|
+
/**
|
|
38
|
+
* Looks up a monitor type's reporting interval (e.g. "1 hour") for tooltips' "(N avg)" note,
|
|
39
|
+
* once per type; null when the provider has none. Failures (a rejection or a synchronous throw)
|
|
40
|
+
* give null and are forgotten, so they're retried.
|
|
41
|
+
*/
|
|
42
|
+
export declare function intervalLookup(): (type: string, metadata: MetadataProvider) => Promise<string | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Air-quality monitors as on today's live map: a shape per monitor type filled with its level's
|
|
45
|
+
* color, inactive monitors in the neutral color, inside monitors outlined, and per-type clusters
|
|
46
|
+
* colored by their average that zoom in when clicked.
|
|
47
|
+
*/
|
|
48
|
+
export declare function monitorsPlugin(options: MonitorsPluginOptions): MapPlugin;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { createRawSnippet, mount, unmount } from "svelte";
|
|
2
|
+
import { circle, square, triangle } from "../plugins/icons.js";
|
|
3
|
+
import { pointsPlugin } from "../plugins/points.svelte.js";
|
|
4
|
+
import { hasNumericValue, levelKeyExpression, levelLookup, MISSING_VALUE } from "../plugins/style.js";
|
|
5
|
+
import { levelChip } from "../ui/level-chip.js";
|
|
6
|
+
import { reactiveProps } from "./reactive-props.svelte.js";
|
|
7
|
+
import MonitorClusterTooltip from "./MonitorClusterTooltip.svelte";
|
|
8
|
+
import MonitorTooltip from "./MonitorTooltip.svelte";
|
|
9
|
+
// Ported from monitor-map 4.3.0 (src/lib/monitors/): shapes, draw order, icon variants, and
|
|
10
|
+
// cluster rendering. Shapes, sizes, and order are presentation; colors come from the scale and
|
|
11
|
+
// the theme, except the two fixed monitor colors below.
|
|
12
|
+
// The user's decision (2026-11-04), an exception to the theme-token rule: inactive monitors are
|
|
13
|
+
// this gray and inside monitors have a black outline, on every map and theme, as on sjvair.com.
|
|
14
|
+
export const MONITOR_INACTIVE_COLOR = "#969696";
|
|
15
|
+
export const MONITOR_INSIDE_OUTLINE = "#000000";
|
|
16
|
+
/** The marker shape for a monitor type: reference monitors are triangles, sensors circles. */
|
|
17
|
+
export function monitorShape(type) {
|
|
18
|
+
switch (type) {
|
|
19
|
+
case "airnow":
|
|
20
|
+
case "aqview":
|
|
21
|
+
case "bam1022":
|
|
22
|
+
case "aqlite":
|
|
23
|
+
return "triangle";
|
|
24
|
+
default:
|
|
25
|
+
return "circle";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** monitor-map's draw order (`getOrder`): higher draws on top. Unknown types draw lowest. */
|
|
29
|
+
function monitorOrder(props) {
|
|
30
|
+
switch (props.type) {
|
|
31
|
+
case "airgradient":
|
|
32
|
+
return 5;
|
|
33
|
+
case "airnow":
|
|
34
|
+
return 2;
|
|
35
|
+
case "aqview":
|
|
36
|
+
return 3;
|
|
37
|
+
case "bam1022":
|
|
38
|
+
return 6;
|
|
39
|
+
case "purpleair":
|
|
40
|
+
return props.isSjvair ? 4 : 1;
|
|
41
|
+
case "vozbox":
|
|
42
|
+
return 7;
|
|
43
|
+
case "aqlite":
|
|
44
|
+
return 8;
|
|
45
|
+
default:
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The marker drawing for each shape. */
|
|
50
|
+
export const SHAPES = { circle, square, triangle };
|
|
51
|
+
const ICON_SIZE = 24;
|
|
52
|
+
const BORDER_WIDTH = 2;
|
|
53
|
+
const CLUSTER_RADIUS = 40;
|
|
54
|
+
const CLUSTER_MAX_ZOOM = 9;
|
|
55
|
+
const CLUSTER_OPACITY = 0.8;
|
|
56
|
+
/** Cluster icons grow with the count: 1.3×, then 1.6× from 10, then 2× from 25. */
|
|
57
|
+
const CLUSTER_SIZES = [1.3, 10, 1.6, 25, 2.0];
|
|
58
|
+
/** `shape:location`, e.g. "triangle:inside". */
|
|
59
|
+
function category(props) {
|
|
60
|
+
return `${monitorShape(props.type)}:${props.location === "inside" ? "inside" : "outside"}`;
|
|
61
|
+
}
|
|
62
|
+
/** Readings by `numericValue`'s rule (numbers and numeric strings), as levels and tooltips count. */
|
|
63
|
+
const IS_READING = hasNumericValue("value");
|
|
64
|
+
const COUNT = ["get", "countValues"];
|
|
65
|
+
const MEAN = ["/", ["get", "sumValues"], ["max", COUNT, 1]];
|
|
66
|
+
/** The cluster's average reading, or MISSING_VALUE when none of its monitors has one. */
|
|
67
|
+
const AVERAGE = ["case", [">", COUNT, 0], MEAN, MISSING_VALUE];
|
|
68
|
+
/** Readable text on `color`, or `fallback` for an unparseable color. */
|
|
69
|
+
function readable(color, fallback) {
|
|
70
|
+
return levelChip(color)?.text ?? fallback;
|
|
71
|
+
}
|
|
72
|
+
/** A type's cluster: its shape colored by the average's level, with the rounded average on it. */
|
|
73
|
+
function clusterLayers({ group, sourceId, scale, theme, iconImage, iconSizeScale }) {
|
|
74
|
+
const filter = ["has", "point_count"];
|
|
75
|
+
const sizes = CLUSTER_SIZES.map((n, i) => (i % 2 === 0 ? n * iconSizeScale : n));
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
local: "cluster-icon",
|
|
79
|
+
spec: {
|
|
80
|
+
type: "symbol",
|
|
81
|
+
source: sourceId,
|
|
82
|
+
filter,
|
|
83
|
+
layout: {
|
|
84
|
+
"icon-image": iconImage(`${monitorShape(group)}:outside`, levelKeyExpression(scale, AVERAGE)),
|
|
85
|
+
"icon-size": [
|
|
86
|
+
"step",
|
|
87
|
+
["get", "point_count"],
|
|
88
|
+
...sizes
|
|
89
|
+
],
|
|
90
|
+
"icon-ignore-placement": true,
|
|
91
|
+
"icon-allow-overlap": true
|
|
92
|
+
},
|
|
93
|
+
paint: { "icon-opacity": CLUSTER_OPACITY }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
local: "cluster-average",
|
|
98
|
+
spec: {
|
|
99
|
+
type: "symbol",
|
|
100
|
+
source: sourceId,
|
|
101
|
+
filter,
|
|
102
|
+
layout: {
|
|
103
|
+
"text-field": [
|
|
104
|
+
"case",
|
|
105
|
+
[">", COUNT, 0],
|
|
106
|
+
["to-string", ["round", MEAN]],
|
|
107
|
+
""
|
|
108
|
+
],
|
|
109
|
+
"text-size": 12,
|
|
110
|
+
"text-ignore-placement": true,
|
|
111
|
+
"text-allow-overlap": true
|
|
112
|
+
},
|
|
113
|
+
paint: {
|
|
114
|
+
"text-color": levelLookup(scale, AVERAGE, readable(theme.neutral, theme.foreground), (l) => readable(l.color || theme.neutral, theme.foreground))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A snippet that mounts `component` with `props` (a tooltip outside the map's component tree).
|
|
122
|
+
* It mounts at once; `late`, when given, updates the props once it resolves (e.g. a note that
|
|
123
|
+
* needs a lookup), so a slow lookup never leaves an empty card. Nothing is mounted or updated
|
|
124
|
+
* after the tooltip closes, and a failure to mount or update is logged, not thrown.
|
|
125
|
+
*/
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
export function componentSnippet(component, props, late) {
|
|
128
|
+
return createRawSnippet(() => ({
|
|
129
|
+
render: () => "<div></div>",
|
|
130
|
+
setup(target) {
|
|
131
|
+
let instance;
|
|
132
|
+
let closed = false;
|
|
133
|
+
const live = reactiveProps(props);
|
|
134
|
+
try {
|
|
135
|
+
instance = mount(component, { target, props: live });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error("map-sdk: a tooltip failed to mount", error);
|
|
139
|
+
}
|
|
140
|
+
void late?.then((extra) => {
|
|
141
|
+
if (closed || !instance)
|
|
142
|
+
return;
|
|
143
|
+
try {
|
|
144
|
+
Object.assign(live, extra);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error("map-sdk: a tooltip failed to update", error);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return () => {
|
|
151
|
+
closed = true;
|
|
152
|
+
if (instance)
|
|
153
|
+
void unmount(instance);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Looks up a monitor type's reporting interval (e.g. "1 hour") for tooltips' "(N avg)" note,
|
|
160
|
+
* once per type; null when the provider has none. Failures (a rejection or a synchronous throw)
|
|
161
|
+
* give null and are forgotten, so they're retried.
|
|
162
|
+
*/
|
|
163
|
+
export function intervalLookup() {
|
|
164
|
+
const intervals = new Map();
|
|
165
|
+
return function intervalFor(type, metadata) {
|
|
166
|
+
if (!metadata.monitorTypeInterval)
|
|
167
|
+
return Promise.resolve(null);
|
|
168
|
+
let found = intervals.get(type);
|
|
169
|
+
if (!found) {
|
|
170
|
+
let call;
|
|
171
|
+
try {
|
|
172
|
+
call = Promise.resolve(metadata.monitorTypeInterval(type));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return Promise.resolve(null);
|
|
176
|
+
}
|
|
177
|
+
const lookup = call.then((value) => (value === "unavailable" ? null : value), () => null);
|
|
178
|
+
found = lookup;
|
|
179
|
+
intervals.set(type, lookup);
|
|
180
|
+
void lookup.then((value) => {
|
|
181
|
+
if (value === null && intervals.get(type) === lookup)
|
|
182
|
+
intervals.delete(type);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return found;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Air-quality monitors as on today's live map: a shape per monitor type filled with its level's
|
|
190
|
+
* color, inactive monitors in the neutral color, inside monitors outlined, and per-type clusters
|
|
191
|
+
* colored by their average that zoom in when clicked.
|
|
192
|
+
*/
|
|
193
|
+
export function monitorsPlugin(options) {
|
|
194
|
+
const clustered = options.clustered ?? true;
|
|
195
|
+
const intervalFor = intervalLookup();
|
|
196
|
+
return pointsPlugin({
|
|
197
|
+
id: options.id ?? "monitors",
|
|
198
|
+
source: options.source,
|
|
199
|
+
layerLabel: options.layerLabel,
|
|
200
|
+
legendTitle: "entry-type",
|
|
201
|
+
selectable: options.selectable ?? true,
|
|
202
|
+
selectedStyle: options.selectedStyle,
|
|
203
|
+
icon: {
|
|
204
|
+
category,
|
|
205
|
+
draw(cat, color) {
|
|
206
|
+
const [shape, location] = cat.split(":");
|
|
207
|
+
return SHAPES[shape](color, BORDER_WIDTH, location === "inside" ? MONITOR_INSIDE_OUTLINE : undefined);
|
|
208
|
+
},
|
|
209
|
+
size: ICON_SIZE,
|
|
210
|
+
muted: (props) => !props.isActive,
|
|
211
|
+
mutedColor: MONITOR_INACTIVE_COLOR
|
|
212
|
+
},
|
|
213
|
+
sortKey: monitorOrder,
|
|
214
|
+
cluster: clustered
|
|
215
|
+
? {
|
|
216
|
+
radius: CLUSTER_RADIUS,
|
|
217
|
+
maxZoom: CLUSTER_MAX_ZOOM,
|
|
218
|
+
groupBy: (props) => props.type,
|
|
219
|
+
properties: {
|
|
220
|
+
sumValues: ["+", ["case", IS_READING, ["to-number", ["get", "value"], 0], 0]],
|
|
221
|
+
countValues: ["+", ["case", IS_READING, 1, 0]]
|
|
222
|
+
},
|
|
223
|
+
layers: clusterLayers,
|
|
224
|
+
tooltip: (cluster, env) => componentSnippet(MonitorClusterTooltip, {
|
|
225
|
+
cluster,
|
|
226
|
+
scale: env.scale,
|
|
227
|
+
messages: env.messages,
|
|
228
|
+
format: env.format,
|
|
229
|
+
metadata: env.metadata
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
: undefined,
|
|
233
|
+
tooltipShowsName: true,
|
|
234
|
+
tooltip: (item, props, env) => componentSnippet(MonitorTooltip, {
|
|
235
|
+
item,
|
|
236
|
+
timestamp: props.timestamp ?? null,
|
|
237
|
+
scale: env.scale,
|
|
238
|
+
messages: env.messages,
|
|
239
|
+
format: env.format,
|
|
240
|
+
interval: null
|
|
241
|
+
}, intervalFor(props.type, env.metadata).then((interval) => ({ interval })))
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MapPlugin } from "../core/plugin.js";
|
|
2
|
+
import type { DataSource } from "../data/types.js";
|
|
3
|
+
export interface RegionsPluginOptions {
|
|
4
|
+
/** Default "regions". */
|
|
5
|
+
id?: string;
|
|
6
|
+
source: DataSource;
|
|
7
|
+
/** Already-translated layer name for the list and summary. */
|
|
8
|
+
layerLabel: string;
|
|
9
|
+
/** Default true. */
|
|
10
|
+
selectable?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Region polygons (counties, cities, and other server regions) filled by a pollutant's level.
|
|
14
|
+
*
|
|
15
|
+
* The source's `scale` is expected to be the ScaleRef
|
|
16
|
+
* `{ dataset: "air-quality", entryType: <pollutant> }` (e.g. `pm25`).
|
|
17
|
+
*
|
|
18
|
+
* This preset does not fetch. The host's data source supplies the features, and each feature's
|
|
19
|
+
* `refKey` must already be `region:<type>:<external_id>`: a region of any type, e.g.
|
|
20
|
+
* `region:county:fresno`. Use the region's `external_id` (its code), never the server's `id` (a
|
|
21
|
+
* sqid), which is not a ref. A picked feature selects
|
|
22
|
+
* `{ kind: "regions", ids: ["<type>:<code>"] }`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function regionsPlugin(options: RegionsPluginOptions): MapPlugin;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { shapesPlugin } from "../plugins/shapes.svelte.js";
|
|
2
|
+
/**
|
|
3
|
+
* Region polygons (counties, cities, and other server regions) filled by a pollutant's level.
|
|
4
|
+
*
|
|
5
|
+
* The source's `scale` is expected to be the ScaleRef
|
|
6
|
+
* `{ dataset: "air-quality", entryType: <pollutant> }` (e.g. `pm25`).
|
|
7
|
+
*
|
|
8
|
+
* This preset does not fetch. The host's data source supplies the features, and each feature's
|
|
9
|
+
* `refKey` must already be `region:<type>:<external_id>`: a region of any type, e.g.
|
|
10
|
+
* `region:county:fresno`. Use the region's `external_id` (its code), never the server's `id` (a
|
|
11
|
+
* sqid), which is not a ref. A picked feature selects
|
|
12
|
+
* `{ kind: "regions", ids: ["<type>:<code>"] }`.
|
|
13
|
+
*/
|
|
14
|
+
export function regionsPlugin(options) {
|
|
15
|
+
return shapesPlugin({
|
|
16
|
+
id: options.id ?? "regions",
|
|
17
|
+
source: options.source,
|
|
18
|
+
layerLabel: options.layerLabel,
|
|
19
|
+
mode: "choropleth",
|
|
20
|
+
selectable: options.selectable ?? true
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SignalOptions = {
|
|
2
|
+
signal?: AbortSignal;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* A signal that aborts as soon as any input does, with that input's reason. Already aborted if
|
|
6
|
+
* any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
|
|
7
|
+
*/
|
|
8
|
+
export declare function anySignal(...signals: Array<AbortSignal | undefined>): {
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Settles like `promise`, unless `signal` aborts first, in which case it rejects with
|
|
14
|
+
* `signal.reason`. The inner promise's later outcome is swallowed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
|