@sjvair/monitor-map 4.1.0 → 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.
- package/README.md +54 -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/core.d.ts +1 -1
- package/dist/lib/data-chart/DataChart.svelte +63 -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 +5 -2
- package/dist/lib/data-chart/requests.js +2 -3
- 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/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/monitor-details.svelte.d.ts +15 -0
- package/dist/lib/monitors/monitor-details.svelte.js +63 -0
- package/dist/lib/monitors/monitors-map-integration.svelte.js +4 -6
- package/dist/lib/monitors/monitors.svelte.d.ts +14 -4
- package/dist/lib/monitors/monitors.svelte.js +13 -5
- package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
- package/dist/lib/monitors/panel-pollutant.js +43 -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 +1 -1
- 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/core.ts +1 -1
- package/src/lib/data-chart/DataChart.svelte +63 -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 +9 -5
- 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/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/monitor-details.svelte.ts +66 -0
- package/src/lib/monitors/monitors-map-integration.svelte.ts +4 -4
- package/src/lib/monitors/monitors.svelte.ts +20 -5
- package/src/lib/monitors/panel-pollutant.ts +76 -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
|
@@ -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 "$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
|
-
|
|
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 "$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
|
-
|
|
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 />
|
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";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
2
3
|
import type { Attachment } from "svelte/attachments";
|
|
3
|
-
import type { MonitorLatestType } from "@sjvair/sdk";
|
|
4
4
|
import type { DateRange as CalendarRange } from "bits-ui";
|
|
5
5
|
import { parseAbsolute } from "@internationalized/date";
|
|
6
6
|
import { Maximize, SquareX } from "@lucide/svelte";
|
|
@@ -9,13 +9,23 @@
|
|
|
9
9
|
import { getMonitorEntriesCSVUrl } from "@sjvair/sdk/monitors";
|
|
10
10
|
import { format, parseISO } from "date-fns";
|
|
11
11
|
import { RangeCalendar } from "$lib/components/ui/range-calendar";
|
|
12
|
+
import type { PanelPollutant } from "../monitors/panel-pollutant";
|
|
13
|
+
import { monitorsManager } from "../monitors/monitors.svelte";
|
|
12
14
|
import { DataChartManager } from "./data-chart.svelte";
|
|
13
|
-
import { createDateRange } from "./DateRange";
|
|
14
|
-
import { fetchChartData } from "./requests";
|
|
15
|
+
import { createDateRange, extendToNow, includesToday } from "./DateRange";
|
|
16
|
+
import { fetchChartData, type ChartMonitor } from "./requests";
|
|
15
17
|
import { getChartConfig } from "./chart-utils";
|
|
16
|
-
import {
|
|
18
|
+
import { levelColors } from "./level-colors";
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
monitor: ChartMonitor;
|
|
22
|
+
/** The pollutant to chart. Independent of the pollutant the map is coloring by. */
|
|
23
|
+
pollutant: PanelPollutant;
|
|
24
|
+
/** Bumped by the owner whenever fresh data may exist; see the effect below. */
|
|
25
|
+
refreshTick?: number;
|
|
26
|
+
}
|
|
17
27
|
|
|
18
|
-
let { monitor
|
|
28
|
+
let { monitor, pollutant, refreshTick = 0 }: Props = $props();
|
|
19
29
|
|
|
20
30
|
const chartManager = new DataChartManager();
|
|
21
31
|
let expanded = $state(false);
|
|
@@ -34,7 +44,8 @@
|
|
|
34
44
|
format(parseISO(chartManager.dateRange.end), "MMM d, yyyy")
|
|
35
45
|
);
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
// A request with no entries still resolves to [[], []], so test the x axis, not the tuple.
|
|
48
|
+
const noChartData = $derived(!chartManager.chartData[0]?.length);
|
|
38
49
|
const message = $derived(
|
|
39
50
|
chartManager.loading
|
|
40
51
|
? "Loading Data"
|
|
@@ -42,10 +53,38 @@
|
|
|
42
53
|
? "No Data Available For Selected Date Range"
|
|
43
54
|
: ""
|
|
44
55
|
);
|
|
45
|
-
const pollutantLabel = $derived(
|
|
56
|
+
const pollutantLabel = $derived(pollutant === "pm25" ? "PM 2.5" : "Ozone");
|
|
57
|
+
// Level definitions are shared metadata, not the map's current selection.
|
|
58
|
+
const colors = $derived(
|
|
59
|
+
levelColors(monitorsManager.meta?.entryType(pollutant).asIter.levels ?? [])
|
|
60
|
+
);
|
|
46
61
|
|
|
62
|
+
// Reload for another monitor or pollutant, not for a new object describing the same one.
|
|
63
|
+
// Both go through a $derived: the owner recomputes what it passes on every refresh, and a
|
|
64
|
+
// prop read directly would re-run this effect even when the value is unchanged.
|
|
65
|
+
const monitorId = $derived(monitor.id);
|
|
66
|
+
const chartPollutant = $derived(pollutant);
|
|
47
67
|
$effect(() => {
|
|
48
|
-
|
|
68
|
+
void monitorId;
|
|
69
|
+
void chartPollutant;
|
|
70
|
+
untrack(() => loadChartData());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Whether the chosen range ended on the day it was chosen, so it should follow the present.
|
|
74
|
+
// Remembered, not re-tested per tick: a panel left open past midnight keeps following.
|
|
75
|
+
let live = includesToday(chartManager.dateRange);
|
|
76
|
+
let seenTick = untrack(() => refreshTick);
|
|
77
|
+
|
|
78
|
+
// A range that ended in the past cannot gain entries, so only a live one reloads.
|
|
79
|
+
$effect(() => {
|
|
80
|
+
const tick = refreshTick;
|
|
81
|
+
if (tick === seenTick) return;
|
|
82
|
+
seenTick = tick;
|
|
83
|
+
if (!live) return;
|
|
84
|
+
untrack(() => {
|
|
85
|
+
chartManager.dateRange = extendToNow(chartManager.dateRange);
|
|
86
|
+
loadChartData({ silent: true });
|
|
87
|
+
});
|
|
49
88
|
});
|
|
50
89
|
|
|
51
90
|
$effect(() => {
|
|
@@ -82,7 +121,7 @@
|
|
|
82
121
|
instance?.destroy?.();
|
|
83
122
|
el.innerHTML = "";
|
|
84
123
|
const maxDiff = Math.max(...flatData) - Math.min(...flatData);
|
|
85
|
-
const opts = getChartConfig(monitor.type, maxDiff, width, height);
|
|
124
|
+
const opts = getChartConfig(monitor.type, maxDiff, width, height, colors, pollutantLabel);
|
|
86
125
|
instance = new uPlot(opts, chartManager.chartData, el);
|
|
87
126
|
};
|
|
88
127
|
|
|
@@ -103,9 +142,19 @@
|
|
|
103
142
|
};
|
|
104
143
|
};
|
|
105
144
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
let chartRequest = 0;
|
|
146
|
+
|
|
147
|
+
// `silent` keeps the current chart on screen while a timed refresh loads.
|
|
148
|
+
async function loadChartData({ silent = false } = {}) {
|
|
149
|
+
const request = ++chartRequest;
|
|
150
|
+
if (!silent) chartManager.loading = true;
|
|
151
|
+
const data = await fetchChartData(monitor, chartManager.dateRange, pollutant);
|
|
152
|
+
// A newer request (another range, monitor, pollutant or refresh) owns the chart now.
|
|
153
|
+
if (request !== chartRequest) return;
|
|
154
|
+
// A failed request resolves to [] (an empty range is [[], []]); a background refresh
|
|
155
|
+
// that failed keeps the chart already on screen.
|
|
156
|
+
if (silent && data.length === 0) return;
|
|
157
|
+
chartManager.chartData = data;
|
|
109
158
|
chartManager.loading = false;
|
|
110
159
|
}
|
|
111
160
|
|
|
@@ -113,6 +162,7 @@
|
|
|
113
162
|
if (calendarValue?.start && calendarValue?.end) {
|
|
114
163
|
dataRange = calendarValue;
|
|
115
164
|
chartManager.dateRange = createDateRange(dataRange.start, dataRange.end);
|
|
165
|
+
live = includesToday(chartManager.dateRange);
|
|
116
166
|
calendarOpen = false;
|
|
117
167
|
loadChartData();
|
|
118
168
|
}
|
|
@@ -121,7 +171,7 @@
|
|
|
121
171
|
function downloadCSV() {
|
|
122
172
|
const url = getMonitorEntriesCSVUrl({
|
|
123
173
|
monitorId: monitor.id,
|
|
124
|
-
entryType:
|
|
174
|
+
entryType: pollutant,
|
|
125
175
|
timestampGte: chartManager.dateRange.start,
|
|
126
176
|
timestampLte: chartManager.dateRange.end
|
|
127
177
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { subDays, startOfDay, endOfDay, isToday } from "date-fns";
|
|
1
|
+
import { subDays, startOfDay, endOfDay, isToday, parseISO } from "date-fns";
|
|
2
2
|
import type { DateValue } from "@internationalized/date";
|
|
3
3
|
|
|
4
4
|
export type DateRange = { start: string; end: string };
|
|
@@ -20,3 +20,13 @@ export function createDateRange(start?: DateValue, end?: DateValue): DateRange {
|
|
|
20
20
|
end: endDate.toISOString()
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/** Whether the range ends on the current calendar day, i.e. it can still gain entries. */
|
|
25
|
+
export function includesToday(range: DateRange): boolean {
|
|
26
|
+
return isToday(parseISO(range.end));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The same range with its end moved to the present. */
|
|
30
|
+
export function extendToNow(range: DateRange): DateRange {
|
|
31
|
+
return { start: range.start, end: new Date().toISOString() };
|
|
32
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { LevelColors } from "./level-colors";
|
|
2
2
|
import { tooltipsPlugin, uPlotCursorConfig } from "./tooltip";
|
|
3
3
|
import { fromUnixTime, getUnixTime, isToday, isAfter } from "date-fns";
|
|
4
4
|
import type uPlot from "uplot";
|
|
@@ -6,15 +6,6 @@ import type { MonitorType } from "@sjvair/sdk";
|
|
|
6
6
|
|
|
7
7
|
const PM25_AVG_60_STROKE = "3572b0";
|
|
8
8
|
|
|
9
|
-
function getColors(): Array<[number, string]> {
|
|
10
|
-
const levels = monitorsManager.levels ?? [];
|
|
11
|
-
const colors = levels.map((level) => [level.range[0], level.color.replace(/^#/, "")]) as Array<
|
|
12
|
-
[number, string]
|
|
13
|
-
>;
|
|
14
|
-
colors.push([Infinity, "000000"]);
|
|
15
|
-
return colors;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
9
|
const baseXSeriesConfig = {};
|
|
19
10
|
|
|
20
11
|
const baseYSeriesConfig = {
|
|
@@ -23,26 +14,33 @@ const baseYSeriesConfig = {
|
|
|
23
14
|
width: 1
|
|
24
15
|
};
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
/** The level stops plus a final black stop, so values above the top level still get a color. */
|
|
18
|
+
function gradientStops(colors: LevelColors): LevelColors {
|
|
19
|
+
return [...colors, [Infinity, "000000"]];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pm25SeriesConfig = (stops: LevelColors) => ({
|
|
27
23
|
...baseYSeriesConfig,
|
|
28
24
|
label: "Real Time",
|
|
29
25
|
width: 2,
|
|
30
|
-
stroke: (u: uPlot) => scaleGradient(u, "y", 1,
|
|
31
|
-
};
|
|
26
|
+
stroke: (u: uPlot) => scaleGradient(u, "y", 1, stops, true)
|
|
27
|
+
});
|
|
32
28
|
|
|
33
|
-
const pm25Avg60SeriesConfig = {
|
|
29
|
+
const pm25Avg60SeriesConfig = (stops: LevelColors) => ({
|
|
34
30
|
...baseYSeriesConfig,
|
|
35
31
|
label: "60 Minute Average",
|
|
36
|
-
stroke: (u: uPlot) => autograd(u, PM25_AVG_60_STROKE)
|
|
37
|
-
};
|
|
32
|
+
stroke: (u: uPlot) => autograd(u, PM25_AVG_60_STROKE, stops)
|
|
33
|
+
});
|
|
38
34
|
|
|
39
35
|
export function getChartConfig(
|
|
40
36
|
sourceName: MonitorType,
|
|
41
37
|
maxDiff: number,
|
|
42
38
|
width: number,
|
|
43
|
-
height: number
|
|
39
|
+
height: number,
|
|
40
|
+
colors: LevelColors,
|
|
41
|
+
label: string
|
|
44
42
|
): uPlot.Options {
|
|
45
|
-
const series: Array<uPlot.Series> = getSeriesConfigs(sourceName);
|
|
43
|
+
const series: Array<uPlot.Series> = getSeriesConfigs(sourceName, gradientStops(colors));
|
|
46
44
|
const getStroke = (u: uPlot, sIdx: number): string => {
|
|
47
45
|
const stroke = series[sIdx].stroke! as (u: uPlot, sIdx: number) => string | CanvasGradient;
|
|
48
46
|
return stroke(u, sIdx) as string;
|
|
@@ -51,7 +49,7 @@ export function getChartConfig(
|
|
|
51
49
|
return {
|
|
52
50
|
width,
|
|
53
51
|
height,
|
|
54
|
-
cursor: uPlotCursorConfig.get(),
|
|
52
|
+
cursor: uPlotCursorConfig.get(colors),
|
|
55
53
|
// @ts-expect-error: select missing pseudo "required" options
|
|
56
54
|
select: {
|
|
57
55
|
show: false
|
|
@@ -94,7 +92,7 @@ export function getChartConfig(
|
|
|
94
92
|
}
|
|
95
93
|
},
|
|
96
94
|
{
|
|
97
|
-
label
|
|
95
|
+
label,
|
|
98
96
|
side: 3,
|
|
99
97
|
values: (_: uPlot, ticks: number[]) =>
|
|
100
98
|
ticks.map((v: number) => v.toFixed(maxDiff < 5 ? 1 : 0))
|
|
@@ -106,9 +104,8 @@ export function getChartConfig(
|
|
|
106
104
|
};
|
|
107
105
|
}
|
|
108
106
|
|
|
109
|
-
function getSeriesConfigs(monitorType: MonitorType) {
|
|
110
|
-
const singleSeriesConfig =
|
|
111
|
-
singleSeriesConfig.width = 2;
|
|
107
|
+
function getSeriesConfigs(monitorType: MonitorType, stops: LevelColors) {
|
|
108
|
+
const singleSeriesConfig = { ...pm25Avg60SeriesConfig(stops), width: 2 };
|
|
112
109
|
|
|
113
110
|
switch (monitorType) {
|
|
114
111
|
case "airnow":
|
|
@@ -120,15 +117,15 @@ function getSeriesConfigs(monitorType: MonitorType) {
|
|
|
120
117
|
|
|
121
118
|
case "airgradient":
|
|
122
119
|
case "purpleair":
|
|
123
|
-
return [baseXSeriesConfig, pm25SeriesConfig];
|
|
120
|
+
return [baseXSeriesConfig, pm25SeriesConfig(stops)];
|
|
124
121
|
}
|
|
125
122
|
}
|
|
126
123
|
|
|
127
|
-
function autograd(u: uPlot, fallback: string) {
|
|
124
|
+
function autograd(u: uPlot, fallback: string, stops: LevelColors) {
|
|
128
125
|
if (u.series.length > 2) {
|
|
129
126
|
return fallback;
|
|
130
127
|
}
|
|
131
|
-
return scaleGradient(u, "y", 1,
|
|
128
|
+
return scaleGradient(u, "y", 1, stops, true);
|
|
132
129
|
}
|
|
133
130
|
|
|
134
131
|
function scaleGradient(
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Color stops for a chart: each level's lower bound and its hex color without the `#`. */
|
|
2
|
+
export type LevelColors = Array<[number, string]>;
|
|
3
|
+
|
|
4
|
+
// `range` is loose on purpose: the SDK types it as a tuple with an open tail, and only its
|
|
5
|
+
// first member (the level's lower bound) is read.
|
|
6
|
+
export function levelColors(
|
|
7
|
+
levels: ReadonlyArray<{
|
|
8
|
+
range: readonly [number, ...unknown[]] | readonly number[];
|
|
9
|
+
color: string;
|
|
10
|
+
}>
|
|
11
|
+
): LevelColors {
|
|
12
|
+
return levels.map((level) => [level.range[0], level.color.replace(/^#/, "")]);
|
|
13
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { getMonitorEntries, type
|
|
1
|
+
import { getMonitorEntries, type MonitorDetails, type MonitorEntry } from "@sjvair/sdk";
|
|
2
2
|
import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
|
|
3
|
-
import {
|
|
3
|
+
import type { PanelPollutant } from "../monitors/panel-pollutant";
|
|
4
4
|
import type { DateRange } from "./DateRange";
|
|
5
5
|
|
|
6
|
+
/** The monitor fields the chart reads. */
|
|
7
|
+
export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
|
|
8
|
+
|
|
6
9
|
export async function fetchChartData(
|
|
7
|
-
m:
|
|
8
|
-
d: DateRange
|
|
10
|
+
m: ChartMonitor,
|
|
11
|
+
d: DateRange,
|
|
12
|
+
entryType: PanelPollutant
|
|
9
13
|
): Promise<uPlot.AlignedData> {
|
|
10
14
|
return await getMonitorEntries({
|
|
11
|
-
entryType
|
|
15
|
+
entryType,
|
|
12
16
|
monitorId: m.id,
|
|
13
17
|
timestampGte: d.start,
|
|
14
18
|
timestampLte: d.end
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { format, fromUnixTime } from "date-fns";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
function getColors(): Array<[number, string]> {
|
|
5
|
-
return (monitorsManager.levels ?? []).map((level) => [
|
|
6
|
-
level.range[0],
|
|
7
|
-
level.color.replace(/^#/, "")
|
|
8
|
-
]) as Array<[number, string]>;
|
|
9
|
-
}
|
|
2
|
+
import type { LevelColors } from "./level-colors";
|
|
10
3
|
|
|
11
4
|
let cursLeft = -100;
|
|
12
5
|
let cursTop = -100;
|
|
@@ -16,7 +9,7 @@ export const uPlotCursorConfig = {
|
|
|
16
9
|
cursLeft = left;
|
|
17
10
|
cursTop = top;
|
|
18
11
|
},
|
|
19
|
-
get: (): uPlot.Cursor => ({
|
|
12
|
+
get: (colors: LevelColors): uPlot.Cursor => ({
|
|
20
13
|
left: cursLeft,
|
|
21
14
|
top: cursTop,
|
|
22
15
|
y: false,
|
|
@@ -24,7 +17,6 @@ export const uPlotCursorConfig = {
|
|
|
24
17
|
size: 7,
|
|
25
18
|
fill: (u: uPlot, sIdx: number) => {
|
|
26
19
|
const xIdx = u.cursor.idxs![sIdx];
|
|
27
|
-
const colors = getColors();
|
|
28
20
|
|
|
29
21
|
if (xIdx != null && u.series.length >= 2 && sIdx === 1) {
|
|
30
22
|
const val = u.data[sIdx][xIdx]!;
|
|
@@ -188,8 +188,9 @@ class EvStationsMapIntegration
|
|
|
188
188
|
clickManager.unregister([this.referenceId]);
|
|
189
189
|
this.tooltipManager.disable();
|
|
190
190
|
this.renderer.remove();
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
// Clustering may be toggled again before the icons arrive; only the latest apply runs.
|
|
192
|
+
this.supersedePending();
|
|
193
|
+
this.whenIconsLoad(() => {
|
|
193
194
|
this.renderer.apply(this.handleStationClick);
|
|
194
195
|
this.tooltipManager.enable();
|
|
195
196
|
});
|
|
@@ -198,8 +199,7 @@ class EvStationsMapIntegration
|
|
|
198
199
|
this.tooltipManager.disable();
|
|
199
200
|
clickManager.unregister([this.referenceId]);
|
|
200
201
|
super.apply();
|
|
201
|
-
this.
|
|
202
|
-
if (mapManager.styleLoading) return;
|
|
202
|
+
this.whenIconsLoad(() => {
|
|
203
203
|
clickManager.register([this.referenceId], this.handleStationClick);
|
|
204
204
|
this.tooltipManager.enable();
|
|
205
205
|
});
|
package/src/lib/index.ts
CHANGED
|
@@ -39,7 +39,7 @@ export {
|
|
|
39
39
|
|
|
40
40
|
// State managers
|
|
41
41
|
export { monitorsManager } from "./monitors/monitors.svelte";
|
|
42
|
-
export type { MonitorsManager } from "./monitors/monitors.svelte";
|
|
42
|
+
export type { MonitorsManager, MonitorsInitOptions } from "./monitors/monitors.svelte";
|
|
43
43
|
export { collocationSitesManager } from "./collocation-sites/collocations.svelte";
|
|
44
44
|
export type { CollocationSitesManager } from "./collocation-sites/collocations.svelte";
|
|
45
45
|
export { evStationsManager } from "./ev-stations/ev-stations.svelte";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { sharedLoad } from "./shared-load";
|
|
2
|
+
|
|
3
|
+
// The heavy chunks behind the lazy routes entry (`./lazy-routes.ts`). They are imported here,
|
|
4
|
+
// not in the wrapper components that await them: sv-router treats any function whose source
|
|
5
|
+
// contains `() => import(...)` as a lazy route, and a compiled component is such a function.
|
|
6
|
+
export const loadMonitorMapLayout = sharedLoad(() => import("./MonitorMapLayout.svelte"));
|
|
7
|
+
export const loadMonitorDetailPanel = sharedLoad(() => import("./MonitorDetailPanel.svelte"));
|
package/src/lib/lazy-routes.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import type { Routes } from "sv-router";
|
|
2
|
+
import LazyMapLayout from "./LazyMapLayout.svelte";
|
|
3
|
+
import LazyMonitorDetailPanel from "./LazyMonitorDetailPanel.svelte";
|
|
4
|
+
import MainMapView from "./MainMapView.svelte";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Lazy routes entry (`@sjvair/monitor-map/routes`): the same route tree as the main entry's
|
|
5
|
-
* `monitorMapRoutes`, but the layout and
|
|
6
|
-
* reference it at startup without loading the map.
|
|
8
|
+
* `monitorMapRoutes`, but the map layout and the monitor panel load on demand, so a host
|
|
9
|
+
* router can reference it at startup without loading the map.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
11
|
+
* The components here are light wrappers that load the real ones themselves
|
|
12
|
+
* (`./lazy-chunks.ts`) and show the load screen meanwhile. They are deliberately not
|
|
13
|
+
* `() => import(...)` routes: sv-router renders nothing for a route until every lazy component
|
|
14
|
+
* on it has loaded, which left the outlet empty on a cold link into the map.
|
|
10
15
|
*/
|
|
11
16
|
export const monitorMapRoutes: Routes = {
|
|
12
|
-
layout:
|
|
13
|
-
"/":
|
|
14
|
-
"/monitor/:id":
|
|
17
|
+
layout: LazyMapLayout,
|
|
18
|
+
"/": MainMapView,
|
|
19
|
+
"/monitor/:id": LazyMonitorDetailPanel
|
|
15
20
|
};
|