@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
@@ -0,0 +1,43 @@
1
+ /** The pollutants the detail panel can show, in order of preference. */
2
+ export const PANEL_POLLUTANTS = ["pm25", "o3"];
3
+ /** The panel pollutants this monitor has a current, numeric reading for. */
4
+ export function readingPollutants(monitor) {
5
+ return PANEL_POLLUTANTS.filter((pollutant) => {
6
+ const value = monitor.latest[pollutant]?.value;
7
+ return typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
8
+ });
9
+ }
10
+ /**
11
+ * The panel pollutants this monitor type publishes. The API marks each entry type with a
12
+ * `published` flag the SDK does not type; a type that carries the entry without the flag
13
+ * counts as publishing it.
14
+ */
15
+ export function publishedPollutants(monitorType, meta) {
16
+ const entries = meta.monitors[monitorType]?.entries ?? {};
17
+ return PANEL_POLLUTANTS.filter((pollutant) => {
18
+ const entry = entries[pollutant];
19
+ return entry !== undefined && entry.published !== false;
20
+ });
21
+ }
22
+ /**
23
+ * The pollutant the detail panel shows: the map's when the monitor has it, otherwise the one
24
+ * the monitor has. A monitor with no current reading (inactive, or nothing reported) still
25
+ * gets a pollutant when there is one to chart, so its past data can be browsed.
26
+ */
27
+ export function choosePanelPollutant(mapPollutant, monitor, meta) {
28
+ const withReading = readingPollutants(monitor);
29
+ // The API keeps returning an inactive monitor's last reading, however old, so only an
30
+ // active monitor's readings count as current.
31
+ if (monitor.is_active && withReading.length > 0) {
32
+ const pollutant = mapPollutant && withReading.includes(mapPollutant) ? mapPollutant : withReading[0];
33
+ return { pollutant, hasReading: true };
34
+ }
35
+ // Nothing current: chart what the type publishes, or failing that what it last reported.
36
+ const published = publishedPollutants(monitor.type, meta);
37
+ const chartable = published.length > 0 ? published : withReading;
38
+ if (chartable.length > 0) {
39
+ const pollutant = mapPollutant && chartable.includes(mapPollutant) ? mapPollutant : chartable[0];
40
+ return { pollutant, hasReading: false };
41
+ }
42
+ return { pollutant: null, hasReading: false };
43
+ }
@@ -0,0 +1,3 @@
1
+ import type { SignalOptions } from "../abort";
2
+ export declare function getApiToken(): string | undefined;
3
+ export declare function loadSubscriptionLevel(monitorId: string, options?: SignalOptions): Promise<string | null>;
@@ -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
+ }
@@ -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,5 @@
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 declare function sharedLoad<T>(load: () => Promise<T>): () => Promise<T>;
@@ -0,0 +1,14 @@
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(load) {
6
+ let pending = null;
7
+ return () => {
8
+ pending ??= load().catch((error) => {
9
+ pending = null;
10
+ throw error;
11
+ });
12
+ return pending;
13
+ };
14
+ }
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.1.0",
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.5.0",
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",
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import type { Component, Snippet } from "svelte";
3
+ import LoadScreen from "./LoadScreen.svelte";
4
+ import LoadError from "./LoadError.svelte";
5
+ import { loadMonitorDetailPanel, loadMonitorMapLayout } from "./lazy-chunks";
6
+ import { useMonitorMapRouter } from "./router-context";
7
+
8
+ interface Props {
9
+ children: Snippet;
10
+ }
11
+
12
+ let { children }: Props = $props();
13
+
14
+ const { route, basePath } = useMonitorMapRouter();
15
+
16
+ let Layout: Component<Props> | null = $state.raw(null);
17
+ let failed = $state(false);
18
+
19
+ function load() {
20
+ failed = false;
21
+ loadMonitorMapLayout().then(
22
+ (module) => (Layout = module.default),
23
+ (error) => {
24
+ console.error(error);
25
+ failed = true;
26
+ }
27
+ );
28
+ }
29
+
30
+ load();
31
+ // On a link straight to a monitor, fetch its panel alongside the layout; the panel's own
32
+ // wrapper only mounts (and would only ask for it) once the layout has arrived. A failure is
33
+ // reported by that wrapper when it asks again.
34
+ if (route.pathname.startsWith(`${basePath}/monitor/`)) {
35
+ loadMonitorDetailPanel().catch(() => {});
36
+ }
37
+ </script>
38
+
39
+ <!-- The layout renders the same LoadScreen from the same shared state, so it carries on
40
+ from this one without restarting. -->
41
+ {#if Layout}
42
+ <Layout>{@render children()}</Layout>
43
+ {:else}
44
+ <div class="relative h-full w-full">
45
+ <LoadScreen>
46
+ {#snippet error()}
47
+ {#if failed}
48
+ <LoadError message="Couldn't load the map." onretry={load} />
49
+ {/if}
50
+ {/snippet}
51
+ </LoadScreen>
52
+ </div>
53
+ {/if}
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import type { Component } from "svelte";
3
+ import LoadError from "./LoadError.svelte";
4
+ import { loadMonitorDetailPanel } from "./lazy-chunks";
5
+
6
+ let Panel: Component | null = $state.raw(null);
7
+ let failed = $state(false);
8
+
9
+ function load() {
10
+ failed = false;
11
+ loadMonitorDetailPanel().then(
12
+ (module) => (Panel = module.default),
13
+ (error) => {
14
+ console.error(error);
15
+ failed = true;
16
+ }
17
+ );
18
+ }
19
+
20
+ load();
21
+ </script>
22
+
23
+ {#if Panel}
24
+ <Panel />
25
+ {:else}
26
+ <div class="flex h-full w-full items-center justify-center bg-white">
27
+ {#if failed}
28
+ <LoadError message="Couldn't load this monitor." onretry={load} />
29
+ {:else}
30
+ <p class="text-sm text-gray-500">Loading…</p>
31
+ {/if}
32
+ </div>
33
+ {/if}
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ message: string;
4
+ onretry: () => void;
5
+ }
6
+
7
+ let { message, onretry }: Props = $props();
8
+ </script>
9
+
10
+ <div class="flex flex-col items-center gap-3 px-4 text-center" role="alert">
11
+ <p class="text-gray-700">{message}</p>
12
+ <button
13
+ type="button"
14
+ class="rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
15
+ onclick={onretry}
16
+ >
17
+ Try again
18
+ </button>
19
+ </div>
@@ -23,8 +23,16 @@
23
23
  </script>
24
24
 
25
25
  <script lang="ts">
26
+ import type { Snippet } from "svelte";
26
27
  import type { Attachment } from "svelte/attachments";
27
28
 
29
+ interface Props {
30
+ /** Rendered under the logo while the load screen is up, e.g. a load failure and a retry */
31
+ error?: Snippet;
32
+ }
33
+
34
+ let { error }: Props = $props();
35
+
28
36
  const animationTrigger: Attachment<HTMLDivElement> = () => {
29
37
  loadScreenState.entered = true;
30
38
  };
@@ -32,7 +40,7 @@
32
40
 
33
41
  {#if loadScreenState.enabled}
34
42
  <div
35
- class="absolute top-0 left-0 z-50 flex h-full w-full items-center justify-center overflow-hidden bg-white"
43
+ class="absolute top-0 left-0 z-50 flex h-full w-full flex-col items-center justify-center gap-6 overflow-hidden bg-white"
36
44
  {@attach animationTrigger}
37
45
  >
38
46
  <div class="flex justify-around gap-4">
@@ -64,5 +72,6 @@
64
72
  class:opacity-0={!loadScreenState.entered}
65
73
  />
66
74
  </div>
75
+ {@render error?.()}
67
76
  </div>
68
77
  {/if}
@@ -1,11 +1,14 @@
1
1
  <script lang="ts">
2
- import type { ComponentProps } from "svelte";
2
+ import { untrack, type ComponentProps } from "svelte";
3
3
  import type { SJVAirEntryLevel } from "@sjvair/sdk";
4
4
  import DataBox from "$lib/components/DataBox.svelte";
5
+ import LoadError from "$lib/LoadError.svelte";
5
6
  import MonitorWidgetModal from "$lib/monitors/components/MonitorWidgetModal.svelte";
6
7
  import MonitorSubscriptionManager from "$lib/monitors/components/MonitorSubscriptionManager.svelte";
7
8
  import DataChart from "$lib/data-chart/DataChart.svelte";
8
9
  import { monitorsManager } from "$lib";
10
+ import { MonitorDetailsState } from "$lib/monitors/monitor-details.svelte";
11
+ import { choosePanelPollutant } from "$lib/monitors/panel-pollutant";
9
12
  import { useMonitorMapRouter } from "./router-context";
10
13
  import { fetchTempByCoords } from "$lib/weather";
11
14
  import { Colors, valueToColor } from "$lib/colors";
@@ -21,24 +24,75 @@
21
24
  }
22
25
  const { route, navigate, basePath } = useMonitorMapRouter();
23
26
 
24
- const monitor = $derived(monitorsManager.latest?.get(route.params.id ?? ""));
27
+ // The panel fetches its own monitor: the map's readings only hold monitors that have a
28
+ // current reading for the map's pollutant.
29
+ const details = new MonitorDetailsState();
30
+ const meta = $derived(monitorsManager.meta);
31
+ // Shown only once the metadata is in too; labels and levels come from it.
32
+ const monitor = $derived(details.status === "ready" && meta ? details.monitor : null);
33
+
34
+ // Through a $derived: the router replaces `route.params` on every navigation, including
35
+ // a search-only change or Back to the same monitor, which must not reload the panel.
36
+ const routeId = $derived(route.params.id);
37
+ $effect(() => {
38
+ const id = routeId;
39
+ if (id) untrack(() => details.load(id));
40
+ });
41
+
42
+ // Bumped whenever the map's readings are replaced, i.e. its 2-minute refresh ran. The data
43
+ // box follows it; the chart decides for itself whether its date range can have new entries.
44
+ let refreshTick = $state(0);
45
+ let seenLatest = untrack(() => monitorsManager.latest);
46
+
47
+ $effect(() => {
48
+ const latest = monitorsManager.latest;
49
+ // Null while a pollutant change is loading; wait for the readings that follow.
50
+ if (!latest || latest === seenLatest) return;
51
+ // The first readings of a cold load arrive alongside this panel's own request.
52
+ const first = seenLatest === null;
53
+ seenLatest = latest;
54
+ if (first) return;
55
+ untrack(() => {
56
+ details.refresh();
57
+ refreshTick++;
58
+ });
59
+ });
60
+
61
+ const panel = $derived(
62
+ monitor && meta ? choosePanelPollutant(monitorsManager.pollutant, monitor, meta) : null
63
+ );
64
+ const reading = $derived(
65
+ monitor && panel?.pollutant && panel.hasReading ? monitor.latest[panel.pollutant] : undefined
66
+ );
67
+ // Set when the panel shows a different pollutant than the map is coloring by.
68
+ const pollutantNote = $derived.by(() => {
69
+ const mapPollutant = monitorsManager.pollutant;
70
+ if (!meta || !panel?.pollutant || !panel.hasReading || !mapPollutant) return;
71
+ if (panel.pollutant === mapPollutant) return;
72
+ const shown = meta.entryType(panel.pollutant).label;
73
+ const label = meta.entryType(mapPollutant).label;
74
+ // Mid-sentence: lowercase a plain word ("Ozone"), keep a symbol ("PM2.5") as written.
75
+ const missing = /^[A-Z][a-z]+$/.test(label) ? label.toLowerCase() : label;
76
+ return `Showing ${shown}. This monitor has no ${missing} readings.`;
77
+ });
78
+
25
79
  let tempData: TempDataBox | undefined = $state();
26
80
  const entryData: ComponentProps<typeof DataBox> | undefined = $derived.by(() => {
27
- if (!monitor || !monitorsManager.meta) return;
81
+ if (!monitor || !meta || !panel?.pollutant || !reading) return;
28
82
 
29
- const entryType = monitorsManager.meta.entryType(monitor.latest.entry_type);
83
+ const entryType = meta.entryType(panel.pollutant);
30
84
  const level: SJVAirEntryLevel | undefined = getCurrentLevel(
31
- monitor.latest.value,
85
+ reading.value,
32
86
  entryType.asIter.levels!
33
87
  );
34
88
 
35
- const updateDuration = monitorsManager.meta.monitorType(monitor.type).expected_interval;
89
+ const updateDuration = meta.monitorType(monitor.type).expected_interval;
36
90
 
37
91
  return {
38
92
  color: level?.color ?? "",
39
93
  header: entryType.label,
40
94
  subheading: `(${updateDuration} avg)`,
41
- value: Math.round(parseInt(monitor.latest.value, 10)).toString()
95
+ value: Math.round(parseInt(reading.value, 10)).toString()
42
96
  };
43
97
  });
44
98
 
@@ -49,15 +103,26 @@
49
103
  { min: 95, color: Colors.red }
50
104
  ];
51
105
 
106
+ // Re-run for each refreshed copy of the monitor, as before, but never show one monitor's
107
+ // temperature on another: cleared when the id changes, and a late answer is dropped.
108
+ let tempMonitorId: string | undefined;
52
109
  $effect(() => {
53
- if (monitor && monitor.position) {
54
- fetchTempByCoords(monitor.position.coordinates).then((temperature) => {
110
+ const current = monitor;
111
+ const id = current?.id;
112
+ if (id !== tempMonitorId) {
113
+ tempMonitorId = id;
114
+ tempData = undefined;
115
+ }
116
+ if (!current?.position) return;
117
+ fetchTempByCoords(current.position.coordinates)
118
+ .then((temperature) => {
119
+ if (untrack(() => monitor?.id) !== id) return;
55
120
  tempData = {
56
121
  value: `${temperature}°F`,
57
122
  color: valueToColor(temperature, tempColors)
58
123
  };
59
- });
60
- }
124
+ })
125
+ .catch(console.error);
61
126
  });
62
127
  </script>
63
128
 
@@ -104,24 +169,37 @@
104
169
  {@render badge("location-on", monitor.county)}
105
170
  {@render badge("location-searching", monitor.location)}
106
171
  </div>
172
+ {#if pollutantNote}
173
+ <p class="text-center text-sm text-gray-600">{pollutantNote}</p>
174
+ {:else if panel && !panel.hasReading}
175
+ <p class="text-center text-sm text-gray-600">This monitor has no current readings.</p>
176
+ {/if}
107
177
  {/if}
108
178
  </div>
109
179
 
110
180
  <div class="tall:overflow-y-auto flex flex-col items-center gap-12 p-5 pt-10">
111
- {#if monitor}
112
- <div class="flex w-full justify-around">
113
- {#if tempData}
114
- <DataBox color={tempData.color} header="Temperature" value={tempData.value} />
115
- {/if}
116
- {#if entryData}
117
- <DataBox
118
- color={entryData.color}
119
- header={entryData.header}
120
- subheading={entryData.subheading}
121
- value={entryData.value}
122
- />
123
- {/if}
124
- </div>
181
+ {#if details.status === "not-found"}
182
+ <p class="text-gray-700" role="alert">Monitor not found.</p>
183
+ {:else if details.status === "failed"}
184
+ <LoadError message="Couldn't load this monitor." onretry={() => details.retry()} />
185
+ {:else if !monitor || !panel}
186
+ <p class="text-sm text-gray-500">Loading…</p>
187
+ {:else}
188
+ {#if tempData || entryData}
189
+ <div class="flex w-full justify-around">
190
+ {#if tempData}
191
+ <DataBox color={tempData.color} header="Temperature" value={tempData.value} />
192
+ {/if}
193
+ {#if entryData}
194
+ <DataBox
195
+ color={entryData.color}
196
+ header={entryData.header}
197
+ subheading={entryData.subheading}
198
+ value={entryData.value}
199
+ />
200
+ {/if}
201
+ </div>
202
+ {/if}
125
203
 
126
204
  <div class="tall:block hidden">
127
205
  <MonitorWidgetModal monitorId={monitor.id} />
@@ -131,9 +209,11 @@
131
209
  <MonitorSubscriptionManager monitorId={monitor.id} />
132
210
  {/if}
133
211
 
134
- <div class="w-full">
135
- <DataChart {monitor} />
136
- </div>
212
+ {#if panel.pollutant}
213
+ <div class="w-full">
214
+ <DataChart {monitor} pollutant={panel.pollutant} {refreshTick} />
215
+ </div>
216
+ {/if}
137
217
 
138
218
  <div class="w-full">
139
219
  <h3 class="font-semibold">Data Provided Courtesy Of:</h3>
@@ -14,6 +14,7 @@
14
14
  currentSelection,
15
15
  parsePollutantParam
16
16
  } from "$lib/options/pollutant-param";
17
+ import LoadError from "$lib/LoadError.svelte";
17
18
  import Legend from "$lib/legend/Legend.svelte";
18
19
  import {
19
20
  fireSmokeLegendSections,
@@ -61,7 +62,19 @@
61
62
  monitorsMapIntegration.enabled = false;
62
63
  }
63
64
 
64
- monitorsManager.init(route.search.pollutant);
65
+ // The load screen stays up until the monitors load, so a failure has to be shown on it.
66
+ let loadFailed = $state(false);
67
+
68
+ function loadMonitors() {
69
+ loadFailed = false;
70
+ // A rejected init() can be retried: a later call starts a fresh run.
71
+ monitorsManager.init(route.search.pollutant).catch((error) => {
72
+ console.error(error);
73
+ loadFailed = true;
74
+ });
75
+ }
76
+
77
+ loadMonitors();
65
78
  // A previous instance of this layout stopped polling in onDestroy; init() is a no-op by now.
66
79
  monitorsManager.resumeAutoUpdate();
67
80
  collocationSitesManager.init();
@@ -129,6 +142,11 @@
129
142
  knownRoutes={[`${basePath}/monitor/`]}
130
143
  {basePath}
131
144
  >
145
+ {#snippet loadError()}
146
+ {#if loadFailed}
147
+ <LoadError message="Couldn't load air monitors." onretry={loadMonitors} />
148
+ {/if}
149
+ {/snippet}
132
150
  {#snippet menu()}
133
151
  <OptionsGroup>
134
152
  <PollutantMenu />
@@ -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
 
package/src/lib/core.ts CHANGED
@@ -13,5 +13,5 @@ export {
13
13
  type MonitorMapRouterApi
14
14
  } from "./router-context";
15
15
  export { monitorsManager } from "./monitors/monitors.svelte.js";
16
- export type { MonitorsManager } from "./monitors/monitors.svelte.js";
16
+ export type { MonitorsManager, MonitorsInitOptions } from "./monitors/monitors.svelte.js";
17
17
  export type { MonitorsDataSource, MonitorsPollutant } from "./monitors/types";