@sjvair/monitor-map 4.2.0 → 5.0.0-beta.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 (265) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -126
  3. package/dist/lib/MonitorDetailPanel.svelte +23 -20
  4. package/dist/lib/MonitorMapLayout.svelte +147 -77
  5. package/dist/lib/abort.d.ts +16 -0
  6. package/dist/lib/abort.js +51 -0
  7. package/dist/lib/components/DataBox.svelte +4 -1
  8. package/dist/lib/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
  9. package/dist/lib/config.svelte.d.ts +7 -0
  10. package/dist/lib/config.svelte.js +10 -0
  11. package/dist/lib/core.d.ts +2 -2
  12. package/dist/lib/core.js +2 -1
  13. package/dist/lib/data-chart/DataChart.svelte +54 -24
  14. package/dist/lib/data-chart/DateRange.d.ts +13 -4
  15. package/dist/lib/data-chart/DateRange.js +50 -14
  16. package/dist/lib/data-chart/chart-utils.js +5 -7
  17. package/dist/lib/data-chart/level-colors.d.ts +3 -4
  18. package/dist/lib/data-chart/level-colors.js +7 -4
  19. package/dist/lib/data-chart/pacific-time.d.ts +9 -0
  20. package/dist/lib/data-chart/pacific-time.js +25 -0
  21. package/dist/lib/data-chart/requests.d.ts +2 -1
  22. package/dist/lib/data-chart/requests.js +5 -2
  23. package/dist/lib/data-chart/tooltip.js +2 -2
  24. package/{src/lib/ev-stations/components/EvStationTooltip.svelte → dist/lib/ev-stations/EvPopup.svelte} +6 -5
  25. package/dist/lib/ev-stations/EvPopup.svelte.d.ts +7 -0
  26. package/dist/lib/ev-stations/ev-plugin.svelte.d.ts +14 -0
  27. package/dist/lib/ev-stations/ev-plugin.svelte.js +71 -0
  28. package/dist/lib/ev-stations/ev-station-icons.d.ts +1 -4
  29. package/dist/lib/ev-stations/ev-station-icons.js +1 -13
  30. package/dist/lib/ev-stations/ev-stations.d.ts +7 -0
  31. package/dist/lib/ev-stations/ev-stations.js +53 -0
  32. package/dist/lib/ev-stations/ev-store.svelte.d.ts +21 -0
  33. package/dist/lib/ev-stations/ev-store.svelte.js +83 -0
  34. package/dist/lib/ev-stations/station-popup.svelte.d.ts +14 -0
  35. package/dist/lib/ev-stations/station-popup.svelte.js +54 -0
  36. package/dist/lib/ev-stations/types.d.ts +0 -29
  37. package/dist/lib/ev-stations/zip-codes.d.ts +11 -0
  38. package/dist/lib/ev-stations/zip-codes.js +28 -16
  39. package/dist/lib/index.d.ts +8 -56
  40. package/dist/lib/index.js +7 -46
  41. package/dist/lib/legend/Legend.svelte +18 -19
  42. package/dist/lib/legend/Legend.svelte.d.ts +2 -2
  43. package/dist/lib/levels.d.ts +7 -0
  44. package/dist/lib/levels.js +19 -0
  45. package/dist/lib/map/MapShell.svelte +17 -24
  46. package/dist/lib/map/MapShell.svelte.d.ts +4 -4
  47. package/dist/lib/map/basemaps.d.ts +25 -0
  48. package/dist/lib/map/basemaps.js +66 -0
  49. package/dist/lib/map/live-map.svelte.d.ts +3 -0
  50. package/dist/lib/map/live-map.svelte.js +22 -0
  51. package/dist/lib/map/sync-plugins.svelte.d.ts +19 -0
  52. package/dist/lib/map/sync-plugins.svelte.js +52 -0
  53. package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +82 -82
  54. package/dist/lib/monitors/filtered-source.svelte.d.ts +9 -0
  55. package/dist/lib/monitors/filtered-source.svelte.js +28 -0
  56. package/dist/lib/monitors/monitor-details.svelte.js +11 -3
  57. package/dist/lib/monitors/monitors.svelte.d.ts +29 -26
  58. package/dist/lib/monitors/monitors.svelte.js +35 -126
  59. package/dist/lib/monitors/retry-load.d.ts +8 -0
  60. package/dist/lib/monitors/retry-load.js +11 -0
  61. package/dist/lib/monitors/subscriptions.d.ts +3 -0
  62. package/dist/lib/monitors/subscriptions.js +9 -0
  63. package/dist/lib/monitors/types.d.ts +1 -41
  64. package/dist/lib/options/map-option-defaults.d.ts +25 -36
  65. package/dist/lib/options/map-option-defaults.js +12 -30
  66. package/dist/lib/options/map-options.svelte.d.ts +13 -0
  67. package/dist/lib/options/map-options.svelte.js +26 -0
  68. package/dist/lib/options/menus/LayersMenu.svelte +8 -20
  69. package/dist/lib/options/menus/LayersMenu.svelte.d.ts +17 -2
  70. package/dist/lib/options/menus/MonitorsMenu.svelte +34 -18
  71. package/dist/lib/options/menus/OverlaysMenu.svelte +2 -2
  72. package/dist/lib/options/menus/PollutantMenu.svelte +4 -9
  73. package/dist/lib/options/menus/SettingsMenu.svelte +32 -25
  74. package/dist/lib/options/menus/SettingsMenu.svelte.d.ts +5 -16
  75. package/dist/lib/options/plugin-order.d.ts +8 -0
  76. package/dist/lib/options/plugin-order.js +36 -0
  77. package/dist/lib/options/pollutant-param.d.ts +7 -5
  78. package/dist/lib/options/pollutant-param.js +9 -5
  79. package/dist/lib/options/row-icons.d.ts +8 -0
  80. package/dist/lib/options/row-icons.js +15 -0
  81. package/dist/lib/options/rows/MarkerIcon.svelte +5 -5
  82. package/dist/lib/options/rows/MarkerIcon.svelte.d.ts +1 -2
  83. package/dist/lib/search/Search.svelte +20 -33
  84. package/dist/lib/search/Search.svelte.d.ts +5 -1
  85. package/dist/lib/search/search-pin.svelte.d.ts +11 -0
  86. package/dist/lib/search/search-pin.svelte.js +28 -0
  87. package/dist/lib/search/service.d.ts +7 -2
  88. package/dist/lib/search/service.js +17 -10
  89. package/dist/lib/stores.svelte.d.ts +8 -0
  90. package/dist/lib/stores.svelte.js +9 -0
  91. package/dist/lib/wind/wind-plugin.d.ts +11 -0
  92. package/dist/lib/wind/wind-plugin.js +25 -0
  93. package/package.json +12 -10
  94. package/src/lib/MonitorDetailPanel.svelte +23 -20
  95. package/src/lib/MonitorMapLayout.svelte +147 -77
  96. package/src/lib/abort.ts +59 -0
  97. package/src/lib/components/DataBox.svelte +4 -1
  98. package/src/lib/config.svelte.ts +16 -0
  99. package/src/lib/core.ts +2 -2
  100. package/src/lib/data-chart/DataChart.svelte +54 -24
  101. package/src/lib/data-chart/DateRange.ts +58 -15
  102. package/src/lib/data-chart/chart-utils.ts +6 -7
  103. package/src/lib/data-chart/level-colors.ts +8 -9
  104. package/src/lib/data-chart/pacific-time.ts +31 -0
  105. package/src/lib/data-chart/requests.ts +14 -7
  106. package/src/lib/data-chart/tooltip.ts +2 -2
  107. package/{dist/lib/ev-stations/components/EvStationTooltip.svelte → src/lib/ev-stations/EvPopup.svelte} +6 -5
  108. package/src/lib/ev-stations/ev-plugin.svelte.ts +86 -0
  109. package/src/lib/ev-stations/ev-station-icons.ts +1 -17
  110. package/src/lib/ev-stations/ev-stations.ts +72 -0
  111. package/src/lib/ev-stations/ev-store.svelte.ts +117 -0
  112. package/src/lib/ev-stations/station-popup.svelte.ts +64 -0
  113. package/src/lib/ev-stations/types.ts +0 -34
  114. package/src/lib/ev-stations/zip-codes.ts +29 -16
  115. package/src/lib/index.ts +8 -99
  116. package/src/lib/legend/Legend.svelte +18 -19
  117. package/src/lib/levels.ts +24 -0
  118. package/src/lib/map/MapShell.svelte +17 -24
  119. package/src/lib/map/basemaps.ts +76 -0
  120. package/src/lib/map/live-map.svelte.ts +24 -0
  121. package/src/lib/map/sync-plugins.svelte.ts +69 -0
  122. package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +82 -82
  123. package/src/lib/monitors/filtered-source.svelte.ts +38 -0
  124. package/src/lib/monitors/monitor-details.svelte.ts +12 -3
  125. package/src/lib/monitors/monitors.svelte.ts +62 -153
  126. package/src/lib/monitors/retry-load.ts +14 -0
  127. package/src/lib/monitors/subscriptions.ts +17 -0
  128. package/src/lib/monitors/types.ts +1 -47
  129. package/src/lib/options/map-option-defaults.ts +30 -53
  130. package/src/lib/options/map-options.svelte.ts +30 -0
  131. package/src/lib/options/menus/LayersMenu.svelte +8 -20
  132. package/src/lib/options/menus/MonitorsMenu.svelte +34 -18
  133. package/src/lib/options/menus/OverlaysMenu.svelte +2 -2
  134. package/src/lib/options/menus/PollutantMenu.svelte +4 -9
  135. package/src/lib/options/menus/SettingsMenu.svelte +32 -25
  136. package/src/lib/options/plugin-order.ts +38 -0
  137. package/src/lib/options/pollutant-param.ts +14 -9
  138. package/src/lib/options/row-icons.ts +17 -0
  139. package/src/lib/options/rows/MarkerIcon.svelte +5 -5
  140. package/src/lib/search/Search.svelte +20 -33
  141. package/src/lib/search/search-pin.svelte.ts +35 -0
  142. package/src/lib/search/service.ts +34 -15
  143. package/src/lib/stores.svelte.ts +15 -0
  144. package/src/lib/wind/wind-plugin.ts +33 -0
  145. package/dist/lib/collocation-sites/CollocationTooltip.svelte +0 -98
  146. package/dist/lib/collocation-sites/CollocationTooltip.svelte.d.ts +0 -7
  147. package/dist/lib/collocation-sites/collocations-icon-manager.svelte.d.ts +0 -7
  148. package/dist/lib/collocation-sites/collocations-icon-manager.svelte.js +0 -18
  149. package/dist/lib/collocation-sites/collocations-map-integration.svelte.d.ts +0 -21
  150. package/dist/lib/collocation-sites/collocations-map-integration.svelte.js +0 -113
  151. package/dist/lib/collocation-sites/collocations.svelte.d.ts +0 -9
  152. package/dist/lib/collocation-sites/collocations.svelte.js +0 -17
  153. package/dist/lib/collocation-sites/types.d.ts +0 -8
  154. package/dist/lib/collocation-sites/types.js +0 -1
  155. package/dist/lib/ev-stations/components/EvStationClusterTooltip.svelte +0 -16
  156. package/dist/lib/ev-stations/components/EvStationClusterTooltip.svelte.d.ts +0 -7
  157. package/dist/lib/ev-stations/components/EvStationTooltip.svelte.d.ts +0 -7
  158. package/dist/lib/ev-stations/ev-stations-cluster-renderer.d.ts +0 -19
  159. package/dist/lib/ev-stations/ev-stations-cluster-renderer.js +0 -131
  160. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.d.ts +0 -31
  161. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +0 -226
  162. package/dist/lib/ev-stations/ev-stations.svelte.d.ts +0 -9
  163. package/dist/lib/ev-stations/ev-stations.svelte.js +0 -55
  164. package/dist/lib/hms/hms-fire-icon-manager.d.ts +0 -13
  165. package/dist/lib/hms/hms-fire-icon-manager.js +0 -39
  166. package/dist/lib/hms/hms-fire-map-integration.svelte.d.ts +0 -23
  167. package/dist/lib/hms/hms-fire-map-integration.svelte.js +0 -94
  168. package/dist/lib/hms/hms-smoke-map-integration.svelte.d.ts +0 -39
  169. package/dist/lib/hms/hms-smoke-map-integration.svelte.js +0 -87
  170. package/dist/lib/hms/hms.svelte.d.ts +0 -19
  171. package/dist/lib/hms/hms.svelte.js +0 -227
  172. package/dist/lib/legend/LegendBar.svelte +0 -45
  173. package/dist/lib/legend/LegendBar.svelte.d.ts +0 -7
  174. package/dist/lib/legend/legend-data.d.ts +0 -40
  175. package/dist/lib/legend/legend-data.js +0 -65
  176. package/dist/lib/map/Map.svelte +0 -13
  177. package/dist/lib/map/Map.svelte.d.ts +0 -8
  178. package/dist/lib/map/basemap.d.ts +0 -14
  179. package/dist/lib/map/basemap.js +0 -10
  180. package/dist/lib/map/icons.d.ts +0 -7
  181. package/dist/lib/map/icons.js +0 -33
  182. package/dist/lib/map/integrations/base-layer-seperator.d.ts +0 -9
  183. package/dist/lib/map/integrations/base-layer-seperator.js +0 -11
  184. package/dist/lib/map/integrations/click-manager.d.ts +0 -14
  185. package/dist/lib/map/integrations/click-manager.js +0 -52
  186. package/dist/lib/map/integrations/integrations-manager.d.ts +0 -8
  187. package/dist/lib/map/integrations/integrations-manager.js +0 -20
  188. package/dist/lib/map/integrations/map-display-option.svelte.d.ts +0 -7
  189. package/dist/lib/map/integrations/map-display-option.svelte.js +0 -10
  190. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +0 -32
  191. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +0 -60
  192. package/dist/lib/map/integrations/map-icon-manager.d.ts +0 -15
  193. package/dist/lib/map/integrations/map-icon-manager.js +0 -52
  194. package/dist/lib/map/integrations/map-integration.svelte.d.ts +0 -6
  195. package/dist/lib/map/integrations/map-integration.svelte.js +0 -2
  196. package/dist/lib/map/integrations/map-layer-integration.svelte.d.ts +0 -11
  197. package/dist/lib/map/integrations/map-layer-integration.svelte.js +0 -40
  198. package/dist/lib/map/integrations/tooltip.svelte.d.ts +0 -36
  199. package/dist/lib/map/integrations/tooltip.svelte.js +0 -131
  200. package/dist/lib/map/integrations/types.d.ts +0 -14
  201. package/dist/lib/map/integrations/types.js +0 -1
  202. package/dist/lib/map/map-manager.svelte.d.ts +0 -20
  203. package/dist/lib/map/map-manager.svelte.js +0 -63
  204. package/dist/lib/map/map-style-state.svelte.d.ts +0 -23
  205. package/dist/lib/map/map-style-state.svelte.js +0 -86
  206. package/dist/lib/map/utils.d.ts +0 -7
  207. package/dist/lib/map/utils.js +0 -36
  208. package/dist/lib/monitors/components/MonitorClusterTooltip.svelte +0 -112
  209. package/dist/lib/monitors/components/MonitorClusterTooltip.svelte.d.ts +0 -7
  210. package/dist/lib/monitors/components/MonitorTooltip.svelte +0 -61
  211. package/dist/lib/monitors/components/MonitorTooltip.svelte.d.ts +0 -7
  212. package/dist/lib/monitors/monitor-utils.d.ts +0 -4
  213. package/dist/lib/monitors/monitor-utils.js +0 -41
  214. package/dist/lib/monitors/monitors-cluster-renderer.d.ts +0 -33
  215. package/dist/lib/monitors/monitors-cluster-renderer.js +0 -221
  216. package/dist/lib/monitors/monitors-icon-manager.svelte.d.ts +0 -15
  217. package/dist/lib/monitors/monitors-icon-manager.svelte.js +0 -78
  218. package/dist/lib/monitors/monitors-map-integration.svelte.d.ts +0 -43
  219. package/dist/lib/monitors/monitors-map-integration.svelte.js +0 -301
  220. package/dist/lib/options/reset-map-options.d.ts +0 -2
  221. package/dist/lib/options/reset-map-options.js +0 -24
  222. package/dist/lib/reactivity.svelte.d.ts +0 -0
  223. package/dist/lib/reactivity.svelte.js +0 -83
  224. package/dist/lib/wind/wind.svelte.d.ts +0 -10
  225. package/dist/lib/wind/wind.svelte.js +0 -13
  226. package/src/lib/collocation-sites/CollocationTooltip.svelte +0 -98
  227. package/src/lib/collocation-sites/collocations-icon-manager.svelte.ts +0 -21
  228. package/src/lib/collocation-sites/collocations-map-integration.svelte.ts +0 -136
  229. package/src/lib/collocation-sites/collocations.svelte.ts +0 -24
  230. package/src/lib/collocation-sites/types.ts +0 -10
  231. package/src/lib/ev-stations/components/EvStationClusterTooltip.svelte +0 -16
  232. package/src/lib/ev-stations/ev-stations-cluster-renderer.ts +0 -162
  233. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +0 -248
  234. package/src/lib/ev-stations/ev-stations.svelte.ts +0 -79
  235. package/src/lib/hms/hms-fire-icon-manager.ts +0 -48
  236. package/src/lib/hms/hms-fire-map-integration.svelte.ts +0 -109
  237. package/src/lib/hms/hms-smoke-map-integration.svelte.ts +0 -105
  238. package/src/lib/hms/hms.svelte.ts +0 -264
  239. package/src/lib/legend/LegendBar.svelte +0 -45
  240. package/src/lib/legend/legend-data.ts +0 -101
  241. package/src/lib/map/Map.svelte +0 -13
  242. package/src/lib/map/basemap.ts +0 -24
  243. package/src/lib/map/icons.ts +0 -42
  244. package/src/lib/map/integrations/base-layer-seperator.ts +0 -17
  245. package/src/lib/map/integrations/click-manager.ts +0 -64
  246. package/src/lib/map/integrations/integrations-manager.ts +0 -25
  247. package/src/lib/map/integrations/map-display-option.svelte.ts +0 -13
  248. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +0 -77
  249. package/src/lib/map/integrations/map-icon-manager.ts +0 -67
  250. package/src/lib/map/integrations/map-integration.svelte.ts +0 -8
  251. package/src/lib/map/integrations/map-layer-integration.svelte.ts +0 -49
  252. package/src/lib/map/integrations/tooltip.svelte.ts +0 -153
  253. package/src/lib/map/integrations/types.ts +0 -24
  254. package/src/lib/map/map-manager.svelte.ts +0 -83
  255. package/src/lib/map/map-style-state.svelte.ts +0 -94
  256. package/src/lib/map/utils.ts +0 -53
  257. package/src/lib/monitors/components/MonitorClusterTooltip.svelte +0 -112
  258. package/src/lib/monitors/components/MonitorTooltip.svelte +0 -61
  259. package/src/lib/monitors/monitor-utils.ts +0 -57
  260. package/src/lib/monitors/monitors-cluster-renderer.ts +0 -285
  261. package/src/lib/monitors/monitors-icon-manager.svelte.ts +0 -98
  262. package/src/lib/monitors/monitors-map-integration.svelte.ts +0 -318
  263. package/src/lib/options/reset-map-options.ts +0 -28
  264. package/src/lib/reactivity.svelte.ts +0 -82
  265. package/src/lib/wind/wind.svelte.ts +0 -20
