@sjvair/monitor-map 4.2.0 → 4.3.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 +14 -0
- package/dist/lib/abort.d.ts +16 -0
- package/dist/lib/abort.js +51 -0
- package/dist/lib/collocation-sites/collocations.svelte.d.ts +3 -2
- package/dist/lib/collocation-sites/collocations.svelte.js +8 -4
- package/dist/lib/data-chart/DataChart.svelte +16 -1
- package/dist/lib/data-chart/requests.d.ts +2 -1
- package/dist/lib/data-chart/requests.js +5 -2
- package/dist/lib/ev-stations/ev-stations.svelte.d.ts +3 -2
- package/dist/lib/ev-stations/ev-stations.svelte.js +8 -6
- package/dist/lib/hms/hms.svelte.d.ts +4 -3
- package/dist/lib/hms/hms.svelte.js +16 -6
- package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
- package/dist/lib/monitors/monitor-details.svelte.js +11 -3
- package/dist/lib/monitors/monitors.svelte.d.ts +14 -1
- package/dist/lib/monitors/monitors.svelte.js +119 -27
- package/dist/lib/monitors/subscriptions.d.ts +3 -0
- package/dist/lib/monitors/subscriptions.js +9 -0
- package/package.json +2 -2
- package/src/lib/abort.ts +59 -0
- package/src/lib/collocation-sites/collocations.svelte.ts +7 -4
- package/src/lib/data-chart/DataChart.svelte +16 -1
- package/src/lib/data-chart/requests.ts +14 -7
- package/src/lib/ev-stations/ev-stations.svelte.ts +11 -6
- package/src/lib/hms/hms.svelte.ts +12 -6
- package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
- package/src/lib/monitors/monitor-details.svelte.ts +12 -3
- package/src/lib/monitors/monitors.svelte.ts +142 -30
- package/src/lib/monitors/subscriptions.ts +17 -0
package/README.md
CHANGED
|
@@ -106,6 +106,20 @@ parameter: the panel shows the map's pollutant when the monitor reports it and o
|
|
|
106
106
|
one it does report, saying so. An inactive monitor gets a panel with its past data and a
|
|
107
107
|
note that it has no current readings; an unknown id gets "Monitor not found."
|
|
108
108
|
|
|
109
|
+
### Cancelling requests
|
|
110
|
+
|
|
111
|
+
`monitorsManager.init()` and `update()`, `collocationSitesManager.init()` and `update()`,
|
|
112
|
+
`hmsManager.init()`, `loadSmoke()` and `loadFire()`, and `evStationsManager.loadLvl2Stations()`
|
|
113
|
+
and `loadLvl3Stations()` take `{ signal }` (an `AbortSignal`). Aborting rejects that call with
|
|
114
|
+
the signal's reason (a call that is a no-op, such as `update()` before init or `init()` once
|
|
115
|
+
initialized, resolves as before). The panel, chart and subscription status cancel their own superseded
|
|
116
|
+
requests when the selection changes; that work resolves quietly. A `monitorsManager.init()`
|
|
117
|
+
run is shared: aborting your signal stops only your wait, and the run itself is cancelled only
|
|
118
|
+
when every caller waiting on it has aborted.
|
|
119
|
+
|
|
120
|
+
A host that depends on `@sjvair/sdk` directly must use `^4.7.0`, so npm installs one copy. A
|
|
121
|
+
nested copy ignores the host's `setOrigin`.
|
|
122
|
+
|
|
109
123
|
### Embedding `MapShell` directly, with a reduced integration set
|
|
110
124
|
|
|
111
125
|
`MonitorMapLayout` is an opinionated wrapper around the lower-level `MapShell`
|
|
@@ -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>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A signal that aborts as soon as any input does, with that input's reason. Already aborted if
|
|
3
|
+
* any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
|
|
4
|
+
*/
|
|
5
|
+
export function anySignal(...signals) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const cleanups = [];
|
|
8
|
+
const dispose = () => {
|
|
9
|
+
for (const cleanup of cleanups.splice(0))
|
|
10
|
+
cleanup();
|
|
11
|
+
};
|
|
12
|
+
for (const input of signals) {
|
|
13
|
+
if (!input)
|
|
14
|
+
continue;
|
|
15
|
+
if (input.aborted) {
|
|
16
|
+
controller.abort(input.reason);
|
|
17
|
+
dispose();
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
const onAbort = () => {
|
|
21
|
+
controller.abort(input.reason);
|
|
22
|
+
dispose();
|
|
23
|
+
};
|
|
24
|
+
input.addEventListener("abort", onAbort, { once: true });
|
|
25
|
+
cleanups.push(() => input.removeEventListener("abort", onAbort));
|
|
26
|
+
}
|
|
27
|
+
return { signal: controller.signal, dispose };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Settles like `promise`, unless `signal` aborts first, in which case it rejects with
|
|
31
|
+
* `signal.reason`. The inner promise's later outcome is swallowed.
|
|
32
|
+
*/
|
|
33
|
+
export function untilAborted(promise, signal) {
|
|
34
|
+
if (!signal)
|
|
35
|
+
return promise;
|
|
36
|
+
if (signal.aborted) {
|
|
37
|
+
promise.catch(() => { });
|
|
38
|
+
return Promise.reject(signal.reason);
|
|
39
|
+
}
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const onAbort = () => reject(signal.reason);
|
|
42
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
43
|
+
promise.then((value) => {
|
|
44
|
+
signal.removeEventListener("abort", onAbort);
|
|
45
|
+
resolve(value);
|
|
46
|
+
}, (error) => {
|
|
47
|
+
signal.removeEventListener("abort", onAbort);
|
|
48
|
+
reject(error);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type CollocationSite } from "@sjvair/sdk";
|
|
2
|
+
import type { SignalOptions } from "../abort";
|
|
2
3
|
declare class CollocationSitesManager {
|
|
3
4
|
initialized: boolean;
|
|
4
5
|
collocationSites: Array<CollocationSite> | null;
|
|
5
|
-
init(): Promise<void>;
|
|
6
|
-
update(): Promise<void>;
|
|
6
|
+
init({ signal }?: SignalOptions): Promise<void>;
|
|
7
|
+
update({ signal }?: SignalOptions): Promise<void>;
|
|
7
8
|
}
|
|
8
9
|
export declare const collocationSitesManager: CollocationSitesManager;
|
|
9
10
|
export type { CollocationSitesManager };
|
|
@@ -2,16 +2,20 @@ import { getCollocationSites } from "@sjvair/sdk";
|
|
|
2
2
|
class CollocationSitesManager {
|
|
3
3
|
initialized = $state(false);
|
|
4
4
|
collocationSites = $state(null);
|
|
5
|
-
async init() {
|
|
5
|
+
async init({ signal } = {}) {
|
|
6
6
|
if (this.initialized)
|
|
7
7
|
return;
|
|
8
|
-
|
|
8
|
+
if (signal?.aborted)
|
|
9
|
+
throw signal.reason;
|
|
10
|
+
this.collocationSites = await getCollocationSites({ signal });
|
|
9
11
|
this.initialized = true;
|
|
10
12
|
}
|
|
11
|
-
async update() {
|
|
13
|
+
async update({ signal } = {}) {
|
|
12
14
|
if (!this.initialized)
|
|
13
15
|
return;
|
|
14
|
-
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
throw signal.reason;
|
|
18
|
+
this.collocationSites = await getCollocationSites({ signal });
|
|
15
19
|
}
|
|
16
20
|
}
|
|
17
21
|
export const collocationSitesManager = new CollocationSitesManager();
|
|
@@ -143,12 +143,27 @@
|
|
|
143
143
|
};
|
|
144
144
|
|
|
145
145
|
let chartRequest = 0;
|
|
146
|
+
let chartController: AbortController | undefined;
|
|
147
|
+
|
|
148
|
+
// A closed panel cancels its in-flight fetch.
|
|
149
|
+
$effect(() => () => chartController?.abort());
|
|
146
150
|
|
|
147
151
|
// `silent` keeps the current chart on screen while a timed refresh loads.
|
|
148
152
|
async function loadChartData({ silent = false } = {}) {
|
|
149
153
|
const request = ++chartRequest;
|
|
154
|
+
chartController?.abort();
|
|
155
|
+
const controller = (chartController = new AbortController());
|
|
150
156
|
if (!silent) chartManager.loading = true;
|
|
151
|
-
|
|
157
|
+
let data: Awaited<ReturnType<typeof fetchChartData>>;
|
|
158
|
+
try {
|
|
159
|
+
data = await fetchChartData(monitor, chartManager.dateRange, pollutant, {
|
|
160
|
+
signal: controller.signal
|
|
161
|
+
});
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// Superseded or closed: the newer call owns `loading` and the chart, so leave both.
|
|
164
|
+
if (controller.signal.aborted) return;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
152
167
|
// A newer request (another range, monitor, pollutant or refresh) owns the chart now.
|
|
153
168
|
if (request !== chartRequest) return;
|
|
154
169
|
// A failed request resolves to [] (an empty range is [[], []]); a background refresh
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type MonitorDetails } from "@sjvair/sdk";
|
|
2
2
|
import type { PanelPollutant } from "../monitors/panel-pollutant";
|
|
3
3
|
import type { DateRange } from "./DateRange";
|
|
4
|
+
import type { SignalOptions } from "../abort";
|
|
4
5
|
/** The monitor fields the chart reads. */
|
|
5
6
|
export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
|
|
6
|
-
export declare function fetchChartData(m: ChartMonitor, d: DateRange, entryType: PanelPollutant): Promise<uPlot.AlignedData>;
|
|
7
|
+
export declare function fetchChartData(m: ChartMonitor, d: DateRange, entryType: PanelPollutant, options?: SignalOptions): Promise<uPlot.AlignedData>;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { getMonitorEntries } from "@sjvair/sdk";
|
|
2
2
|
import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
|
|
3
|
-
export async function fetchChartData(m, d, entryType) {
|
|
3
|
+
export async function fetchChartData(m, d, entryType, options = {}) {
|
|
4
4
|
return await getMonitorEntries({
|
|
5
5
|
entryType,
|
|
6
6
|
monitorId: m.id,
|
|
7
7
|
timestampGte: d.start,
|
|
8
8
|
timestampLte: d.end
|
|
9
|
-
})
|
|
9
|
+
}, { signal: options.signal })
|
|
10
10
|
.then((entries) => {
|
|
11
11
|
const xAxisData = [];
|
|
12
12
|
const updateDuration = m.data_source.name === "PurpleAir" ? 2 : 60;
|
|
@@ -26,6 +26,9 @@ export async function fetchChartData(m, d, entryType) {
|
|
|
26
26
|
return [xAxisData.map((d) => getUnixTime(d)), yAxisRecord];
|
|
27
27
|
})
|
|
28
28
|
.catch((err) => {
|
|
29
|
+
// A cancelled request is not a failure: let the caller see the reason, log nothing.
|
|
30
|
+
if (options.signal?.aborted)
|
|
31
|
+
throw options.signal.reason;
|
|
29
32
|
console.error("Failed to load chart data: ", err);
|
|
30
33
|
return [];
|
|
31
34
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type { SignalOptions } from "../abort";
|
|
1
2
|
import type { EvStation } from "./types";
|
|
2
3
|
declare class EvStationsManager {
|
|
3
4
|
lvl2Stations: Array<EvStation> | undefined;
|
|
4
5
|
lvl3Stations: Array<EvStation> | undefined;
|
|
5
|
-
loadLvl3Stations(): Promise<void>;
|
|
6
|
-
loadLvl2Stations(): Promise<void>;
|
|
6
|
+
loadLvl3Stations({ signal }?: SignalOptions): Promise<void>;
|
|
7
|
+
loadLvl2Stations({ signal }?: SignalOptions): Promise<void>;
|
|
7
8
|
}
|
|
8
9
|
export declare const evStationsManager: EvStationsManager;
|
|
9
10
|
export type { EvStationsManager };
|
|
@@ -3,17 +3,19 @@ import * as ZipCodes from "./zip-codes";
|
|
|
3
3
|
class EvStationsManager {
|
|
4
4
|
lvl2Stations = $state();
|
|
5
5
|
lvl3Stations = $state();
|
|
6
|
-
async loadLvl3Stations() {
|
|
7
|
-
this.lvl3Stations = await fetchEvStations("dc_fast");
|
|
6
|
+
async loadLvl3Stations({ signal } = {}) {
|
|
7
|
+
this.lvl3Stations = await fetchEvStations("dc_fast", signal);
|
|
8
8
|
}
|
|
9
|
-
async loadLvl2Stations() {
|
|
10
|
-
this.lvl2Stations = await fetchEvStations("2");
|
|
9
|
+
async loadLvl2Stations({ signal } = {}) {
|
|
10
|
+
this.lvl2Stations = await fetchEvStations("2", signal);
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
const zipCodes = Object.values(ZipCodes)
|
|
14
14
|
.reduce((prev, curr) => prev.concat(curr))
|
|
15
15
|
.join(",");
|
|
16
|
-
async function fetchEvStations(chargingLevel) {
|
|
16
|
+
async function fetchEvStations(chargingLevel, signal) {
|
|
17
|
+
if (signal?.aborted)
|
|
18
|
+
throw signal.reason;
|
|
17
19
|
const url = new URL(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json`);
|
|
18
20
|
url.searchParams.set("fuel_type", "ELEC");
|
|
19
21
|
url.searchParams.set("state", "CA");
|
|
@@ -22,7 +24,7 @@ async function fetchEvStations(chargingLevel) {
|
|
|
22
24
|
url.searchParams.set("ev_charging_level", chargingLevel);
|
|
23
25
|
url.searchParams.set("zip", zipCodes);
|
|
24
26
|
url.searchParams.set("api_key", import.meta.env.VITE_NREL_KEY);
|
|
25
|
-
return await fetch(url)
|
|
27
|
+
return await fetch(url, { signal })
|
|
26
28
|
.then(async (res) => await res.json())
|
|
27
29
|
.then((data) => {
|
|
28
30
|
const fuelStations = data.fuel_stations;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type HMSFireGeoJSON, type HMSSmokeGeoJSON } from "@sjvair/sdk/hms";
|
|
2
|
+
import type { SignalOptions } from "../abort";
|
|
2
3
|
export interface HMSFireGroup {
|
|
3
4
|
id: string;
|
|
4
5
|
coordinates: [number, number];
|
|
@@ -11,9 +12,9 @@ declare class HmsManager {
|
|
|
11
12
|
frp: number;
|
|
12
13
|
}> | undefined;
|
|
13
14
|
fireGroups: Array<HMSFireGroup> | undefined;
|
|
14
|
-
init(): Promise<void>;
|
|
15
|
-
loadSmoke(): Promise<void>;
|
|
16
|
-
loadFire(): Promise<void>;
|
|
15
|
+
init({ signal }?: SignalOptions): Promise<void>;
|
|
16
|
+
loadSmoke({ signal }?: SignalOptions): Promise<void>;
|
|
17
|
+
loadFire({ signal }?: SignalOptions): Promise<void>;
|
|
17
18
|
}
|
|
18
19
|
export declare const hmsManager: HmsManager;
|
|
19
20
|
export type { HmsManager as HMSManager };
|
|
@@ -7,21 +7,31 @@ class HmsManager {
|
|
|
7
7
|
smoke = $state();
|
|
8
8
|
fire = $state();
|
|
9
9
|
fireGroups = $state();
|
|
10
|
-
async init() {
|
|
11
|
-
|
|
10
|
+
async init({ signal } = {}) {
|
|
11
|
+
if (signal?.aborted)
|
|
12
|
+
throw signal.reason;
|
|
13
|
+
await Promise.allSettled([this.loadSmoke({ signal }), this.loadFire({ signal })]);
|
|
14
|
+
if (signal?.aborted)
|
|
15
|
+
throw signal.reason;
|
|
12
16
|
}
|
|
13
|
-
async loadSmoke() {
|
|
14
|
-
|
|
17
|
+
async loadSmoke({ signal } = {}) {
|
|
18
|
+
if (signal?.aborted)
|
|
19
|
+
throw signal.reason;
|
|
20
|
+
this.smoke = await getHMSSmoke({ signal });
|
|
15
21
|
}
|
|
16
|
-
async loadFire() {
|
|
22
|
+
async loadFire({ signal } = {}) {
|
|
17
23
|
try {
|
|
18
|
-
|
|
24
|
+
if (signal?.aborted)
|
|
25
|
+
throw signal.reason;
|
|
26
|
+
const raw = await getHMSFire({ signal });
|
|
19
27
|
const filtered = raw.filter((d) => d.frp !== null);
|
|
20
28
|
const deduped = deduplicateByCoordinates(filtered);
|
|
21
29
|
this.fire = deduped;
|
|
22
30
|
this.fireGroups = computeFireGroups(deduped);
|
|
23
31
|
}
|
|
24
32
|
catch (err) {
|
|
33
|
+
if (signal?.aborted)
|
|
34
|
+
throw err;
|
|
25
35
|
console.error("HMS fire load failed:", err);
|
|
26
36
|
}
|
|
27
37
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { BellCheckIcon, BellOffIcon, ChevronDownIcon } from "@lucide/svelte";
|
|
3
|
-
import {
|
|
3
|
+
import { subscribe, unsubscribe } from "@sjvair/sdk/account";
|
|
4
4
|
import { monitorsManager } from "../..";
|
|
5
|
+
import { getApiToken, loadSubscriptionLevel } from "../subscriptions";
|
|
5
6
|
|
|
6
7
|
interface MonitorSubscriptionManagerProps {
|
|
7
8
|
monitorId: string;
|
|
@@ -35,24 +36,24 @@
|
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
$effect(() => {
|
|
38
|
-
|
|
39
|
+
const id = monitorId;
|
|
39
40
|
open = false;
|
|
40
41
|
subscribedLevel = null;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
async function loadSubscription() {
|
|
45
|
-
const id = monitorId;
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const { signal } = controller;
|
|
46
44
|
loading = true;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
loadSubscriptionLevel(id, { signal })
|
|
46
|
+
.then((level) => {
|
|
47
|
+
if (!signal.aborted) subscribedLevel = level;
|
|
48
|
+
})
|
|
49
|
+
.catch((error) => {
|
|
50
|
+
if (!signal.aborted) throw error;
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
if (!signal.aborted) loading = false;
|
|
54
|
+
});
|
|
55
|
+
return () => controller.abort();
|
|
56
|
+
});
|
|
56
57
|
|
|
57
58
|
async function handleLevelClick(level: string | null) {
|
|
58
59
|
if (saving || !level) return;
|
|
@@ -75,12 +76,6 @@
|
|
|
75
76
|
}
|
|
76
77
|
open = false;
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
-
function getApiToken(): string {
|
|
80
|
-
return (
|
|
81
|
-
(globalThis as unknown as Record<string, Record<string, string>>)["USER"]?.api_token ?? ""
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
79
|
</script>
|
|
85
80
|
|
|
86
81
|
<div class="relative select-none">
|
|
@@ -9,13 +9,15 @@ export class MonitorDetailsState {
|
|
|
9
9
|
#id = null;
|
|
10
10
|
// Bumped per request; a response for anything but the latest request is dropped.
|
|
11
11
|
#request = 0;
|
|
12
|
+
#controller = null;
|
|
12
13
|
async load(id) {
|
|
13
14
|
this.#id = id;
|
|
14
15
|
const request = ++this.#request;
|
|
16
|
+
const { signal } = this.#restart();
|
|
15
17
|
this.monitor = null;
|
|
16
18
|
this.status = "loading";
|
|
17
19
|
try {
|
|
18
|
-
const monitor = await getMonitorDetails(id);
|
|
20
|
+
const monitor = await getMonitorDetails(id, { signal });
|
|
19
21
|
if (request !== this.#request)
|
|
20
22
|
return;
|
|
21
23
|
this.monitor = monitor;
|
|
@@ -37,17 +39,23 @@ export class MonitorDetailsState {
|
|
|
37
39
|
if (this.status !== "ready" || this.#id === null)
|
|
38
40
|
return;
|
|
39
41
|
const request = ++this.#request;
|
|
42
|
+
const { signal } = this.#restart();
|
|
40
43
|
try {
|
|
41
|
-
const monitor = await getMonitorDetails(this.#id);
|
|
44
|
+
const monitor = await getMonitorDetails(this.#id, { signal });
|
|
42
45
|
if (request !== this.#request)
|
|
43
46
|
return;
|
|
44
47
|
this.monitor = monitor;
|
|
45
48
|
}
|
|
46
49
|
catch (error) {
|
|
47
|
-
if (request === this.#request)
|
|
50
|
+
if (!signal.aborted && request === this.#request)
|
|
48
51
|
console.error(error);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
54
|
+
/** Aborts whatever request is in flight and starts a new controller for the next one. */
|
|
55
|
+
#restart() {
|
|
56
|
+
this.#controller?.abort();
|
|
57
|
+
return (this.#controller = new AbortController());
|
|
58
|
+
}
|
|
51
59
|
retry() {
|
|
52
60
|
return this.#id === null ? Promise.resolve() : this.load(this.#id);
|
|
53
61
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type MonitorData, type MonitorLatestType, type MonitorsMeta, type SJVAirEntryLevel } from "@sjvair/sdk";
|
|
2
2
|
import { XMap } from "@tstk/builtin-extensions";
|
|
3
3
|
import { Interval } from "@tstk/utils";
|
|
4
|
+
import { type SignalOptions } from "../abort";
|
|
4
5
|
import type { MonitorsDataSource } from "./types";
|
|
5
6
|
export interface MonitorsInitOptions {
|
|
6
7
|
/**
|
|
@@ -8,6 +9,13 @@ export interface MonitorsInitOptions {
|
|
|
8
9
|
* load the data without showing a map (`resumeAutoUpdate()` starts polling later).
|
|
9
10
|
*/
|
|
10
11
|
autoUpdate?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Stops waiting: the returned promise rejects with the signal's reason. The shared run keeps
|
|
14
|
+
* going for the other calls waiting on it, and is cancelled only when every call waiting on it
|
|
15
|
+
* passed a signal and all of those aborted. An aborted call's pollutant and `autoUpdate` stay
|
|
16
|
+
* recorded. Ignored once initialized.
|
|
17
|
+
*/
|
|
18
|
+
signal?: AbortSignal;
|
|
11
19
|
}
|
|
12
20
|
declare class MonitorsManager implements MonitorsDataSource {
|
|
13
21
|
#private;
|
|
@@ -33,7 +41,12 @@ declare class MonitorsManager implements MonitorsDataSource {
|
|
|
33
41
|
* be stale. A no-op before init or while polling.
|
|
34
42
|
*/
|
|
35
43
|
resumeAutoUpdate(): void;
|
|
36
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Refreshes the list and the latest readings. Starting an update aborts the previous
|
|
46
|
+
* in-flight one, which then resolves without touching state. Pass `signal` to cancel from the
|
|
47
|
+
* outside: that rejects with the signal's reason.
|
|
48
|
+
*/
|
|
49
|
+
update(options?: SignalOptions): Promise<void>;
|
|
37
50
|
}
|
|
38
51
|
export declare const monitorsManager: MonitorsManager;
|
|
39
52
|
export type { MonitorsManager };
|
|
@@ -2,6 +2,7 @@ import { getMonitors, getMonitorsList, getMonitorsMeta } from "@sjvair/sdk";
|
|
|
2
2
|
import { XMap } from "@tstk/builtin-extensions";
|
|
3
3
|
import { Interval } from "@tstk/utils";
|
|
4
4
|
import { untrack } from "svelte";
|
|
5
|
+
import { anySignal, untilAborted } from "../abort";
|
|
5
6
|
class MonitorsManager {
|
|
6
7
|
autoUpdate = new Interval(async () => await this.update(), 2 * 60 * 1000);
|
|
7
8
|
initialized = $state(false);
|
|
@@ -10,9 +11,10 @@ class MonitorsManager {
|
|
|
10
11
|
meta = $state(null);
|
|
11
12
|
pollutant = $state(null);
|
|
12
13
|
levels = $derived(this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null);
|
|
13
|
-
#
|
|
14
|
+
#initRun = null;
|
|
14
15
|
#requestedPollutant = null;
|
|
15
16
|
#requestedAutoUpdate = false;
|
|
17
|
+
#updateController = null;
|
|
16
18
|
constructor() {
|
|
17
19
|
// Refetch monitors when the pollutant changes after the initial load, wherever the change
|
|
18
20
|
// comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
|
|
@@ -44,23 +46,67 @@ class MonitorsManager {
|
|
|
44
46
|
init(urlPollutant, options = {}) {
|
|
45
47
|
if (this.initialized)
|
|
46
48
|
return Promise.resolve();
|
|
49
|
+
const { signal } = options;
|
|
50
|
+
// Nothing is recorded or started for a call that is already cancelled.
|
|
51
|
+
if (signal?.aborted)
|
|
52
|
+
return Promise.reject(signal.reason);
|
|
47
53
|
if (options.autoUpdate !== false)
|
|
48
54
|
this.#requestedAutoUpdate = true;
|
|
49
55
|
if (urlPollutant === "pm25" || urlPollutant === "o3") {
|
|
50
56
|
this.#requestedPollutant = urlPollutant;
|
|
51
57
|
}
|
|
52
|
-
this.#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
+
const run = (this.#initRun ??= this.#startInitRun());
|
|
59
|
+
if (!signal) {
|
|
60
|
+
run.unsignalled = true;
|
|
61
|
+
return run.promise;
|
|
62
|
+
}
|
|
63
|
+
run.signalled++;
|
|
64
|
+
const onAbort = () => {
|
|
65
|
+
run.aborted++;
|
|
66
|
+
if (!run.unsignalled && run.aborted === run.signalled)
|
|
67
|
+
this.#abortInitRun(run);
|
|
68
|
+
};
|
|
69
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
71
|
+
run.promise.then(cleanup, cleanup);
|
|
72
|
+
return untilAborted(run.promise, signal);
|
|
73
|
+
}
|
|
74
|
+
#startInitRun() {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const run = {
|
|
77
|
+
promise: this.#runInit(controller.signal).finally(() => {
|
|
78
|
+
if (this.#initRun === run)
|
|
79
|
+
this.#clearInitRun();
|
|
80
|
+
}),
|
|
81
|
+
controller,
|
|
82
|
+
signalled: 0,
|
|
83
|
+
aborted: 0,
|
|
84
|
+
unsignalled: false
|
|
85
|
+
};
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Cancels a run nobody is waiting on. It is detached at once, so a call arriving before its
|
|
90
|
+
* rejection settles starts a fresh run instead of joining the cancelled one.
|
|
91
|
+
*/
|
|
92
|
+
#abortInitRun(run) {
|
|
93
|
+
if (this.#initRun !== run)
|
|
94
|
+
return;
|
|
95
|
+
this.#clearInitRun();
|
|
96
|
+
run.controller.abort();
|
|
97
|
+
}
|
|
98
|
+
#clearInitRun() {
|
|
99
|
+
this.#initRun = null;
|
|
100
|
+
this.#requestedPollutant = null;
|
|
101
|
+
this.#requestedAutoUpdate = false;
|
|
58
102
|
}
|
|
59
|
-
async #runInit() {
|
|
103
|
+
async #runInit(signal) {
|
|
60
104
|
// The pollutant the latest readings were requested for.
|
|
61
105
|
let fetchedPollutant = this.#requestedPollutant;
|
|
62
|
-
const metaRequest = getMonitorsMeta();
|
|
63
|
-
const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
|
|
106
|
+
const metaRequest = getMonitorsMeta({ signal });
|
|
107
|
+
const listAndMeta = Promise.all([getMonitorsList({ signal }), metaRequest]).then(([list, meta]) => {
|
|
108
|
+
if (signal.aborted)
|
|
109
|
+
throw signal.reason;
|
|
64
110
|
this.list = list;
|
|
65
111
|
this.meta = meta;
|
|
66
112
|
return meta;
|
|
@@ -68,11 +114,11 @@ class MonitorsManager {
|
|
|
68
114
|
// The latest readings only need the pollutant, so they never wait for the list. An explicit
|
|
69
115
|
// pollutant may still arrive before meta does, in which case the default is never fetched.
|
|
70
116
|
const latestRequest = fetchedPollutant
|
|
71
|
-
? getMonitorsLatestMap(fetchedPollutant)
|
|
117
|
+
? getMonitorsLatestMap(fetchedPollutant, signal)
|
|
72
118
|
: metaRequest.then((meta) => {
|
|
73
119
|
const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
|
|
74
120
|
fetchedPollutant = pollutant;
|
|
75
|
-
return getMonitorsLatestMap(pollutant);
|
|
121
|
+
return getMonitorsLatestMap(pollutant, signal);
|
|
76
122
|
});
|
|
77
123
|
// One Promise.all so a failure in either never leaves the other unhandled.
|
|
78
124
|
const [meta, firstLatest] = await Promise.all([listAndMeta, latestRequest]);
|
|
@@ -80,10 +126,13 @@ class MonitorsManager {
|
|
|
80
126
|
// An explicit pollutant may have arrived while the requests above were in flight.
|
|
81
127
|
let wanted = this.#requestedPollutant ?? meta.default_pollutant;
|
|
82
128
|
while (wanted !== fetchedPollutant) {
|
|
83
|
-
latest = await getMonitorsLatestMap(wanted);
|
|
129
|
+
latest = await getMonitorsLatestMap(wanted, signal);
|
|
84
130
|
fetchedPollutant = wanted;
|
|
85
131
|
wanted = this.#requestedPollutant ?? meta.default_pollutant;
|
|
86
132
|
}
|
|
133
|
+
// Cancelled while a request that ignores the signal was settling: leave state to the next run.
|
|
134
|
+
if (signal.aborted)
|
|
135
|
+
throw signal.reason;
|
|
87
136
|
this.pollutant = fetchedPollutant;
|
|
88
137
|
this.latest = latest;
|
|
89
138
|
if (this.#requestedAutoUpdate)
|
|
@@ -101,24 +150,67 @@ class MonitorsManager {
|
|
|
101
150
|
this.update().catch(console.error);
|
|
102
151
|
this.autoUpdate.start();
|
|
103
152
|
}
|
|
104
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Refreshes the list and the latest readings. Starting an update aborts the previous
|
|
155
|
+
* in-flight one, which then resolves without touching state. Pass `signal` to cancel from the
|
|
156
|
+
* outside: that rejects with the signal's reason.
|
|
157
|
+
*/
|
|
158
|
+
async update(options = {}) {
|
|
105
159
|
if (!this.initialized)
|
|
106
160
|
return;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
161
|
+
// Nothing has started yet, so an in-flight update is left alone.
|
|
162
|
+
if (options.signal?.aborted)
|
|
163
|
+
throw options.signal.reason;
|
|
164
|
+
const supersededInFlight = this.#updateController !== null;
|
|
165
|
+
this.#updateController?.abort();
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
this.#updateController = controller;
|
|
168
|
+
const combined = anySignal(controller.signal, options.signal);
|
|
169
|
+
// The caller cancelled this update. If it is still the latest and it cut off an update that
|
|
170
|
+
// was in flight, that update's data is lost too, so refresh again without a signal.
|
|
171
|
+
const callerAborted = (signal) => {
|
|
172
|
+
if (supersededInFlight && this.#updateController === controller) {
|
|
173
|
+
this.update().catch(console.error);
|
|
174
|
+
}
|
|
175
|
+
return signal.reason;
|
|
176
|
+
};
|
|
177
|
+
// Set once callerAborted has run, so the catch below does not run it a second time.
|
|
178
|
+
let abortedError;
|
|
179
|
+
try {
|
|
180
|
+
const pollutant = this.pollutant;
|
|
181
|
+
const [list, latest] = await Promise.all([
|
|
182
|
+
getMonitorsList({ signal: combined.signal }),
|
|
183
|
+
getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25", combined.signal)
|
|
184
|
+
]);
|
|
185
|
+
// Superseded or cancelled while the requests were settling.
|
|
186
|
+
if (options.signal?.aborted)
|
|
187
|
+
throw (abortedError = callerAborted(options.signal));
|
|
188
|
+
if (controller.signal.aborted)
|
|
189
|
+
return;
|
|
190
|
+
// The pollutant changed while this was in flight: these readings belong to the previous
|
|
191
|
+
// one, and the change has already started its own update.
|
|
192
|
+
if (this.pollutant !== pollutant)
|
|
193
|
+
return;
|
|
194
|
+
this.list = list;
|
|
195
|
+
this.latest = latest;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// The caller's own abort rejects with its reason. A superseded update resolves quietly.
|
|
199
|
+
if (options.signal?.aborted)
|
|
200
|
+
throw error === abortedError ? error : callerAborted(options.signal);
|
|
201
|
+
if (controller.signal.aborted)
|
|
202
|
+
return;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
combined.dispose();
|
|
207
|
+
if (this.#updateController === controller)
|
|
208
|
+
this.#updateController = null;
|
|
209
|
+
}
|
|
118
210
|
}
|
|
119
211
|
}
|
|
120
|
-
async function getMonitorsLatestMap(pollutant) {
|
|
121
|
-
const monitors = await getMonitors(pollutant);
|
|
212
|
+
async function getMonitorsLatestMap(pollutant, signal) {
|
|
213
|
+
const monitors = await getMonitors(pollutant, undefined, { signal });
|
|
122
214
|
const latest = new XMap();
|
|
123
215
|
for (const monitor of monitors) {
|
|
124
216
|
latest.set(monitor.id, monitor);
|