@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.
Files changed (75) hide show
  1. package/README.md +54 -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/core.d.ts +1 -1
  13. package/dist/lib/data-chart/DataChart.svelte +63 -13
  14. package/dist/lib/data-chart/DataChart.svelte.d.ts +10 -5
  15. package/dist/lib/data-chart/DateRange.d.ts +4 -0
  16. package/dist/lib/data-chart/DateRange.js +9 -1
  17. package/dist/lib/data-chart/chart-utils.d.ts +2 -1
  18. package/dist/lib/data-chart/chart-utils.js +19 -23
  19. package/dist/lib/data-chart/level-colors.d.ts +6 -0
  20. package/dist/lib/data-chart/level-colors.js +5 -0
  21. package/dist/lib/data-chart/requests.d.ts +5 -2
  22. package/dist/lib/data-chart/requests.js +2 -3
  23. package/dist/lib/data-chart/tooltip.d.ts +2 -1
  24. package/dist/lib/data-chart/tooltip.js +1 -9
  25. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +4 -6
  26. package/dist/lib/index.d.ts +1 -1
  27. package/dist/lib/lazy-chunks.d.ts +2 -0
  28. package/dist/lib/lazy-chunks.js +6 -0
  29. package/dist/lib/lazy-routes.d.ts +6 -4
  30. package/dist/lib/lazy-routes.js +12 -7
  31. package/dist/lib/map/MapShell.svelte +7 -1
  32. package/dist/lib/map/MapShell.svelte.d.ts +5 -0
  33. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +13 -0
  34. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +30 -4
  35. package/dist/lib/map/map-style-state.svelte.d.ts +10 -0
  36. package/dist/lib/map/map-style-state.svelte.js +56 -5
  37. package/dist/lib/monitors/monitor-details.svelte.d.ts +15 -0
  38. package/dist/lib/monitors/monitor-details.svelte.js +63 -0
  39. package/dist/lib/monitors/monitors-map-integration.svelte.js +4 -6
  40. package/dist/lib/monitors/monitors.svelte.d.ts +14 -4
  41. package/dist/lib/monitors/monitors.svelte.js +13 -5
  42. package/dist/lib/monitors/panel-pollutant.d.ts +37 -0
  43. package/dist/lib/monitors/panel-pollutant.js +43 -0
  44. package/dist/lib/options/OptionsBar.svelte +8 -1
  45. package/dist/lib/options/OptionsMenu.svelte +10 -1
  46. package/dist/lib/shared-load.d.ts +5 -0
  47. package/dist/lib/shared-load.js +14 -0
  48. package/package.json +1 -1
  49. package/src/lib/LazyMapLayout.svelte +53 -0
  50. package/src/lib/LazyMonitorDetailPanel.svelte +33 -0
  51. package/src/lib/LoadError.svelte +19 -0
  52. package/src/lib/LoadScreen.svelte +10 -1
  53. package/src/lib/MonitorDetailPanel.svelte +108 -28
  54. package/src/lib/MonitorMapLayout.svelte +19 -1
  55. package/src/lib/core.ts +1 -1
  56. package/src/lib/data-chart/DataChart.svelte +63 -13
  57. package/src/lib/data-chart/DateRange.ts +11 -1
  58. package/src/lib/data-chart/chart-utils.ts +23 -26
  59. package/src/lib/data-chart/level-colors.ts +13 -0
  60. package/src/lib/data-chart/requests.ts +9 -5
  61. package/src/lib/data-chart/tooltip.ts +2 -10
  62. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +4 -4
  63. package/src/lib/index.ts +1 -1
  64. package/src/lib/lazy-chunks.ts +7 -0
  65. package/src/lib/lazy-routes.ts +12 -7
  66. package/src/lib/map/MapShell.svelte +7 -1
  67. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +32 -3
  68. package/src/lib/map/map-style-state.svelte.ts +58 -6
  69. package/src/lib/monitors/monitor-details.svelte.ts +66 -0
  70. package/src/lib/monitors/monitors-map-integration.svelte.ts +4 -4
  71. package/src/lib/monitors/monitors.svelte.ts +20 -5
  72. package/src/lib/monitors/panel-pollutant.ts +76 -0
  73. package/src/lib/options/OptionsBar.svelte +8 -1
  74. package/src/lib/options/OptionsMenu.svelte +10 -1
  75. package/src/lib/shared-load.ts +15 -0