@@ -7,14 +7,20 @@
7
7
  import uPlot from "uplot";
8
8
  import "uplot/dist/uPlot.min.css";
9
9
  import { getMonitorEntriesCSVUrl } from "@sjvair/sdk/monitors";
10
- import { format, parseISO } from "date-fns";
11
10
  import { RangeCalendar } from "$lib/components/ui/range-calendar";
12
11
  import type { PanelPollutant } from "../monitors/panel-pollutant";
13
12
  import { monitorsManager } from "../monitors/monitors.svelte";
14
13
  import { DataChartManager } from "./data-chart.svelte";
15
- import { createDateRange, extendToNow, includesToday } from "./DateRange";
14
+ import {
15
+ createDateRange,
16
+ dateRangeLabel as formatDateRange,
17
+ extendToNow,
18
+ fileDay,
19
+ includesToday
20
+ } from "./DateRange";
16
21
  import { fetchChartData, type ChartMonitor } from "./requests";
17
22
  import { getChartConfig } from "./chart-utils";
23
+ import { PACIFIC_TIME_ZONE } from "./pacific-time";
18
24
  import { levelColors } from "./level-colors";
19
25
 
20
26
  interface Props {
@@ -38,11 +44,7 @@
38
44
  let dateRangeContainer: HTMLElement | undefined = $state();
39
45
  let triggerRebuild: (() => void) | undefined = $state();
40
46
 
41
- const dateRangeLabel = $derived(
42
- format(parseISO(chartManager.dateRange.start), "MMM d") +
43
- " – " +
44
- format(parseISO(chartManager.dateRange.end), "MMM d, yyyy")
45
- );
47
+ const dateRangeLabel = $derived(formatDateRange(chartManager.dateRange));
46
48
 
47
49
  // A request with no entries still resolves to [[], []], so test the x axis, not the tuple.
48
50
  const noChartData = $derived(!chartManager.chartData[0]?.length);
@@ -53,17 +55,18 @@
53
55
  ? "No Data Available For Selected Date Range"
54
56
  : ""
55
57
  );
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
- );
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.
58
+ // Props are read through a $derived: the owner recomputes what it passes on every refresh,
59
+ // and a prop read directly would re-run an effect even when the value is unchanged.
65
60
  const monitorId = $derived(monitor.id);
