@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
@@ -15,5 +15,18 @@ export declare abstract class MapGeoJSONIntegration<T = Record<string, unknown>>
15
15
  }
16
16
  export declare abstract class MapIconLayerIntegration<T = Record<string, unknown>> extends MapGeoJSONIntegration<T> {
17
17
  abstract icons: MapIconManager;
18
+ private generation;
19
+ /**
20
+ * Drops every callback still waiting on icons. `apply()` and `remove()` call it, so a
21
+ * subclass overriding `apply()` without calling `super.apply()` first must call it itself.
22
+ */
23
+ protected supersedePending(): void;
24
+ /**
25
+ * Runs `run` once the icons have loaded, unless an `apply()` or `remove()` has happened
26
+ * since (the state it was scheduled for is gone) or a basemap change is in flight (the
27
+ * refresh on "style.load" re-applies).
28
+ */
29
+ protected whenIconsLoad(run: () => void): void;
18
30
  apply(): void;
31
+ remove(): void;
19
32
  }
@@ -19,16 +19,42 @@ export class MapGeoJSONIntegration extends MapLayerIntegration {
19
19
  }
20
20
  }
21
21
  export class MapIconLayerIntegration extends MapGeoJSONIntegration {
22
+ generation = 0;
23
+ /**
24
+ * Drops every callback still waiting on icons. `apply()` and `remove()` call it, so a
25
+ * subclass overriding `apply()` without calling `super.apply()` first must call it itself.
26
+ */
27
+ supersedePending() {
28
+ this.generation++;
29
+ }
30
+ /**
31
+ * Runs `run` once the icons have loaded, unless an `apply()` or `remove()` has happened
32
+ * since (the state it was scheduled for is gone) or a basemap change is in flight (the
33
+ * refresh on "style.load" re-applies).
34
+ */
35
+ whenIconsLoad(run) {
36
+ const generation = this.generation;
37
+ this.icons
38
+ .loadIcons()
39
+ .then(() => {
40
+ if (generation !== this.generation || mapManager.styleLoading)
41
+ return;
42
+ run();
43
+ })
44
+ .catch(console.error);
45
+ }
22
46
  apply() {
23
47
  if (!mapManager.map)
24
48
  return;
25
- this.icons.loadIcons().then(() => {
26
- // A basemap change started while icons loaded; the refresh on "style.load" re-applies.
27
- if (mapManager.styleLoading)
28
- return;
49
+ this.supersedePending();
50
+ this.whenIconsLoad(() => {
29
51
  this.removeLayerAndSource();
30
52
  mapManager.map?.addSource(this.referenceId, this.mapSource);
31
53
  super.apply();
32
54
  });
33
55
  }
56
+ remove() {
57
+ this.supersedePending();
58
+ super.remove();
59
+ }
34
60
  }
@@ -4,10 +4,20 @@ declare class MapStyleState {
4
4
  referenceStyle: ReferenceMapStyle;
5
5
  variant: MapStyleVariant;
6
6
  private applied;
7
+ private loaded;
8
+ private stopWaiting;
7
9
  selectReferenceStyle(style: ReferenceMapStyle): void;
8
10
  selectVariant(variant: MapStyleVariant): void;
9
11
  reset(): void;
10
12
  private apply;
13
+ private load;
14
+ /**
15
+ * A style that fails to load leaves the map with no style at all, so go back to the last one
16
+ * that loaded and make the selection say so. When that one fails too (e.g. offline) there is
17
+ * nothing to draw on: `styleLoading` stays set, since integrations cannot add layers to a
18
+ * missing style, and the next selection starts a fresh attempt.
19
+ */
20
+ private recover;
11
21
  }
12
22
  export declare const mapStyleState: MapStyleState;
13
23
  export type { MapStyleState };
@@ -5,7 +5,12 @@ import { variantForStyle } from "./basemap";
5
5
  class MapStyleState {
6
6
  referenceStyle = $state.raw(DefaultMapStyle.getReferenceStyle());
7
7
  variant = $state.raw(DefaultMapStyle);
8
+ // The variant last handed to setStyle (loaded or still loading); null once a load has failed
9
+ // with nothing left to fall back to, so that any later selection is tried again.
8
10
  applied = DefaultMapStyle;
11
+ // The variant the map last finished loading, which a failed change falls back to.
12
+ loaded = DefaultMapStyle;
13
+ stopWaiting = null;
9
14
  selectReferenceStyle(style) {
10
15
  this.referenceStyle = style;
11
16
  this.selectVariant(variantForStyle(style, this.variant));
@@ -19,17 +24,63 @@ class MapStyleState {
19
24
  this.selectVariant(DefaultMapStyle);
20
25
  }
21
26
  apply() {
27
+ if (!mapManager.map || this.variant === this.applied)
28
+ return;
29
+ this.load(this.variant);
30
+ }
31
+ load(variant) {
22
32
  const map = mapManager.map;
23
- if (!map || this.variant === this.applied)
33
+ if (!map)
24
34
  return;
25
- this.applied = this.variant;
35
+ // A newer change supersedes one still in flight.
36
+ this.stopWaiting?.();
37
+ this.applied = variant;
26
38
  // setStyle wipes every custom source/layer; re-add enabled integrations once it loads.
27
- map.once("style.load", () => {
39
+ const onLoad = () => {
40
+ this.stopWaiting?.();
41
+ this.loaded = variant;
28
42
  mapManager.styleLoading = false;
29
43
  integrationsManager.refresh();
30
- });
44
+ };
45
+ // "error" also fires for tiles, sprites and glyphs; only the style document failing means
46
+ // the change failed.
47
+ const onError = (event) => {
48
+ if (!isRequestFor(event.error, variant.getExpandedStyleURL()))
49
+ return;
50
+ this.stopWaiting?.();
51
+ this.recover(variant);
52
+ };
53
+ this.stopWaiting = () => {
54
+ map.off("style.load", onLoad);
55
+ map.off("error", onError);
56
+ this.stopWaiting = null;
57
+ };
58
+ map.on("style.load", onLoad);
59
+ map.on("error", onError);
31
60
  mapManager.styleLoading = true;
32
- map.setStyle(this.variant);
61
+ map.setStyle(variant);
33
62
  }
63
+ /**
64
+ * A style that fails to load leaves the map with no style at all, so go back to the last one
65
+ * that loaded and make the selection say so. When that one fails too (e.g. offline) there is
66
+ * nothing to draw on: `styleLoading` stays set, since integrations cannot add layers to a
67
+ * missing style, and the next selection starts a fresh attempt.
68
+ */
69
+ recover(failed) {
70
+ if (failed === this.loaded) {
71
+ this.applied = null;
72
+ return;
73
+ }
74
+ this.variant = this.loaded;
75
+ this.referenceStyle = this.loaded.getReferenceStyle();
76
+ this.load(this.loaded);
77
+ }
78
+ }
79
+ /** Whether a map "error" event's error is a failed request for `url`, ignoring the query. */
80
+ function isRequestFor(error, url) {
81
+ const failed = error?.url;
82
+ if (typeof failed !== "string")
83
+ return false;
84
+ return failed.split("?")[0] === url.split("?")[0];
34
85
  }
35
86
  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 "../..";
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
- 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,15 @@
1
+ import { type MonitorDetails } from "@sjvair/sdk";
2
+ export type MonitorDetailsStatus = "loading" | "ready" | "not-found" | "failed";
3
+ /**
4
+ * One monitor, fetched by id. Unlike `monitorsManager.latest`, this does not depend on the
5
+ * map's pollutant or on the monitor being active, so the detail panel can show any monitor.
6
+ */
7
+ export declare class MonitorDetailsState {
8
+ #private;
9
+ monitor: MonitorDetails | null;
10
+ status: MonitorDetailsStatus;
11
+ load(id: string): Promise<void>;
12
+ /** Re-fetches the monitor already shown. A failure keeps the data on screen. */
13
+ refresh(): Promise<void>;
14
+ retry(): Promise<void>;
15
+ }
@@ -0,0 +1,71 @@
1
+ import { getMonitorDetails } from "@sjvair/sdk";
2
+ /**
3
+ * One monitor, fetched by id. Unlike `monitorsManager.latest`, this does not depend on the
4
+ * map's pollutant or on the monitor being active, so the detail panel can show any monitor.
5
+ */
6
+ export class MonitorDetailsState {
7
+ monitor = $state.raw(null);
8
+ status = $state("loading");
9
+ #id = null;
10
+ // Bumped per request; a response for anything but the latest request is dropped.
11
+ #request = 0;
12
+ #controller = null;
13
+ async load(id) {
14
+ this.#id = id;
15
+ const request = ++this.#request;
16
+ const { signal } = this.#restart();
17
+ this.monitor = null;
18
+ this.status = "loading";
19
+ try {
20
+ const monitor = await getMonitorDetails(id, { signal });
21
+ if (request !== this.#request)
22
+ return;
23
+ this.monitor = monitor;
24
+ this.status = "ready";
25
+ }
26
+ catch (error) {
27
+ if (request !== this.#request)
28
+ return;
29
+ if (isNotFound(error)) {
30
+ this.status = "not-found";
31
+ return;
32
+ }
33
+ console.error(error);
34
+ this.status = "failed";
35
+ }
36
+ }
37
+ /** Re-fetches the monitor already shown. A failure keeps the data on screen. */
38
+ async refresh() {
39
+ if (this.status !== "ready" || this.#id === null)
40
+ return;
41
+ const request = ++this.#request;
42
+ const { signal } = this.#restart();
43
+ try {
44
+ const monitor = await getMonitorDetails(this.#id, { signal });
45
+ if (request !== this.#request)
46
+ return;
47
+ this.monitor = monitor;
48
+ }
49
+ catch (error) {
50
+ if (!signal.aborted && request === this.#request)
51
+ console.error(error);
52
+ }
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
+ }
59
+ retry() {
60
+ return this.#id === null ? Promise.resolve() : this.load(this.#id);
61
+ }
62
+ }
63
+ /**
64
+ * The SDK rejects a failed request with `cause.result.status` set to the HTTP status, or,
65
+ * when it could not parse the error body, with the `Response` itself as the cause.
66
+ */
67
+ function isNotFound(error) {
68
+ const cause = error
69
+ ?.cause;
70
+ return cause?.result?.status === 404 || cause?.status === 404;
71
+ }
@@ -257,9 +257,9 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
257
257
  clickManager.unregister([this.referenceId]);
258
258
  this.tooltipManager.disable();
259
259
  this.renderer.remove();
260
- this.icons.loadIcons().then(() => {
261
- if (mapManager.styleLoading)
262
- return;
260
+ // Clustering may be toggled again before the icons arrive; only the latest apply runs.
261
+ this.supersedePending();
262
+ this.whenIconsLoad(() => {
263
263
  this.renderer.apply(this.handleMonitorClick);
264
264
  this.tooltipManager.enable();
265
265
  this.applySelectedState();
@@ -271,9 +271,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
271
271
  this.tooltipManager.enable();
272
272
  clickManager.unregister([this.referenceId]);
273
273
  super.apply();
274
- this.icons.loadIcons().then(() => {
275
- if (mapManager.styleLoading)
276
- return;
274
+ this.whenIconsLoad(() => {
277
275
  clickManager.register([this.referenceId], this.handleMonitorClick);
278
276
  this.applySelectedState();
279
277
  });
@@ -1,7 +1,22 @@
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";
6
+ export interface MonitorsInitOptions {
7
+ /**
8
+ * Start the 2-minute polling once the initial load finishes. Defaults to true; pass false to
9
+ * load the data without showing a map (`resumeAutoUpdate()` starts polling later).
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;
19
+ }
5
20
  declare class MonitorsManager implements MonitorsDataSource {
6
21
  #private;
7
22
  autoUpdate: Interval;
@@ -15,15 +30,23 @@ declare class MonitorsManager implements MonitorsDataSource {
15
30
  /**
16
31
  * Safe to call from several places: overlapping calls share one run, and a failed run can
17
32
  * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
18
- * between two explicit values the most recent wins.
33
+ * between two explicit values the most recent wins. Polling starts when the run finishes
34
+ * unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
35
+ * never restarts polling either: that is `resumeAutoUpdate()`.
19
36
  */
20
- init(urlPollutant?: string | number | boolean): Promise<void>;
37
+ init(urlPollutant?: string | number | boolean, options?: MonitorsInitOptions): Promise<void>;
21
38
  /**
22
- * Restarts polling after something stopped it (MonitorMapLayout stops it on destroy),
23
- * refreshing first since the data may be stale. A no-op before init or while polling.
39
+ * Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
40
+ * it on destroy) or after an init that opted out of it. Refreshes first since the data may
41
+ * be stale. A no-op before init or while polling.
24
42
  */
25
43
  resumeAutoUpdate(): void;
26
- update(): Promise<void>;
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>;
27
50
  }
28
51
  export declare const monitorsManager: MonitorsManager;
29
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,8 +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
- #initPromise = null;
14
+ #initRun = null;
14
15
  #requestedPollutant = null;
16
+ #requestedAutoUpdate = false;
17
+ #updateController = null;
15
18
  constructor() {
16
19
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
17
20
  // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
@@ -36,25 +39,74 @@ class MonitorsManager {
36
39
  /**
37
40
  * Safe to call from several places: overlapping calls share one run, and a failed run can
38
41
  * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
39
- * between two explicit values the most recent wins.
42
+ * between two explicit values the most recent wins. Polling starts when the run finishes
43
+ * unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
44
+ * never restarts polling either: that is `resumeAutoUpdate()`.
40
45
  */
41
- init(urlPollutant) {
46
+ init(urlPollutant, options = {}) {
42
47
  if (this.initialized)
43
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);
53
+ if (options.autoUpdate !== false)
54
+ this.#requestedAutoUpdate = true;
44
55
  if (urlPollutant === "pm25" || urlPollutant === "o3") {
45
56
  this.#requestedPollutant = urlPollutant;
46
57
  }
47
- this.#initPromise ??= this.#runInit().finally(() => {
48
- this.#initPromise = null;
49
- this.#requestedPollutant = null;
50
- });
51
- return this.#initPromise;
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;
52
87
  }
53
- async #runInit() {
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;
102
+ }
103
+ async #runInit(signal) {
54
104
  // The pollutant the latest readings were requested for.
55
105
  let fetchedPollutant = this.#requestedPollutant;
56
- const metaRequest = getMonitorsMeta();
57
- 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;
58
110
  this.list = list;
59
111
  this.meta = meta;
60
112
  return meta;
@@ -62,11 +114,11 @@ class MonitorsManager {
62
114
  // The latest readings only need the pollutant, so they never wait for the list. An explicit
63
115
  // pollutant may still arrive before meta does, in which case the default is never fetched.
64
116
  const latestRequest = fetchedPollutant
65
- ? getMonitorsLatestMap(fetchedPollutant)
117
+ ? getMonitorsLatestMap(fetchedPollutant, signal)
66
118
  : metaRequest.then((meta) => {
67
119
  const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
68
120
  fetchedPollutant = pollutant;
69
- return getMonitorsLatestMap(pollutant);
121
+ return getMonitorsLatestMap(pollutant, signal);
70
122
  });
71
123
  // One Promise.all so a failure in either never leaves the other unhandled.
72
124
  const [meta, firstLatest] = await Promise.all([listAndMeta, latestRequest]);
@@ -74,18 +126,23 @@ class MonitorsManager {
74
126
  // An explicit pollutant may have arrived while the requests above were in flight.
75
127
  let wanted = this.#requestedPollutant ?? meta.default_pollutant;
76
128
  while (wanted !== fetchedPollutant) {
77
- latest = await getMonitorsLatestMap(wanted);
129
+ latest = await getMonitorsLatestMap(wanted, signal);
78
130
  fetchedPollutant = wanted;
79
131
  wanted = this.#requestedPollutant ?? meta.default_pollutant;
80
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;
81
136
  this.pollutant = fetchedPollutant;
82
137
  this.latest = latest;
83
- this.autoUpdate.start();
138
+ if (this.#requestedAutoUpdate)
139
+ this.autoUpdate.start();
84
140
  this.initialized = true;
85
141
  }
86
142
  /**
87
- * Restarts polling after something stopped it (MonitorMapLayout stops it on destroy),
88
- * refreshing first since the data may be stale. A no-op before init or while polling.
143
+ * Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
144
+ * it on destroy) or after an init that opted out of it. Refreshes first since the data may
145
+ * be stale. A no-op before init or while polling.
89
146
  */
90
147
  resumeAutoUpdate() {
91
148
  if (!this.initialized || this.autoUpdate.isActive)
@@ -93,24 +150,67 @@ class MonitorsManager {
93
150
  this.update().catch(console.error);
94
151
  this.autoUpdate.start();
95
152
  }
96
- async update() {
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 = {}) {
97
159
  if (!this.initialized)
98
160
  return;
99
- const pollutant = this.pollutant;
100
- const [list, latest] = await Promise.all([
101
- getMonitorsList(),
102
- getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
103
- ]);
104
- // The pollutant changed while this was in flight: these readings belong to the previous
105
- // one, and the change has already started its own update.
106
- if (this.pollutant !== pollutant)
107
- return;
108
- this.list = list;
109
- this.latest = latest;
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
+ }
110
210
  }
111
211
  }
112
- async function getMonitorsLatestMap(pollutant) {
113
- const monitors = await getMonitors(pollutant);
212
+ async function getMonitorsLatestMap(pollutant, signal) {
213
+ const monitors = await getMonitors(pollutant, undefined, { signal });
114
214
  const latest = new XMap();
115
215
  for (const monitor of monitors) {
116
216
  latest.set(monitor.id, monitor);
@@ -0,0 +1,37 @@
1
+ /** The pollutants the detail panel can show, in order of preference. */
2
+ export declare const PANEL_POLLUTANTS: readonly ["pm25", "o3"];
3
+ export type PanelPollutant = (typeof PANEL_POLLUTANTS)[number];
4
+ /** The slice of the SDK's `MonitorDetails` this module reads. */
5
+ export interface PanelMonitor {
6
+ type: string;
7
+ is_active: boolean;
8
+ latest: Partial<Record<PanelPollutant, {
9
+ value?: string;
10
+ }>>;
11
+ }
12
+ /** The slice of the SDK's `MonitorsMeta` this module reads. */
13
+ export interface PanelMeta {
14
+ monitors: Record<string, {
15
+ entries: Record<string, unknown>;
16
+ } | undefined>;
17
+ }
18
+ export interface PanelPollutantChoice {
19
+ /** Null when the monitor has no reading and its type publishes neither pollutant. */
20
+ pollutant: PanelPollutant | null;
21
+ /** Whether the monitor has a current reading for `pollutant`. */
22
+ hasReading: boolean;
23
+ }
24
+ /** The panel pollutants this monitor has a current, numeric reading for. */
25
+ export declare function readingPollutants(monitor: Pick<PanelMonitor, "latest">): PanelPollutant[];
26
+ /**
27
+ * The panel pollutants this monitor type publishes. The API marks each entry type with a
28
+ * `published` flag the SDK does not type; a type that carries the entry without the flag
29
+ * counts as publishing it.
30
+ */
31
+ export declare function publishedPollutants(monitorType: string, meta: PanelMeta): PanelPollutant[];
32
+ /**
33
+ * The pollutant the detail panel shows: the map's when the monitor has it, otherwise the one
34
+ * the monitor has. A monitor with no current reading (inactive, or nothing reported) still
35
+ * gets a pollutant when there is one to chart, so its past data can be browsed.
36
+ */
37
+ export declare function choosePanelPollutant(mapPollutant: PanelPollutant | null, monitor: PanelMonitor, meta: PanelMeta): PanelPollutantChoice;