@sjvair/monitor-map 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -16
- package/dist/lib/LazyMapLayout.svelte +53 -0
- package/dist/lib/LazyMapLayout.svelte.d.ts +7 -0
- package/dist/lib/LazyMonitorDetailPanel.svelte +33 -0
- package/dist/lib/LazyMonitorDetailPanel.svelte.d.ts +4 -0
- package/dist/lib/LoadError.svelte +19 -0
- package/dist/lib/LoadError.svelte.d.ts +7 -0
- package/dist/lib/LoadScreen.svelte +10 -1
- package/dist/lib/LoadScreen.svelte.d.ts +6 -16
- package/dist/lib/MonitorDetailPanel.svelte +108 -28
- package/dist/lib/MonitorMapLayout.svelte +19 -1
- package/dist/lib/core.d.ts +1 -1
- package/dist/lib/data-chart/DataChart.svelte +63 -13
- package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
- package/dist/lib/data-chart/DateRange.d.ts +4 -0
- package/dist/lib/data-chart/DateRange.js +9 -1
- package/dist/lib/data-chart/chart-utils.d.ts +2 -1
- package/dist/lib/data-chart/chart-utils.js +19 -23
- package/dist/lib/data-chart/level-colors.d.ts +6 -0
- package/dist/lib/data-chart/level-colors.js +5 -0
- package/dist/lib/data-chart/requests.d.ts +5 -2
- package/dist/lib/data-chart/requests.js +2 -3
- package/dist/lib/data-chart/tooltip.d.ts +2 -1
- package/dist/lib/data-chart/tooltip.js +1 -9
- package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +4 -6
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/lazy-chunks.d.ts +2 -0
- package/dist/lib/lazy-chunks.js +6 -0
- package/dist/lib/lazy-routes.d.ts +6 -4
- package/dist/lib/lazy-routes.js +12 -7
- package/dist/lib/map/MapShell.svelte +7 -1
- package/dist/lib/map/MapShell.svelte.d.ts +5 -0
- package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +13 -0
- package/dist/lib/map/integrations/map-geojson-integration.svelte.js +30 -4
- package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
- package/dist/lib/map/map-style-state.svelte.js +56 -5
- package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
- package/dist/lib/monitors/monitor-details.svelte.js +63 -0
- package/dist/lib/monitors/monitors-map-integration.svelte.js +4 -6
- package/dist/lib/monitors/monitors.svelte.d.ts +14 -4
- package/dist/lib/monitors/monitors.svelte.js +13 -5
- package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
- package/dist/lib/monitors/panel-pollutant.js +43 -0
- package/dist/lib/options/OptionsBar.svelte +8 -1
- package/dist/lib/options/OptionsMenu.svelte +10 -1
- package/dist/lib/shared-load.d.ts +5 -0
- package/dist/lib/shared-load.js +14 -0
- package/package.json +1 -1
- package/src/lib/LazyMapLayout.svelte +53 -0
- package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
- package/src/lib/LoadError.svelte +19 -0
- package/src/lib/LoadScreen.svelte +10 -1
- package/src/lib/MonitorDetailPanel.svelte +108 -28
- package/src/lib/MonitorMapLayout.svelte +19 -1
- package/src/lib/core.ts +1 -1
- package/src/lib/data-chart/DataChart.svelte +63 -13
- package/src/lib/data-chart/DateRange.ts +11 -1
- package/src/lib/data-chart/chart-utils.ts +23 -26
- package/src/lib/data-chart/level-colors.ts +13 -0
- package/src/lib/data-chart/requests.ts +9 -5
- package/src/lib/data-chart/tooltip.ts +2 -10
- package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +4 -4
- package/src/lib/index.ts +1 -1
- package/src/lib/lazy-chunks.ts +7 -0
- package/src/lib/lazy-routes.ts +12 -7
- package/src/lib/map/MapShell.svelte +7 -1
- package/src/lib/map/integrations/map-geojson-integration.svelte.ts +32 -3
- package/src/lib/map/map-style-state.svelte.ts +58 -6
- package/src/lib/monitors/monitor-details.svelte.ts +66 -0
- package/src/lib/monitors/monitors-map-integration.svelte.ts +4 -4
- package/src/lib/monitors/monitors.svelte.ts +20 -5
- package/src/lib/monitors/panel-pollutant.ts +76 -0
- package/src/lib/options/OptionsBar.svelte +8 -1
- package/src/lib/options/OptionsMenu.svelte +10 -1
- package/src/lib/shared-load.ts +15 -0
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* is true.
|
|
12
12
|
*/
|
|
13
13
|
ready: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Rendered on the load screen, under the logo, for as long as the load screen is up.
|
|
16
|
+
* Render it only when the caller's initial load failed (e.g. a message and a retry).
|
|
17
|
+
*/
|
|
18
|
+
loadError?: Snippet;
|
|
14
19
|
/** True when the routed detail panel (`children`) should be visible */
|
|
15
20
|
panelOpen: boolean;
|
|
16
21
|
/**
|
|
@@ -59,6 +64,7 @@
|
|
|
59
64
|
let {
|
|
60
65
|
integrations,
|
|
61
66
|
ready,
|
|
67
|
+
loadError,
|
|
62
68
|
panelOpen,
|
|
63
69
|
knownRoutes = [],
|
|
64
70
|
menu,
|
|
@@ -123,7 +129,7 @@
|
|
|
123
129
|
</script>
|
|
124
130
|
|
|
125
131
|
<div class="relative flex h-full w-full flex-col md:flex-row">
|
|
126
|
-
<LoadScreen />
|
|
132
|
+
<LoadScreen error={loadError} />
|
|
127
133
|
<div class="relative flex-1 overflow-hidden">
|
|
128
134
|
<Map {integrations} />
|
|
129
135
|
{@render overlays?.()}
|
|
@@ -33,16 +33,45 @@ export abstract class MapIconLayerIntegration<
|
|
|
33
33
|
T = Record<string, unknown>
|
|
34
34
|
> extends MapGeoJSONIntegration<T> {
|
|
35
35
|
abstract icons: MapIconManager;
|
|
36
|
+
private generation = 0;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drops every callback still waiting on icons. `apply()` and `remove()` call it, so a
|
|
40
|
+
* subclass overriding `apply()` without calling `super.apply()` first must call it itself.
|
|
41
|
+
*/
|
|
42
|
+
protected supersedePending(): void {
|
|
43
|
+
this.generation++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs `run` once the icons have loaded, unless an `apply()` or `remove()` has happened
|
|
48
|
+
* since (the state it was scheduled for is gone) or a basemap change is in flight (the
|
|
49
|
+
* refresh on "style.load" re-applies).
|
|
50
|
+
*/
|
|
51
|
+
protected whenIconsLoad(run: () => void): void {
|
|
52
|
+
const generation = this.generation;
|
|
53
|
+
this.icons
|
|
54
|
+
.loadIcons()
|
|
55
|
+
.then(() => {
|
|
56
|
+
if (generation !== this.generation || mapManager.styleLoading) return;
|
|
57
|
+
run();
|
|
58
|
+
})
|
|
59
|
+
.catch(console.error);
|
|
60
|
+
}
|
|
36
61
|
|
|
37
62
|
apply() {
|
|
38
63
|
if (!mapManager.map) return;
|
|
39
64
|
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
if (mapManager.styleLoading) return;
|
|
65
|
+
this.supersedePending();
|
|
66
|
+
this.whenIconsLoad(() => {
|
|
43
67
|
this.removeLayerAndSource();
|
|
44
68
|
mapManager.map?.addSource(this.referenceId, this.mapSource);
|
|
45
69
|
super.apply();
|
|
46
70
|
});
|
|
47
71
|
}
|
|
72
|
+
|
|
73
|
+
remove() {
|
|
74
|
+
this.supersedePending();
|
|
75
|
+
super.remove();
|
|
76
|
+
}
|
|
48
77
|
}
|
|
@@ -7,7 +7,12 @@ import { variantForStyle } from "./basemap";
|
|
|
7
7
|
class MapStyleState {
|
|
8
8
|
referenceStyle: ReferenceMapStyle = $state.raw(DefaultMapStyle.getReferenceStyle());
|
|
9
9
|
variant: MapStyleVariant = $state.raw(DefaultMapStyle);
|
|
10
|
-
|
|
10
|
+
// The variant last handed to setStyle (loaded or still loading); null once a load has failed
|
|
11
|
+
// with nothing left to fall back to, so that any later selection is tried again.
|
|
12
|
+
private applied: MapStyleVariant | null = DefaultMapStyle;
|
|
13
|
+
// The variant the map last finished loading, which a failed change falls back to.
|
|
14
|
+
private loaded: MapStyleVariant = DefaultMapStyle;
|
|
15
|
+
private stopWaiting: (() => void) | null = null;
|
|
11
16
|
|
|
12
17
|
selectReferenceStyle(style: ReferenceMapStyle): void {
|
|
13
18
|
this.referenceStyle = style;
|
|
@@ -25,17 +30,64 @@ class MapStyleState {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
private apply(): void {
|
|
33
|
+
if (!mapManager.map || this.variant === this.applied) return;
|
|
34
|
+
this.load(this.variant);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private load(variant: MapStyleVariant): void {
|
|
28
38
|
const map = mapManager.map;
|
|
29
|
-
if (!map
|
|
30
|
-
|
|
39
|
+
if (!map) return;
|
|
40
|
+
// A newer change supersedes one still in flight.
|
|
41
|
+
this.stopWaiting?.();
|
|
42
|
+
this.applied = variant;
|
|
43
|
+
|
|
31
44
|
// setStyle wipes every custom source/layer; re-add enabled integrations once it loads.
|
|
32
|
-
|
|
45
|
+
const onLoad = () => {
|
|
46
|
+
this.stopWaiting?.();
|
|
47
|
+
this.loaded = variant;
|
|
33
48
|
mapManager.styleLoading = false;
|
|
34
49
|
integrationsManager.refresh();
|
|
35
|
-
}
|
|
50
|
+
};
|
|
51
|
+
// "error" also fires for tiles, sprites and glyphs; only the style document failing means
|
|
52
|
+
// the change failed.
|
|
53
|
+
const onError = (event: { error?: unknown }) => {
|
|
54
|
+
if (!isRequestFor(event.error, variant.getExpandedStyleURL())) return;
|
|
55
|
+
this.stopWaiting?.();
|
|
56
|
+
this.recover(variant);
|
|
57
|
+
};
|
|
58
|
+
this.stopWaiting = () => {
|
|
59
|
+
map.off("style.load", onLoad);
|
|
60
|
+
map.off("error", onError);
|
|
61
|
+
this.stopWaiting = null;
|
|
62
|
+
};
|
|
63
|
+
map.on("style.load", onLoad);
|
|
64
|
+
map.on("error", onError);
|
|
36
65
|
mapManager.styleLoading = true;
|
|
37
|
-
map.setStyle(
|
|
66
|
+
map.setStyle(variant);
|
|
38
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A style that fails to load leaves the map with no style at all, so go back to the last one
|
|
71
|
+
* that loaded and make the selection say so. When that one fails too (e.g. offline) there is
|
|
72
|
+
* nothing to draw on: `styleLoading` stays set, since integrations cannot add layers to a
|
|
73
|
+
* missing style, and the next selection starts a fresh attempt.
|
|
74
|
+
*/
|
|
75
|
+
private recover(failed: MapStyleVariant): void {
|
|
76
|
+
if (failed === this.loaded) {
|
|
77
|
+
this.applied = null;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.variant = this.loaded;
|
|
81
|
+
this.referenceStyle = this.loaded.getReferenceStyle();
|
|
82
|
+
this.load(this.loaded);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether a map "error" event's error is a failed request for `url`, ignoring the query. */
|
|
87
|
+
function isRequestFor(error: unknown, url: string): boolean {
|
|
88
|
+
const failed = (error as { url?: unknown } | null | undefined)?.url;
|
|
89
|
+
if (typeof failed !== "string") return false;
|
|
90
|
+
return failed.split("?")[0] === url.split("?")[0];
|
|
39
91
|
}
|
|
40
92
|
|
|
41
93
|
export const mapStyleState = new MapStyleState();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getMonitorDetails, type MonitorDetails } from "@sjvair/sdk";
|
|
2
|
+
|
|
3
|
+
export type MonitorDetailsStatus = "loading" | "ready" | "not-found" | "failed";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One monitor, fetched by id. Unlike `monitorsManager.latest`, this does not depend on the
|
|
7
|
+
* map's pollutant or on the monitor being active, so the detail panel can show any monitor.
|
|
8
|
+
*/
|
|
9
|
+
export class MonitorDetailsState {
|
|
10
|
+
monitor: MonitorDetails | null = $state.raw(null);
|
|
11
|
+
status: MonitorDetailsStatus = $state("loading");
|
|
12
|
+
|
|
13
|
+
#id: string | null = null;
|
|
14
|
+
// Bumped per request; a response for anything but the latest request is dropped.
|
|
15
|
+
#request = 0;
|
|
16
|
+
|
|
17
|
+
async load(id: string): Promise<void> {
|
|
18
|
+
this.#id = id;
|
|
19
|
+
const request = ++this.#request;
|
|
20
|
+
this.monitor = null;
|
|
21
|
+
this.status = "loading";
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const monitor = await getMonitorDetails(id);
|
|
25
|
+
if (request !== this.#request) return;
|
|
26
|
+
this.monitor = monitor;
|
|
27
|
+
this.status = "ready";
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (request !== this.#request) return;
|
|
30
|
+
if (isNotFound(error)) {
|
|
31
|
+
this.status = "not-found";
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
console.error(error);
|
|
35
|
+
this.status = "failed";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Re-fetches the monitor already shown. A failure keeps the data on screen. */
|
|
40
|
+
async refresh(): Promise<void> {
|
|
41
|
+
if (this.status !== "ready" || this.#id === null) return;
|
|
42
|
+
const request = ++this.#request;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const monitor = await getMonitorDetails(this.#id);
|
|
46
|
+
if (request !== this.#request) return;
|
|
47
|
+
this.monitor = monitor;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (request === this.#request) console.error(error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
retry(): Promise<void> {
|
|
54
|
+
return this.#id === null ? Promise.resolve() : this.load(this.#id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The SDK rejects a failed request with `cause.result.status` set to the HTTP status, or,
|
|
60
|
+
* when it could not parse the error body, with the `Response` itself as the cause.
|
|
61
|
+
*/
|
|
62
|
+
function isNotFound(error: unknown): boolean {
|
|
63
|
+
const cause = (error as { cause?: { status?: unknown; result?: { status?: unknown } } } | null)
|
|
64
|
+
?.cause;
|
|
65
|
+
return cause?.result?.status === 404 || cause?.status === 404;
|
|
66
|
+
}
|
|
@@ -274,8 +274,9 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
|
|
|
274
274
|
clickManager.unregister([this.referenceId]);
|
|
275
275
|
this.tooltipManager.disable();
|
|
276
276
|
this.renderer.remove();
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
// Clustering may be toggled again before the icons arrive; only the latest apply runs.
|
|
278
|
+
this.supersedePending();
|
|
279
|
+
this.whenIconsLoad(() => {
|
|
279
280
|
this.renderer.apply(this.handleMonitorClick);
|
|
280
281
|
this.tooltipManager.enable();
|
|
281
282
|
this.applySelectedState();
|
|
@@ -286,8 +287,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
|
|
|
286
287
|
this.tooltipManager.enable();
|
|
287
288
|
clickManager.unregister([this.referenceId]);
|
|
288
289
|
super.apply();
|
|
289
|
-
this.
|
|
290
|
-
if (mapManager.styleLoading) return;
|
|
290
|
+
this.whenIconsLoad(() => {
|
|
291
291
|
clickManager.register([this.referenceId], this.handleMonitorClick);
|
|
292
292
|
this.applySelectedState();
|
|
293
293
|
});
|
|
@@ -12,6 +12,14 @@ import { Interval } from "@tstk/utils";
|
|
|
12
12
|
import { untrack } from "svelte";
|
|
13
13
|
import type { MonitorsDataSource, MonitorsPollutant } from "./types";
|
|
14
14
|
|
|
15
|
+
export interface MonitorsInitOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Start the 2-minute polling once the initial load finishes. Defaults to true; pass false to
|
|
18
|
+
* load the data without showing a map (`resumeAutoUpdate()` starts polling later).
|
|
19
|
+
*/
|
|
20
|
+
autoUpdate?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
class MonitorsManager implements MonitorsDataSource {
|
|
16
24
|
autoUpdate: Interval = new Interval(async () => await this.update(), 2 * 60 * 1000);
|
|
17
25
|
initialized: boolean = $state(false);
|
|
@@ -29,6 +37,7 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
29
37
|
|
|
30
38
|
#initPromise: Promise<void> | null = null;
|
|
31
39
|
#requestedPollutant: MonitorsPollutant | null = null;
|
|
40
|
+
#requestedAutoUpdate = false;
|
|
32
41
|
|
|
33
42
|
constructor() {
|
|
34
43
|
// Refetch monitors when the pollutant changes after the initial load, wherever the change
|
|
@@ -54,11 +63,15 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
54
63
|
/**
|
|
55
64
|
* Safe to call from several places: overlapping calls share one run, and a failed run can
|
|
56
65
|
* be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
|
|
57
|
-
* between two explicit values the most recent wins.
|
|
66
|
+
* between two explicit values the most recent wins. Polling starts when the run finishes
|
|
67
|
+
* unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
|
|
68
|
+
* never restarts polling either: that is `resumeAutoUpdate()`.
|
|
58
69
|
*/
|
|
59
|
-
init(urlPollutant?: string | number | boolean): Promise<void> {
|
|
70
|
+
init(urlPollutant?: string | number | boolean, options: MonitorsInitOptions = {}): Promise<void> {
|
|
60
71
|
if (this.initialized) return Promise.resolve();
|
|
61
72
|
|
|
73
|
+
if (options.autoUpdate !== false) this.#requestedAutoUpdate = true;
|
|
74
|
+
|
|
62
75
|
if (urlPollutant === "pm25" || urlPollutant === "o3") {
|
|
63
76
|
this.#requestedPollutant = urlPollutant;
|
|
64
77
|
}
|
|
@@ -66,6 +79,7 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
66
79
|
this.#initPromise ??= this.#runInit().finally(() => {
|
|
67
80
|
this.#initPromise = null;
|
|
68
81
|
this.#requestedPollutant = null;
|
|
82
|
+
this.#requestedAutoUpdate = false;
|
|
69
83
|
});
|
|
70
84
|
|
|
71
85
|
return this.#initPromise;
|
|
@@ -105,13 +119,14 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
105
119
|
|
|
106
120
|
this.pollutant = fetchedPollutant;
|
|
107
121
|
this.latest = latest;
|
|
108
|
-
this.autoUpdate.start();
|
|
122
|
+
if (this.#requestedAutoUpdate) this.autoUpdate.start();
|
|
109
123
|
this.initialized = true;
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
127
|
+
* Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
|
|
128
|
+
* it on destroy) or after an init that opted out of it. Refreshes first since the data may
|
|
129
|
+
* be stale. A no-op before init or while polling.
|
|
115
130
|
*/
|
|
116
131
|
resumeAutoUpdate(): void {
|
|
117
132
|
if (!this.initialized || this.autoUpdate.isActive) return;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** The pollutants the detail panel can show, in order of preference. */
|
|
2
|
+
export const PANEL_POLLUTANTS = ["pm25", "o3"] as const;
|
|
3
|
+
export type PanelPollutant = (typeof PANEL_POLLUTANTS)[number];
|
|
4
|
+
|
|
5
|
+
/** The slice of the SDK's `MonitorDetails` this module reads. */
|
|
6
|
+
export interface PanelMonitor {
|
|
7
|
+
type: string;
|
|
8
|
+
is_active: boolean;
|
|
9
|
+
// Only the panel pollutants are read; the SDK's `latest` has more entry types, without an
|
|
10
|
+
// index signature.
|
|
11
|
+
latest: Partial<Record<PanelPollutant, { value?: string }>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The slice of the SDK's `MonitorsMeta` this module reads. */
|
|
15
|
+
export interface PanelMeta {
|
|
16
|
+
monitors: Record<string, { entries: Record<string, unknown> } | undefined>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PanelPollutantChoice {
|
|
20
|
+
/** Null when the monitor has no reading and its type publishes neither pollutant. */
|
|
21
|
+
pollutant: PanelPollutant | null;
|
|
22
|
+
/** Whether the monitor has a current reading for `pollutant`. */
|
|
23
|
+
hasReading: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The panel pollutants this monitor has a current, numeric reading for. */
|
|
27
|
+
export function readingPollutants(monitor: Pick<PanelMonitor, "latest">): PanelPollutant[] {
|
|
28
|
+
return PANEL_POLLUTANTS.filter((pollutant) => {
|
|
29
|
+
const value = monitor.latest[pollutant]?.value;
|
|
30
|
+
return typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The panel pollutants this monitor type publishes. The API marks each entry type with a
|
|
36
|
+
* `published` flag the SDK does not type; a type that carries the entry without the flag
|
|
37
|
+
* counts as publishing it.
|
|
38
|
+
*/
|
|
39
|
+
export function publishedPollutants(monitorType: string, meta: PanelMeta): PanelPollutant[] {
|
|
40
|
+
const entries = meta.monitors[monitorType]?.entries ?? {};
|
|
41
|
+
return PANEL_POLLUTANTS.filter((pollutant) => {
|
|
42
|
+
const entry = entries[pollutant] as { published?: boolean } | undefined;
|
|
43
|
+
return entry !== undefined && entry.published !== false;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The pollutant the detail panel shows: the map's when the monitor has it, otherwise the one
|
|
49
|
+
* the monitor has. A monitor with no current reading (inactive, or nothing reported) still
|
|
50
|
+
* gets a pollutant when there is one to chart, so its past data can be browsed.
|
|
51
|
+
*/
|
|
52
|
+
export function choosePanelPollutant(
|
|
53
|
+
mapPollutant: PanelPollutant | null,
|
|
54
|
+
monitor: PanelMonitor,
|
|
55
|
+
meta: PanelMeta
|
|
56
|
+
): PanelPollutantChoice {
|
|
57
|
+
const withReading = readingPollutants(monitor);
|
|
58
|
+
// The API keeps returning an inactive monitor's last reading, however old, so only an
|
|
59
|
+
// active monitor's readings count as current.
|
|
60
|
+
if (monitor.is_active && withReading.length > 0) {
|
|
61
|
+
const pollutant =
|
|
62
|
+
mapPollutant && withReading.includes(mapPollutant) ? mapPollutant : withReading[0];
|
|
63
|
+
return { pollutant, hasReading: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Nothing current: chart what the type publishes, or failing that what it last reported.
|
|
67
|
+
const published = publishedPollutants(monitor.type, meta);
|
|
68
|
+
const chartable = published.length > 0 ? published : withReading;
|
|
69
|
+
if (chartable.length > 0) {
|
|
70
|
+
const pollutant =
|
|
71
|
+
mapPollutant && chartable.includes(mapPollutant) ? mapPollutant : chartable[0];
|
|
72
|
+
return { pollutant, hasReading: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { pollutant: null, hasReading: false };
|
|
76
|
+
}
|
|
@@ -42,7 +42,14 @@
|
|
|
42
42
|
function onkeydown(e: KeyboardEvent) {
|
|
43
43
|
if (e.key !== "Escape") return;
|
|
44
44
|
panelOpen = false;
|
|
45
|
-
if (isWideLayout())
|
|
45
|
+
if (!isWideLayout()) return;
|
|
46
|
+
// Focus on a row would be left inside the hidden dropdown; hand it back to the trigger.
|
|
47
|
+
const focused = document.activeElement;
|
|
48
|
+
const trigger = focused
|
|
49
|
+
?.closest("[data-options-menu]")
|
|
50
|
+
?.querySelector<HTMLElement>(":scope > button");
|
|
51
|
+
menus.closeAll();
|
|
52
|
+
if (trigger !== focused) trigger?.focus();
|
|
46
53
|
}
|
|
47
54
|
</script>
|
|
48
55
|
|
|
@@ -33,10 +33,19 @@
|
|
|
33
33
|
|
|
34
34
|
<div
|
|
35
35
|
role="presentation"
|
|
36
|
+
data-options-menu
|
|
36
37
|
class="group/menu relative md:first:[&>button]:rounded-l-md md:last:[&>button]:rounded-r-md"
|
|
37
38
|
onmouseenter={() => {
|
|
38
39
|
if (isWideLayout()) menus.closeOthers(id);
|
|
39
40
|
}}
|
|
41
|
+
onfocusout={(e) => {
|
|
42
|
+
// Toolbar only: focus moving on to something outside this menu (tabbing past the last
|
|
43
|
+
// row) closes its dropdown. No destination means a click on something unfocusable, which
|
|
44
|
+
// the outside-click handler in OptionsBar decides.
|
|
45
|
+
const next = e.relatedTarget;
|
|
46
|
+
if (!isWideLayout() || !(next instanceof Node) || e.currentTarget.contains(next)) return;
|
|
47
|
+
menus.close(id);
|
|
48
|
+
}}
|
|
40
49
|
>
|
|
41
50
|
<button
|
|
42
51
|
type="button"
|
|
@@ -61,7 +70,7 @@
|
|
|
61
70
|
"max-md:pt-1 md:absolute md:top-full md:z-20 md:min-w-64 md:pt-1",
|
|
62
71
|
alignRight ? "md:right-0" : "md:left-0",
|
|
63
72
|
open ? "block" : "hidden",
|
|
64
|
-
!disabled && "md:group-hover/menu:block
|
|
73
|
+
!disabled && "md:group-hover/menu:block"
|
|
65
74
|
]}
|
|
66
75
|
>
|
|
67
76
|
<div class="py-1 md:max-h-[69vh] md:overflow-y-auto md:rounded-md md:bg-white md:shadow-lg">
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a loader so overlapping calls share one run and a resolved run is reused, while a
|
|
3
|
+
* rejected run is forgotten: the next call tries again.
|
|
4
|
+
*/
|
|
5
|
+
export function sharedLoad<T>(load: () => Promise<T>): () => Promise<T> {
|
|
6
|
+
let pending: Promise<T> | null = null;
|
|
7
|
+
|
|
8
|
+
return () => {
|
|
9
|
+
pending ??= load().catch((error: unknown) => {
|
|
10
|
+
pending = null;
|
|
11
|
+
throw error;
|
|
12
|
+
});
|
|
13
|
+
return pending;
|
|
14
|
+
};
|
|
15
|
+
}
|