61
+ const monitorType = $derived(monitor.type);
66
62
  const chartPollutant = $derived(pollutant);
63
+
64
+ // Level definitions and labels are shared metadata, not the map's current selection.
65
+ const entryType = $derived(monitorsManager.meta?.entryType(chartPollutant));
66
+ const pollutantLabel = $derived(entryType?.label ?? "");
67
+ const colors = $derived(levelColors(entryType?.scale ?? null));
68
+
69
+ // Reload for another monitor or pollutant, not for a new object describing the same one.
67
70
  $effect(() => {
68
71
  void monitorId;
69
72
  void chartPollutant;
@@ -101,9 +104,15 @@
101
104
  return () => document.removeEventListener("click", handleDocClick);
102
105
  });
103
106
 
107
+ // Rebuild for new data or anything the chart draws with, and nothing else: `rebuild` runs
108
+ // untracked, so a refreshed copy of the same monitor does not recreate the chart.
104
109
  $effect(() => {
105
- void chartManager.chartData; // re-run whenever chart data updates
106
- triggerRebuild?.();
110
+ void chartManager.chartData;
111
+ void monitorType;
112
+ void colors;
113
+ void pollutantLabel;
114
+ const rebuild = triggerRebuild;
115
+ untrack(() => rebuild?.());
107
116
  });
