@sjvair/monitor-map 4.0.3 → 4.2.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 (106) hide show
  1. package/README.md +94 -14
  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 +21 -1
  12. package/dist/lib/collocation-sites/collocations-map-integration.svelte.js +1 -1
  13. package/dist/lib/core.d.ts +12 -0
  14. package/dist/lib/core.js +10 -0
  15. package/dist/lib/data-chart/DataChart.svelte +63 -13
  16. package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
  17. package/dist/lib/data-chart/DateRange.d.ts +4 -0
  18. package/dist/lib/data-chart/DateRange.js +9 -1
  19. package/dist/lib/data-chart/chart-utils.d.ts +2 -1
  20. package/dist/lib/data-chart/chart-utils.js +19 -23
  21. package/dist/lib/data-chart/level-colors.d.ts +6 -0
  22. package/dist/lib/data-chart/level-colors.js +5 -0
  23. package/dist/lib/data-chart/requests.d.ts +5 -2
  24. package/dist/lib/data-chart/requests.js +2 -3
  25. package/dist/lib/data-chart/tooltip.d.ts +2 -1
  26. package/dist/lib/data-chart/tooltip.js +1 -9
  27. package/dist/lib/ev-stations/ev-stations-cluster-renderer.js +1 -1
  28. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +5 -7
  29. package/dist/lib/hms/hms-fire-map-integration.svelte.js +1 -1
  30. package/dist/lib/hms/hms-smoke-map-integration.svelte.js +1 -1
  31. package/dist/lib/index.d.ts +8 -3
  32. package/dist/lib/index.js +6 -1
  33. package/dist/lib/lazy-chunks.d.ts +2 -0
  34. package/dist/lib/lazy-chunks.js +6 -0
  35. package/dist/lib/lazy-routes.d.ts +12 -0
  36. package/dist/lib/lazy-routes.js +18 -0
  37. package/dist/lib/map/Map.svelte +1 -1
  38. package/dist/lib/map/MapShell.svelte +9 -3
  39. package/dist/lib/map/MapShell.svelte.d.ts +5 -0
  40. package/dist/lib/map/integrations/click-manager.js +1 -1
  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 +31 -5
  43. package/dist/lib/map/integrations/map-icon-manager.js +1 -1
  44. package/dist/lib/map/integrations/map-layer-integration.svelte.js +1 -1
  45. package/dist/lib/map/integrations/tooltip.svelte.js +1 -1
  46. package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
  47. package/dist/lib/map/map-style-state.svelte.js +57 -6
  48. package/dist/lib/monitors/components/MonitorClusterTooltip.svelte +1 -1
  49. package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
  50. package/dist/lib/monitors/monitor-details.svelte.js +63 -0
  51. package/dist/lib/monitors/monitors-cluster-renderer.js +1 -1
  52. package/dist/lib/monitors/monitors-icon-manager.svelte.js +1 -1
  53. package/dist/lib/monitors/monitors-map-integration.svelte.js +5 -7
  54. package/dist/lib/monitors/monitors.svelte.d.ts +22 -1
  55. package/dist/lib/monitors/monitors.svelte.js +75 -8
  56. package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
  57. package/dist/lib/monitors/panel-pollutant.js +43 -0
  58. package/dist/lib/options/OptionsBar.svelte +8 -1
  59. package/dist/lib/options/OptionsMenu.svelte +10 -1
  60. package/dist/lib/search/Search.svelte +1 -1
  61. package/dist/lib/shared-load.d.ts +5 -0
  62. package/dist/lib/shared-load.js +14 -0
  63. package/package.json +13 -2
  64. package/src/lib/LazyMapLayout.svelte +53 -0
  65. package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
  66. package/src/lib/LoadError.svelte +19 -0
  67. package/src/lib/LoadScreen.svelte +10 -1
  68. package/src/lib/MonitorDetailPanel.svelte +108 -28
  69. package/src/lib/MonitorMapLayout.svelte +21 -1
  70. package/src/lib/collocation-sites/collocations-map-integration.svelte.ts +1 -1
  71. package/src/lib/core.ts +17 -0
  72. package/src/lib/data-chart/DataChart.svelte +63 -13
  73. package/src/lib/data-chart/DateRange.ts +11 -1
  74. package/src/lib/data-chart/chart-utils.ts +23 -26
  75. package/src/lib/data-chart/level-colors.ts +13 -0
  76. package/src/lib/data-chart/requests.ts +9 -5
  77. package/src/lib/data-chart/tooltip.ts +2 -10
  78. package/src/lib/ev-stations/ev-stations-cluster-renderer.ts +1 -1
  79. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +5 -5
  80. package/src/lib/hms/hms-fire-map-integration.svelte.ts +1 -1
  81. package/src/lib/hms/hms-smoke-map-integration.svelte.ts +1 -1
  82. package/src/lib/index.ts +8 -3
  83. package/src/lib/lazy-chunks.ts +7 -0
  84. package/src/lib/lazy-routes.ts +20 -0
  85. package/src/lib/map/Map.svelte +1 -1
  86. package/src/lib/map/MapShell.svelte +9 -3
  87. package/src/lib/map/integrations/click-manager.ts +1 -1
  88. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +33 -4
  89. package/src/lib/map/integrations/map-icon-manager.ts +1 -1
  90. package/src/lib/map/integrations/map-layer-integration.svelte.ts +1 -1
  91. package/src/lib/map/integrations/tooltip.svelte.ts +1 -1
  92. package/src/lib/map/map-style-state.svelte.ts +59 -7
  93. package/src/lib/monitors/components/MonitorClusterTooltip.svelte +1 -1
  94. package/src/lib/monitors/monitor-details.svelte.ts +66 -0
  95. package/src/lib/monitors/monitors-cluster-renderer.ts +1 -1
  96. package/src/lib/monitors/monitors-icon-manager.svelte.ts +1 -1
  97. package/src/lib/monitors/monitors-map-integration.svelte.ts +5 -5
  98. package/src/lib/monitors/monitors.svelte.ts +93 -10
  99. package/src/lib/monitors/panel-pollutant.ts +76 -0
  100. package/src/lib/options/OptionsBar.svelte +8 -1
  101. package/src/lib/options/OptionsMenu.svelte +10 -1
  102. package/src/lib/search/Search.svelte +1 -1
  103. package/src/lib/shared-load.ts +15 -0
  104. /package/dist/lib/map/{map.svelte.d.ts → map-manager.svelte.d.ts} +0 -0
  105. /package/dist/lib/map/{map.svelte.js → map-manager.svelte.js} +0 -0
  106. /package/src/lib/map/{map.svelte.ts → map-manager.svelte.ts} +0 -0
