@sjvair/monitor-map 4.0.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +94 -14
  2. package/dist/lib/LazyMapLayout.svelte +53 -0
  3. package/dist/lib/LazyMapLayout.svelte.d.ts +7 -0
  4. package/dist/lib/LazyMonitorDetailPanel.svelte +33 -0
  5. package/dist/lib/LazyMonitorDetailPanel.svelte.d.ts +4 -0
  6. package/dist/lib/LoadError.svelte +19 -0
  7. package/dist/lib/LoadError.svelte.d.ts +7 -0
  8. package/dist/lib/LoadScreen.svelte +10 -1
  9. package/dist/lib/LoadScreen.svelte.d.ts +6 -16
  10. package/dist/lib/MonitorDetailPanel.svelte +108 -28
  11. package/dist/lib/MonitorMapLayout.svelte +21 -1
  12. package/dist/lib/collocation-sites/collocations-map-integration.svelte.js +1 -1
  13. package/dist/lib/core.d.ts +12 -0
  14. package/dist/lib/core.js +10 -0
  15. package/dist/lib/data-chart/DataChart.svelte +63 -13
  16. package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
  17. package/dist/lib/data-chart/DateRange.d.ts +4 -0
  18. package/dist/lib/data-chart/DateRange.js +9 -1
  19. package/dist/lib/data-chart/chart-utils.d.ts +2 -1
  20. package/dist/lib/data-chart/chart-utils.js +19 -23
  21. package/dist/lib/data-chart/level-colors.d.ts +6 -0
  22. package/dist/lib/data-chart/level-colors.js +5 -0
  23. package/dist/lib/data-chart/requests.d.ts +5 -2
  24. package/dist/lib/data-chart/requests.js +2 -3
  25. package/dist/lib/data-chart/tooltip.d.ts +2 -1
  26. package/dist/lib/data-chart/tooltip.js +1 -9
  27. package/dist/lib/ev-stations/ev-stations-cluster-renderer.js +1 -1
  28. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +5 -7
  29. package/dist/lib/hms/hms-fire-map-integration.svelte.js +1 -1
  30. package/dist/lib/hms/hms-smoke-map-integration.svelte.js +1 -1
  31. package/dist/lib/index.d.ts +8 -3
  32. package/dist/lib/index.js +6 -1
  33. package/dist/lib/lazy-chunks.d.ts +2 -0
  34. package/dist/lib/lazy-chunks.js +6 -0
  35. package/dist/lib/lazy-routes.d.ts +12 -0
  36. package/dist/lib/lazy-routes.js +18 -0
  37. package/dist/lib/map/Map.svelte +1 -1
  38. package/dist/lib/map/MapShell.svelte +9 -3
  39. package/dist/lib/map/MapShell.svelte.d.ts +5 -0
  40. package/dist/lib/map/integrations/click-manager.js +1 -1
  41. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +13 -0
  42. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +31 -5
  43. package/dist/lib/map/integrations/map-icon-manager.js +1 -1
  44. package/dist/lib/map/integrations/map-layer-integration.svelte.js +1 -1
  45. package/dist/lib/map/integrations/tooltip.svelte.js +1 -1
  46. package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
  47. package/dist/lib/map/map-style-state.svelte.js +57 -6
  48. package/dist/lib/monitors/components/MonitorClusterTooltip.svelte +1 -1
  49. package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
  50. package/dist/lib/monitors/monitor-details.svelte.js +63 -0
  51. package/dist/lib/monitors/monitors-cluster-renderer.js +1 -1
  52. package/dist/lib/monitors/monitors-icon-manager.svelte.js +1 -1
  53. package/dist/lib/monitors/monitors-map-integration.svelte.js +5 -7
  54. package/dist/lib/monitors/monitors.svelte.d.ts +22 -1
  55. package/dist/lib/monitors/monitors.svelte.js +75 -8
  56. package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
  57. package/dist/lib/monitors/panel-pollutant.js +43 -0
  58. package/dist/lib/options/OptionsBar.svelte +8 -1
  59. package/dist/lib/options/OptionsMenu.svelte +10 -1
  60. package/dist/lib/search/Search.svelte +1 -1
  61. package/dist/lib/shared-load.d.ts +5 -0
  62. package/dist/lib/shared-load.js +14 -0
  63. package/package.json +13 -2
  64. package/src/lib/LazyMapLayout.svelte +53 -0
  65. package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
  66. package/src/lib/LoadError.svelte +19 -0
  67. package/src/lib/LoadScreen.svelte +10 -1
  68. package/src/lib/MonitorDetailPanel.svelte +108 -28
  69. package/src/lib/MonitorMapLayout.svelte +21 -1
  70. package/src/lib/collocation-sites/collocations-map-integration.svelte.ts +1 -1
  71. package/src/lib/core.ts +17 -0
  72. package/src/lib/data-chart/DataChart.svelte +63 -13
  73. package/src/lib/data-chart/DateRange.ts +11 -1
  74. package/src/lib/data-chart/chart-utils.ts +23 -26
  75. package/src/lib/data-chart/level-colors.ts +13 -0
  76. package/src/lib/data-chart/requests.ts +9 -5
  77. package/src/lib/data-chart/tooltip.ts +2 -10
  78. package/src/lib/ev-stations/ev-stations-cluster-renderer.ts +1 -1
  79. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +5 -5
  80. package/src/lib/hms/hms-fire-map-integration.svelte.ts +1 -1
  81. package/src/lib/hms/hms-smoke-map-integration.svelte.ts +1 -1
  82. package/src/lib/index.ts +8 -3
  83. package/src/lib/lazy-chunks.ts +7 -0
  84. package/src/lib/lazy-routes.ts +20 -0
  85. package/src/lib/map/Map.svelte +1 -1
  86. package/src/lib/map/MapShell.svelte +9 -3
  87. package/src/lib/map/integrations/click-manager.ts +1 -1
  88. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +33 -4
  89. package/src/lib/map/integrations/map-icon-manager.ts +1 -1
  90. package/src/lib/map/integrations/map-layer-integration.svelte.ts +1 -1
  91. package/src/lib/map/integrations/tooltip.svelte.ts +1 -1
  92. package/src/lib/map/map-style-state.svelte.ts +59 -7
  93. package/src/lib/monitors/components/MonitorClusterTooltip.svelte +1 -1
  94. package/src/lib/monitors/monitor-details.svelte.ts +66 -0
  95. package/src/lib/monitors/monitors-cluster-renderer.ts +1 -1
  96. package/src/lib/monitors/monitors-icon-manager.svelte.ts +1 -1
  97. package/src/lib/monitors/monitors-map-integration.svelte.ts +5 -5
  98. package/src/lib/monitors/monitors.svelte.ts +93 -10
  99. package/src/lib/monitors/panel-pollutant.ts +76 -0
  100. package/src/lib/options/OptionsBar.svelte +8 -1
  101. package/src/lib/options/OptionsMenu.svelte +10 -1
  102. package/src/lib/search/Search.svelte +1 -1
  103. package/src/lib/shared-load.ts +15 -0
  104. /package/dist/lib/map/{map.svelte.d.ts → map-manager.svelte.d.ts} +0 -0
  105. /package/dist/lib/map/{map.svelte.js → map-manager.svelte.js} +0 -0
  106. /package/src/lib/map/{map.svelte.ts → map-manager.svelte.ts} +0 -0