108
117
 
109
118
  const chartAttachment: Attachment<HTMLElement> = (el) => {
@@ -121,7 +130,9 @@
121
130
  instance?.destroy?.();
122
131
  el.innerHTML = "";
123
132
  const maxDiff = Math.max(...flatData) - Math.min(...flatData);
124
- const opts = getChartConfig(monitor.type, maxDiff, width, height, colors, pollutantLabel);
133
+ const opts = getChartConfig(monitorType, maxDiff, width, height, colors, pollutantLabel);
134
+ // Axis ticks in Pacific time, like the tooltip.
135
+ opts.tzDate = (ts) => uPlot.tzDate(new Date(ts * 1000), PACIFIC_TIME_ZONE);
125
136
  instance = new uPlot(opts, chartManager.chartData, el);
126
137
  };
127
138
 
@@ -143,17 +154,36 @@
143
154
  };
144
155
 
145
156
  let chartRequest = 0;
157
+ let chartController: AbortController | undefined;
158
+
159
+ // A closed panel cancels its in-flight fetch.
160
+ $effect(() => () => chartController?.abort());
146
161
 
147
162
  // `silent` keeps the current chart on screen while a timed refresh loads.
148
163
  async function loadChartData({ silent = false } = {}) {
149
164
  const request = ++chartRequest;
150
- if (!silent) chartManager.loading = true;
151
- const data = await fetchChartData(monitor, chartManager.dateRange, pollutant);
165
+ chartController?.abort();
166
+ const controller = (chartController = new AbortController());
167
+ // A refresh that supersedes a load still in flight takes over its loading flag: the
168
+ // superseded load will never clear it.
169
+ const ownsLoading = !silent || chartManager.loading;
170
+ chartManager.loading = ownsLoading;
171
+ let data: Awaited<ReturnType<typeof fetchChartData>>;
172
+ try {
173
+ data = await fetchChartData(monitor, chartManager.dateRange, pollutant, {
174
+ signal: controller.signal
175
+ });
176
+ } catch (err) {
177
+ // Superseded or closed: the newer call owns `loading` and the chart, so leave both.
178
+ if (controller.signal.aborted || request !== chartRequest) return;
179
+ console.error("Failed to load chart data: ", err);
180
+ data = [];
181
+ }
152
182
  // A newer request (another range, monitor, pollutant or refresh) owns the chart now.
153
183
  if (request !== chartRequest) return;
154
184
  // A failed request resolves to [] (an empty range is [[], []]); a background refresh
155
185
  // that failed keeps the chart already on screen.
156
- if (silent && data.length === 0) return;
186
+ if (!ownsLoading && data.length === 0) return;
157
187
  chartManager.chartData = data;
158
188
  chartManager.loading = false;
159
189
  }
