@sjvair/monitor-map 4.0.3 → 4.1.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 (55) hide show
  1. package/README.md +42 -0
  2. package/dist/lib/MonitorMapLayout.svelte +2 -0
  3. package/dist/lib/collocation-sites/collocations-map-integration.svelte.js +1 -1
  4. package/dist/lib/core.d.ts +12 -0
  5. package/dist/lib/core.js +10 -0
  6. package/dist/lib/ev-stations/ev-stations-cluster-renderer.js +1 -1
  7. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +1 -1
  8. package/dist/lib/hms/hms-fire-map-integration.svelte.js +1 -1
  9. package/dist/lib/hms/hms-smoke-map-integration.svelte.js +1 -1
  10. package/dist/lib/index.d.ts +7 -2
  11. package/dist/lib/index.js +6 -1
  12. package/dist/lib/lazy-routes.d.ts +10 -0
  13. package/dist/lib/lazy-routes.js +13 -0
  14. package/dist/lib/map/Map.svelte +1 -1
  15. package/dist/lib/map/MapShell.svelte +2 -2
  16. package/dist/lib/map/integrations/click-manager.js +1 -1
  17. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +1 -1
  18. package/dist/lib/map/integrations/map-icon-manager.js +1 -1
  19. package/dist/lib/map/integrations/map-layer-integration.svelte.js +1 -1
  20. package/dist/lib/map/integrations/tooltip.svelte.js +1 -1
  21. package/dist/lib/map/map-style-state.svelte.js +1 -1
  22. package/dist/lib/monitors/components/MonitorClusterTooltip.svelte +1 -1
  23. package/dist/lib/monitors/monitors-cluster-renderer.js +1 -1
  24. package/dist/lib/monitors/monitors-icon-manager.svelte.js +1 -1
  25. package/dist/lib/monitors/monitors-map-integration.svelte.js +1 -1
  26. package/dist/lib/monitors/monitors.svelte.d.ts +11 -0
  27. package/dist/lib/monitors/monitors.svelte.js +67 -8
  28. package/dist/lib/search/Search.svelte +1 -1
  29. package/package.json +13 -2
  30. package/src/lib/MonitorMapLayout.svelte +2 -0
  31. package/src/lib/collocation-sites/collocations-map-integration.svelte.ts +1 -1
  32. package/src/lib/core.ts +17 -0
  33. package/src/lib/ev-stations/ev-stations-cluster-renderer.ts +1 -1
  34. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +1 -1
  35. package/src/lib/hms/hms-fire-map-integration.svelte.ts +1 -1
  36. package/src/lib/hms/hms-smoke-map-integration.svelte.ts +1 -1
  37. package/src/lib/index.ts +7 -2
  38. package/src/lib/lazy-routes.ts +15 -0
  39. package/src/lib/map/Map.svelte +1 -1
  40. package/src/lib/map/MapShell.svelte +2 -2
  41. package/src/lib/map/integrations/click-manager.ts +1 -1
  42. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +1 -1
  43. package/src/lib/map/integrations/map-icon-manager.ts +1 -1
  44. package/src/lib/map/integrations/map-layer-integration.svelte.ts +1 -1
  45. package/src/lib/map/integrations/tooltip.svelte.ts +1 -1
  46. package/src/lib/map/map-style-state.svelte.ts +1 -1
  47. package/src/lib/monitors/components/MonitorClusterTooltip.svelte +1 -1
  48. package/src/lib/monitors/monitors-cluster-renderer.ts +1 -1
  49. package/src/lib/monitors/monitors-icon-manager.svelte.ts +1 -1
  50. package/src/lib/monitors/monitors-map-integration.svelte.ts +1 -1
  51. package/src/lib/monitors/monitors.svelte.ts +77 -9
  52. package/src/lib/search/Search.svelte +1 -1
  53. /package/dist/lib/map/{map.svelte.d.ts → map-manager.svelte.d.ts} +0 -0
  54. /package/dist/lib/map/{map.svelte.js → map-manager.svelte.js} +0 -0
  55. /package/src/lib/map/{map.svelte.ts → map-manager.svelte.ts} +0 -0
