@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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getSubscriptions } from "@sjvair/sdk/account";
|
|
2
|
+
export function getApiToken() {
|
|
3
|
+
const token = globalThis["USER"]?.api_token;
|
|
4
|
+
return typeof token === "string" && token !== "" ? token : undefined;
|
|
5
|
+
}
|
|
6
|
+
export async function loadSubscriptionLevel(monitorId, options) {
|
|
7
|
+
const subscriptions = await getSubscriptions(getApiToken(), { signal: options?.signal });
|
|
8
|
+
return subscriptions.find((s) => s.monitor === monitorId)?.level ?? null;
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjvair/monitor-map",
|
|
3
3
|
"description": "SJVAir's interactive monitor map",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.3.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/SJVAir/monitor-map"
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@lucide/svelte": "^1.46.0",
|
|
85
85
|
"@maptiler/sdk": "^4.1.0",
|
|
86
86
|
"@maptiler/weather": "^4.0.1",
|
|
87
|
-
"@sjvair/sdk": "npm:@jsr/sjvair__sdk@^4.
|
|
87
|
+
"@sjvair/sdk": "npm:@jsr/sjvair__sdk@^4.7.0",
|
|
88
88
|
"@tstk/builtin-extensions": "npm:@jsr/tstk__builtin-extensions@^1.0.8",
|
|
89
89
|
"@tstk/utils": "npm:@jsr/tstk__utils@^1.1.2",
|
|
90
90
|
"@turf/area": "^7.4.0",
|
package/src/lib/abort.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type SignalOptions = { signal?: AbortSignal };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A signal that aborts as soon as any input does, with that input's reason. Already aborted if
|
|
5
|
+
* any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
|
|
6
|
+
*/
|
|
7
|
+
export function anySignal(...signals: Array<AbortSignal | undefined>): {
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
dispose(): void;
|
|
10
|
+
} {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const cleanups: Array<() => void> = [];
|
|
13
|
+
const dispose = () => {
|
|
14
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (const input of signals) {
|
|
18
|
+
if (!input) continue;
|
|
19
|
+
if (input.aborted) {
|
|
20
|
+
controller.abort(input.reason);
|
|
21
|
+
dispose();
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
const onAbort = () => {
|
|
25
|
+
controller.abort(input.reason);
|
|
26
|
+
dispose();
|
|
27
|
+
};
|
|
28
|
+
input.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
cleanups.push(() => input.removeEventListener("abort", onAbort));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { signal: controller.signal, dispose };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Settles like `promise`, unless `signal` aborts first, in which case it rejects with
|
|
37
|
+
* `signal.reason`. The inner promise's later outcome is swallowed.
|
|
38
|
+
*/
|
|
39
|
+
export function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
40
|
+
if (!signal) return promise;
|
|
41
|
+
if (signal.aborted) {
|
|
42
|
+
promise.catch(() => {});
|
|
43
|
+
return Promise.reject(signal.reason);
|
|
44
|
+
}
|
|
45
|
+
return new Promise<T>((resolve, reject) => {
|
|
46
|
+
const onAbort = () => reject(signal.reason);
|
|
47
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
promise.then(
|
|
49
|
+
(value) => {
|
|
50
|
+
signal.removeEventListener("abort", onAbort);
|
|
51
|
+
resolve(value);
|
|
52
|
+
},
|
|
53
|
+
(error) => {
|
|
54
|
+
signal.removeEventListener("abort", onAbort);
|
|
55
|
+
reject(error);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { getCollocationSites, type CollocationSite } from "@sjvair/sdk";
|
|
2
|
+
import type { SignalOptions } from "$lib/abort";
|
|
2
3
|
|
|
3
4
|
class CollocationSitesManager {
|
|
4
5
|
initialized: boolean = $state(false);
|
|
5
6
|
|
|
6
7
|
collocationSites: Array<CollocationSite> | null = $state(null);
|
|
7
8
|
|
|
8
|
-
async init(): Promise<void> {
|
|
9
|
+
async init({ signal }: SignalOptions = {}): Promise<void> {
|
|
9
10
|
if (this.initialized) return;
|
|
11
|
+
if (signal?.aborted) throw signal.reason;
|
|
10
12
|
|
|
11
|
-
this.collocationSites = await getCollocationSites();
|
|
13
|
+
this.collocationSites = await getCollocationSites({ signal });
|
|
12
14
|
|
|
13
15
|
this.initialized = true;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
async update(): Promise<void> {
|
|
18
|
+
async update({ signal }: SignalOptions = {}): Promise<void> {
|
|
17
19
|
if (!this.initialized) return;
|
|
20
|
+
if (signal?.aborted) throw signal.reason;
|
|
18
21
|
|
|
19
|
-
this.collocationSites = await getCollocationSites();
|
|
22
|
+
this.collocationSites = await getCollocationSites({ signal });
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -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
|
|
@@ -2,6 +2,7 @@ import { getMonitorEntries, type MonitorDetails, type MonitorEntry } from "@sjva
|
|
|
2
2
|
import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
|
|
3
3
|
import type { PanelPollutant } from "../monitors/panel-pollutant";
|
|
4
4
|
import type { DateRange } from "./DateRange";
|
|
5
|
+
import type { SignalOptions } from "$lib/abort";
|
|
5
6
|
|
|
6
7
|
/** The monitor fields the chart reads. */
|
|
7
8
|
export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
|
|
@@ -9,14 +10,18 @@ export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_s
|
|
|
9
10
|
export async function fetchChartData(
|
|
10
11
|
m: ChartMonitor,
|
|
11
12
|
d: DateRange,
|
|
12
|
-
entryType: PanelPollutant
|
|
13
|
+
entryType: PanelPollutant,
|
|
14
|
+
options: SignalOptions = {}
|
|
13
15
|
): Promise<uPlot.AlignedData> {
|
|
14
|
-
return await getMonitorEntries(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
return await getMonitorEntries(
|
|
17
|
+
{
|
|
18
|
+
entryType,
|
|
19
|
+
monitorId: m.id,
|
|
20
|
+
timestampGte: d.start,
|
|
21
|
+
timestampLte: d.end
|
|
22
|
+
},
|
|
23
|
+
{ signal: options.signal }
|
|
24
|
+
)
|
|
20
25
|
.then((entries) => {
|
|
21
26
|
const xAxisData: Array<Date> = [];
|
|
22
27
|
const updateDuration = m.data_source.name === "PurpleAir" ? 2 : 60;
|
|
@@ -41,6 +46,8 @@ export async function fetchChartData(
|
|
|
41
46
|
return [xAxisData.map((d) => getUnixTime(d)), yAxisRecord] as uPlot.AlignedData;
|
|
42
47
|
})
|
|
43
48
|
.catch((err) => {
|
|
49
|
+
// A cancelled request is not a failure: let the caller see the reason, log nothing.
|
|
50
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
44
51
|
console.error("Failed to load chart data: ", err);
|
|
45
52
|
return [] as uPlot.AlignedData;
|
|
46
53
|
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
evNetworkTypes,
|
|
6
6
|
facilityTypes
|
|
7
7
|
} from "./data-maps";
|
|
8
|
+
import type { SignalOptions } from "$lib/abort";
|
|
8
9
|
import type { EvStation } from "./types";
|
|
9
10
|
import * as ZipCodes from "./zip-codes";
|
|
10
11
|
|
|
@@ -12,12 +13,12 @@ class EvStationsManager {
|
|
|
12
13
|
lvl2Stations: Array<EvStation> | undefined = $state();
|
|
13
14
|
lvl3Stations: Array<EvStation> | undefined = $state();
|
|
14
15
|
|
|
15
|
-
async loadLvl3Stations(): Promise<void> {
|
|
16
|
-
this.lvl3Stations = await fetchEvStations("dc_fast");
|
|
16
|
+
async loadLvl3Stations({ signal }: SignalOptions = {}): Promise<void> {
|
|
17
|
+
this.lvl3Stations = await fetchEvStations("dc_fast", signal);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async loadLvl2Stations(): Promise<void> {
|
|
20
|
-
this.lvl2Stations = await fetchEvStations("2");
|
|
20
|
+
async loadLvl2Stations({ signal }: SignalOptions = {}): Promise<void> {
|
|
21
|
+
this.lvl2Stations = await fetchEvStations("2", signal);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -25,7 +26,11 @@ const zipCodes = Object.values(ZipCodes)
|
|
|
25
26
|
.reduce((prev, curr) => prev.concat(curr))
|
|
26
27
|
.join(",");
|
|
27
28
|
|
|
28
|
-
async function fetchEvStations(
|
|
29
|
+
async function fetchEvStations(
|
|
30
|
+
chargingLevel: "2" | "dc_fast",
|
|
31
|
+
signal?: AbortSignal
|
|
32
|
+
): Promise<Array<EvStation>> {
|
|
33
|
+
if (signal?.aborted) throw signal.reason;
|
|
29
34
|
const url = new URL(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json`);
|
|
30
35
|
url.searchParams.set("fuel_type", "ELEC");
|
|
31
36
|
url.searchParams.set("state", "CA");
|
|
@@ -35,7 +40,7 @@ async function fetchEvStations(chargingLevel: "2" | "dc_fast"): Promise<Array<Ev
|
|
|
35
40
|
url.searchParams.set("zip", zipCodes);
|
|
36
41
|
url.searchParams.set("api_key", import.meta.env.VITE_NREL_KEY);
|
|
37
42
|
|
|
38
|
-
return await fetch(url)
|
|
43
|
+
return await fetch(url, { signal })
|
|
39
44
|
.then(async (res) => await res.json())
|
|
40
45
|
.then((data) => {
|
|
41
46
|
const fuelStations: Array<EvStation> = data.fuel_stations!;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type HMSFireGeoJSON,
|
|
5
5
|
type HMSSmokeGeoJSON
|
|
6
6
|
} from "@sjvair/sdk/hms";
|
|
7
|
+
import type { SignalOptions } from "$lib/abort";
|
|
7
8
|
import clustersDbscan from "@turf/clusters-dbscan";
|
|
8
9
|
import { featureCollection, point } from "@turf/helpers";
|
|
9
10
|
|
|
@@ -22,22 +23,27 @@ class HmsManager {
|
|
|
22
23
|
fire: Array<HMSFireGeoJSON & { frp: number }> | undefined = $state();
|
|
23
24
|
fireGroups: Array<HMSFireGroup> | undefined = $state();
|
|
24
25
|
|
|
25
|
-
async init() {
|
|
26
|
-
|
|
26
|
+
async init({ signal }: SignalOptions = {}) {
|
|
27
|
+
if (signal?.aborted) throw signal.reason;
|
|
28
|
+
await Promise.allSettled([this.loadSmoke({ signal }), this.loadFire({ signal })]);
|
|
29
|
+
if (signal?.aborted) throw signal.reason;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
async loadSmoke() {
|
|
30
|
-
|
|
32
|
+
async loadSmoke({ signal }: SignalOptions = {}) {
|
|
33
|
+
if (signal?.aborted) throw signal.reason;
|
|
34
|
+
this.smoke = await getHMSSmoke({ signal });
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
async loadFire() {
|
|
37
|
+
async loadFire({ signal }: SignalOptions = {}) {
|
|
34
38
|
try {
|
|
35
|
-
|
|
39
|
+
if (signal?.aborted) throw signal.reason;
|
|
40
|
+
const raw = await getHMSFire({ signal });
|
|
36
41
|
const filtered = raw.filter((d): d is HMSFireGeoJSON & { frp: number } => d.frp !== null);
|
|
37
42
|
const deduped = deduplicateByCoordinates(filtered);
|
|
38
43
|
this.fire = deduped;
|
|
39
44
|
this.fireGroups = computeFireGroups(deduped);
|
|
40
45
|
} catch (err) {
|
|
46
|
+
if (signal?.aborted) throw err;
|
|
41
47
|
console.error("HMS fire load failed:", err);
|
|
42
48
|
}
|
|
43
49
|
}
|
|
@@ -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 "$lib";
|
|
5
|
+
import { getApiToken, loadSubscriptionLevel } from "$lib/monitors/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">
|
|
@@ -13,15 +13,17 @@ export class MonitorDetailsState {
|
|
|
13
13
|
#id: string | null = null;
|
|
14
14
|
// Bumped per request; a response for anything but the latest request is dropped.
|
|
15
15
|
#request = 0;
|
|
16
|
+
#controller: AbortController | null = null;
|
|
16
17
|
|
|
17
18
|
async load(id: string): Promise<void> {
|
|
18
19
|
this.#id = id;
|
|
19
20
|
const request = ++this.#request;
|
|
21
|
+
const { signal } = this.#restart();
|
|
20
22
|
this.monitor = null;
|
|
21
23
|
this.status = "loading";
|
|
22
24
|
|
|
23
25
|
try {
|
|
24
|
-
const monitor = await getMonitorDetails(id);
|
|
26
|
+
const monitor = await getMonitorDetails(id, { signal });
|
|
25
27
|
if (request !== this.#request) return;
|
|
26
28
|
this.monitor = monitor;
|
|
27
29
|
this.status = "ready";
|
|
@@ -40,16 +42,23 @@ export class MonitorDetailsState {
|
|
|
40
42
|
async refresh(): Promise<void> {
|
|
41
43
|
if (this.status !== "ready" || this.#id === null) return;
|
|
42
44
|
const request = ++this.#request;
|
|
45
|
+
const { signal } = this.#restart();
|
|
43
46
|
|
|
44
47
|
try {
|
|
45
|
-
const monitor = await getMonitorDetails(this.#id);
|
|
48
|
+
const monitor = await getMonitorDetails(this.#id, { signal });
|
|
46
49
|
if (request !== this.#request) return;
|
|
47
50
|
this.monitor = monitor;
|
|
48
51
|
} catch (error) {
|
|
49
|
-
if (request === this.#request) console.error(error);
|
|
52
|
+
if (!signal.aborted && request === this.#request) console.error(error);
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/** Aborts whatever request is in flight and starts a new controller for the next one. */
|
|
57
|
+
#restart(): AbortController {
|
|
58
|
+
this.#controller?.abort();
|
|
59
|
+
return (this.#controller = new AbortController());
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
retry(): Promise<void> {
|
|
54
63
|
return this.#id === null ? Promise.resolve() : this.load(this.#id);
|
|
55
64
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { XMap } from "@tstk/builtin-extensions";
|
|
11
11
|
import { Interval } from "@tstk/utils";
|
|
12
12
|
import { untrack } from "svelte";
|
|
13
|
+
import { anySignal, untilAborted, type SignalOptions } from "$lib/abort";
|
|
13
14
|
import type { MonitorsDataSource, MonitorsPollutant } from "./types";
|
|
14
15
|
|
|
15
16
|
export interface MonitorsInitOptions {
|
|
@@ -18,6 +19,24 @@ export interface MonitorsInitOptions {
|
|
|
18
19
|
* load the data without showing a map (`resumeAutoUpdate()` starts polling later).
|
|
19
20
|
*/
|
|
20
21
|
autoUpdate?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Stops waiting: the returned promise rejects with the signal's reason. The shared run keeps
|
|
24
|
+
* going for the other calls waiting on it, and is cancelled only when every call waiting on it
|
|
25
|
+
* passed a signal and all of those aborted. An aborted call's pollutant and `autoUpdate` stay
|
|
26
|
+
* recorded. Ignored once initialized.
|
|
27
|
+
*/
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One shared init run and the calls waiting on it. */
|
|
32
|
+
interface InitRun {
|
|
33
|
+
promise: Promise<void>;
|
|
34
|
+
controller: AbortController;
|
|
35
|
+
/** Calls waiting with a signal, and how many of those aborted. */
|
|
36
|
+
signalled: number;
|
|
37
|
+
aborted: number;
|
|
38
|
+
/** Whether any call waits without a signal, which keeps the run going. */
|
|
39
|
+
unsignalled: boolean;
|
|
21
40
|
}
|
|
22
41
|
|
|
23
42
|
class MonitorsManager implements MonitorsDataSource {
|
|
@@ -35,9 +54,10 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
35
54
|
this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null
|
|
36
55
|
);
|
|
37
56
|
|
|
38
|
-
#
|
|
57
|
+
#initRun: InitRun | null = null;
|
|
39
58
|
#requestedPollutant: MonitorsPollutant | null = null;
|
|
40
59
|
#requestedAutoUpdate = false;
|
|
60
|
+
#updateController: AbortController | null = null;
|
|
41
61
|
|
|
42
62
|
constructor() {
|
|
43
63
|
// Refetch monitors when the pollutant changes after the initial load, wherever the change
|
|
@@ -70,39 +90,86 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
70
90
|
init(urlPollutant?: string | number | boolean, options: MonitorsInitOptions = {}): Promise<void> {
|
|
71
91
|
if (this.initialized) return Promise.resolve();
|
|
72
92
|
|
|
93
|
+
const { signal } = options;
|
|
94
|
+
// Nothing is recorded or started for a call that is already cancelled.
|
|
95
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
96
|
+
|
|
73
97
|
if (options.autoUpdate !== false) this.#requestedAutoUpdate = true;
|
|
74
98
|
|
|
75
99
|
if (urlPollutant === "pm25" || urlPollutant === "o3") {
|
|
76
100
|
this.#requestedPollutant = urlPollutant;
|
|
77
101
|
}
|
|
78
102
|
|
|
79
|
-
this.#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
const run = (this.#initRun ??= this.#startInitRun());
|
|
104
|
+
|
|
105
|
+
if (!signal) {
|
|
106
|
+
run.unsignalled = true;
|
|
107
|
+
return run.promise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
run.signalled++;
|
|
111
|
+
const onAbort = () => {
|
|
112
|
+
run.aborted++;
|
|
113
|
+
if (!run.unsignalled && run.aborted === run.signalled) this.#abortInitRun(run);
|
|
114
|
+
};
|
|
115
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
116
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
117
|
+
run.promise.then(cleanup, cleanup);
|
|
118
|
+
|
|
119
|
+
return untilAborted(run.promise, signal);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#startInitRun(): InitRun {
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const run: InitRun = {
|
|
125
|
+
promise: this.#runInit(controller.signal).finally(() => {
|
|
126
|
+
if (this.#initRun === run) this.#clearInitRun();
|
|
127
|
+
}),
|
|
128
|
+
controller,
|
|
129
|
+
signalled: 0,
|
|
130
|
+
aborted: 0,
|
|
131
|
+
unsignalled: false
|
|
132
|
+
};
|
|
133
|
+
return run;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cancels a run nobody is waiting on. It is detached at once, so a call arriving before its
|
|
138
|
+
* rejection settles starts a fresh run instead of joining the cancelled one.
|
|
139
|
+
*/
|
|
140
|
+
#abortInitRun(run: InitRun): void {
|
|
141
|
+
if (this.#initRun !== run) return;
|
|
142
|
+
this.#clearInitRun();
|
|
143
|
+
run.controller.abort();
|
|
144
|
+
}
|
|
84
145
|
|
|
85
|
-
|
|
146
|
+
#clearInitRun(): void {
|
|
147
|
+
this.#initRun = null;
|
|
148
|
+
this.#requestedPollutant = null;
|
|
149
|
+
this.#requestedAutoUpdate = false;
|
|
86
150
|
}
|
|
87
151
|
|
|
88
|
-
async #runInit(): Promise<void> {
|
|
152
|
+
async #runInit(signal: AbortSignal): Promise<void> {
|
|
89
153
|
// The pollutant the latest readings were requested for.
|
|
90
154
|
let fetchedPollutant = this.#requestedPollutant;
|
|
91
155
|
|
|
92
|
-
const metaRequest = getMonitorsMeta();
|
|
93
|
-
const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
const metaRequest = getMonitorsMeta({ signal });
|
|
157
|
+
const listAndMeta = Promise.all([getMonitorsList({ signal }), metaRequest]).then(
|
|
158
|
+
([list, meta]) => {
|
|
159
|
+
if (signal.aborted) throw signal.reason;
|
|
160
|
+
this.list = list;
|
|
161
|
+
this.meta = meta;
|
|
162
|
+
return meta;
|
|
163
|
+
}
|
|
164
|
+
);
|
|
98
165
|
// The latest readings only need the pollutant, so they never wait for the list. An explicit
|
|
99
166
|
// pollutant may still arrive before meta does, in which case the default is never fetched.
|
|
100
167
|
const latestRequest = fetchedPollutant
|
|
101
|
-
? getMonitorsLatestMap(fetchedPollutant)
|
|
168
|
+
? getMonitorsLatestMap(fetchedPollutant, signal)
|
|
102
169
|
: metaRequest.then((meta) => {
|
|
103
170
|
const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
|
|
104
171
|
fetchedPollutant = pollutant;
|
|
105
|
-
return getMonitorsLatestMap(pollutant);
|
|
172
|
+
return getMonitorsLatestMap(pollutant, signal);
|
|
106
173
|
});
|
|
107
174
|
|
|
108
175
|
// One Promise.all so a failure in either never leaves the other unhandled.
|
|
@@ -112,11 +179,14 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
112
179
|
// An explicit pollutant may have arrived while the requests above were in flight.
|
|
113
180
|
let wanted = this.#requestedPollutant ?? meta.default_pollutant;
|
|
114
181
|
while (wanted !== fetchedPollutant) {
|
|
115
|
-
latest = await getMonitorsLatestMap(wanted);
|
|
182
|
+
latest = await getMonitorsLatestMap(wanted, signal);
|
|
116
183
|
fetchedPollutant = wanted;
|
|
117
184
|
wanted = this.#requestedPollutant ?? meta.default_pollutant;
|
|
118
185
|
}
|
|
119
186
|
|
|
187
|
+
// Cancelled while a request that ignores the signal was settling: leave state to the next run.
|
|
188
|
+
if (signal.aborted) throw signal.reason;
|
|
189
|
+
|
|
120
190
|
this.pollutant = fetchedPollutant;
|
|
121
191
|
this.latest = latest;
|
|
122
192
|
if (this.#requestedAutoUpdate) this.autoUpdate.start();
|
|
@@ -135,28 +205,70 @@ class MonitorsManager implements MonitorsDataSource {
|
|
|
135
205
|
this.autoUpdate.start();
|
|
136
206
|
}
|
|
137
207
|
|
|
138
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Refreshes the list and the latest readings. Starting an update aborts the previous
|
|
210
|
+
* in-flight one, which then resolves without touching state. Pass `signal` to cancel from the
|
|
211
|
+
* outside: that rejects with the signal's reason.
|
|
212
|
+
*/
|
|
213
|
+
async update(options: SignalOptions = {}): Promise<void> {
|
|
139
214
|
if (!this.initialized) return;
|
|
140
215
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
getMonitorsList(),
|
|
144
|
-
getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
|
|
145
|
-
]);
|
|
216
|
+
// Nothing has started yet, so an in-flight update is left alone.
|
|
217
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
146
218
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
219
|
+
const supersededInFlight = this.#updateController !== null;
|
|
220
|
+
this.#updateController?.abort();
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
this.#updateController = controller;
|
|
223
|
+
const combined = anySignal(controller.signal, options.signal);
|
|
150
224
|
|
|
151
|
-
this.
|
|
152
|
-
|
|
225
|
+
// The caller cancelled this update. If it is still the latest and it cut off an update that
|
|
226
|
+
// was in flight, that update's data is lost too, so refresh again without a signal.
|
|
227
|
+
const callerAborted = (signal: AbortSignal) => {
|
|
228
|
+
if (supersededInFlight && this.#updateController === controller) {
|
|
229
|
+
this.update().catch(console.error);
|
|
230
|
+
}
|
|
231
|
+
return signal.reason;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Set once callerAborted has run, so the catch below does not run it a second time.
|
|
235
|
+
let abortedError: unknown;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const pollutant = this.pollutant;
|
|
239
|
+
const [list, latest] = await Promise.all([
|
|
240
|
+
getMonitorsList({ signal: combined.signal }),
|
|
241
|
+
getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25", combined.signal)
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
// Superseded or cancelled while the requests were settling.
|
|
245
|
+
if (options.signal?.aborted) throw (abortedError = callerAborted(options.signal));
|
|
246
|
+
if (controller.signal.aborted) return;
|
|
247
|
+
|
|
248
|
+
// The pollutant changed while this was in flight: these readings belong to the previous
|
|
249
|
+
// one, and the change has already started its own update.
|
|
250
|
+
if (this.pollutant !== pollutant) return;
|
|
251
|
+
|
|
252
|
+
this.list = list;
|
|
253
|
+
this.latest = latest;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// The caller's own abort rejects with its reason. A superseded update resolves quietly.
|
|
256
|
+
if (options.signal?.aborted)
|
|
257
|
+
throw error === abortedError ? error : callerAborted(options.signal);
|
|
258
|
+
if (controller.signal.aborted) return;
|
|
259
|
+
throw error;
|
|
260
|
+
} finally {
|
|
261
|
+
combined.dispose();
|
|
262
|
+
if (this.#updateController === controller) this.#updateController = null;
|
|
263
|
+
}
|
|
153
264
|
}
|
|
154
265
|
}
|
|
155
266
|
|
|
156
267
|
async function getMonitorsLatestMap(
|
|
157
|
-
pollutant: "pm25" | "o3"
|
|
268
|
+
pollutant: "pm25" | "o3",
|
|
269
|
+
signal?: AbortSignal
|
|
158
270
|
): Promise<XMap<string, MonitorLatestType<"pm25" | "o3">>> {
|
|
159
|
-
const monitors = await getMonitors(pollutant);
|
|
271
|
+
const monitors = await getMonitors(pollutant, undefined, { signal });
|
|
160
272
|
const latest = new XMap<string, MonitorLatestType<"pm25" | "o3">>();
|
|
161
273
|
|
|
162
274
|
for (const monitor of monitors) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getSubscriptions } from "@sjvair/sdk/account";
|
|
2
|
+
import type { SignalOptions } from "$lib/abort";
|
|
3
|
+
|
|
4
|
+
export function getApiToken(): string | undefined {
|
|
5
|
+
const token = (globalThis as unknown as Record<string, Record<string, unknown> | undefined>)[
|
|
6
|
+
"USER"
|
|
7
|
+
]?.api_token;
|
|
8
|
+
return typeof token === "string" && token !== "" ? token : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loadSubscriptionLevel(
|
|
12
|
+
monitorId: string,
|
|
13
|
+
options?: SignalOptions
|
|
14
|
+
): Promise<string | null> {
|
|
15
|
+
const subscriptions = await getSubscriptions(getApiToken(), { signal: options?.signal });
|
|
16
|
+
return subscriptions.find((s) => s.monitor === monitorId)?.level ?? null;
|
|
17
|
+
}
|