@@ -186,8 +216,8 @@
186
216
  if (!canvas) return;
187
217
  const link = document.createElement("a");
188
218
  const monitorName = monitor.name.split(" ").join("-");
189
- const start = format(parseISO(chartManager.dateRange.start), "dd.MMM.yyyy");
190
- const end = format(parseISO(chartManager.dateRange.end), "dd.MMM.yyyy");
219
+ const start = fileDay(chartManager.dateRange.start);
220
+ const end = fileDay(chartManager.dateRange.end);
191
221
  link.download = `${monitorName}_${start}-${end}.png`;
192
222
  link.href = canvas.toDataURL();
193
223
  link.click();
@@ -1,32 +1,75 @@
1
- import { subDays, startOfDay, endOfDay, isToday, parseISO } from "date-fns";
1
+ import { addPacificDays, endOfPacificDay, startOfPacificDay } from "@sjvair/sdk/datetime/pacific";
2
2
  import type { DateValue } from "@internationalized/date";
3
3
 
4
+ /** ISO instants. Days are Pacific days, whatever zone the device is in. */
4
5
  export type DateRange = { start: string; end: string };
5
6
 
6
- export function createDateRange(start?: DateValue, end?: DateValue): DateRange {
7
+ /** An instant on the given calendar day in Pacific time (noon UTC is 4–5 AM Pacific). */
8
+ function onPacificDay(day: DateValue): Date {
9
+ return new Date(Date.UTC(day.year, day.month - 1, day.day, 12));
10
+ }
11
+
12
+ function samePacificDay(a: Date, b: Date): boolean {
13
+ return startOfPacificDay(a).getTime() === startOfPacificDay(b).getTime();
14
+ }
15
+
16
+ /**
17
+ * The chart's range: with no days picked, the start of yesterday through now; otherwise the
18
+ * whole picked days, ending now when the end day is today.
19
+ */
20
+ export function createDateRange(start?: DateValue, end?: DateValue, now = new Date()): DateRange {
7
21
  if (!start || !end) {
8
22
  return {
9
- start: startOfDay(subDays(new Date(), 1)).toISOString(),
10
- end: new Date().toISOString()
23
+ start: startOfPacificDay(addPacificDays(now, -1)).toISOString(),
24
+ end: now.toISOString()
11
25
  };
12
26
  }
13
27
 
14
- const startDate = startOfDay(new Date(start.year, start.month - 1, start.day));
15
- let endDate = new Date(end.year, end.month - 1, end.day);
16
- endDate = isToday(endDate) ? new Date() : endOfDay(endDate);
17
-
28
+ const endDay = onPacificDay(end);
18
29
  return {
19
- start: startDate.toISOString(),
20
- end: endDate.toISOString()
30
+ start: startOfPacificDay(onPacificDay(start)).toISOString(),
31
+ end: (samePacificDay(endDay, now) ? now : endOfPacificDay(endDay)).toISOString()
21
32
  };
22
33
  }
23
34
 
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));
35
+ /** Whether the range ends on the current Pacific day, i.e. it can still gain entries. */
36
+ export function includesToday(range: DateRange, now = new Date()): boolean {
37
+ return samePacificDay(new Date(range.end), now);
27
38
  }
28
39
 