@@ -5,7 +5,12 @@ import { variantForStyle } from "./basemap";
5
5
  class MapStyleState {
6
6
  referenceStyle = $state.raw(DefaultMapStyle.getReferenceStyle());
7
7
  variant = $state.raw(DefaultMapStyle);
8
+ // The variant last handed to setStyle (loaded or still loading); null once a load has failed
9
+ // with nothing left to fall back to, so that any later selection is tried again.
8
10
  applied = DefaultMapStyle;
11
+ // The variant the map last finished loading, which a failed change falls back to.
12
+ loaded = DefaultMapStyle;
13
+ stopWaiting = null;
9
14
  selectReferenceStyle(style) {
10
15
  this.referenceStyle = style;
11
16
  this.selectVariant(variantForStyle(style, this.variant));
@@ -19,17 +24,63 @@ class MapStyleState {
19
24
  this.selectVariant(DefaultMapStyle);
20
25
  }
21
26
  apply() {
27
+ if (!mapManager.map || this.variant === this.applied)
28
+ return;
29
+ this.load(this.variant);
30
+ }
31
+ load(variant) {
22
32
  const map = mapManager.map;
23
- if (!map || this.variant === this.applied)
33
+ if (!map)
24
34
  return;
25
- this.applied = this.variant;
35
+ // A newer change supersedes one still in flight.
36
+ this.stopWaiting?.();
37
+ this.applied = variant;
26
38
  // setStyle wipes every custom source/layer; re-add enabled integrations once it loads.
27
- map.once("style.load", () => {
39
+ const onLoad = () => {
40
+ this.stopWaiting?.();
41
+ this.loaded = variant;
28
42
  mapManager.styleLoading = false;
29
43
  integrationsManager.refresh();
30
- });
44
+ };
45
+ // "error" also fires for tiles, sprites and glyphs; only the style document failing means
46
+ // the change failed.
47
+ const onError = (event) => {
48
+ if (!isRequestFor(event.error, variant.getExpandedStyleURL()))
49
+ return;
50
+ this.stopWaiting?.();
51
+ this.recover(variant);
52
+ };
53
+ this.stopWaiting = () => {
54
+ map.off("style.load", onLoad);
55
+ map.off("error", onError);
56
+ this.stopWaiting = null;
57
+ };
58
+ map.on("style.load", onLoad);
59
+ map.on("error", onError);
31
60
  mapManager.styleLoading = true;
32
- map.setStyle(this.variant);
61
+ map.setStyle(variant);
33
62
  }
63
+ /**
64
+ * A style that fails to load leaves the map with no style at all, so go back to the last one
65
+ * that loaded and make the selection say so. When that one fails too (e.g. offline) there is
66
+ * nothing to draw on: `styleLoading` stays set, since integrations cannot add layers to a
67
+ * missing style, and the next selection starts a fresh attempt.
68
+ */
69
+ recover(failed) {
70
+ if (failed === this.loaded) {
71
+ this.applied = null;
72
+ return;
73
+ }
74
+ this.variant = this.loaded;
75
+ this.referenceStyle = this.loaded.getReferenceStyle();
76
+ this.load(this.loaded);
77
+ }
78
+ }
79
+ /** Whether a map "error" event's error is a failed request for `url`, ignoring the query. */
80
+ function isRequestFor(error, url) {
81
+ const failed = error?.url;
82
+ if (typeof failed !== "string")
83
+ return false;
84
+ return failed.split("?")[0] === url.split("?")[0];
34
85
  }
35
86
  export const mapStyleState = new MapStyleState();
@@ -0,0 +1,15 @@
1
+ import { type MonitorDetails } from "@sjvair/sdk";
2
+ export type MonitorDetailsStatus = "loading" | "ready" | "not-found" | "failed";
3
+ /**
4
+ * One monitor, fetched by id. Unlike `monitorsManager.latest`, this does not depend on the
5
+ * map's pollutant or on the monitor being active, so the detail panel can show any monitor.
6
+ */
7
+ export declare class MonitorDetailsState {
8
+ #private;
9
+ monitor: MonitorDetails | null;
10
+ status: MonitorDetailsStatus;
11
+ load(id: string): Promise<void>;
12
+ /** Re-fetches the monitor already shown. A failure keeps the data on screen. */
13
+ refresh(): Promise<void>;
14
+ retry(): Promise<void>;
15
+ }
@@ -0,0 +1,63 @@
1
+ import { getMonitorDetails } from "@sjvair/sdk";
2
+ /**
3
+ * One monitor, fetched by id. Unlike `monitorsManager.latest`, this does not depend on the
4
+ * map's pollutant or on the monitor being active, so the detail panel can show any monitor.
5
+ */
6
+ export class MonitorDetailsState {
7
+ monitor = $state.raw(null);
8
+ status = $state("loading");
9
+ #id = null;
10
+ // Bumped per request; a response for anything but the latest request is dropped.
11
+ #request = 0;
12
+ async load(id) {
13
+ this.#id = id;
14
+ const request = ++this.#request;
15
+ this.monitor = null;
16
+ this.status = "loading";
17
+ try {
18
+ const monitor = await getMonitorDetails(id);
19
+ if (request !== this.#request)
20
+ return;
21
+ this.monitor = monitor;
22
+ this.status = "ready";
23
+ }
24
+ catch (error) {
25
+ if (request !== this.#request)
26
+ return;
27
+ if (isNotFound(error)) {
28
+ this.status = "not-found";
29
+ return;
30
+ }
31
+ console.error(error);
32
+ this.status = "failed";
33
+ }
34
+ }
35
+ /** Re-fetches the monitor already shown. A failure keeps the data on screen. */
36
+ async refresh() {
37
+ if (this.status !== "ready" || this.#id === null)
38
+ return;
39
+ const request = ++this.#request;
40
+ try {
41
+ const monitor = await getMonitorDetails(this.#id);
42
+ if (request !== this.#request)
43
+ return;
44
+ this.monitor = monitor;
45
+ }
46
+ catch (error) {
47
+ if (request === this.#request)
48
+ console.error(error);
49
+ }
50
+ }
51
+ retry() {
52
+ return this.#id === null ? Promise.resolve() : this.load(this.#id);
53
+ }
54
+ }
55
+ /**
56
+ * The SDK rejects a failed request with `cause.result.status` set to the HTTP status, or,
57
+ * when it could not parse the error body, with the `Response` itself as the cause.
58
+ */
59
+ function isNotFound(error) {
60
+ const cause = error
61
+ ?.cause;
62
+ return cause?.result?.status === 404 || cause?.status === 404;
63
+ }
@@ -257,9 +257,9 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
257
257
  clickManager.unregister([this.referenceId]);
258
258
  this.tooltipManager.disable();
259
259
  this.renderer.remove();
260
- this.icons.loadIcons().then(() => {
261
- if (mapManager.styleLoading)
262
- return;
260
+ // Clustering may be toggled again before the icons arrive; only the latest apply runs.
261
+ this.supersedePending();
262
+ this.whenIconsLoad(() => {
263
263
  this.renderer.apply(this.handleMonitorClick);
264
264
  this.tooltipManager.enable();
265
265
  this.applySelectedState();
@@ -271,9 +271,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
271
271
  this.tooltipManager.enable();
272
272
  clickManager.unregister([this.referenceId]);
273
273
  super.apply();
274
- this.icons.loadIcons().then(() => {
275
- if (mapManager.styleLoading)
276
- return;
274
+ this.whenIconsLoad(() => {
277
275
  clickManager.register([this.referenceId], this.handleMonitorClick);
278
276
  this.applySelectedState();
279
277
  });
@@ -2,6 +2,13 @@ import { type MonitorData, type MonitorLatestType, type MonitorsMeta, type SJVAi
2
2
  import { XMap } from "@tstk/builtin-extensions";
3
3
  import { Interval } from "@tstk/utils";
4
4
  import type { MonitorsDataSource } from "./types";
5
+ export interface MonitorsInitOptions {
6
+ /**
7
+ * Start the 2-minute polling once the initial load finishes. Defaults to true; pass false to
8
+ * load the data without showing a map (`resumeAutoUpdate()` starts polling later).
9
+ */
10
+ autoUpdate?: boolean;
11
+ }
5
12
  declare class MonitorsManager implements MonitorsDataSource {
6
13
  #private;
7
14
  autoUpdate: Interval;
@@ -15,12 +22,15 @@ declare class MonitorsManager implements MonitorsDataSource {
15
22
  /**
16
23
  * Safe to call from several places: overlapping calls share one run, and a failed run can
17
24
  * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
18
- * between two explicit values the most recent wins.
25
+ * between two explicit values the most recent wins. Polling starts when the run finishes
26
+ * unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
27
+ * never restarts polling either: that is `resumeAutoUpdate()`.
19
28
  */
20
- init(urlPollutant?: string | number | boolean): Promise<void>;
29
+ init(urlPollutant?: string | number | boolean, options?: MonitorsInitOptions): Promise<void>;
21
30
  /**
22
- * Restarts polling after something stopped it (MonitorMapLayout stops it on destroy),
23
- * refreshing first since the data may be stale. A no-op before init or while polling.
31
+ * Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
32
+ * it on destroy) or after an init that opted out of it. Refreshes first since the data may
33
+ * be stale. A no-op before init or while polling.
24
34
  */
25
35
  resumeAutoUpdate(): void;
26
36
  update(): Promise<void>;
@@ -12,6 +12,7 @@ class MonitorsManager {
12
12
  levels = $derived(this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null);
13
13
  #initPromise = null;
14
14
  #requestedPollutant = null;
15
+ #requestedAutoUpdate = false;
15
16
  constructor() {
16
17
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
17
18
  // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
@@ -36,17 +37,22 @@ class MonitorsManager {
36
37
  /**
37
38
  * Safe to call from several places: overlapping calls share one run, and a failed run can
38
39
  * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
39
- * between two explicit values the most recent wins.
40
+ * between two explicit values the most recent wins. Polling starts when the run finishes
41
+ * unless every call sharing it passed `autoUpdate: false`. A no-op once initialized, so it
42
+ * never restarts polling either: that is `resumeAutoUpdate()`.
40
43
  */
41
- init(urlPollutant) {
44
+ init(urlPollutant, options = {}) {
42
45
  if (this.initialized)
43
46
  return Promise.resolve();
47
+ if (options.autoUpdate !== false)
48
+ this.#requestedAutoUpdate = true;
44
49
  if (urlPollutant === "pm25" || urlPollutant === "o3") {
45
50
  this.#requestedPollutant = urlPollutant;
46
51
  }
47
52
  this.#initPromise ??= this.#runInit().finally(() => {
48
53
  this.#initPromise = null;
49
54
  this.#requestedPollutant = null;
55
+ this.#requestedAutoUpdate = false;
50
56
  });
51
57
  return this.#initPromise;
52
58
  }
@@ -80,12 +86,14 @@ class MonitorsManager {
80
86
  }
81
87
  this.pollutant = fetchedPollutant;
82
88
  this.latest = latest;
83
- this.autoUpdate.start();
89
+ if (this.#requestedAutoUpdate)
90
+ this.autoUpdate.start();
84
91
  this.initialized = true;
85
92
  }
86
93
  /**
87
- * Restarts polling after something stopped it (MonitorMapLayout stops it on destroy),
88
- * refreshing first since the data may be stale. A no-op before init or while polling.
94
+ * Starts polling when it is not running: after something stopped it (MonitorMapLayout stops
95
+ * it on destroy) or after an init that opted out of it. Refreshes first since the data may
96
+ * be stale. A no-op before init or while polling.
89
97
  */
90
98
  resumeAutoUpdate() {
91
99
  if (!this.initialized || this.autoUpdate.isActive)
@@ -0,0 +1,37 @@
1
+ /** The pollutants the detail panel can show, in order of preference. */
2
+ export declare const PANEL_POLLUTANTS: readonly ["pm25", "o3"];
3
+ export type PanelPollutant = (typeof PANEL_POLLUTANTS)[number];
4
+ /** The slice of the SDK's `MonitorDetails` this module reads. */
5
+ export interface PanelMonitor {
6
+ type: string;
7
+ is_active: boolean;
8
+ latest: Partial<Record<PanelPollutant, {
9
+ value?: string;
10
+ }>>;
11
+ }
12
+ /** The slice of the SDK's `MonitorsMeta` this module reads. */
13
+ export interface PanelMeta {
14
+ monitors: Record<string, {
15
+ entries: Record<string, unknown>;
16
+ } | undefined>;
17
+ }
18
+ export interface PanelPollutantChoice {
19
+ /** Null when the monitor has no reading and its type publishes neither pollutant. */
20
+ pollutant: PanelPollutant | null;
21
+ /** Whether the monitor has a current reading for `pollutant`. */
22
+ hasReading: boolean;
23
+ }
24
+ /** The panel pollutants this monitor has a current, numeric reading for. */
25
+ export declare function readingPollutants(monitor: Pick<PanelMonitor, "latest">): PanelPollutant[];
26
+ /**
27
+ * The panel pollutants this monitor type publishes. The API marks each entry type with a
28
+ * `published` flag the SDK does not type; a type that carries the entry without the flag
29
+ * counts as publishing it.
30
+ */
31
+ export declare function publishedPollutants(monitorType: string, meta: PanelMeta): PanelPollutant[];
32
+ /**
33
+ * The pollutant the detail panel shows: the map's when the monitor has it, otherwise the one
34
+ * the monitor has. A monitor with no current reading (inactive, or nothing reported) still
35
+ * gets a pollutant when there is one to chart, so its past data can be browsed.
36
+ */
37
+ export declare function choosePanelPollutant(mapPollutant: PanelPollutant | null, monitor: PanelMonitor, meta: PanelMeta): PanelPollutantChoice;
@@ -0,0 +1,43 @@
1
+ /** The pollutants the detail panel can show, in order of preference. */
2
+ export const PANEL_POLLUTANTS = ["pm25", "o3"];
3
+ /** The panel pollutants this monitor has a current, numeric reading for. */
4
+ export function readingPollutants(monitor) {
5
+ return PANEL_POLLUTANTS.filter((pollutant) => {
6
+ const value = monitor.latest[pollutant]?.value;
7
+ return typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
8
+ });
9
+ }
10
+ /**
11
+ * The panel pollutants this monitor type publishes. The API marks each entry type with a
12
+ * `published` flag the SDK does not type; a type that carries the entry without the flag
13
+ * counts as publishing it.
14
+ */
15
+ export function publishedPollutants(monitorType, meta) {
16
+ const entries = meta.monitors[monitorType]?.entries ?? {};
17
+ return PANEL_POLLUTANTS.filter((pollutant) => {
18
+ const entry = entries[pollutant];
19
+ return entry !== undefined && entry.published !== false;
20
+ });
21
+ }
22
+ /**
23
+ * The pollutant the detail panel shows: the map's when the monitor has it, otherwise the one
24
+ * the monitor has. A monitor with no current reading (inactive, or nothing reported) still
25
+ * gets a pollutant when there is one to chart, so its past data can be browsed.
26
+ */
27
+ export function choosePanelPollutant(mapPollutant, monitor, meta) {
28
+ const withReading = readingPollutants(monitor);
29
+ // The API keeps returning an inactive monitor's last reading, however old, so only an
30
+ // active monitor's readings count as current.
31
+ if (monitor.is_active && withReading.length > 0) {
32
+ const pollutant = mapPollutant && withReading.includes(mapPollutant) ? mapPollutant : withReading[0];
33
+ return { pollutant, hasReading: true };
34
+ }
35
+ // Nothing current: chart what the type publishes, or failing that what it last reported.
36
+ const published = publishedPollutants(monitor.type, meta);
37
+ const chartable = published.length > 0 ? published : withReading;
38
+ if (chartable.length > 0) {
39
+ const pollutant = mapPollutant && chartable.includes(mapPollutant) ? mapPollutant : chartable[0];
40
+ return { pollutant, hasReading: false };
41
+ }
42
+ return { pollutant: null, hasReading: false };
43
+ }
@@ -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">
@@ -0,0 +1,5 @@
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 declare function sharedLoad<T>(load: () => Promise<T>): () => Promise<T>;
@@ -0,0 +1,14 @@
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(load) {
6
+ let pending = null;
7
+ return () => {
8
+ pending ??= load().catch((error) => {
9
+ pending = null;
10
+ throw error;
11
+ });
12
+ return pending;
13
+ };
14
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjvair/monitor-map",
3
3
  "description": "SJVAir's interactive monitor map",
4
- "version": "4.1.0",
4
+ "version": "4.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/SJVAir/monitor-map"
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import type { Component, Snippet } from "svelte";
3
+ import LoadScreen from "./LoadScreen.svelte";
4
+ import LoadError from "./LoadError.svelte";
5
+ import { loadMonitorDetailPanel, loadMonitorMapLayout } from "./lazy-chunks";
6
+ import { useMonitorMapRouter } from "./router-context";
7
+
8
+ interface Props {
9
+ children: Snippet;
10
+ }
11
+
12
+ let { children }: Props = $props();
13
+
14
+ const { route, basePath } = useMonitorMapRouter();
15
+
16
+ let Layout: Component<Props> | null = $state.raw(null);
17
+ let failed = $state(false);
18
+
19
+ function load() {
20
+ failed = false;
21
+ loadMonitorMapLayout().then(
22
+ (module) => (Layout = module.default),
23
+ (error) => {
24
+ console.error(error);
25
+ failed = true;
26
+ }
27
+ );
28
+ }
29
+
30
+ load();
31
+ // On a link straight to a monitor, fetch its panel alongside the layout; the panel's own
32
+ // wrapper only mounts (and would only ask for it) once the layout has arrived. A failure is
33
+ // reported by that wrapper when it asks again.
34
+ if (route.pathname.startsWith(`${basePath}/monitor/`)) {
35
+ loadMonitorDetailPanel().catch(() => {});
36
+ }
37
+ </script>
38
+
39
+ <!-- The layout renders the same LoadScreen from the same shared state, so it carries on
40
+ from this one without restarting. -->
41
+ {#if Layout}
42
+ <Layout>{@render children()}</Layout>
43
+ {:else}
44
+ <div class="relative h-full w-full">
45
+ <LoadScreen>
46
+ {#snippet error()}
47
+ {#if failed}
48
+ <LoadError message="Couldn't load the map." onretry={load} />
49
+ {/if}
50
+ {/snippet}
51
+ </LoadScreen>
52
+ </div>
53
+ {/if}
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import type { Component } from "svelte";
3
+ import LoadError from "./LoadError.svelte";
4
+ import { loadMonitorDetailPanel } from "./lazy-chunks";
5
+
6
+ let Panel: Component | null = $state.raw(null);
7
+ let failed = $state(false);
8
+
9
+ function load() {
10
+ failed = false;
11
+ loadMonitorDetailPanel().then(
12
+ (module) => (Panel = module.default),
13
+ (error) => {
14
+ console.error(error);
15
+ failed = true;
16
+ }
17
+ );
18
+ }
19
+
20
+ load();
21
+ </script>
22
+
23
+ {#if Panel}
24
+ <Panel />
25
+ {:else}
26
+ <div class="flex h-full w-full items-center justify-center bg-white">
27
+ {#if failed}
28
+ <LoadError message="Couldn't load this monitor." onretry={load} />
29
+ {:else}
30
+ <p class="text-sm text-gray-500">Loading…</p>
31
+ {/if}
32
+ </div>
33
+ {/if}
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ message: string;
4
+ onretry: () => void;
5
+ }
6
+
7
+ let { message, onretry }: Props = $props();
8
+ </script>
9
+
10
+ <div class="flex flex-col items-center gap-3 px-4 text-center" role="alert">
11
+ <p class="text-gray-700">{message}</p>
12
+ <button
13
+ type="button"
14
+ class="rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
15
+ onclick={onretry}
16
+ >
17
+ Try again
18
+ </button>
19
+ </div>
@@ -23,8 +23,16 @@
23
23
  </script>
24
24
 
25
25
  <script lang="ts">
26
+ import type { Snippet } from "svelte";
26
27
  import type { Attachment } from "svelte/attachments";
27
28
 
29
+ interface Props {
30
+ /** Rendered under the logo while the load screen is up, e.g. a load failure and a retry */
31
+ error?: Snippet;
32
+ }
33
+
34
+ let { error }: Props = $props();
35
+
28
36
  const animationTrigger: Attachment<HTMLDivElement> = () => {
29
37
  loadScreenState.entered = true;
30
38
  };
@@ -32,7 +40,7 @@
32
40
 
33
41
  {#if loadScreenState.enabled}
34
42
  <div
35
- class="absolute top-0 left-0 z-50 flex h-full w-full items-center justify-center overflow-hidden bg-white"
43
+ class="absolute top-0 left-0 z-50 flex h-full w-full flex-col items-center justify-center gap-6 overflow-hidden bg-white"
36
44
  {@attach animationTrigger}
37
45
  >
38
46
  <div class="flex justify-around gap-4">
@@ -64,5 +72,6 @@
64
72
  class:opacity-0={!loadScreenState.entered}
65
73
  />
66
74
  </div>
75
+ {@render error?.()}
67
76
  </div>
68
77
  {/if}