package/README.md CHANGED
@@ -50,6 +50,48 @@ export const { route, navigate, p, isActive, Router } = createRouter({
50
50
  This is what the sjvair.com widget and `sjvair-mobile` use today, via `MonitorMapLayout`
51
51
  (the thin wrapper `monitorMapRoutes` points to).
52
52
 
53
+ ### Keeping the map off a host app's startup path
54
+
55
+ The package root re-exports everything, and its `monitorMapRoutes` imports the map layout
56
+ eagerly, so referencing it in a router puts maplibre (about 2 MB) in the startup bundle. A
57
+ host whose first screen is not the map uses the two subpath entries instead:
58
+
59
+ ```ts
60
+ // router.ts
61
+ import { createRouter } from "sv-router";
62
+ import { monitorMapRoutes } from "@sjvair/monitor-map/routes";
63
+
64
+ export const { route, navigate, p, isActive, Router } = createRouter({
65
+ "/my-map": monitorMapRoutes
66
+ });
67
+ ```
68
+
69
+ ```svelte
70
+ <!-- app root -->
71
+ <script>
72
+ import { provideMonitorMapRouter } from "@sjvair/monitor-map/core";
73
+ import { route, navigate, p, isActive, Router } from "./router";
74
+
75
+ provideMonitorMapRouter({ route, navigate, p, isActive, basePath: "/my-map" });
76
+ </script>
77
+
78
+ <Router />
79
+ ```
80
+
81
+ - `@sjvair/monitor-map/core` exports the router context (`provideMonitorMapRouter`,
82
+ `useMonitorMapRouter`, `ROUTER_KEY`, `MonitorMapRouterApi`) and `monitorsManager`, with
83
+ no map code behind them.
84
+ - `@sjvair/monitor-map/routes` exports a `monitorMapRoutes` with the same route tree, whose
85
+ layout and routes load on demand. On a cold deep link into the map, the router outlet is
86
+ empty until that chunk arrives.
87
+
88
+ Both entries share their modules with the package root, so `monitorsManager` is the same
89
+ instance whichever entry it is imported from.
90
+
91
+ `monitorsManager.init()` may be called from several places (for example, a background
92
+ preload and the map layout): overlapping calls share one set of requests, a failed call can
93
+ be retried, and an explicit `"pm25"`/`"o3"` argument wins over a call without one.
94
+
53
95
  ### Embedding `MapShell` directly, with a reduced integration set
54
96
 
55
97
  `MonitorMapLayout` is an opinionated wrapper around the lower-level `MapShell`
@@ -62,6 +62,8 @@
62
62
  }
63
63
 
64
64
  monitorsManager.init(route.search.pollutant);
65
+ // A previous instance of this layout stopped polling in onDestroy; init() is a no-op by now.
66
+ monitorsManager.resumeAutoUpdate();
65
67
  collocationSitesManager.init();
66
68
  hmsManager.init();
67
69
  monitorsMapIntegration.onMonitorClick = (id: string) => {
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map/map.svelte";
1
+ import { mapManager } from "../map/map-manager.svelte.js";
2
2
  import { MapIconLayerIntegration } from "../map/integrations/map-geojson-integration.svelte";
3
3
  import { CollocationIconManager, getCollocationIconId } from "./collocations-icon-manager.svelte";
4
4
  import { TooltipManager } from "../map/integrations/tooltip.svelte";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Light entry (`@sjvair/monitor-map/core`): what a host app needs at startup, without the map.
3
+ *
4
+ * Nothing imported here may reach @maptiler/sdk, @maptiler/weather or uplot at runtime;
5
+ * `npm run test:bundle` enforces it. Everything is re-exported from the same modules the
6
+ * main entry uses, so `monitorsManager` and the router context stay single instances
7
+ * whichever entries a host mixes.
8
+ */
9
+ export { ROUTER_KEY, provideMonitorMapRouter, useMonitorMapRouter, type MonitorMapRouterApi } from "./router-context";
10
+ export { monitorsManager } from "./monitors/monitors.svelte.js";
11
+ export type { MonitorsManager } from "./monitors/monitors.svelte.js";
12
+ export type { MonitorsDataSource, MonitorsPollutant } from "./monitors/types";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Light entry (`@sjvair/monitor-map/core`): what a host app needs at startup, without the map.
3
+ *
4
+ * Nothing imported here may reach @maptiler/sdk, @maptiler/weather or uplot at runtime;
5
+ * `npm run test:bundle` enforces it. Everything is re-exported from the same modules the
6
+ * main entry uses, so `monitorsManager` and the router context stay single instances
7
+ * whichever entries a host mixes.
8
+ */
9
+ export { ROUTER_KEY, provideMonitorMapRouter, useMonitorMapRouter } from "./router-context";
10
+ export { monitorsManager } from "./monitors/monitors.svelte.js";
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map/map.svelte";
1
+ import { mapManager } from "../map/map-manager.svelte.js";
2
2
  import { mountPopup } from "../map/utils";
3
3
  import { clickManager } from "../map/integrations/click-manager";
4
4
  import { EV_STATION_LEVELS } from "./ev-station-icons";
@@ -1,5 +1,5 @@
1
1
  import { untrack } from "svelte";
2
- import { mapManager } from "../map/map.svelte";
2
+ import { mapManager } from "../map/map-manager.svelte.js";
3
3
  import { clickManager } from "../map/integrations/click-manager";
4
4
  import { MapIconLayerIntegration } from "../map/integrations/map-geojson-integration.svelte";
5
5
  import { MapDisplayOption } from "../map/integrations/map-display-option.svelte";
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map/map.svelte";
1
+ import { mapManager } from "../map/map-manager.svelte.js";
2
2
  import { MapIconLayerIntegration } from "../map/integrations/map-geojson-integration.svelte";
3
3
  import { hmsManager } from "./hms.svelte";
4
4
  import { HMSFireIconManager, getTierIconId } from "./hms-fire-icon-manager";
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map/map.svelte";
1
+ import { mapManager } from "../map/map-manager.svelte.js";
2
2
  import { MapGeoJSONIntegration } from "../map/integrations/map-geojson-integration.svelte";
3
3
  import { hmsManager } from "./hms.svelte";
4
4
  /** Smoke fill styles, shared by the map layer and the legend so they can't drift apart. */
@@ -23,6 +23,11 @@
23
23
  *
24
24
  * <Router />
25
25
  * ```
26
+ *
27
+ * A host that must stay light at startup imports from the subpath entries instead:
28
+ * `provideMonitorMapRouter`/`monitorsManager` from "@sjvair/monitor-map/core" (no map), and
29
+ * `monitorMapRoutes` from "@sjvair/monitor-map/routes" (same tree, lazy layout and routes).
30
+ * `monitorMapRoutes` from this entry stays eager.
26
31
  */
27
32
  export { monitorMapRoutes } from "./routes";
28
33
  export { ROUTER_KEY, provideMonitorMapRouter, useMonitorMapRouter, type MonitorMapRouterApi } from "./router-context";
@@ -34,8 +39,8 @@ export { evStationsManager } from "./ev-stations/ev-stations.svelte";
34
39
  export type { EvStationsManager } from "./ev-stations/ev-stations.svelte";
35
40
  export { hmsManager } from "./hms/hms.svelte";
36
41
  export type { HMSManager } from "./hms/hms.svelte";
37
- export { mapManager, DefaultMapStyle, initializeMap } from "./map/map.svelte";
38
- export type { MapManager } from "./map/map.svelte";
42
+ export { mapManager, DefaultMapStyle, initializeMap } from "./map/map-manager.svelte.js";
43
+ export type { MapManager } from "./map/map-manager.svelte.js";
39
44
  export { monitorsMapIntegration, MonitorsMapIntegration } from "./monitors/monitors-map-integration.svelte";
40
45
  export { collocationSitesMapIntegration } from "./collocation-sites/collocations-map-integration.svelte";
41
46
  export { evStationsMapIntegration } from "./ev-stations/ev-stations-map-integration.svelte";
package/dist/lib/index.js CHANGED
@@ -23,6 +23,11 @@
23
23
  *
24
24
  * <Router />
25
25
  * ```
26
+ *
27
+ * A host that must stay light at startup imports from the subpath entries instead:
28
+ * `provideMonitorMapRouter`/`monitorsManager` from "@sjvair/monitor-map/core" (no map), and
29
+ * `monitorMapRoutes` from "@sjvair/monitor-map/routes" (same tree, lazy layout and routes).
30
+ * `monitorMapRoutes` from this entry stays eager.
26
31
  */
27
32
  export { monitorMapRoutes } from "./routes";
28
33
  export { ROUTER_KEY, provideMonitorMapRouter, useMonitorMapRouter } from "./router-context";
@@ -31,7 +36,7 @@ export { monitorsManager } from "./monitors/monitors.svelte";
31
36
  export { collocationSitesManager } from "./collocation-sites/collocations.svelte";
32
37
  export { evStationsManager } from "./ev-stations/ev-stations.svelte";
33
38
  export { hmsManager } from "./hms/hms.svelte";
34
- export { mapManager, DefaultMapStyle, initializeMap } from "./map/map.svelte";
39
+ export { mapManager, DefaultMapStyle, initializeMap } from "./map/map-manager.svelte.js";
35
40
  // Map integrations
36
41
  export { monitorsMapIntegration, MonitorsMapIntegration } from "./monitors/monitors-map-integration.svelte";
37
42
  export { collocationSitesMapIntegration } from "./collocation-sites/collocations-map-integration.svelte";
@@ -0,0 +1,10 @@
1
+ import type { Routes } from "sv-router";
2
+ /**
3
+ * Lazy routes entry (`@sjvair/monitor-map/routes`): the same route tree as the main entry's
4
+ * `monitorMapRoutes`, but the layout and every route load on demand, so a host router can
5
+ * reference it at startup without loading the map.
6
+ *
7
+ * Each component must stay a literal `() => import(...)`: sv-router recognises lazy
8
+ * components by matching the function's source text, so a helper wrapper would break it.
9
+ */
10
+ export declare const monitorMapRoutes: Routes;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lazy routes entry (`@sjvair/monitor-map/routes`): the same route tree as the main entry's
3
+ * `monitorMapRoutes`, but the layout and every route load on demand, so a host router can
4
+ * reference it at startup without loading the map.
5
+ *
6
+ * Each component must stay a literal `() => import(...)`: sv-router recognises lazy
7
+ * components by matching the function's source text, so a helper wrapper would break it.
8
+ */
9
+ export const monitorMapRoutes = {
10
+ layout: () => import("./MonitorMapLayout.svelte"),
11
+ "/": () => import("./MainMapView.svelte"),
12
+ "/monitor/:id": () => import("./MonitorDetailPanel.svelte")
13
+ };
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import "@maptiler/sdk/dist/maptiler-sdk.css";
3
- import { initializeMap } from "./map.svelte";
3
+ import { initializeMap } from "./map-manager.svelte.js";
4
4
  import { integrationsManager } from "./integrations/integrations-manager";
5
5
  import type { SomeMapIntegration } from "./integrations/types";
6
6
 
@@ -53,7 +53,7 @@
53
53
  import LoadScreen, { disable as disableLoadScreen } from "../LoadScreen.svelte";
54
54
  import Map from "./Map.svelte";
55
55
  import OptionsBar from "../options/OptionsBar.svelte";
56
- import { mapManager } from "./map.svelte";
56
+ import { mapManager } from "./map-manager.svelte.js";
57
57
  import { useMonitorMapRouter } from "../router-context";
58
58
 
59
59
  let {
@@ -74,7 +74,7 @@
74
74
 
75
75
  $effect(() => {
76
76
  // mapManager.map is only ever assigned once its own initial "load" has
77
- // already fired (see initializeMap in map.svelte.ts), so by the time this
77
+ // already fired (see initializeMap in map-manager.svelte.ts), so by the time this
78
78
  // effect sees a non-null map, it's already safe to reveal — waiting for a
79
79
  // further "idle" event here is unreliable since one may never fire again.
80
80
  if (mapManager.map && ready) {
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map.svelte";
1
+ import { mapManager } from "../map-manager.svelte.js";
2
2
  class ClickManager {
3
3
  registrations = [];
4
4
  isListening = false;
@@ -1,5 +1,5 @@
1
1
  import { MapLayerIntegration } from "./map-layer-integration.svelte";
2
- import { mapManager } from "../map.svelte";
2
+ import { mapManager } from "../map-manager.svelte.js";
3
3
  export class MapGeoJSONIntegration extends MapLayerIntegration {
4
4
  remove() {
5
5
  this.removeLayerAndSource();
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map.svelte";
1
+ import { mapManager } from "../map-manager.svelte.js";
2
2
  import { SvelteMap } from "svelte/reactivity";
3
3
  export class MapIconManager {
4
4
  icons = new SvelteMap();
@@ -1,5 +1,5 @@
1
1
  import { untrack } from "svelte";
2
- import { mapManager } from "../map.svelte";
2
+ import { mapManager } from "../map-manager.svelte.js";
3
3
  import { MapIntegration } from "./map-integration.svelte";
4
4
  export class MapLayerIntegration extends MapIntegration {
5
5
  beforeLayer;
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map.svelte";
1
+ import { mapManager } from "../map-manager.svelte.js";
2
2
  import { XMap } from "@tstk/builtin-extensions";
3
3
  function handleZoom() {
4
4
  TooltipManager.activeHandle?.clearTooltip();
@@ -1,4 +1,4 @@
1
- import { DefaultMapStyle, mapManager } from "./map.svelte";
1
+ import { DefaultMapStyle, mapManager } from "./map-manager.svelte.js";
2
2
  import { integrationsManager } from "./integrations/integrations-manager";
3
3
  import { variantForStyle } from "./basemap";
4
4
  /** Basemap + theme selection, shared so the Settings menu and Reset Map Options agree. */
@@ -6,7 +6,7 @@
6
6
  import { area } from "@turf/area";
7
7
  import { convertArea } from "@turf/helpers";
8
8
  import DataBox from "../../components/DataBox.svelte";
9
- import { mapManager } from "../../map/map.svelte";
9
+ import { mapManager } from "../../map/map-manager.svelte.js";
10
10
  import type { MonitorClusterMapFeature, MonitorMapFeature } from "../types";
11
11
  import { monitorsManager } from "../monitors.svelte";
12
12
  import { getCurrentLevel } from "../monitor-utils";
@@ -1,4 +1,4 @@
1
- import { mapManager } from "../map/map.svelte";
1
+ import { mapManager } from "../map/map-manager.svelte.js";
2
2
  import { mountPopup } from "../map/utils";
3
3
  import { clickManager } from "../map/integrations/click-manager";
4
4
  import { getTypeShape } from "./monitor-utils";
@@ -1,6 +1,6 @@
1
1
  import { asDataURI, circle, square, triangle } from "../map/icons";
2
2
  import { MapIconManager } from "../map/integrations/map-icon-manager";
3
- import { mapManager } from "../map/map.svelte";
3
+ import { mapManager } from "../map/map-manager.svelte.js";
4
4
  const MONITOR_ICONS = { circle, square, triangle };
5
5
  const MONITOR_ICON_BORDER_WIDTH = 2;
6
6
  const MONITOR_ICON_DEFAULT_COLOR = "#969696"; // light gray
@@ -1,5 +1,5 @@
1
1
  import { untrack } from "svelte";
2
- import { mapManager } from "../map/map.svelte";
2
+ import { mapManager } from "../map/map-manager.svelte.js";
3
3
  import { clickManager } from "../map/integrations/click-manager";
4
4
  import { MapIconLayerIntegration } from "../map/integrations/map-geojson-integration.svelte";
5
5
  import { monitorsManager } from "./monitors.svelte";
@@ -3,6 +3,7 @@ import { XMap } from "@tstk/builtin-extensions";
3
3
  import { Interval } from "@tstk/utils";
4
4
  import type { MonitorsDataSource } from "./types";
5
5
  declare class MonitorsManager implements MonitorsDataSource {
6
+ #private;
6
7
  autoUpdate: Interval;
7
8
  initialized: boolean;
8
9
  latest: XMap<string, MonitorLatestType<"pm25" | "o3">> | null;
@@ -11,7 +12,17 @@ declare class MonitorsManager implements MonitorsDataSource {
11
12
  pollutant: "pm25" | "o3" | null;
12
13
  levels: Array<SJVAirEntryLevel> | null;
13
14
  constructor();
15
+ /**
16
+ * Safe to call from several places: overlapping calls share one run, and a failed run can
17
+ * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
18
+ * between two explicit values the most recent wins.
19
+ */
14
20
  init(urlPollutant?: string | number | boolean): Promise<void>;
21
+ /**
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.
24
+ */
25
+ resumeAutoUpdate(): void;
15
26
  update(): Promise<void>;
16
27
  }
17
28
  export declare const monitorsManager: MonitorsManager;
@@ -10,6 +10,8 @@ class MonitorsManager {
10
10
  meta = $state(null);
11
11
  pollutant = $state(null);
12
12
  levels = $derived(this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null);
13
+ #initPromise = null;
14
+ #requestedPollutant = null;
13
15
  constructor() {
14
16
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
15
17
  // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
@@ -31,23 +33,80 @@ class MonitorsManager {
31
33
  });
32
34
  });
33
35
  }
34
- async init(urlPollutant) {
36
+ /**
37
+ * Safe to call from several places: overlapping calls share one run, and a failed run can
38
+ * 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
+ */
41
+ init(urlPollutant) {
35
42
  if (this.initialized)
36
- return;
37
- [this.list, this.meta] = await Promise.all([getMonitorsList(), getMonitorsMeta()]);
38
- this.pollutant =
39
- urlPollutant === "pm25" || urlPollutant === "o3" ? urlPollutant : this.meta.default_pollutant;
40
- this.latest = await getMonitorsLatestMap(this.pollutant);
43
+ return Promise.resolve();
44
+ if (urlPollutant === "pm25" || urlPollutant === "o3") {
45
+ this.#requestedPollutant = urlPollutant;
46
+ }
47
+ this.#initPromise ??= this.#runInit().finally(() => {
48
+ this.#initPromise = null;
49
+ this.#requestedPollutant = null;
50
+ });
51
+ return this.#initPromise;
52
+ }
53
+ async #runInit() {
54
+ // The pollutant the latest readings were requested for.
55
+ let fetchedPollutant = this.#requestedPollutant;
56
+ const metaRequest = getMonitorsMeta();
57
+ const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
58
+ this.list = list;
59
+ this.meta = meta;
60
+ return meta;
61
+ });
62
+ // The latest readings only need the pollutant, so they never wait for the list. An explicit
63
+ // pollutant may still arrive before meta does, in which case the default is never fetched.
64
+ const latestRequest = fetchedPollutant
65
+ ? getMonitorsLatestMap(fetchedPollutant)
66
+ : metaRequest.then((meta) => {
67
+ const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
68
+ fetchedPollutant = pollutant;
69
+ return getMonitorsLatestMap(pollutant);
70
+ });
71
+ // One Promise.all so a failure in either never leaves the other unhandled.
72
+ const [meta, firstLatest] = await Promise.all([listAndMeta, latestRequest]);
73
+ let latest = firstLatest;
74
+ // An explicit pollutant may have arrived while the requests above were in flight.
75
+ let wanted = this.#requestedPollutant ?? meta.default_pollutant;
76
+ while (wanted !== fetchedPollutant) {
77
+ latest = await getMonitorsLatestMap(wanted);
78
+ fetchedPollutant = wanted;
79
+ wanted = this.#requestedPollutant ?? meta.default_pollutant;
80
+ }
81
+ this.pollutant = fetchedPollutant;
82
+ this.latest = latest;
41
83
  this.autoUpdate.start();
42
84
  this.initialized = true;
43
85
  }
86
+ /**
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.
89
+ */
90
+ resumeAutoUpdate() {
91
+ if (!this.initialized || this.autoUpdate.isActive)
92
+ return;
93
+ this.update().catch(console.error);
94
+ this.autoUpdate.start();
95
+ }
44
96
  async update() {
45
97
  if (!this.initialized)
46
98
  return;
47
- [this.list, this.latest] = await Promise.all([
99
+ const pollutant = this.pollutant;
100
+ const [list, latest] = await Promise.all([
48
101
  getMonitorsList(),
49
- getMonitorsLatestMap(this.pollutant || this.meta?.default_pollutant || "pm25")
102
+ getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
50
103
  ]);
104
+ // The pollutant changed while this was in flight: these readings belong to the previous
105
+ // one, and the change has already started its own update.
106
+ if (this.pollutant !== pollutant)
107
+ return;
108
+ this.list = list;
109
+ this.latest = latest;
51
110
  }
52
111
  }
53
112
  async function getMonitorsLatestMap(pollutant) {
@@ -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 "../map/map.svelte";
6
+ import { mapManager } from "../map/map-manager.svelte.js";
7
7
  import { debounce } from "../utils";
8
8
  import { monitorSearch, geocode, type SearchResult } from "./service";
9
9
  import { monitorsMapIntegration } from "../monitors/monitors-map-integration.svelte";
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.0.3",
4
+ "version": "4.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/SJVAir/monitor-map"
@@ -16,7 +16,8 @@
16
16
  "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
17
17
  "format": "prettier --write .",
18
18
  "lint": "prettier --check . && eslint .",
19
- "test": "vitest run"
19
+ "test": "vitest run",
20
+ "test:bundle": "npm run build:lib && node tests/bundle/check-bundle.mjs"
20
21
  },
21
22
  "type": "module",
22
23
  "svelte": "./dist/lib/index.js",
@@ -26,6 +27,16 @@
26
27
  "svelte": "./dist/lib/index.js",
27
28
  "types": "./dist/lib/index.d.ts",
28
29
  "default": "./dist/lib/index.js"
30
+ },
31
+ "./core": {
32
+ "svelte": "./dist/lib/core.js",
33
+ "types": "./dist/lib/core.d.ts",
34
+ "default": "./dist/lib/core.js"
35
+ },
36
+ "./routes": {
37
+ "svelte": "./dist/lib/lazy-routes.js",
38
+ "types": "./dist/lib/lazy-routes.d.ts",
39
+ "default": "./dist/lib/lazy-routes.js"
29
40
  }
30
41
  },
31
42
  "files": [
@@ -62,6 +62,8 @@
62
62
  }
63
63
 
64
64
  monitorsManager.init(route.search.pollutant);
65
+ // A previous instance of this layout stopped polling in onDestroy; init() is a no-op by now.
66
+ monitorsManager.resumeAutoUpdate();
65
67
  collocationSitesManager.init();
66
68
  hmsManager.init();
67
69
  monitorsMapIntegration.onMonitorClick = (id: string) => {
@@ -1,6 +1,6 @@
1
1
  import { type Popup, type MapLayerEventType, type Map as MaptilerMap } from "@maptiler/sdk";
2
2
  import type { Geometry } from "geojson";
3
- import { mapManager } from "$lib/map/map.svelte";
3
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
4
4
  import { MapIconLayerIntegration } from "$lib/map/integrations/map-geojson-integration.svelte";
5
5
  import { CollocationIconManager, getCollocationIconId } from "./collocations-icon-manager.svelte";
6
6
  import { TooltipManager } from "$lib/map/integrations/tooltip.svelte";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Light entry (`@sjvair/monitor-map/core`): what a host app needs at startup, without the map.
3
+ *
4
+ * Nothing imported here may reach @maptiler/sdk, @maptiler/weather or uplot at runtime;
5
+ * `npm run test:bundle` enforces it. Everything is re-exported from the same modules the
6
+ * main entry uses, so `monitorsManager` and the router context stay single instances
7
+ * whichever entries a host mixes.
8
+ */
9
+ export {
10
+ ROUTER_KEY,
11
+ provideMonitorMapRouter,
12
+ useMonitorMapRouter,
13
+ type MonitorMapRouterApi
14
+ } from "./router-context";
15
+ export { monitorsManager } from "./monitors/monitors.svelte.js";
16
+ export type { MonitorsManager } from "./monitors/monitors.svelte.js";
17
+ export type { MonitorsDataSource, MonitorsPollutant } from "./monitors/types";
@@ -1,6 +1,6 @@
1
1
  import type { GeoJSONSource, MapLayerEventType, Popup } from "@maptiler/sdk";
2
2
  import type { Point } from "geojson";
3
- import { mapManager } from "$lib/map/map.svelte";
3
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
4
4
  import { mountPopup } from "$lib/map/utils";
5
5
  import { clickManager, type ClickHandler } from "$lib/map/integrations/click-manager";
6
6
  import { TooltipManager } from "$lib/map/integrations/tooltip.svelte";
@@ -6,7 +6,7 @@ import type {
6
6
  } from "@maptiler/sdk";
7
7
  import type { Geometry } from "geojson";
8
8
  import { untrack } from "svelte";
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 { MapDisplayOption } from "$lib/map/integrations/map-display-option.svelte";
@@ -1,7 +1,7 @@
1
1
  import type { Map as MaptilerMap } from "@maptiler/sdk";
2
2
  import type { Feature } from "geojson";
3
3
  import type { Point } from "geojson";
4
- import { mapManager } from "$lib/map/map.svelte";
4
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
5
5
  import { MapIconLayerIntegration } from "$lib/map/integrations/map-geojson-integration.svelte";
6
6
  import { hmsManager } from "./hms.svelte";
7
7
  import { HMSFireIconManager, getTierIconId } from "./hms-fire-icon-manager";
@@ -1,7 +1,7 @@
1
1
  import type { Map as MaptilerMap } from "@maptiler/sdk";
2
2
  import type { Feature, MultiPolygon } from "geojson";
3
3
  import type { HMSSmokeGeoJSON } from "@sjvair/sdk/hms";
4
- import { mapManager } from "$lib/map/map.svelte";
4
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
5
5
  import { MapGeoJSONIntegration } from "$lib/map/integrations/map-geojson-integration.svelte";
6
6
  import { hmsManager } from "./hms.svelte";
7
7
  import type { SmokeDensityStyle } from "$lib/legend/legend-data";
package/src/lib/index.ts CHANGED
@@ -23,6 +23,11 @@
23
23
  *
24
24
  * <Router />
25
25
  * ```
26
+ *
27
+ * A host that must stay light at startup imports from the subpath entries instead:
28
+ * `provideMonitorMapRouter`/`monitorsManager` from "@sjvair/monitor-map/core" (no map), and
29
+ * `monitorMapRoutes` from "@sjvair/monitor-map/routes" (same tree, lazy layout and routes).
30
+ * `monitorMapRoutes` from this entry stays eager.
26
31
  */
27
32
  export { monitorMapRoutes } from "./routes";
28
33
  export {
@@ -41,8 +46,8 @@ export { evStationsManager } from "./ev-stations/ev-stations.svelte";
41
46
  export type { EvStationsManager } from "./ev-stations/ev-stations.svelte";
42
47
  export { hmsManager } from "./hms/hms.svelte";
43
48
  export type { HMSManager } from "./hms/hms.svelte";
44
- export { mapManager, DefaultMapStyle, initializeMap } from "./map/map.svelte";
45
- export type { MapManager } from "./map/map.svelte";
49
+ export { mapManager, DefaultMapStyle, initializeMap } from "./map/map-manager.svelte.js";
50
+ export type { MapManager } from "./map/map-manager.svelte.js";
46
51
 
47
52
  // Map integrations
48
53
  export {
@@ -0,0 +1,15 @@
1
+ import type { Routes } from "sv-router";
2
+
3
+ /**
4
+ * 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.
7
+ *
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.
10
+ */
11
+ export const monitorMapRoutes: Routes = {
12
+ layout: () => import("./MonitorMapLayout.svelte"),
13
+ "/": () => import("./MainMapView.svelte"),
14
+ "/monitor/:id": () => import("./MonitorDetailPanel.svelte")
15
+ };
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import "@maptiler/sdk/dist/maptiler-sdk.css";
3
- import { initializeMap } from "./map.svelte";
3
+ import { initializeMap } from "./map-manager.svelte.js";
4
4
  import { integrationsManager } from "$lib/map/integrations/integrations-manager";
5
5
  import type { SomeMapIntegration } from "./integrations/types";
6
6
 
@@ -53,7 +53,7 @@
53
53
  import LoadScreen, { disable as disableLoadScreen } from "$lib/LoadScreen.svelte";
54
54
  import Map from "$lib/map/Map.svelte";
55
55
  import OptionsBar from "$lib/options/OptionsBar.svelte";
56
- import { mapManager } from "$lib/map/map.svelte";
56
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
57
57
  import { useMonitorMapRouter } from "$lib/router-context";
58
58
 
59
59
  let {
@@ -74,7 +74,7 @@
74
74
 
75
75
  $effect(() => {
76
76
  // mapManager.map is only ever assigned once its own initial "load" has
77
- // already fired (see initializeMap in map.svelte.ts), so by the time this
77
+ // already fired (see initializeMap in map-manager.svelte.ts), so by the time this
78
78
  // effect sees a non-null map, it's already safe to reveal — waiting for a
79
79
  // further "idle" event here is unreliable since one may never fire again.
80
80
  if (mapManager.map && ready) {
@@ -1,5 +1,5 @@
1
1
  import type { MapGeoJSONFeature, MapMouseEvent } from "@maptiler/sdk";
2
- import { mapManager } from "../map.svelte";
2
+ import { mapManager } from "../map-manager.svelte.js";
3
3
 
4
4
  export type ClickHandler = (features: MapGeoJSONFeature[], evt: MapMouseEvent & object) => void;
5
5
 
@@ -2,7 +2,7 @@ import type { Map as MaptilerMap } from "@maptiler/sdk";
2
2
  import type { Feature, Geometry } from "geojson";
3
3
  import type { MapIconManager } from "./map-icon-manager";
4
4
  import { MapLayerIntegration } from "./map-layer-integration.svelte";
5
- import { mapManager } from "../map.svelte";
5
+ import { mapManager } from "../map-manager.svelte.js";
6
6
 
7
7
  export abstract class MapGeoJSONIntegration<
8
8
  T = Record<string, unknown>
@@ -1,5 +1,5 @@
1
1
  import type { Map as MaptilerMap } from "@maptiler/sdk";
2
- import { mapManager } from "../map.svelte";
2
+ import { mapManager } from "../map-manager.svelte.js";
3
3
  import type { MapImageIcon } from "./types";
4
4
  import { SvelteMap } from "svelte/reactivity";
5
5
 
@@ -1,6 +1,6 @@
1
1
  import type { Map as MaptilerMap } from "@maptiler/sdk";
2
2
  import { untrack } from "svelte";
3
- import { mapManager } from "../map.svelte";
3
+ import { mapManager } from "../map-manager.svelte.js";
4
4
  import { MapIntegration } from "./map-integration.svelte";
5
5
 
6
6
  export abstract class MapLayerIntegration extends MapIntegration {
@@ -1,5 +1,5 @@
1
1
  import type { MapLayerEventType, Popup } from "@maptiler/sdk";
2
- import { mapManager } from "../map.svelte";
2
+ import { mapManager } from "../map-manager.svelte.js";
3
3
  import { XMap } from "@tstk/builtin-extensions";
4
4
 
5
5
  export type TooltipBuilder = (evt: MapLayerEventType["mousemove"] & object) => Popup | void;
@@ -1,5 +1,5 @@
1
1
  import type { MapStyleVariant, ReferenceMapStyle } from "@maptiler/sdk";
2
- import { DefaultMapStyle, mapManager } from "./map.svelte";
2
+ import { DefaultMapStyle, mapManager } from "./map-manager.svelte.js";
3
3
  import { integrationsManager } from "./integrations/integrations-manager";
4
4
  import { variantForStyle } from "./basemap";
5
5
 
@@ -6,7 +6,7 @@
6
6
  import { area } from "@turf/area";
7
7
  import { convertArea } from "@turf/helpers";
8
8
  import DataBox from "$lib/components/DataBox.svelte";
9
- import { mapManager } from "$lib/map/map.svelte";
9
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
10
10
  import type { MonitorClusterMapFeature, MonitorMapFeature } from "../types";
11
11
  import { monitorsManager } from "../monitors.svelte";
12
12
  import { getCurrentLevel } from "../monitor-utils";
@@ -7,7 +7,7 @@ import type {
7
7
  } from "@maptiler/sdk";
8
8
  import type { Point } from "geojson";
9
9
  import type { SJVAirEntryLevel } from "@sjvair/sdk";
10
- import { mapManager } from "$lib/map/map.svelte";
10
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
11
11
  import { mountPopup } from "$lib/map/utils";
12
12
  import { clickManager, type ClickHandler } from "$lib/map/integrations/click-manager";
13
13
  import { TooltipManager } from "$lib/map/integrations/tooltip.svelte";
@@ -1,7 +1,7 @@
1
1
  import type { MonitorData, SJVAirEntryLevel } from "@sjvair/sdk";
2
2
  import { asDataURI, circle, square, triangle } from "$lib/map/icons";
3
3
  import { MapIconManager } from "$lib/map/integrations/map-icon-manager";
4
- import { mapManager } from "$lib/map/map.svelte";
4
+ import { mapManager } from "$lib/map/map-manager.svelte.js";
5
5
  import type { MonitorsDataSource } from "./types";
6
6
 
7
7
  const MONITOR_ICONS = { circle, square, triangle };
@@ -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";
@@ -10,7 +10,7 @@ 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
14
 
15
15
  class MonitorsManager implements MonitorsDataSource {
16
16
  autoUpdate: Interval = new Interval(async () => await this.update(), 2 * 60 * 1000);
@@ -27,6 +27,9 @@ class MonitorsManager implements MonitorsDataSource {
27
27
  this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null
28
28
  );
29
29
 
30
+ #initPromise: Promise<void> | null = null;
31
+ #requestedPollutant: MonitorsPollutant | null = null;
32
+
30
33
  constructor() {
31
34
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
32
35
  // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
@@ -48,25 +51,90 @@ class MonitorsManager implements MonitorsDataSource {
48
51
  });
49
52
  }
50
53
 
51
- async init(urlPollutant?: string | number | boolean): Promise<void> {
52
- if (this.initialized) return;
54
+ /**
55
+ * Safe to call from several places: overlapping calls share one run, and a failed run can
56
+ * be retried. An explicit "pm25"/"o3" wins over a call without one, whichever came first;
57
+ * between two explicit values the most recent wins.
58
+ */
59
+ init(urlPollutant?: string | number | boolean): Promise<void> {
60
+ if (this.initialized) return Promise.resolve();
61
+
62
+ if (urlPollutant === "pm25" || urlPollutant === "o3") {
63
+ this.#requestedPollutant = urlPollutant;
64
+ }
65
+
66
+ this.#initPromise ??= this.#runInit().finally(() => {
67
+ this.#initPromise = null;
68
+ this.#requestedPollutant = null;
69
+ });
70
+
71
+ return this.#initPromise;
72
+ }
73
+
74
+ async #runInit(): Promise<void> {
75
+ // The pollutant the latest readings were requested for.
76
+ let fetchedPollutant = this.#requestedPollutant;
77
+
78
+ const metaRequest = getMonitorsMeta();
79
+ const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
80
+ this.list = list;
81
+ this.meta = meta;
82
+ return meta;
83
+ });
84
+ // The latest readings only need the pollutant, so they never wait for the list. An explicit
85
+ // pollutant may still arrive before meta does, in which case the default is never fetched.
86
+ const latestRequest = fetchedPollutant
87
+ ? getMonitorsLatestMap(fetchedPollutant)
88
+ : metaRequest.then((meta) => {
89
+ const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
90
+ fetchedPollutant = pollutant;
91
+ return getMonitorsLatestMap(pollutant);
92
+ });
93
+
94
+ // One Promise.all so a failure in either never leaves the other unhandled.
95
+ const [meta, firstLatest] = await Promise.all([listAndMeta, latestRequest]);
96
+ let latest = firstLatest;
53
97
 
54
- [this.list, this.meta] = await Promise.all([getMonitorsList(), getMonitorsMeta()]);
98
+ // An explicit pollutant may have arrived while the requests above were in flight.
99
+ let wanted = this.#requestedPollutant ?? meta.default_pollutant;
100
+ while (wanted !== fetchedPollutant) {
101
+ latest = await getMonitorsLatestMap(wanted);
102
+ fetchedPollutant = wanted;
103
+ wanted = this.#requestedPollutant ?? meta.default_pollutant;
104
+ }
55
105
 
56
- this.pollutant =
57
- urlPollutant === "pm25" || urlPollutant === "o3" ? urlPollutant : this.meta.default_pollutant;
58
- this.latest = await getMonitorsLatestMap(this.pollutant);
106
+ this.pollutant = fetchedPollutant;
107
+ this.latest = latest;
59
108
  this.autoUpdate.start();
60
109
  this.initialized = true;
61
110
  }
62
111
 
112
+ /**
113
+ * Restarts polling after something stopped it (MonitorMapLayout stops it on destroy),
114
+ * refreshing first since the data may be stale. A no-op before init or while polling.
115
+ */
116
+ resumeAutoUpdate(): void {
117
+ if (!this.initialized || this.autoUpdate.isActive) return;
118
+
119
+ this.update().catch(console.error);
120
+ this.autoUpdate.start();
121
+ }
122
+
63
123
  async update(): Promise<void> {
64
124
  if (!this.initialized) return;
65
125
 
66
- [this.list, this.latest] = await Promise.all([
126
+ const pollutant = this.pollutant;
127
+ const [list, latest] = await Promise.all([
67
128
  getMonitorsList(),
68
- getMonitorsLatestMap(this.pollutant || this.meta?.default_pollutant || "pm25")
129
+ getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
69
130
  ]);
131
+
132
+ // The pollutant changed while this was in flight: these readings belong to the previous
133
+ // one, and the change has already started its own update.
134
+ if (this.pollutant !== pollutant) return;
135
+
136
+ this.list = list;
137
+ this.latest = latest;
70
138
  }
71
139
  }
72
140
 
@@ -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";