29
40
  /** 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() };
41
+ export function extendToNow(range: DateRange, now = new Date()): DateRange {
42
+ return { start: range.start, end: now.toISOString() };
43
+ }
44
+
45
+ const PACIFIC = "America/Los_Angeles";
46
+ const shortDay = new Intl.DateTimeFormat("en-US", {
47
+ timeZone: PACIFIC,
48
+ month: "short",
49
+ day: "numeric"
50
+ });
51
+ const shortDayYear = new Intl.DateTimeFormat("en-US", {
52
+ timeZone: PACIFIC,
53
+ month: "short",
54
+ day: "numeric",
55
+ year: "numeric"
56
+ });
57
+ const fileDayParts = new Intl.DateTimeFormat("en-US", {
58
+ timeZone: PACIFIC,
59
+ day: "2-digit",
60
+ month: "short",
61
+ year: "numeric"
62
+ });
63
+
64
+ /** The range's Pacific days for display, e.g. "Nov 3 – Nov 4, 2026". */
65
+ export function dateRangeLabel(range: DateRange): string {
66
+ return `${shortDay.format(new Date(range.start))} – ${shortDayYear.format(new Date(range.end))}`;
67
+ }
68
+
69
+ /** An instant's Pacific day for a file name, e.g. "04.Nov.2026". */
70
+ export function fileDay(iso: string): string {
71
+ const parts = Object.fromEntries(
72
+ fileDayParts.formatToParts(new Date(iso)).map((part) => [part.type, part.value])
73
+ );
74
+ return `${parts.day}.${parts.month}.${parts.year}`;
32
75
  }
@@ -1,6 +1,6 @@
1
1
  import type { LevelColors } from "./level-colors";
2
2
  import { tooltipsPlugin, uPlotCursorConfig } from "./tooltip";
3
- import { fromUnixTime, getUnixTime, isToday, isAfter } from "date-fns";
3
+ import { chartRangeMax } from "./pacific-time";
4
4
  import type uPlot from "uplot";
5
5
  import type { MonitorType } from "@sjvair/sdk";
6
6
 
@@ -64,12 +64,7 @@ export function getChartConfig(
64
64
  },
