@sjvair/monitor-map 4.1.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.
Files changed (92) hide show
  1. package/README.md +68 -16
  2. package/dist/lib/LazyMapLayout.svelte +53 -0
  3. package/dist/lib/LazyMapLayout.svelte.d.ts +7 -0
  4. package/dist/lib/LazyMonitorDetailPanel.svelte +33 -0
  5. package/dist/lib/LazyMonitorDetailPanel.svelte.d.ts +4 -0
  6. package/dist/lib/LoadError.svelte +19 -0
  7. package/dist/lib/LoadError.svelte.d.ts +7 -0
  8. package/dist/lib/LoadScreen.svelte +10 -1
  9. package/dist/lib/LoadScreen.svelte.d.ts +6 -16
  10. package/dist/lib/MonitorDetailPanel.svelte +108 -28
  11. package/dist/lib/MonitorMapLayout.svelte +19 -1
  12. package/dist/lib/abort.d.ts +16 -0
  13. package/dist/lib/abort.js +51 -0
  14. package/dist/lib/collocation-sites/collocations.svelte.d.ts +3 -2
  15. package/dist/lib/collocation-sites/collocations.svelte.js +8 -4
  16. package/dist/lib/core.d.ts +1 -1
  17. package/dist/lib/data-chart/DataChart.svelte +78 -13
  18. package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
  19. package/dist/lib/data-chart/DateRange.d.ts +4 -0
  20. package/dist/lib/data-chart/DateRange.js +9 -1
  21. package/dist/lib/data-chart/chart-utils.d.ts +2 -1
  22. package/dist/lib/data-chart/chart-utils.js +19 -23
  23. package/dist/lib/data-chart/level-colors.d.ts +6 -0
  24. package/dist/lib/data-chart/level-colors.js +5 -0
  25. package/dist/lib/data-chart/requests.d.ts +6 -2
  26. package/dist/lib/data-chart/requests.js +6 -4
  27. package/dist/lib/data-chart/tooltip.d.ts +2 -1
  28. package/dist/lib/data-chart/tooltip.js +1 -9
  29. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +4 -6
  30. package/dist/lib/ev-stations/ev-stations.svelte.d.ts +3 -2
  31. package/dist/lib/ev-stations/ev-stations.svelte.js +8 -6
  32. package/dist/lib/hms/hms.svelte.d.ts +4 -3
  33. package/dist/lib/hms/hms.svelte.js +16 -6
  34. package/dist/lib/index.d.ts +1 -1
  35. package/dist/lib/lazy-chunks.d.ts +2 -0
  36. package/dist/lib/lazy-chunks.js +6 -0
  37. package/dist/lib/lazy-routes.d.ts +6 -4
  38. package/dist/lib/lazy-routes.js +12 -7
  39. package/dist/lib/map/MapShell.svelte +7 -1
  40. package/dist/lib/map/MapShell.svelte.d.ts +5 -0
  41. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +13 -0
  42. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +30 -4
  43. package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
  44. package/dist/lib/map/map-style-state.svelte.js +56 -5
  45. package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  46. package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
  47. package/dist/lib/monitors/monitor-details.svelte.js +71 -0
  48. package/dist/lib/monitors/monitors-map-integration.svelte.js +4 -6
  49. package/dist/lib/monitors/monitors.svelte.d.ts +28 -5
  50. package/dist/lib/monitors/monitors.svelte.js +131 -31
  51. package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
  52. package/dist/lib/monitors/panel-pollutant.js +43 -0
  53. package/dist/lib/monitors/subscriptions.d.ts +3 -0
  54. package/dist/lib/monitors/subscriptions.js +9 -0
  55. package/dist/lib/options/OptionsBar.svelte +8 -1
  56. package/dist/lib/options/OptionsMenu.svelte +10 -1
  57. package/dist/lib/shared-load.d.ts +5 -0
  58. package/dist/lib/shared-load.js +14 -0
  59. package/package.json +2 -2
  60. package/src/lib/LazyMapLayout.svelte +53 -0
  61. package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
  62. package/src/lib/LoadError.svelte +19 -0
  63. package/src/lib/LoadScreen.svelte +10 -1
  64. package/src/lib/MonitorDetailPanel.svelte +108 -28
  65. package/src/lib/MonitorMapLayout.svelte +19 -1
  66. package/src/lib/abort.ts +59 -0
  67. package/src/lib/collocation-sites/collocations.svelte.ts +7 -4
  68. package/src/lib/core.ts +1 -1
  69. package/src/lib/data-chart/DataChart.svelte +78 -13
  70. package/src/lib/data-chart/DateRange.ts +11 -1
  71. package/src/lib/data-chart/chart-utils.ts +23 -26
  72. package/src/lib/data-chart/level-colors.ts +13 -0
  73. package/src/lib/data-chart/requests.ts +21 -10
  74. package/src/lib/data-chart/tooltip.ts +2 -10
  75. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +4 -4
  76. package/src/lib/ev-stations/ev-stations.svelte.ts +11 -6
  77. package/src/lib/hms/hms.svelte.ts +12 -6
  78. package/src/lib/index.ts +1 -1
  79. package/src/lib/lazy-chunks.ts +7 -0
  80. package/src/lib/lazy-routes.ts +12 -7
  81. package/src/lib/map/MapShell.svelte +7 -1
  82. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +32 -3
  83. package/src/lib/map/map-style-state.svelte.ts +58 -6
  84. package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  85. package/src/lib/monitors/monitor-details.svelte.ts +75 -0
  86. package/src/lib/monitors/monitors-map-integration.svelte.ts +4 -4
  87. package/src/lib/monitors/monitors.svelte.ts +161 -34
  88. package/src/lib/monitors/panel-pollutant.ts +76 -0
  89. package/src/lib/monitors/subscriptions.ts +17 -0
  90. package/src/lib/options/OptionsBar.svelte +8 -1
  91. package/src/lib/options/OptionsMenu.svelte +10 -1
  92. package/src/lib/shared-load.ts +15 -0