@@ -6,7 +6,7 @@ import {
6
6
  import type { MonitorType, SJVAirEntryLevel } from "@sjvair/sdk";
7
7
  import { untrack } from "svelte";
8
8
  import type { Geometry } from "geojson";
9
- import { mapManager } from "$lib/map/map.svelte";
9
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
10
10
  import { clickManager, type ClickHandler } from "$lib/map/integrations/click-manager";
11
11
  import { MapIconLayerIntegration } from "$lib/map/integrations/map-geojson-integration.svelte";
12
12
  import { monitorsManager } from "./monitors.svelte";
@@ -274,8 +274,9 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
274
274
  clickManager.unregister([this.referenceId]);
275
275
  this.tooltipManager.disable();
276
276
  this.renderer.remove();
277
- this.icons.loadIcons().then(() => {
278
- if (mapManager.styleLoading) return;
277
+ // Clustering may be toggled again before the icons arrive; only the latest apply runs.
278
+ this.supersedePending();
279
+ this.whenIconsLoad(() => {
279
280
  this.renderer.apply(this.handleMonitorClick);
280
281
  this.tooltipManager.enable();
281
282
  this.applySelectedState();
@@ -286,8 +287,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
286
287
  this.tooltipManager.enable();
287
288
  clickManager.unregister([this.referenceId]);
288
289
  super.apply();
289
- this.icons.loadIcons().then(() => {
290
- if (mapManager.styleLoading) return;
290
+ this.whenIconsLoad(() => {
291
291
  clickManager.register([this.referenceId], this.handleMonitorClick);
292
292
  this.applySelectedState();
293
293
  });
@@ -10,7 +10,15 @@ import {
10
10
  import { XMap } from "@tstk/builtin-extensions";
11
11
  import { Interval } from "@tstk/utils";
12
12
  import { untrack } from "svelte";
13
- import type { MonitorsDataSource } from "./types";
13
+ import type { MonitorsDataSource, MonitorsPollutant } from "./types";
14
+
15
+ export interface MonitorsInitOptions {
16
+ /**
17
+ * Start the 2-minute polling once the initial load finishes. Defaults to true; pass false to
18
+ * load the data without showing a map (`resumeAutoUpdate()` starts polling later).
19
+ */
20
+ autoUpdate?: boolean;
21
+ }
14
22
 
15
23
  class MonitorsManager implements MonitorsDataSource {
16
24
  autoUpdate: Interval = new Interval(async () => await this.update(), 2 * 60 * 1000);
@@ -27,6 +35,10 @@ class MonitorsManager implements MonitorsDataSource {
27
35
  this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null
28
36
  );
29
37
 
38
+ #initPromise: Promise<void> | null = null;
39
+ #requestedPollutant: MonitorsPollutant | null = null;
40
+ #requestedAutoUpdate = false;
41
+
30
42
  constructor() {
31
43
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
32
44
  // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
@@ -48,25 +60,96 @@ class MonitorsManager implements MonitorsDataSource {
48
60
  });
49
61
  }
50
62
 
51
- async init(urlPollutant?: string | number | boolean): Promise<void> {
52
- if (this.initialized) return;
63
+ /**
64
+ * Safe to call from several places: overlapping calls share one run, and a failed run can
65
+ * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
66
+ * between two explicit values the most recent wins. Polling starts when the run finishes
67
+ * unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
68
+ * never restarts polling either: that is `resumeAutoUpdate()`.
69
+ */
70
+ init(urlPollutant?: string | number | boolean, options: MonitorsInitOptions = {}): Promise<void> {
71
+ if (this.initialized) return Promise.resolve();
53
72
 
54
- [this.list, this.meta] = await Promise.all([getMonitorsList(), getMonitorsMeta()]);
73
+ if (options.autoUpdate !== false) this.#requestedAutoUpdate = true;
55
74
 
56
- this.pollutant =
57
- urlPollutant === "pm25" || urlPollutant === "o3" ? urlPollutant : this.meta.default_pollutant;
58
- this.latest = await getMonitorsLatestMap(this.pollutant);
59
- this.autoUpdate.start();
75
+ if (urlPollutant === "pm25" || urlPollutant === "o3") {
76
+ this.#requestedPollutant = urlPollutant;
77
+ }
78
+
79
+ this.#initPromise ??= this.#runInit().finally(() => {
80
+ this.#initPromise = null;
81
+ this.#requestedPollutant = null;
82
+ this.#requestedAutoUpdate = false;
83
+ });
84
+
85
+ return this.#initPromise;
86
+ }
87
+
88
+ async #runInit(): Promise<void> {
89
+ // The pollutant the latest readings were requested for.
90
+ let fetchedPollutant = this.#requestedPollutant;
91
+
92
+ const metaRequest = getMonitorsMeta();
93
+ const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
94
+ this.list = list;
95
+ this.meta = meta;
96
+ return meta;
97
+ });
98
+ // The latest readings only need the pollutant, so they never wait for the list. An explicit
99
+ // pollutant may still arrive before meta does, in which case the default is never fetched.
100
+ const latestRequest = fetchedPollutant
101
+ ? getMonitorsLatestMap(fetchedPollutant)
102
+ : metaRequest.then((meta) => {
103
+ const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
104
+ fetchedPollutant = pollutant;
105
+ return getMonitorsLatestMap(pollutant);
106
+ });
107
+
108
+ // One Promise.all so a failure in either never leaves the other unhandled.
109
+ const [meta, firstLatest] = await Promise.all([listAndMeta, latestRequest]);
110
+ let latest = firstLatest;
111
+
112
+ // An explicit pollutant may have arrived while the requests above were in flight.
113
+ let wanted = this.#requestedPollutant ?? meta.default_pollutant;
114
+ while (wanted !== fetchedPollutant) {
115
+ latest = await getMonitorsLatestMap(wanted);
116
+ fetchedPollutant = wanted;
117
+ wanted = this.#requestedPollutant ?? meta.default_pollutant;
118
+ }
119
+
120
+ this.pollutant = fetchedPollutant;
121
+ this.latest = latest;
122
+ if (this.#requestedAutoUpdate) this.autoUpdate.start();
60
123
  this.initialized = true;
61
124
  }
62
125
 
126
+ /**
127
+ * Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
128
+ * it on destroy) or after an init that opted out of it. Refreshes first since the data may
129
+ * be stale. A no-op before init or while polling.
130
+ */
131
+ resumeAutoUpdate(): void {
132
+ if (!this.initialized || this.autoUpdate.isActive) return;
133
+
134
+ this.update().catch(console.error);
135
+ this.autoUpdate.start();
136
+ }
137
+
63
138
  async update(): Promise<void> {
64
139
  if (!this.initialized) return;
65
140
 
66
- [this.list, this.latest] = await Promise.all([
141
+ const pollutant = this.pollutant;
142
+ const [list, latest] = await Promise.all([
67
143
  getMonitorsList(),
68
- getMonitorsLatestMap(this.pollutant || this.meta?.default_pollutant || "pm25")
144
+ getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
69
145
  ]);
146
+
147
+ // The pollutant changed while this was in flight: these readings belong to the previous
148
+ // one, and the change has already started its own update.
149
+ if (this.pollutant !== pollutant) return;
150
+
151
+ this.list = list;
152
+ this.latest = latest;
70
153
  }
71
154
  }
72
155
 
@@ -0,0 +1,76 @@
1
+ /** The pollutants the detail panel can show, in order of preference. */
2
+ export const PANEL_POLLUTANTS = ["pm25", "o3"] as const;
3
+ export type PanelPollutant = (typeof PANEL_POLLUTANTS)[number];
4
+
5
+ /** The slice of the SDK's `MonitorDetails` this module reads. */
6
+ export interface PanelMonitor {
7
+ type: string;
8
+ is_active: boolean;
9
+ // Only the panel pollutants are read; the SDK's `latest` has more entry types, without an
10
+ // index signature.
11
+ latest: Partial<Record<PanelPollutant, { value?: string }>>;
12
+ }
13
+
14
+ /** The slice of the SDK's `MonitorsMeta` this module reads. */
15
+ export interface PanelMeta {
16
+ monitors: Record<string, { entries: Record<string, unknown> } | undefined>;
17
+ }
18
+
19
+ export interface PanelPollutantChoice {
20
+ /** Null when the monitor has no reading and its type publishes neither pollutant. */
21
+ pollutant: PanelPollutant | null;
22
+ /** Whether the monitor has a current reading for `pollutant`. */
23
+ hasReading: boolean;
24
+ }
25
+
26
+ /** The panel pollutants this monitor has a current, numeric reading for. */
27
+ export function readingPollutants(monitor: Pick<PanelMonitor, "latest">): PanelPollutant[] {
28
+ return PANEL_POLLUTANTS.filter((pollutant) => {
29
+ const value = monitor.latest[pollutant]?.value;
30
+ return typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
31
+ });
32
+ }
33
+
34
+ /**
35
+ * The panel pollutants this monitor type publishes. The API marks each entry type with a
36
+ * `published` flag the SDK does not type; a type that carries the entry without the flag
37
+ * counts as publishing it.
38
+ */
39
+ export function publishedPollutants(monitorType: string, meta: PanelMeta): PanelPollutant[] {
40
+ const entries = meta.monitors[monitorType]?.entries ?? {};
41
+ return PANEL_POLLUTANTS.filter((pollutant) => {
42
+ const entry = entries[pollutant] as { published?: boolean } | undefined;
43
+ return entry !== undefined && entry.published !== false;
44
+ });
45
+ }
46
+
47
+ /**
48
+ * The pollutant the detail panel shows: the map's when the monitor has it, otherwise the one
49
+ * the monitor has. A monitor with no current reading (inactive, or nothing reported) still
50
+ * gets a pollutant when there is one to chart, so its past data can be browsed.
51
+ */
52
+ export function choosePanelPollutant(
53
+ mapPollutant: PanelPollutant | null,
54
+ monitor: PanelMonitor,
55
+ meta: PanelMeta
56
+ ): PanelPollutantChoice {
57
+ const withReading = readingPollutants(monitor);
58
+ // The API keeps returning an inactive monitor's last reading, however old, so only an
59
+ // active monitor's readings count as current.
60
+ if (monitor.is_active && withReading.length > 0) {
61
+ const pollutant =
62
+ mapPollutant && withReading.includes(mapPollutant) ? mapPollutant : withReading[0];
63
+ return { pollutant, hasReading: true };
64
+ }
65
+
66
+ // Nothing current: chart what the type publishes, or failing that what it last reported.
67
+ const published = publishedPollutants(monitor.type, meta);
68
+ const chartable = published.length > 0 ? published : withReading;
69
+ if (chartable.length > 0) {
70
+ const pollutant =
71
+ mapPollutant && chartable.includes(mapPollutant) ? mapPollutant : chartable[0];
72
+ return { pollutant, hasReading: false };
73
+ }
74
+
75
+ return { pollutant: null, hasReading: false };
76
+ }
@@ -42,7 +42,14 @@
42
42
  function onkeydown(e: KeyboardEvent) {
43
43
  if (e.key !== "Escape") return;
44
44
  panelOpen = false;
45
- if (isWideLayout()) menus.closeAll();
45
+ if (!isWideLayout()) return;
46
+ // Focus on a row would be left inside the hidden dropdown; hand it back to the trigger.
47
+ const focused = document.activeElement;
48
+ const trigger = focused
49
+ ?.closest("[data-options-menu]")
50
+ ?.querySelector<HTMLElement>(":scope > button");
51
+ menus.closeAll();
52
+ if (trigger !== focused) trigger?.focus();
46
53
  }
47
54
  </script>
48
55
 
@@ -33,10 +33,19 @@
33
33
 
34
34
  <div
35
35
  role="presentation"
36
+ data-options-menu
36
37
  class="group/menu relative md:first:[&>button]:rounded-l-md md:last:[&>button]:rounded-r-md"
37
38
  onmouseenter={() => {
38
39
  if (isWideLayout()) menus.closeOthers(id);
39
40
  }}
41
+ onfocusout={(e) => {
42
+ // Toolbar only: focus moving on to something outside this menu (tabbing past the last
43
+ // row) closes its dropdown. No destination means a click on something unfocusable, which
44
+ // the outside-click handler in OptionsBar decides.
45
+ const next = e.relatedTarget;
46
+ if (!isWideLayout() || !(next instanceof Node) || e.currentTarget.contains(next)) return;
47
+ menus.close(id);
48
+ }}
40
49
  >
41
50
  <button
42
51
  type="button"
@@ -61,7 +70,7 @@
61
70
  "max-md:pt-1 md:absolute md:top-full md:z-20 md:min-w-64 md:pt-1",
62
71
  alignRight ? "md:right-0" : "md:left-0",
63
72
  open ? "block" : "hidden",
64
- !disabled && "md:group-hover/menu:block md:group-has-[:focus-visible]/menu:block"
73
+ !disabled && "md:group-hover/menu:block"
65
74
  ]}
66
75
  >
67
76
  <div class="py-1 md:max-h-[69vh] md:overflow-y-auto md:rounded-md md:bg-white md:shadow-lg">
@@ -3,7 +3,7 @@
3
3
  import { Marker } from "@maptiler/sdk";
4
4
  import { MapPinIcon, SearchIcon } from "@lucide/svelte";
5
5
  import { useMonitorMapRouter } from "../router-context";
6
- import { mapManager } from "$lib/map/map.svelte";
6
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
7
7
  import { debounce } from "$lib/utils";
8
8
  import { monitorSearch, geocode, type SearchResult } from "./service";
9
9
  import { monitorsMapIntegration } from "$lib/monitors/monitors-map-integration.svelte";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Wraps a loader so overlapping calls share one run and a resolved run is reused, while a
3
+ * rejected run is forgotten: the next call tries again.
4
+ */
5
+ export function sharedLoad<T>(load: () => Promise<T>): () => Promise<T> {
6
+ let pending: Promise<T> | null = null;
7
+
8
+ return () => {
9
+ pending ??= load().catch((error: unknown) => {
10
+ pending = null;
11
+ throw error;
12
+ });
13
+ return pending;
14
+ };
15
+ }