@sjvair/monitor-map 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +68 -16
  2. package/dist/lib/LazyMapLayout.svelte +53 -0
  3. package/dist/lib/LazyMapLayout.svelte.d.ts +7 -0
  4. package/dist/lib/LazyMonitorDetailPanel.svelte +33 -0
  5. package/dist/lib/LazyMonitorDetailPanel.svelte.d.ts +4 -0
  6. package/dist/lib/LoadError.svelte +19 -0
  7. package/dist/lib/LoadError.svelte.d.ts +7 -0
  8. package/dist/lib/LoadScreen.svelte +10 -1
  9. package/dist/lib/LoadScreen.svelte.d.ts +6 -16
  10. package/dist/lib/MonitorDetailPanel.svelte +108 -28
  11. package/dist/lib/MonitorMapLayout.svelte +19 -1
  12. package/dist/lib/abort.d.ts +16 -0
  13. package/dist/lib/abort.js +51 -0
  14. package/dist/lib/collocation-sites/collocations.svelte.d.ts +3 -2
  15. package/dist/lib/collocation-sites/collocations.svelte.js +8 -4
  16. package/dist/lib/core.d.ts +1 -1
  17. package/dist/lib/data-chart/DataChart.svelte +78 -13
  18. package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
  19. package/dist/lib/data-chart/DateRange.d.ts +4 -0
  20. package/dist/lib/data-chart/DateRange.js +9 -1
  21. package/dist/lib/data-chart/chart-utils.d.ts +2 -1
  22. package/dist/lib/data-chart/chart-utils.js +19 -23
  23. package/dist/lib/data-chart/level-colors.d.ts +6 -0
  24. package/dist/lib/data-chart/level-colors.js +5 -0
  25. package/dist/lib/data-chart/requests.d.ts +6 -2
  26. package/dist/lib/data-chart/requests.js +6 -4
  27. package/dist/lib/data-chart/tooltip.d.ts +2 -1
  28. package/dist/lib/data-chart/tooltip.js +1 -9
  29. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +4 -6
  30. package/dist/lib/ev-stations/ev-stations.svelte.d.ts +3 -2
  31. package/dist/lib/ev-stations/ev-stations.svelte.js +8 -6
  32. package/dist/lib/hms/hms.svelte.d.ts +4 -3
  33. package/dist/lib/hms/hms.svelte.js +16 -6
  34. package/dist/lib/index.d.ts +1 -1
  35. package/dist/lib/lazy-chunks.d.ts +2 -0
  36. package/dist/lib/lazy-chunks.js +6 -0
  37. package/dist/lib/lazy-routes.d.ts +6 -4
  38. package/dist/lib/lazy-routes.js +12 -7
  39. package/dist/lib/map/MapShell.svelte +7 -1
  40. package/dist/lib/map/MapShell.svelte.d.ts +5 -0
  41. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +13 -0
  42. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +30 -4
  43. package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
  44. package/dist/lib/map/map-style-state.svelte.js +56 -5
  45. package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  46. package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
  47. package/dist/lib/monitors/monitor-details.svelte.js +71 -0
  48. package/dist/lib/monitors/monitors-map-integration.svelte.js +4 -6
  49. package/dist/lib/monitors/monitors.svelte.d.ts +28 -5
  50. package/dist/lib/monitors/monitors.svelte.js +131 -31
  51. package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
  52. package/dist/lib/monitors/panel-pollutant.js +43 -0
  53. package/dist/lib/monitors/subscriptions.d.ts +3 -0
  54. package/dist/lib/monitors/subscriptions.js +9 -0
  55. package/dist/lib/options/OptionsBar.svelte +8 -1
  56. package/dist/lib/options/OptionsMenu.svelte +10 -1
  57. package/dist/lib/shared-load.d.ts +5 -0
  58. package/dist/lib/shared-load.js +14 -0
  59. package/package.json +2 -2
  60. package/src/lib/LazyMapLayout.svelte +53 -0
  61. package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
  62. package/src/lib/LoadError.svelte +19 -0
  63. package/src/lib/LoadScreen.svelte +10 -1
  64. package/src/lib/MonitorDetailPanel.svelte +108 -28
  65. package/src/lib/MonitorMapLayout.svelte +19 -1
  66. package/src/lib/abort.ts +59 -0
  67. package/src/lib/collocation-sites/collocations.svelte.ts +7 -4
  68. package/src/lib/core.ts +1 -1
  69. package/src/lib/data-chart/DataChart.svelte +78 -13
  70. package/src/lib/data-chart/DateRange.ts +11 -1
  71. package/src/lib/data-chart/chart-utils.ts +23 -26
  72. package/src/lib/data-chart/level-colors.ts +13 -0
  73. package/src/lib/data-chart/requests.ts +21 -10
  74. package/src/lib/data-chart/tooltip.ts +2 -10
  75. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +4 -4
  76. package/src/lib/ev-stations/ev-stations.svelte.ts +11 -6
  77. package/src/lib/hms/hms.svelte.ts +12 -6
  78. package/src/lib/index.ts +1 -1
  79. package/src/lib/lazy-chunks.ts +7 -0
  80. package/src/lib/lazy-routes.ts +12 -7
  81. package/src/lib/map/MapShell.svelte +7 -1
  82. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +32 -3
  83. package/src/lib/map/map-style-state.svelte.ts +58 -6
  84. package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  85. package/src/lib/monitors/monitor-details.svelte.ts +75 -0
  86. package/src/lib/monitors/monitors-map-integration.svelte.ts +4 -4
  87. package/src/lib/monitors/monitors.svelte.ts +161 -34
  88. package/src/lib/monitors/panel-pollutant.ts +76 -0
  89. package/src/lib/monitors/subscriptions.ts +17 -0
  90. package/src/lib/options/OptionsBar.svelte +8 -1
  91. package/src/lib/options/OptionsMenu.svelte +10 -1
  92. package/src/lib/shared-load.ts +15 -0