@@ -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
- private applied: MapStyleVariant = DefaultMapStyle;
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 || this.variant === this.applied) return;
30
- this.applied = this.variant;
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
- map.once("style.load", () => {
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(this.variant);
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();
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { BellCheckIcon, BellOffIcon, ChevronDownIcon } from "@lucide/svelte";
3
- import { getSubscriptions, subscribe, unsubscribe } from "@sjvair/sdk/account";
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
- void monitorId;
39
+ const id = monitorId;
39
40
  open = false;
40
41
  subscribedLevel = null;
41
- loadSubscription();
42
- });
43
-
44
- async function loadSubscription() {
45
- const id = monitorId;
42
+ const controller = new AbortController();
43
+ const { signal } = controller;
46
44
  loading = true;
47
- try {
48
- const subscriptions = await getSubscriptions(getApiToken());
49
- if (id !== monitorId) return;
50
- const match = subscriptions.find((s) => s.monitor === id);
51
- subscribedLevel = match?.level ?? null;
52
- } finally {
53
- if (id === monitorId) loading = false;
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">
@@ -0,0 +1,75 @@
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
+ #controller: AbortController | null = null;
17
+
18
+ async load(id: string): Promise<void> {
19
+ this.#id = id;
20
+ const request = ++this.#request;
21
+ const { signal } = this.#restart();
22
+ this.monitor = null;
23
+ this.status = "loading";
24
+
25
+ try {
26
+ const monitor = await getMonitorDetails(id, { signal });
27
+ if (request !== this.#request) return;
28
+ this.monitor = monitor;
29
+ this.status = "ready";
30
+ } catch (error) {
31
+ if (request !== this.#request) return;
32
+ if (isNotFound(error)) {
33
+ this.status = "not-found";
34
+ return;
35
+ }
36
+ console.error(error);
37
+ this.status = "failed";
38
+ }
39
+ }
40
+
41
+ /** Re-fetches the monitor already shown. A failure keeps the data on screen. */
42
+ async refresh(): Promise<void> {
43
+ if (this.status !== "ready" || this.#id === null) return;
44
+ const request = ++this.#request;
45
+ const { signal } = this.#restart();
46
+
47
+ try {
48
+ const monitor = await getMonitorDetails(this.#id, { signal });
49
+ if (request !== this.#request) return;
50
+ this.monitor = monitor;
51
+ } catch (error) {
52
+ if (!signal.aborted && request === this.#request) console.error(error);
53
+ }
54
+ }
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
+
62
+ retry(): Promise<void> {
63
+ return this.#id === null ? Promise.resolve() : this.load(this.#id);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The SDK rejects a failed request with `cause.result.status` set to the HTTP status, or,
69
+ * when it could not parse the error body, with the `Response` itself as the cause.
70
+ */
71
+ function isNotFound(error: unknown): boolean {
72
+ const cause = (error as { cause?: { status?: unknown; result?: { status?: unknown } } } | null)
73
+ ?.cause;
74
+ return cause?.result?.status === 404 || cause?.status === 404;
75
+ }
@@ -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
- this.icons.loadIcons().then(() => {
278
- if (mapManager.styleLoading) return;
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.icons.loadIcons().then(() => {
290
- if (mapManager.styleLoading) return;
290
+ this.whenIconsLoad(() => {
291
291
  clickManager.register([this.referenceId], this.handleMonitorClick);
292
292
  this.applySelectedState();
293
293
  });
@@ -10,8 +10,35 @@ 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
 
16
+ export interface MonitorsInitOptions {
17
+ /**
18
+ * Start the 2-minute polling once the initial load finishes. Defaults to true; pass false to
19
+ * load the data without showing a map (`resumeAutoUpdate()` starts polling later).
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;
40
+ }
41
+
15
42
  class MonitorsManager implements MonitorsDataSource {
16
43
  autoUpdate: Interval = new Interval(async () => await this.update(), 2 * 60 * 1000);
17
44
  initialized: boolean = $state(false);
@@ -27,8 +54,10 @@ class MonitorsManager implements MonitorsDataSource {
27
54
  this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null
28
55
  );
29
56
 
30
- #initPromise: Promise<void> | null = null;
57
+ #initRun: InitRun | null = null;
31
58
  #requestedPollutant: MonitorsPollutant | null = null;
59
+ #requestedAutoUpdate = false;
60
+ #updateController: AbortController | null = null;
32
61
 
33
62
  constructor() {
34
63
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
@@ -54,41 +83,93 @@ class MonitorsManager implements MonitorsDataSource {
54
83
  /**
55
84
  * Safe to call from several places: overlapping calls share one run, and a failed run can
56
85
  * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
57
- * between two explicit values the most recent wins.
86
+ * between two explicit values the most recent wins. Polling starts when the run finishes
87
+ * unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
88
+ * never restarts polling either: that is `resumeAutoUpdate()`.
58
89
  */
59
- init(urlPollutant?: string | number | boolean): Promise<void> {
90
+ init(urlPollutant?: string | number | boolean, options: MonitorsInitOptions = {}): Promise<void> {
60
91
  if (this.initialized) return Promise.resolve();
61
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
+
97
+ if (options.autoUpdate !== false) this.#requestedAutoUpdate = true;
98
+
62
99
  if (urlPollutant === "pm25" || urlPollutant === "o3") {
63
100
  this.#requestedPollutant = urlPollutant;
64
101
  }
65
102
 
66
- this.#initPromise ??= this.#runInit().finally(() => {
67
- this.#initPromise = null;
68
- this.#requestedPollutant = null;
69
- });
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
+ }
70
135
 
71
- return this.#initPromise;
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();
72
144
  }
73
145
 
74
- async #runInit(): Promise<void> {
146
+ #clearInitRun(): void {
147
+ this.#initRun = null;
148
+ this.#requestedPollutant = null;
149
+ this.#requestedAutoUpdate = false;
150
+ }
151
+
152
+ async #runInit(signal: AbortSignal): Promise<void> {
75
153
  // The pollutant the latest readings were requested for.
76
154
  let fetchedPollutant = this.#requestedPollutant;
77
155
 
78
- const metaRequest = getMonitorsMeta();
79
- const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
80
- this.list = list;
81
- this.meta = meta;
82
- return meta;
83
- });
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
+ );
84
165
  // The latest readings only need the pollutant, so they never wait for the list. An explicit
85
166
  // pollutant may still arrive before meta does, in which case the default is never fetched.
86
167
  const latestRequest = fetchedPollutant
87
- ? getMonitorsLatestMap(fetchedPollutant)
168
+ ? getMonitorsLatestMap(fetchedPollutant, signal)
88
169
  : metaRequest.then((meta) => {
89
170
  const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
90
171
  fetchedPollutant = pollutant;
91
- return getMonitorsLatestMap(pollutant);
172
+ return getMonitorsLatestMap(pollutant, signal);
92
173
  });
93
174
 
94
175
  // One Promise.all so a failure in either never leaves the other unhandled.
@@ -98,20 +179,24 @@ class MonitorsManager implements MonitorsDataSource {
98
179
  // An explicit pollutant may have arrived while the requests above were in flight.
99
180
  let wanted = this.#requestedPollutant ?? meta.default_pollutant;
100
181
  while (wanted !== fetchedPollutant) {
101
- latest = await getMonitorsLatestMap(wanted);
182
+ latest = await getMonitorsLatestMap(wanted, signal);
102
183
  fetchedPollutant = wanted;
103
184
  wanted = this.#requestedPollutant ?? meta.default_pollutant;
104
185
  }
105
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
+
106
190
  this.pollutant = fetchedPollutant;
107
191
  this.latest = latest;
108
- this.autoUpdate.start();
192
+ if (this.#requestedAutoUpdate) this.autoUpdate.start();
109
193
  this.initialized = true;
110
194
  }
111
195
 
112
196
  /**
113
- * Restarts polling after something stopped it (MonitorMapLayout stops it on destroy),
114
- * refreshing first since the data may be stale. A no-op before init or while polling.
197
+ * Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
198
+ * it on destroy) or after an init that opted out of it. Refreshes first since the data may
199
+ * be stale. A no-op before init or while polling.
115
200
  */
116
201
  resumeAutoUpdate(): void {
117
202
  if (!this.initialized || this.autoUpdate.isActive) return;
@@ -120,28 +205,70 @@ class MonitorsManager implements MonitorsDataSource {
120
205
  this.autoUpdate.start();
121
206
  }
122
207
 
123
- async update(): Promise<void> {
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> {
124
214
  if (!this.initialized) return;
125
215
 
126
- const pollutant = this.pollutant;
127
- const [list, latest] = await Promise.all([
128
- getMonitorsList(),
129
- getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
130
- ]);
216
+ // Nothing has started yet, so an in-flight update is left alone.
217
+ if (options.signal?.aborted) throw options.signal.reason;
131
218
 
132
- // The pollutant changed while this was in flight: these readings belong to the previous
133
- // one, and the change has already started its own update.
134
- if (this.pollutant !== pollutant) return;
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);
135
224
 
136
- this.list = list;
137
- this.latest = latest;
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
+ }
138
264
  }
139
265
  }
140
266
 
141
267
  async function getMonitorsLatestMap(
142
- pollutant: "pm25" | "o3"
268
+ pollutant: "pm25" | "o3",
269
+ signal?: AbortSignal
143
270
  ): Promise<XMap<string, MonitorLatestType<"pm25" | "o3">>> {
144
- const monitors = await getMonitors(pollutant);
271
+ const monitors = await getMonitors(pollutant, undefined, { signal });
145
272
  const latest = new XMap<string, MonitorLatestType<"pm25" | "o3">>();
146
273
 
147
274
  for (const monitor of monitors) {
@@ -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
+ }
@@ -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
+ }
@@ -42,7 +42,14 @@
42
42
  function onkeydown(e: KeyboardEvent) {
43
43
  if (e.key !== "Escape") return;
44
44
  panelOpen = false;
45
- if (isWideLayout()) menus.closeAll();
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 md:group-has-[:focus-visible]/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
+ }