65
65
  scales: {
66
66
  x: {
67
- range: (_: uPlot, min: number, max: number) => {
68
- const maxDate = fromUnixTime(max);
69
- const now = new Date();
70
- max = isToday(maxDate) && isAfter(now, maxDate) ? getUnixTime(now) : max;
71
- return [min, max];
72
- }
67
+ range: (_: uPlot, min: number, max: number) => [min, chartRangeMax(max)]
73
68
  },
74
69
  y: {
75
70
  range: {
@@ -118,6 +113,10 @@ function getSeriesConfigs(monitorType: MonitorType, stops: LevelColors) {
118
113
  case "airgradient":
119
114
  case "purpleair":
120
115
  return [baseXSeriesConfig, pm25SeriesConfig(stops)];
116
+
117
+ // Types without a real-time series of their own (CIMIS weather stations, from SDK 5.0).
118
+ default:
119
+ return [baseXSeriesConfig, singleSeriesConfig];
121
120
  }
122
121
  }
123
122
 
@@ -1,13 +1,12 @@
1
+ import type { Scale } from "@sjvair/map-sdk/data";
2
+
1
3
  /** Color stops for a chart: each level's lower bound and its hex color without the `#`. */
2
4
  export type LevelColors = Array<[number, string]>;
3
5
 
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(/^#/, "")]);
6
+ /** The chart's color stops from a range scale; a level with no lower bound or color is skipped. */
7
+ export function levelColors(scale: Scale | null): LevelColors {
8
+ return (scale?.levels ?? []).flatMap((level): LevelColors => {
9
+ if (level.min === null || !level.color) return [];
10
+ return [[level.min, level.color.replace(/^#/, "")]];
11
+ });
13
12
  }
@@ -0,0 +1,31 @@
1
+ import { startOfPacificDay } from "@sjvair/sdk/datetime/pacific";
2
+
3
+ /** The chart's zone: times and days are Pacific, whatever zone the device is in. */
4
+ export const PACIFIC_TIME_ZONE = "America/Los_Angeles";
5
+
6
+ /**
7
+ * The x-axis end: `now` when the data ends earlier on the current Pacific day (so the axis
8
+ * reaches the present), else the data's end. Both are Unix seconds.
9
+ */
10
+ export function chartRangeMax(max: number, now: Date = new Date()): number {
11
+ const end = new Date(max * 1000);
12
+ const today = startOfPacificDay(end).getTime() === startOfPacificDay(now).getTime();
13
+ return today && now > end ? Math.floor(now.getTime() / 1000) : max;
14
+ }
15
+
16
+ const formatter = new Intl.DateTimeFormat("en-US", {
17
+ timeZone: PACIFIC_TIME_ZONE,
18
+ month: "short",
19
+ day: "numeric",
20
+ year: "numeric",
21
+ hour: "numeric",
22
+ minute: "2-digit"
23
+ });
24
+
25
+ /** A Unix time as Pacific wall-clock time, e.g. "Nov 4, 2026 8:05 PM". */
26
+ export function formatPacificTime(unixSeconds: number): string {
27
+ const part = Object.fromEntries(
28
+ formatter.formatToParts(new Date(unixSeconds * 1000)).map((p) => [p.type, p.value])
29
+ );
30
+ return `${part.month} ${part.day}, ${part.year} ${part.hour}:${part.minute} ${part.dayPeriod}`;
31
+ }
@@ -2,6 +2,7 @@ import { getMonitorEntries, type MonitorDetails, type MonitorEntry } from "@sjva
2
2
  import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
3
3
  import type { PanelPollutant } from "../monitors/panel-pollutant";
4
4
  import type { DateRange } from "./DateRange";
5
+ import type { SignalOptions } from "$lib/abort";
5
6
 
6
7
  /** The monitor fields the chart reads. */
7
8
  export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
@@ -9,14 +10,18 @@ export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_s
9
10
  export async function fetchChartData(
10
11
  m: ChartMonitor,
11
12
  d: DateRange,
12
- entryType: PanelPollutant
13
+ entryType: PanelPollutant,
14
+ options: SignalOptions = {}
13
15
  ): Promise<uPlot.AlignedData> {
14
- return await getMonitorEntries({
15
- entryType,
16
- monitorId: m.id,
17
- timestampGte: d.start,
18
- timestampLte: d.end
19
- })
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
+ )
20
25
  .then((entries) => {
21
26
  const xAxisData: Array<Date> = [];
22
27
  const updateDuration = m.data_source.name === "PurpleAir" ? 2 : 60;
@@ -41,6 +46,8 @@ export async function fetchChartData(
41
46
  return [xAxisData.map((d) => getUnixTime(d)), yAxisRecord] as uPlot.AlignedData;
42
47
  })
43
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;
44
51
  console.error("Failed to load chart data: ", err);
45
52
  return [] as uPlot.AlignedData;
46
53
  });
@@ -1,4 +1,4 @@
1
- import { format, fromUnixTime } from "date-fns";
1
+ import { formatPacificTime } from "./pacific-time";
2
2
  import type { LevelColors } from "./level-colors";
3
3
 
4
4
  let cursLeft = -100;
@@ -153,7 +153,7 @@ export function tooltipsPlugin() {
153
153
  let label = u.series[i].label as string;
154
154
  label = label?.substring(label.indexOf("(") + 1, label.indexOf(")")) || label;
155
155
 
156
- ttHeader.textContent = format(fromUnixTime(xVal), "MMM d, yyyy h:mm a");
156
+ ttHeader.textContent = formatPacificTime(xVal);
157
157
  tt!.textContent = `${label}: ${yVal}`;
158
158
  }
159
159
  }
@@ -1,18 +1,19 @@
1
1
  <script lang="ts">
2
2
  import CreditCard from "@lucide/svelte/icons/credit-card";
3
3
  import Zap from "@lucide/svelte/icons/zap";
4
- import type { EvStationMapFeature } from "../types";
4
+ import type { EvStation } from "./types";
5
5
 
6
6
  interface Props {
7
- feature: EvStationMapFeature;
7
+ station: EvStation;
8
8
  }
9
9
 
10
- const { feature }: Props = $props();
10
+ const { station }: Props = $props();
11
11
 
12
+ // 4.x's station popup, from the station NREL returned (codes already turned into labels).
12
13
  const computed = $derived.by(() => {
13
- const p = feature.properties;
14
+ const p = station;
14
15
 
15
- const connectors: string[] = JSON.parse(p.ev_connector_types || "[]");
16
+ const connectors: string[] = (p.ev_connector_types ?? []).filter(Boolean);
16
17
 
17
18
  const cards: string[] = p.cards_accepted
18
19
  ? p.cards_accepted.split(",").map((c) => c.trim())
@@ -0,0 +1,86 @@
1
+ import { createRawSnippet } from "svelte";
2
+ import { pointsPlugin, type MapPlugin } from "@sjvair/map-sdk";
3
+ import type { DataSource } from "@sjvair/map-sdk/data";
4
+ import { EV_STATION_LEVELS, evStationIcon } from "./ev-station-icons";
5
+ import type { EvFeatureProperties, EvLevel } from "./ev-store.svelte.js";
6
+
7
+ export type EvPluginId = "ev-l2" | "ev-l3";
8
+
9
+ export const EV_PLUGIN_IDS: Record<EvLevel, EvPluginId> = { lvl2: "ev-l2", lvl3: "ev-l3" };
10
+
11
+ const LEVEL_LABELS: Record<EvLevel, string> = { lvl2: "Level 2", lvl3: "Level 3" };
12
+
13
+ /** The marker size, as in 4.x. */
14
+ const ICON_SIZE = 32;
15
+
16
+ /** "rgb(r, g, b)" at 0.6 alpha, the cluster bubbles' fill in 4.x. */
17
+ function clusterFill(rgb: string): string {
18
+ return rgb.replace("rgb(", "rgba(").replace(")", ", 0.6)");
19
+ }
20
+
21
+ /** 4.x's cluster tooltip: "Level 2 Chargers" over "N stations". */
22
+ function clusterTooltip(level: EvLevel, count: number) {
23
+ return createRawSnippet(() => ({
24
+ render: () =>
25
+ `<div class="flex flex-col gap-1 p-1"><p class="font-semibold">${LEVEL_LABELS[level]} Chargers</p><p class="text-sm">${count} stations</p></div>`
26
+ }));
27
+ }
28
+
29
+ /**
30
+ * One level of EV chargers, as 4.x drew them: the level's charger icon and, clustered, bubbles in
31
+ * the level's color (radius 15/20/25 by count, with a white count) that zoom in on click. Stations
32
+ * are listed for the feature list; a click on one is a `featureclick` with a `feature` ref whose
33
+ * id is the station id (the layout opens the popup).
34
+ */
35
+ export function evPlugin(
36
+ level: EvLevel,
37
+ source: DataSource<EvFeatureProperties>,
38
+ options: { clustered: boolean }
39
+ ): MapPlugin {
40
+ const { bg, border } = EV_STATION_LEVELS[level];
41
+ const icon = evStationIcon(bg, border);
42
+ return pointsPlugin<EvFeatureProperties>({
43
+ id: EV_PLUGIN_IDS[level],
44
+ source,
45
+ layerLabel: `${LEVEL_LABELS[level]} Chargers`,
46
+ selectable: false,
47
+ icon: { category: () => level, draw: () => icon, size: ICON_SIZE },
48
+ detail: () => `${LEVEL_LABELS[level]} charger`,
49
+ ...(options.clustered && {
50
+ cluster: {
51
+ radius: 40,
52
+ maxZoom: 9,
53
+ layers: ({ sourceId }) => [
54
+ {
55
+ local: "cluster-circle",
56
+ spec: {
57
+ type: "circle",
58
+ source: sourceId,
59
+ filter: ["has", "point_count"],
60
+ paint: {
61
+ "circle-color": clusterFill(bg),
62
+ "circle-radius": ["step", ["get", "point_count"], 15, 10, 20, 25, 25]
63
+ }
64
+ }
65
+ },
66
+ {
67
+ local: "cluster-label",
68
+ spec: {
69
+ type: "symbol",
70
+ source: sourceId,
71
+ filter: ["has", "point_count"],
72
+ layout: {
73
+ "text-field": ["get", "point_count_abbreviated"],
74
+ "text-size": 12,
75
+ "text-allow-overlap": true,
76
+ "text-ignore-placement": true
77
+ },
78
+ paint: { "text-color": "#ffffff" }
79
+ }
80
+ }
81
+ ],
82
+ tooltip: (cluster) => clusterTooltip(level, cluster.pointCount)
83
+ }
84
+ })
85
+ });
86
+ }
@@ -1,6 +1,3 @@
1
- import { MapIconManager } from "$lib/map/integrations/map-icon-manager";
2
- import { asDataURI } from "$lib/map/icons";
3
-
4
1
  const EV_STATION_PATH =
5
2
  "m340-200 100-160h-60v-120L280-320h60v120ZM240-560h240v-200H240v200Zm0 360h240v-280H240v280Zm-80 80v-640q0-33 23.5-56.5T240-840h240q33 0 56.5 23.5T560-760v280h50q29 0 49.5 20.5T680-410v185q0 17 14 31t31 14q18 0 31.5-14t13.5-31v-375h-10q-17 0-28.5-11.5T720-640v-80h20v-60h40v60h40v-60h40v60h20v80q0 17-11.5 28.5T840-600h-10v375q0 42-30.5 73.5T725-120q-43 0-74-31.5T620-225v-185q0-5-2.5-7.5T610-420h-50v300H160Zm320-80H240h240Z";
6
3
 
@@ -11,6 +8,7 @@ export const EV_STATION_LEVELS = {
11
8
 
12
9
  export type EvStationLevel = keyof typeof EV_STATION_LEVELS;
13
10
 
11
+ /** A charger marker: a circle in the level's colors with the white charger glyph, 32px. */
14
12
  export function evStationIcon(bg: string, border: string): string {
15
13
  return `<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
16
14
  <circle cx="16" cy="16" r="14" fill="${bg}" stroke="${border}" stroke-width="2"/>
@@ -19,17 +17,3 @@ export function evStationIcon(bg: string, border: string): string {
19
17
  </svg>
20
18
  </svg>`;
21
19
  }
22
-
23
- export class EvStationIconManager extends MapIconManager {
24
- constructor() {
25
- super();
26
- for (const [level, colors] of Object.entries(EV_STATION_LEVELS) as Array<
27
- [EvStationLevel, (typeof EV_STATION_LEVELS)[EvStationLevel]]
28
- >) {
29
- const id = `ev-station-${level}`;
30
- const icon = new Image(32, 32);
31
- icon.src = asDataURI(evStationIcon(colors.bg, colors.border));
32
- this.register(id, icon);
33
- }
34
- }
35
- }
@@ -0,0 +1,72 @@
1
+ import {
2
+ accessDetailCodeTypes,
3
+ creditCardTypes,
4
+ evConnectorTypes,
5
+ evNetworkTypes,
6
+ facilityTypes
7
+ } from "./data-maps";
8
+ import { monitorMapConfig } from "../config.svelte.js";
9
+ import type { EvStation } from "./types";
10
+ import { EV_ZIP_CODES } from "./zip-codes";
11
+
12
+ /** Which NREL charging level each map level asks for. */
13
+ const CHARGING_LEVEL = { lvl2: "2", lvl3: "dc_fast" } as const;
14
+
15
+ /**
16
+ * Fetches the public electric chargers of one level in the Valley's ZIPs from NREL (key from
17
+ * `monitorMapConfig().nrelKey`), with codes turned into labels. Station 226154 is dropped: its
18
+ * coordinates are wrong.
19
+ */
20
+ export async function fetchEvStations(
21
+ level: "lvl2" | "lvl3",
22
+ signal?: AbortSignal
23
+ ): Promise<Array<EvStation>> {
24
+ if (signal?.aborted) throw signal.reason;
25
+ const url = new URL(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json`);
26
+ url.searchParams.set("fuel_type", "ELEC");
27
+ url.searchParams.set("state", "CA");
28
+ url.searchParams.set("status", "E");
29
+ url.searchParams.set("access", "public");
30
+ url.searchParams.set("ev_charging_level", CHARGING_LEVEL[level]);
31
+ url.searchParams.set("zip", EV_ZIP_CODES.join(","));
32
+ url.searchParams.set("api_key", monitorMapConfig().nrelKey);
33
+
34
+ const res = await fetch(url, { signal });
35
+ if (!res.ok) throw new Error(`EV chargers: NREL answered ${res.status}`);
36
+ const data = await res.json();
37
+ const fuelStations: Array<EvStation> = data.fuel_stations;
38
+ if (!Array.isArray(fuelStations)) throw new Error("EV chargers: no fuel_stations in the answer");
39
+ return (
40
+ fuelStations
41
+ .map((station) => {
42
+ if (station.access_detail_code) {
43
+ station.access_detail_code = accessDetailCodeTypes.get(station.access_detail_code);
44
+ }
45
+
46
+ if (station.cards_accepted) {
47
+ station.cards_accepted = station.cards_accepted
48
+ .split(" ")
49
+ .map((c: string) => creditCardTypes.get(c))
50
+ .join(", ");
51
+ }
52
+
53
+ if (station.ev_connector_types && station.ev_connector_types.length) {
54
+ station.ev_connector_types = station.ev_connector_types.map((t: string) =>
55
+ evConnectorTypes.get(t)
56
+ );
57
+ }
58
+
59
+ if (station.ev_network) {
60
+ station.ev_network = evNetworkTypes.get(station.ev_network);
61
+ }
62
+
63
+ if (station.facility_type) {
64
+ station.facility_type = facilityTypes.get(station.facility_type);
65
+ }
66
+
67
+ return station;
68
+ })
69
+ // remove stations with invalid coordinates
70
+ .filter((station) => station.id !== 226154)
71
+ );
72
+ }