@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.
- package/README.md +68 -16
- package/dist/lib/LazyMapLayout.svelte +53 -0
- package/dist/lib/LazyMapLayout.svelte.d.ts +7 -0
- package/dist/lib/LazyMonitorDetailPanel.svelte +33 -0
- package/dist/lib/LazyMonitorDetailPanel.svelte.d.ts +4 -0
- package/dist/lib/LoadError.svelte +19 -0
- package/dist/lib/LoadError.svelte.d.ts +7 -0
- package/dist/lib/LoadScreen.svelte +10 -1
- package/dist/lib/LoadScreen.svelte.d.ts +6 -16
- package/dist/lib/MonitorDetailPanel.svelte +108 -28
- package/dist/lib/MonitorMapLayout.svelte +19 -1
- package/dist/lib/abort.d.ts +16 -0
- package/dist/lib/abort.js +51 -0
- package/dist/lib/collocation-sites/collocations.svelte.d.ts +3 -2
- package/dist/lib/collocation-sites/collocations.svelte.js +8 -4
- package/dist/lib/core.d.ts +1 -1
- package/dist/lib/data-chart/DataChart.svelte +78 -13
- package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
- package/dist/lib/data-chart/DateRange.d.ts +4 -0
- package/dist/lib/data-chart/DateRange.js +9 -1
- package/dist/lib/data-chart/chart-utils.d.ts +2 -1
- package/dist/lib/data-chart/chart-utils.js +19 -23
- package/dist/lib/data-chart/level-colors.d.ts +6 -0
- package/dist/lib/data-chart/level-colors.js +5 -0
- package/dist/lib/data-chart/requests.d.ts +6 -2
- package/dist/lib/data-chart/requests.js +6 -4
- package/dist/lib/data-chart/tooltip.d.ts +2 -1
- package/dist/lib/data-chart/tooltip.js +1 -9
- package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +4 -6
- package/dist/lib/ev-stations/ev-stations.svelte.d.ts +3 -2
- package/dist/lib/ev-stations/ev-stations.svelte.js +8 -6
- package/dist/lib/hms/hms.svelte.d.ts +4 -3
- package/dist/lib/hms/hms.svelte.js +16 -6
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/lazy-chunks.d.ts +2 -0
- package/dist/lib/lazy-chunks.js +6 -0
- package/dist/lib/lazy-routes.d.ts +6 -4
- package/dist/lib/lazy-routes.js +12 -7
- package/dist/lib/map/MapShell.svelte +7 -1
- package/dist/lib/map/MapShell.svelte.d.ts +5 -0
- package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +13 -0
- package/dist/lib/map/integrations/map-geojson-integration.svelte.js +30 -4
- package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
- package/dist/lib/map/map-style-state.svelte.js +56 -5
- package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
- package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
- package/dist/lib/monitors/monitor-details.svelte.js +71 -0
- package/dist/lib/monitors/monitors-map-integration.svelte.js +4 -6
- package/dist/lib/monitors/monitors.svelte.d.ts +28 -5
- package/dist/lib/monitors/monitors.svelte.js +131 -31
- package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
- package/dist/lib/monitors/panel-pollutant.js +43 -0
- package/dist/lib/monitors/subscriptions.d.ts +3 -0
- package/dist/lib/monitors/subscriptions.js +9 -0
- package/dist/lib/options/OptionsBar.svelte +8 -1
- package/dist/lib/options/OptionsMenu.svelte +10 -1
- package/dist/lib/shared-load.d.ts +5 -0
- package/dist/lib/shared-load.js +14 -0
- package/package.json +2 -2
- package/src/lib/LazyMapLayout.svelte +53 -0
- package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
- package/src/lib/LoadError.svelte +19 -0
- package/src/lib/LoadScreen.svelte +10 -1
- package/src/lib/MonitorDetailPanel.svelte +108 -28
- package/src/lib/MonitorMapLayout.svelte +19 -1
- package/src/lib/abort.ts +59 -0
- package/src/lib/collocation-sites/collocations.svelte.ts +7 -4
- package/src/lib/core.ts +1 -1
- package/src/lib/data-chart/DataChart.svelte +78 -13
- package/src/lib/data-chart/DateRange.ts +11 -1
- package/src/lib/data-chart/chart-utils.ts +23 -26
- package/src/lib/data-chart/level-colors.ts +13 -0
- package/src/lib/data-chart/requests.ts +21 -10
- package/src/lib/data-chart/tooltip.ts +2 -10
- package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +4 -4
- package/src/lib/ev-stations/ev-stations.svelte.ts +11 -6
- package/src/lib/hms/hms.svelte.ts +12 -6
- package/src/lib/index.ts +1 -1
- package/src/lib/lazy-chunks.ts +7 -0
- package/src/lib/lazy-routes.ts +12 -7
- package/src/lib/map/MapShell.svelte +7 -1
- package/src/lib/map/integrations/map-geojson-integration.svelte.ts +32 -3
- package/src/lib/map/map-style-state.svelte.ts +58 -6
- package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
- package/src/lib/monitors/monitor-details.svelte.ts +75 -0
- package/src/lib/monitors/monitors-map-integration.svelte.ts +4 -4
- package/src/lib/monitors/monitors.svelte.ts +161 -34
- package/src/lib/monitors/panel-pollutant.ts +76 -0
- package/src/lib/monitors/subscriptions.ts +17 -0
- package/src/lib/options/OptionsBar.svelte +8 -1
- package/src/lib/options/OptionsMenu.svelte +10 -1
- package/src/lib/shared-load.ts +15 -0
package/README.md
CHANGED
|
@@ -47,8 +47,9 @@ export const { route, navigate, p, isActive, Router } = createRouter({
|
|
|
47
47
|
<Router />
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
This is what the sjvair.com widget
|
|
51
|
-
|
|
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.
|
|
52
53
|
|
|
53
54
|
### Keeping the map off a host app's startup path
|
|
54
55
|
|
|
@@ -82,8 +83,8 @@ export const { route, navigate, p, isActive, Router } = createRouter({
|
|
|
82
83
|
`useMonitorMapRouter`, `ROUTER_KEY`, `MonitorMapRouterApi`) and `monitorsManager`, with
|
|
83
84
|
no map code behind them.
|
|
84
85
|
- `@sjvair/monitor-map/routes` exports a `monitorMapRoutes` with the same route tree, whose
|
|
85
|
-
layout and
|
|
86
|
-
|
|
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.
|
|
87
88
|
|
|
88
89
|
Both entries share their modules with the package root, so `monitorsManager` is the same
|
|
89
90
|
instance whichever entry it is imported from.
|
|
@@ -92,18 +93,57 @@ instance whichever entry it is imported from.
|
|
|
92
93
|
preload and the map layout): overlapping calls share one set of requests, a failed call can
|
|
93
94
|
be retried, and an explicit `"pm25"`/`"o3"` argument wins over a call without one.
|
|
94
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."
|
|
108
|
+
|
|
109
|
+
### Cancelling requests
|
|
110
|
+
|
|
111
|
+
`monitorsManager.init()` and `update()`, `collocationSitesManager.init()` and `update()`,
|
|
112
|
+
`hmsManager.init()`, `loadSmoke()` and `loadFire()`, and `evStationsManager.loadLvl2Stations()`
|
|
113
|
+
and `loadLvl3Stations()` take `{ signal }` (an `AbortSignal`). Aborting rejects that call with
|
|
114
|
+
the signal's reason (a call that is a no-op, such as `update()` before init or `init()` once
|
|
115
|
+
initialized, resolves as before). The panel, chart and subscription status cancel their own superseded
|
|
116
|
+
requests when the selection changes; that work resolves quietly. A `monitorsManager.init()`
|
|
117
|
+
run is shared: aborting your signal stops only your wait, and the run itself is cancelled only
|
|
118
|
+
when every caller waiting on it has aborted.
|
|
119
|
+
|
|
120
|
+
A host that depends on `@sjvair/sdk` directly must use `^4.7.0`, so npm installs one copy. A
|
|
121
|
+
nested copy ignores the host's `setOrigin`.
|
|
122
|
+
|
|
95
123
|
### Embedding `MapShell` directly, with a reduced integration set
|
|
96
124
|
|
|
97
125
|
`MonitorMapLayout` is an opinionated wrapper around the lower-level `MapShell`
|
|
98
|
-
component: it wires up every integration, every manager, and the full
|
|
99
|
-
|
|
126
|
+
component: it wires up every integration, every manager, and the full options toolbar and
|
|
127
|
+
legend. A host that only wants some of that — say, just the monitors integration, with no
|
|
100
128
|
EV stations/wind/HMS — should compose `MapShell` directly instead:
|
|
101
129
|
|
|
102
130
|
```svelte
|
|
103
131
|
<script>
|
|
104
|
-
import {
|
|
105
|
-
|
|
106
|
-
|
|
132
|
+
import {
|
|
133
|
+
MapShell,
|
|
134
|
+
MonitorsMenu,
|
|
135
|
+
OptionsGroup,
|
|
136
|
+
PollutantMenu,
|
|
137
|
+
monitorsMapIntegration,
|
|
138
|
+
monitorsManager
|
|
139
|
+
} from "@sjvair/monitor-map";
|
|
140
|
+
|
|
141
|
+
let failed = $state(false);
|
|
142
|
+
function load() {
|
|
143
|
+
failed = false;
|
|
144
|
+
monitorsManager.init().catch(() => (failed = true));
|
|
145
|
+
}
|
|
146
|
+
load();
|
|
107
147
|
</script>
|
|
108
148
|
|
|
109
149
|
<MapShell
|
|
@@ -113,13 +153,25 @@ EV stations/wind/HMS — should compose `MapShell` directly instead:
|
|
|
113
153
|
routerEscapeHatch={false}
|
|
114
154
|
>
|
|
115
155
|
{#snippet menu()}
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
156
|
+
<OptionsGroup>
|
|
157
|
+
<PollutantMenu />
|
|
158
|
+
<MonitorsMenu />
|
|
159
|
+
</OptionsGroup>
|
|
160
|
+
{/snippet}
|
|
161
|
+
{#snippet loadError()}
|
|
162
|
+
{#if failed}
|
|
163
|
+
<button onclick={load}>Couldn't load air monitors. Try again</button>
|
|
164
|
+
{/if}
|
|
119
165
|
{/snippet}
|
|
120
166
|
</MapShell>
|
|
121
167
|
```
|
|
122
168
|
|
|
169
|
+
The `menu` snippet renders inside the options toolbar; compose `OptionsGroup` with the
|
|
170
|
+
exported menus (`PollutantMenu`, `MonitorsMenu`, `LayersMenu`, `OverlaysMenu`,
|
|
171
|
+
`SettingsMenu`), or `OptionsMenu` with the row components for a custom data source. The
|
|
172
|
+
optional `loadError` snippet renders on the load screen, under the logo, which stays up
|
|
173
|
+
until `ready` is true; use it to show a failed initial load with a retry.
|
|
174
|
+
|
|
123
175
|
See `src/lib/MonitorMapLayout.svelte` for the reference example of wiring a manager, an
|
|
124
176
|
integration list, and menu/overlay content into `MapShell`.
|
|
125
177
|
|
|
@@ -197,13 +249,13 @@ npm run check # Type-check Svelte components
|
|
|
197
249
|
npm run check:watch # Type-check in watch mode
|
|
198
250
|
npm run lint # Prettier check + ESLint
|
|
199
251
|
npm run format # Auto-format with Prettier
|
|
252
|
+
npm test # Vitest (specs in tests/**)
|
|
253
|
+
npm run test:bundle # build:lib, then check the light entries keep maplibre off the eager path
|
|
200
254
|
```
|
|
201
255
|
|
|
202
|
-
No test framework is currently configured.
|
|
203
|
-
|
|
204
256
|
## Publishing
|
|
205
257
|
|
|
206
258
|
Publishing to npm (`@sjvair/monitor-map`) happens via GitHub Actions
|
|
207
259
|
(`.github/workflows/release-package.yml`) whenever a GitHub Release is published (or the
|
|
208
|
-
workflow is triggered manually).
|
|
209
|
-
release.
|
|
260
|
+
workflow is triggered manually). The workflow runs `npm test` and `npm run test:bundle`
|
|
261
|
+
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,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}
|
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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:
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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 || !
|
|
81
|
+
if (!monitor || !meta || !panel?.pollutant || !reading) return;
|
|
28
82
|
|
|
29
|
-
const entryType =
|
|
83
|
+
const entryType = meta.entryType(panel.pollutant);
|
|
30
84
|
const level: SJVAirEntryLevel | undefined = getCurrentLevel(
|
|
31
|
-
|
|
85
|
+
reading.value,
|
|
32
86
|
entryType.asIter.levels!
|
|
33
87
|
);
|
|
34
88
|
|
|
35
|
-
const updateDuration =
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
value={
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
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,19 @@
|
|
|
61
62
|
monitorsMapIntegration.enabled = false;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
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,16 @@
|
|
|
1
|
+
export type SignalOptions = {
|
|
2
|
+
signal?: AbortSignal;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* A signal that aborts as soon as any input does, with that input's reason. Already aborted if
|
|
6
|
+
* any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
|
|
7
|
+
*/
|
|
8
|
+
export declare function anySignal(...signals: Array<AbortSignal | undefined>): {
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Settles like `promise`, unless `signal` aborts first, in which case it rejects with
|
|
14
|
+
* `signal.reason`. The inner promise's later outcome is swallowed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A signal that aborts as soon as any input does, with that input's reason. Already aborted if
|
|
3
|
+
* any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
|
|
4
|
+
*/
|
|
5
|
+
export function anySignal(...signals) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const cleanups = [];
|
|
8
|
+
const dispose = () => {
|
|
9
|
+
for (const cleanup of cleanups.splice(0))
|
|
10
|
+
cleanup();
|
|
11
|
+
};
|
|
12
|
+
for (const input of signals) {
|
|
13
|
+
if (!input)
|
|
14
|
+
continue;
|
|
15
|
+
if (input.aborted) {
|
|
16
|
+
controller.abort(input.reason);
|
|
17
|
+
dispose();
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
const onAbort = () => {
|
|
21
|
+
controller.abort(input.reason);
|
|
22
|
+
dispose();
|
|
23
|
+
};
|
|
24
|
+
input.addEventListener("abort", onAbort, { once: true });
|
|
25
|
+
cleanups.push(() => input.removeEventListener("abort", onAbort));
|
|
26
|
+
}
|
|
27
|
+
return { signal: controller.signal, dispose };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Settles like `promise`, unless `signal` aborts first, in which case it rejects with
|
|
31
|
+
* `signal.reason`. The inner promise's later outcome is swallowed.
|
|
32
|
+
*/
|
|
33
|
+
export function untilAborted(promise, signal) {
|
|
34
|
+
if (!signal)
|
|
35
|
+
return promise;
|
|
36
|
+
if (signal.aborted) {
|
|
37
|
+
promise.catch(() => { });
|
|
38
|
+
return Promise.reject(signal.reason);
|
|
39
|
+
}
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const onAbort = () => reject(signal.reason);
|
|
42
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
43
|
+
promise.then((value) => {
|
|
44
|
+
signal.removeEventListener("abort", onAbort);
|
|
45
|
+
resolve(value);
|
|
46
|
+
}, (error) => {
|
|
47
|
+
signal.removeEventListener("abort", onAbort);
|
|
48
|
+
reject(error);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type CollocationSite } from "@sjvair/sdk";
|
|
2
|
+
import type { SignalOptions } from "../abort";
|
|
2
3
|
declare class CollocationSitesManager {
|
|
3
4
|
initialized: boolean;
|
|
4
5
|
collocationSites: Array<CollocationSite> | null;
|
|
5
|
-
init(): Promise<void>;
|
|
6
|
-
update(): Promise<void>;
|
|
6
|
+
init({ signal }?: SignalOptions): Promise<void>;
|
|
7
|
+
update({ signal }?: SignalOptions): Promise<void>;
|
|
7
8
|
}
|
|
8
9
|
export declare const collocationSitesManager: CollocationSitesManager;
|
|
9
10
|
export type { CollocationSitesManager };
|
|
@@ -2,16 +2,20 @@ import { getCollocationSites } from "@sjvair/sdk";
|
|
|
2
2
|
class CollocationSitesManager {
|
|
3
3
|
initialized = $state(false);
|
|
4
4
|
collocationSites = $state(null);
|
|
5
|
-
async init() {
|
|
5
|
+
async init({ signal } = {}) {
|
|
6
6
|
if (this.initialized)
|
|
7
7
|
return;
|
|
8
|
-
|
|
8
|
+
if (signal?.aborted)
|
|
9
|
+
throw signal.reason;
|
|
10
|
+
this.collocationSites = await getCollocationSites({ signal });
|
|
9
11
|
this.initialized = true;
|
|
10
12
|
}
|
|
11
|
-
async update() {
|
|
13
|
+
async update({ signal } = {}) {
|
|
12
14
|
if (!this.initialized)
|
|
13
15
|
return;
|
|
14
|
-
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
throw signal.reason;
|
|
18
|
+
this.collocationSites = await getCollocationSites({ signal });
|
|
15
19
|
}
|
|
16
20
|
}
|
|
17
21
|
export const collocationSitesManager = new CollocationSitesManager();
|