@@ -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 { monitorsManager } from "../monitors/monitors.svelte";
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 }: { monitor: MonitorLatestType<"pm25" | "o3"> } = $props();
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
- const noChartData = $derived(!chartManager.chartData.length);
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(monitorsManager.pollutant === "pm25" ? "PM 2.5" : "Ozone");
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
- if (monitor) loadChartData();
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,34 @@
103
142
  };
104
143
  };
105
144
 
106
- async function loadChartData() {
107
- chartManager.loading = true;
108
- chartManager.chartData = await fetchChartData(monitor, chartManager.dateRange);
145
+ let chartRequest = 0;
146
+ let chartController: AbortController | undefined;
147
+
148
+ // A closed panel cancels its in-flight fetch.
149
+ $effect(() => () => chartController?.abort());
150
+
151
+ // `silent` keeps the current chart on screen while a timed refresh loads.
152
+ async function loadChartData({ silent = false } = {}) {
153
+ const request = ++chartRequest;
154
+ chartController?.abort();
155
+ const controller = (chartController = new AbortController());
156
+ if (!silent) chartManager.loading = true;
157
+ let data: Awaited<ReturnType<typeof fetchChartData>>;
158
+ try {
159
+ data = await fetchChartData(monitor, chartManager.dateRange, pollutant, {
160
+ signal: controller.signal
161
+ });
162
+ } catch (err) {
163
+ // Superseded or closed: the newer call owns `loading` and the chart, so leave both.
164
+ if (controller.signal.aborted) return;
165
+ throw err;
166
+ }
167
+ // A newer request (another range, monitor, pollutant or refresh) owns the chart now.
168
+ if (request !== chartRequest) return;
169
+ // A failed request resolves to [] (an empty range is [[], []]); a background refresh
170
+ // that failed keeps the chart already on screen.
171
+ if (silent && data.length === 0) return;
172
+ chartManager.chartData = data;
109
173
  chartManager.loading = false;
110
174
  }
111
175
 
@@ -113,6 +177,7 @@
113
177
  if (calendarValue?.start && calendarValue?.end) {
114
178
  dataRange = calendarValue;
115
179
  chartManager.dateRange = createDateRange(dataRange.start, dataRange.end);
180
+ live = includesToday(chartManager.dateRange);
116
181
  calendarOpen = false;
117
182
  loadChartData();
118
183
  }
@@ -121,7 +186,7 @@
121
186
  function downloadCSV() {
122
187
  const url = getMonitorEntriesCSVUrl({
123
188
  monitorId: monitor.id,
124
- entryType: monitorsManager.pollutant ?? "pm25",
189
+ entryType: pollutant,
125
190
  timestampGte: chartManager.dateRange.start,
126
191
  timestampLte: chartManager.dateRange.end
127
192
  });
@@ -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 { monitorsManager } from "../monitors/monitors.svelte";
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
- const pm25SeriesConfig = {
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, getColors(), true)
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: monitorsManager.pollutant === "pm25" ? "PM 2.5" : "Ozone",
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 = Object.assign({}, pm25Avg60SeriesConfig);
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, getColors(), true);
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,18 +1,27 @@
1
- import { getMonitorEntries, type MonitorEntry, type MonitorLatestType } from "@sjvair/sdk";
1
+ import { getMonitorEntries, type MonitorDetails, type MonitorEntry } from "@sjvair/sdk";
2
2
  import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
3
- import { monitorsManager } from "../monitors/monitors.svelte";
3
+ import type { PanelPollutant } from "../monitors/panel-pollutant";
4
4
  import type { DateRange } from "./DateRange";
5
+ import type { SignalOptions } from "$lib/abort";
6
+
7
+ /** The monitor fields the chart reads. */
8
+ export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
5
9
 
6
10
  export async function fetchChartData(
7
- m: MonitorLatestType<"pm25" | "o3">,
8
- d: DateRange
11
+ m: ChartMonitor,
12
+ d: DateRange,
13
+ entryType: PanelPollutant,
14
+ options: SignalOptions = {}
9
15
  ): Promise<uPlot.AlignedData> {
10
- return await getMonitorEntries({
11
- entryType: monitorsManager.pollutant ?? "pm25",
12
- monitorId: m.id,
13
- timestampGte: d.start,
14
- timestampLte: d.end
15
- })
16
+ return await getMonitorEntries(
17
+ {
18
+ entryType,
19
+ monitorId: m.id,
20
+ timestampGte: d.start,
21
+ timestampLte: d.end
22
+ },
23
+ { signal: options.signal }
24
+ )
16
25
  .then((entries) => {
17
26
  const xAxisData: Array<Date> = [];
18
27
  const updateDuration = m.data_source.name === "PurpleAir" ? 2 : 60;
@@ -37,6 +46,8 @@ export async function fetchChartData(
37
46
  return [xAxisData.map((d) => getUnixTime(d)), yAxisRecord] as uPlot.AlignedData;
38
47
  })
39
48
  .catch((err) => {
49
+ // A cancelled request is not a failure: let the caller see the reason, log nothing.
50
+ if (options.signal?.aborted) throw options.signal.reason;
40
51
  console.error("Failed to load chart data: ", err);
41
52
  return [] as uPlot.AlignedData;
42
53
  });
@@ -1,12 +1,5 @@
1
1
  import { format, fromUnixTime } from "date-fns";
2
- import { monitorsManager } from "../monitors/monitors.svelte";
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
- this.icons.loadIcons().then(() => {
192
- if (mapManager.styleLoading) return;
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.icons.loadIcons().then(() => {
202
- if (mapManager.styleLoading) return;
202
+ this.whenIconsLoad(() => {
203
203
  clickManager.register([this.referenceId], this.handleStationClick);
204
204
  this.tooltipManager.enable();
205
205
  });
@@ -5,6 +5,7 @@ import {
5
5
  evNetworkTypes,
6
6
  facilityTypes
7
7
  } from "./data-maps";
8
+ import type { SignalOptions } from "$lib/abort";
8
9
  import type { EvStation } from "./types";
9
10
  import * as ZipCodes from "./zip-codes";
10
11
 
@@ -12,12 +13,12 @@ class EvStationsManager {
12
13
  lvl2Stations: Array<EvStation> | undefined = $state();
13
14
  lvl3Stations: Array<EvStation> | undefined = $state();
14
15
 
15
- async loadLvl3Stations(): Promise<void> {
16
- this.lvl3Stations = await fetchEvStations("dc_fast");
16
+ async loadLvl3Stations({ signal }: SignalOptions = {}): Promise<void> {
17
+ this.lvl3Stations = await fetchEvStations("dc_fast", signal);
17
18
  }
18
19
 
19
- async loadLvl2Stations(): Promise<void> {
20
- this.lvl2Stations = await fetchEvStations("2");
20
+ async loadLvl2Stations({ signal }: SignalOptions = {}): Promise<void> {
21
+ this.lvl2Stations = await fetchEvStations("2", signal);
21
22
  }
22
23
  }
23
24
 
@@ -25,7 +26,11 @@ const zipCodes = Object.values(ZipCodes)
25
26
  .reduce((prev, curr) => prev.concat(curr))
26
27
  .join(",");
27
28
 
28
- async function fetchEvStations(chargingLevel: "2" | "dc_fast"): Promise<Array<EvStation>> {
29
+ async function fetchEvStations(
30
+ chargingLevel: "2" | "dc_fast",
31
+ signal?: AbortSignal
32
+ ): Promise<Array<EvStation>> {
33
+ if (signal?.aborted) throw signal.reason;
29
34
  const url = new URL(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json`);
30
35
  url.searchParams.set("fuel_type", "ELEC");
31
36
  url.searchParams.set("state", "CA");
@@ -35,7 +40,7 @@ async function fetchEvStations(chargingLevel: "2" | "dc_fast"): Promise<Array<Ev
35
40
  url.searchParams.set("zip", zipCodes);
36
41
  url.searchParams.set("api_key", import.meta.env.VITE_NREL_KEY);
37
42
 
38
- return await fetch(url)
43
+ return await fetch(url, { signal })
39
44
  .then(async (res) => await res.json())
40
45
  .then((data) => {
41
46
  const fuelStations: Array<EvStation> = data.fuel_stations!;
@@ -4,6 +4,7 @@ import {
4
4
  type HMSFireGeoJSON,
5
5
  type HMSSmokeGeoJSON
6
6
  } from "@sjvair/sdk/hms";
7
+ import type { SignalOptions } from "$lib/abort";
7
8
  import clustersDbscan from "@turf/clusters-dbscan";
8
9
  import { featureCollection, point } from "@turf/helpers";
9
10
 
@@ -22,22 +23,27 @@ class HmsManager {
22
23
  fire: Array<HMSFireGeoJSON & { frp: number }> | undefined = $state();
23
24
  fireGroups: Array<HMSFireGroup> | undefined = $state();
24
25
 
25
- async init() {
26
- await Promise.allSettled([this.loadSmoke(), this.loadFire()]);
26
+ async init({ signal }: SignalOptions = {}) {
27
+ if (signal?.aborted) throw signal.reason;
28
+ await Promise.allSettled([this.loadSmoke({ signal }), this.loadFire({ signal })]);
29
+ if (signal?.aborted) throw signal.reason;
27
30
  }
28
31
 
29
- async loadSmoke() {
30
- this.smoke = await getHMSSmoke();
32
+ async loadSmoke({ signal }: SignalOptions = {}) {
33
+ if (signal?.aborted) throw signal.reason;
34
+ this.smoke = await getHMSSmoke({ signal });
31
35
  }
32
36
 
33
- async loadFire() {
37
+ async loadFire({ signal }: SignalOptions = {}) {
34
38
  try {
35
- const raw = await getHMSFire();
39
+ if (signal?.aborted) throw signal.reason;
40
+ const raw = await getHMSFire({ signal });
36
41
  const filtered = raw.filter((d): d is HMSFireGeoJSON & { frp: number } => d.frp !== null);
37
42
  const deduped = deduplicateByCoordinates(filtered);
38
43
  this.fire = deduped;
39
44
  this.fireGroups = computeFireGroups(deduped);
40
45
  } catch (err) {
46
+ if (signal?.aborted) throw err;
41
47
  console.error("HMS fire load failed:", err);
42
48
  }
43
49
  }
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"));
@@ -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 every route load on demand, so a host router can
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
- * Each component must stay a literal `() => import(...)`: sv-router recognises lazy
9
- * components by matching the function's source text, so a helper wrapper would break it.
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: () => import("./MonitorMapLayout.svelte"),
13
- "/": () => import("./MainMapView.svelte"),
14
- "/monitor/:id": () => import("./MonitorDetailPanel.svelte")
17
+ layout: LazyMapLayout,
18
+ "/": MainMapView,
19
+ "/monitor/:id": LazyMonitorDetailPanel
15
20
  };
@@ -11,6 +11,11 @@
11
11
  * is true.
12
12
  */
13
13
  ready: boolean;
14
+ /**
15
+ * Rendered on the load screen, under the logo, for as long as the load screen is up.
16
+ * Render it only when the caller's initial load failed (e.g. a message and a retry).
17
+ */
18
+ loadError?: Snippet;
14
19
  /** True when the routed detail panel (`children`) should be visible */
15
20
  panelOpen: boolean;
16
21
  /**
@@ -59,6 +64,7 @@
59
64
  let {
60
65
  integrations,
61
66
  ready,
67
+ loadError,
62
68
  panelOpen,
63
69
  knownRoutes = [],
64
70
  menu,
@@ -123,7 +129,7 @@
123
129
  </script>
124
130
 
125
131
  <div class="relative flex h-full w-full flex-col md:flex-row">
126
- <LoadScreen />
132
+ <LoadScreen error={loadError} />
127
133
  <div class="relative flex-1 overflow-hidden">
128
134
  <Map {integrations} />
129
135
  {@render overlays?.()}
@@ -33,16 +33,45 @@ export abstract class MapIconLayerIntegration<
33
33
  T = Record<string, unknown>
34
34
  > extends MapGeoJSONIntegration<T> {
35
35
  abstract icons: MapIconManager;
36
+ private generation = 0;
37
+
38
+ /**
39
+ * Drops every callback still waiting on icons. `apply()` and `remove()` call it, so a
40
+ * subclass overriding `apply()` without calling `super.apply()` first must call it itself.
41
+ */
42
+ protected supersedePending(): void {
43
+ this.generation++;
44
+ }
45
+
46
+ /**
47
+ * Runs `run` once the icons have loaded, unless an `apply()` or `remove()` has happened
48
+ * since (the state it was scheduled for is gone) or a basemap change is in flight (the
49
+ * refresh on "style.load" re-applies).
50
+ */
51
+ protected whenIconsLoad(run: () => void): void {
52
+ const generation = this.generation;
53
+ this.icons
54
+ .loadIcons()
55
+ .then(() => {
56
+ if (generation !== this.generation || mapManager.styleLoading) return;
57
+ run();
58
+ })
59
+ .catch(console.error);
60
+ }
36
61
 
37
62
  apply() {
38
63
  if (!mapManager.map) return;
39
64
 
40
- this.icons.loadIcons().then(() => {
41
- // A basemap change started while icons loaded; the refresh on "style.load" re-applies.
42
- if (mapManager.styleLoading) return;
65
+ this.supersedePending();
66
+ this.whenIconsLoad(() => {
43
67
  this.removeLayerAndSource();
44
68
  mapManager.map?.addSource(this.referenceId, this.mapSource);
45
69
  super.apply();
46
70
  });
47
71
  }
72
+
73
+ remove() {
74
+ this.supersedePending();
75
+ super.remove();
76
+ }
48
77
  }