package/README.md CHANGED
@@ -47,21 +47,89 @@ export const { route, navigate, p, isActive, Router } = createRouter({
47
47
  <Router />
48
48
  ```
49
49
 
50
- This is what the sjvair.com widget and `sjvair-mobile` use today, via `MonitorMapLayout`
51
- (the thin wrapper `monitorMapRoutes` points to).
50
+ This is what the sjvair.com widget uses, via `MonitorMapLayout` (the thin wrapper
51
+ `monitorMapRoutes` points to). `sjvair-mobile` mounts the same route tree through the
52
+ subpath entries described next, so the map stays off its startup path.
53
+
54
+ ### Keeping the map off a host app's startup path
55
+
56
+ The package root re-exports everything, and its `monitorMapRoutes` imports the map layout
57
+ eagerly, so referencing it in a router puts maplibre (about 2 MB) in the startup bundle. A
58
+ host whose first screen is not the map uses the two subpath entries instead:
59
+
60
+ ```ts
61
+ // router.ts
62
+ import { createRouter } from "sv-router";
63
+ import { monitorMapRoutes } from "@sjvair/monitor-map/routes";
64
+
65
+ export const { route, navigate, p, isActive, Router } = createRouter({
66
+ "/my-map": monitorMapRoutes
67
+ });
68
+ ```
69
+
70
+ ```svelte
71
+ <!-- app root -->
72
+ <script>
73
+ import { provideMonitorMapRouter } from "@sjvair/monitor-map/core";
74
+ import { route, navigate, p, isActive, Router } from "./router";
75
+
76
+ provideMonitorMapRouter({ route, navigate, p, isActive, basePath: "/my-map" });
77
+ </script>
78
+
79
+ <Router />
80
+ ```
81
+
82
+ - `@sjvair/monitor-map/core` exports the router context (`provideMonitorMapRouter`,
83
+ `useMonitorMapRouter`, `ROUTER_KEY`, `MonitorMapRouterApi`) and `monitorsManager`, with
84
+ no map code behind them.
85
+ - `@sjvair/monitor-map/routes` exports a `monitorMapRoutes` with the same route tree, whose
86
+ map layout and monitor panel load on demand. The load screen shows while they do, including
87
+ on a cold deep link into the map, with a retry if a chunk fails to load.
88
+
89
+ Both entries share their modules with the package root, so `monitorsManager` is the same
90
+ instance whichever entry it is imported from.
91
+
92
+ `monitorsManager.init()` may be called from several places (for example, a background
93
+ preload and the map layout): overlapping calls share one set of requests, a failed call can
94
+ be retried, and an explicit `"pm25"`/`"o3"` argument wins over a call without one.
95
+
96
+ `init()` starts the 2-minute polling when it finishes. A background preload that shows no
97
+ map should pass `{ autoUpdate: false }` (`monitorsManager.init(undefined, { autoUpdate: false })`);
98
+ the map layout starts polling when it mounts. If any call sharing a run wants polling, it
99
+ starts.
100
+
101
+ ### The monitor detail panel
102
+
103
+ `/monitor/:id` under the map's routes opens a panel for that monitor. It loads the monitor
104
+ itself, so a link works whatever pollutant the map is showing and needs no `?pollutant=`
105
+ parameter: the panel shows the map's pollutant when the monitor reports it and otherwise the
106
+ one it does report, saying so. An inactive monitor gets a panel with its past data and a
107
+ note that it has no current readings; an unknown id gets "Monitor not found."
52
108
 
53
109
  ### Embedding `MapShell` directly, with a reduced integration set
54
110
 
55
111
  `MonitorMapLayout` is an opinionated wrapper around the lower-level `MapShell`
56
- component: it wires up every integration, every manager, and the full display-options
57
- menu. A host that only wants some of that — say, just the monitors integration, with no
112
+ component: it wires up every integration, every manager, and the full options toolbar and
113
+ legend. A host that only wants some of that — say, just the monitors integration, with no
58
114
  EV stations/wind/HMS — should compose `MapShell` directly instead:
59
115
 
60
116
  ```svelte
61
117
  <script>
62
- import { MapShell, monitorsMapIntegration, monitorsManager, Menu } from "@sjvair/monitor-map";
63
-
64
- monitorsManager.init();
118
+ import {
119
+ MapShell,
120
+ MonitorsMenu,
121
+ OptionsGroup,
122
+ PollutantMenu,
123
+ monitorsMapIntegration,
124
+ monitorsManager
125
+ } from "@sjvair/monitor-map";
126
+
127
+ let failed = $state(false);
128
+ function load() {
129
+ failed = false;
130
+ monitorsManager.init().catch(() => (failed = true));
131
+ }
132
+ load();
65
133
  </script>
66
134
 
67
135
  <MapShell
@@ -71,13 +139,25 @@ EV stations/wind/HMS — should compose `MapShell` directly instead:
71
139
  routerEscapeHatch={false}
72
140
  >
73
141
  {#snippet menu()}
74
- <Menu>
75
- <!-- display options for the integrations you included -->
76
- </Menu>
142
+ <OptionsGroup>
143
+ <PollutantMenu />
144
+ <MonitorsMenu />
145
+ </OptionsGroup>
146
+ {/snippet}
147
+ {#snippet loadError()}
148
+ {#if failed}
149
+ <button onclick={load}>Couldn't load air monitors. Try again</button>
150
+ {/if}
77
151
  {/snippet}
78
152
  </MapShell>
79
153
  ```
80
154
 
155
+ The `menu` snippet renders inside the options toolbar; compose `OptionsGroup` with the
156
+ exported menus (`PollutantMenu`, `MonitorsMenu`, `LayersMenu`, `OverlaysMenu`,
157
+ `SettingsMenu`), or `OptionsMenu` with the row components for a custom data source. The
158
+ optional `loadError` snippet renders on the load screen, under the logo, which stays up
159
+ until `ready` is true; use it to show a failed initial load with a retry.
160
+
81
161
  See `src/lib/MonitorMapLayout.svelte` for the reference example of wiring a manager, an
82
162
  integration list, and menu/overlay content into `MapShell`.
83
163
 
@@ -155,13 +235,13 @@ npm run check # Type-check Svelte components
155
235
  npm run check:watch # Type-check in watch mode
156
236
  npm run lint # Prettier check + ESLint
157
237
  npm run format # Auto-format with Prettier
238
+ npm test # Vitest (specs in tests/**)
239
+ npm run test:bundle # build:lib, then check the light entries keep maplibre off the eager path
158
240
  ```
159
241
 
160
- No test framework is currently configured.
161
-
162
242
  ## Publishing
163
243
 
164
244
  Publishing to npm (`@sjvair/monitor-map`) happens via GitHub Actions
165
245
  (`.github/workflows/release-package.yml`) whenever a GitHub Release is published (or the
166
- workflow is triggered manually). Bump `version` in `package.json` before cutting the
167
- release.
246
+ workflow is triggered manually). The workflow runs `npm test` and `npm run test:bundle`
247
+ before publishing. Bump `version` in `package.json` before cutting the release.
@@ -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,7 @@
1
+ import type { Component, Snippet } from "svelte";
2
+ interface Props {
3
+ children: Snippet;
4
+ }
5
+ declare const LazyMapLayout: Component<Props, {}, "">;
6
+ type LazyMapLayout = ReturnType<typeof LazyMapLayout>;
7
+ export default LazyMapLayout;
@@ -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,4 @@
1
+ import type { Component } from "svelte";
2
+ declare const LazyMonitorDetailPanel: Component<Record<string, never>, {}, "">;
3
+ type LazyMonitorDetailPanel = ReturnType<typeof LazyMonitorDetailPanel>;
4
+ export default LazyMonitorDetailPanel;
@@ -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>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ message: string;
3
+ onretry: () => void;
4
+ }
5
+ declare const LoadError: import("svelte").Component<Props, {}, "">;
6
+ type LoadError = ReturnType<typeof LoadError>;
7
+ export default LoadError;
@@ -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}
@@ -5,21 +5,11 @@ export interface LoadScreenState {
5
5
  export declare const loadScreenState: LoadScreenState;
6
6
  export declare function enable(): void;
7
7
  export declare function disable(): void;
8
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
9
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
10
- $$bindings?: Bindings;
11
- } & Exports;
12
- (internal: unknown, props: {
13
- $$events?: Events;
14
- $$slots?: Slots;
15
- }): Exports & {
16
- $set?: any;
17
- $on?: any;
18
- };
19
- z_$$bindings?: Bindings;
8
+ import type { Snippet } from "svelte";
9
+ interface Props {
10
+ /** Rendered under the logo while the load screen is up, e.g. a load failure and a retry */
11
+ error?: Snippet;
20
12
  }
21
- declare const LoadScreen: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
22
- [evt: string]: CustomEvent<any>;
23
- }, {}, {}, string>;
24
- type LoadScreen = InstanceType<typeof LoadScreen>;
13
+ declare const LoadScreen: import("svelte").Component<Props, {}, "">;
14
+ type LoadScreen = ReturnType<typeof LoadScreen>;
25
15
  export default LoadScreen;
@@ -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 "./components/DataBox.svelte";
5
+ import LoadError from "./LoadError.svelte";
5
6
  import MonitorWidgetModal from "./monitors/components/MonitorWidgetModal.svelte";
6
7
  import MonitorSubscriptionManager from "./monitors/components/MonitorSubscriptionManager.svelte";
7
8
  import DataChart from "./data-chart/DataChart.svelte";
8
9
  import { monitorsManager } from "./";
10
+ import { MonitorDetailsState } from "./monitors/monitor-details.svelte";
11
+ import { choosePanelPollutant } from "./monitors/panel-pollutant";
9
12
  import { useMonitorMapRouter } from "./router-context";
10
13
  import { fetchTempByCoords } from "./weather";
11
14
  import { Colors, valueToColor } from "./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 "./options/pollutant-param";
17
+ import LoadError from "./LoadError.svelte";
17
18
  import Legend from "./legend/Legend.svelte";
18
19
  import {
19
20
  fireSmokeLegendSections,
@@ -61,7 +62,21 @@
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();
78
+ // A previous instance of this layout stopped polling in onDestroy; init() is a no-op by now.
79
+ monitorsManager.resumeAutoUpdate();
65
80
  collocationSitesManager.init();
66
81
  hmsManager.init();
67
82
  monitorsMapIntegration.onMonitorClick = (id: string) => {
@@ -127,6 +142,11 @@
127
142
  knownRoutes={[`${basePath}/monitor/`]}
128
143
  {basePath}
129
144
  >
145
+ {#snippet loadError()}
146
+ {#if loadFailed}
147
+ <LoadError message="Couldn't load air monitors." onretry={loadMonitors} />
148
+ {/if}
149
+ {/snippet}
130
150
  {#snippet menu()}
131
151
  <OptionsGroup>
132
152
  <PollutantMenu />
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map/map.svelte";
1
+ import { mapManager } from "../map/map-manager.svelte.js";
2
2
  import { MapIconLayerIntegration } from "../map/integrations/map-geojson-integration.svelte";
3
3
  import { CollocationIconManager, getCollocationIconId } from "./collocations-icon-manager.svelte";
4
4
  import { TooltipManager } from "../map/integrations/tooltip.svelte";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Light entry (`@sjvair/monitor-map/core`): what a host app needs at startup, without the map.
3
+ *
4
+ * Nothing imported here may reach @maptiler/sdk, @maptiler/weather or uplot at runtime;
5
+ * `npm run test:bundle` enforces it. Everything is re-exported from the same modules the
6
+ * main entry uses, so `monitorsManager` and the router context stay single instances
7
+ * whichever entries a host mixes.
8
+ */
9
+ export { ROUTER_KEY, provideMonitorMapRouter, useMonitorMapRouter, type MonitorMapRouterApi } from "./router-context";
10
+ export { monitorsManager } from "./monitors/monitors.svelte.js";
11
+ export type { MonitorsManager, MonitorsInitOptions } from "./monitors/monitors.svelte.js";
12
+ export type { MonitorsDataSource, MonitorsPollutant } from "./monitors/types";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Light entry (`@sjvair/monitor-map/core`): what a host app needs at startup, without the map.
3
+ *
4
+ * Nothing imported here may reach @maptiler/sdk, @maptiler/weather or uplot at runtime;
5
+ * `npm run test:bundle` enforces it. Everything is re-exported from the same modules the
6
+ * main entry uses, so `monitorsManager` and the router context stay single instances
7
+ * whichever entries a host mixes.
8
+ */
9
+ export { ROUTER_KEY, provideMonitorMapRouter, useMonitorMapRouter } from "./router-context";
10
+ export { monitorsManager } from "./monitors/monitors.svelte.js";