@sjvair/monitor-map 3.6.3 → 4.0.1

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 (156) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/MonitorMapLayout.svelte +74 -36
  3. package/dist/lib/assets/icons/ozone/ozone-08-sunrise-haze.svg +1 -0
  4. package/dist/lib/assets/icons/pm25/pm25-02-particle-field.svg +1 -0
  5. package/dist/lib/collocation-sites/collocations-map-integration.svelte.d.ts +3 -0
  6. package/dist/lib/collocation-sites/collocations-map-integration.svelte.js +15 -1
  7. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +27 -6
  8. package/dist/lib/hms/hms-fire-icon-manager.d.ts +8 -0
  9. package/dist/lib/hms/hms-fire-icon-manager.js +10 -1
  10. package/dist/lib/hms/hms-fire-map-integration.svelte.js +1 -1
  11. package/dist/lib/hms/hms-smoke-map-integration.svelte.d.ts +20 -0
  12. package/dist/lib/hms/hms-smoke-map-integration.svelte.js +22 -7
  13. package/dist/lib/index.d.ts +21 -7
  14. package/dist/lib/index.js +22 -7
  15. package/dist/lib/legend/Legend.svelte +40 -0
  16. package/dist/lib/legend/Legend.svelte.d.ts +7 -0
  17. package/dist/lib/legend/LegendBar.svelte +45 -0
  18. package/dist/lib/legend/LegendBar.svelte.d.ts +7 -0
  19. package/dist/lib/legend/legend-data.d.ts +40 -0
  20. package/dist/lib/legend/legend-data.js +65 -0
  21. package/dist/lib/map/MapShell.svelte +7 -6
  22. package/dist/lib/map/MapShell.svelte.d.ts +3 -1
  23. package/dist/lib/map/basemap.d.ts +14 -0
  24. package/dist/lib/map/basemap.js +10 -0
  25. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +6 -0
  26. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +15 -2
  27. package/dist/lib/map/integrations/map-layer-integration.svelte.js +7 -3
  28. package/dist/lib/map/map-style-state.svelte.d.ts +13 -0
  29. package/dist/lib/map/map-style-state.svelte.js +35 -0
  30. package/dist/lib/map/map.svelte.d.ts +6 -0
  31. package/dist/lib/map/map.svelte.js +6 -0
  32. package/dist/lib/monitors/monitor-groups.d.ts +11 -0
  33. package/dist/lib/monitors/monitor-groups.js +7 -0
  34. package/dist/lib/monitors/monitor-utils.js +1 -3
  35. package/dist/lib/monitors/monitors-icon-manager.svelte.js +2 -5
  36. package/dist/lib/monitors/monitors-map-integration.svelte.d.ts +4 -4
  37. package/dist/lib/monitors/monitors-map-integration.svelte.js +45 -32
  38. package/dist/lib/monitors/monitors.svelte.d.ts +1 -0
  39. package/dist/lib/monitors/monitors.svelte.js +22 -0
  40. package/dist/lib/options/OptionsBar.svelte +96 -0
  41. package/dist/lib/options/OptionsBar.svelte.d.ts +10 -0
  42. package/dist/lib/options/OptionsGroup.svelte +12 -0
  43. package/dist/lib/options/OptionsGroup.svelte.d.ts +7 -0
  44. package/dist/lib/options/OptionsMenu.svelte +71 -0
  45. package/dist/lib/options/OptionsMenu.svelte.d.ts +15 -0
  46. package/dist/lib/options/group-state.d.ts +7 -0
  47. package/dist/lib/options/group-state.js +15 -0
  48. package/dist/lib/options/layout.d.ts +7 -0
  49. package/dist/lib/options/layout.js +9 -0
  50. package/dist/lib/options/map-option-defaults.d.ts +56 -0
  51. package/dist/lib/options/map-option-defaults.js +47 -0
  52. package/dist/lib/options/menus/LayersMenu.svelte +47 -0
  53. package/dist/lib/options/menus/LayersMenu.svelte.d.ts +3 -0
  54. package/dist/lib/options/menus/MonitorsMenu.svelte +68 -0
  55. package/dist/lib/options/menus/MonitorsMenu.svelte.d.ts +3 -0
  56. package/dist/lib/options/menus/OverlaysMenu.svelte +18 -0
  57. package/dist/lib/{MapLegend.svelte.d.ts → options/menus/OverlaysMenu.svelte.d.ts} +3 -3
  58. package/dist/lib/options/menus/PollutantMenu.svelte +89 -0
  59. package/dist/lib/options/menus/PollutantMenu.svelte.d.ts +3 -0
  60. package/dist/lib/options/menus/SettingsMenu.svelte +73 -0
  61. package/dist/lib/{hms/components/HMSFireLegend.svelte.d.ts → options/menus/SettingsMenu.svelte.d.ts} +3 -3
  62. package/dist/lib/options/options-state.svelte.d.ts +12 -0
  63. package/dist/lib/options/options-state.svelte.js +42 -0
  64. package/dist/lib/options/pollutant-param.d.ts +17 -0
  65. package/dist/lib/options/pollutant-param.js +14 -0
  66. package/dist/lib/options/reset-map-options.d.ts +2 -0
  67. package/dist/lib/options/reset-map-options.js +24 -0
  68. package/dist/lib/options/rows/CheckboxRow.svelte +51 -0
  69. package/dist/lib/options/rows/CheckboxRow.svelte.d.ts +13 -0
  70. package/dist/lib/options/rows/GroupRow.svelte +43 -0
  71. package/dist/lib/options/rows/GroupRow.svelte.d.ts +17 -0
  72. package/dist/lib/options/rows/MarkerIcon.svelte +10 -0
  73. package/dist/lib/options/rows/MarkerIcon.svelte.d.ts +8 -0
  74. package/dist/lib/options/rows/RadioRow.svelte +33 -0
  75. package/dist/lib/options/rows/RadioRow.svelte.d.ts +13 -0
  76. package/dist/lib/options/rows/SubmenuRow.svelte +22 -0
  77. package/dist/lib/options/rows/SubmenuRow.svelte.d.ts +11 -0
  78. package/dist/lib/search/Search.svelte +6 -6
  79. package/package.json +5 -3
  80. package/src/lib/MonitorMapLayout.svelte +74 -36
  81. package/src/lib/assets/icons/ozone/ozone-08-sunrise-haze.svg +1 -0
  82. package/src/lib/assets/icons/pm25/pm25-02-particle-field.svg +1 -0
  83. package/src/lib/collocation-sites/collocations-map-integration.svelte.ts +17 -1
  84. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +26 -6
  85. package/src/lib/hms/hms-fire-icon-manager.ts +16 -2
  86. package/src/lib/hms/hms-fire-map-integration.svelte.ts +1 -1
  87. package/src/lib/hms/hms-smoke-map-integration.svelte.ts +25 -7
  88. package/src/lib/index.ts +31 -7
  89. package/src/lib/legend/Legend.svelte +40 -0
  90. package/src/lib/legend/LegendBar.svelte +45 -0
  91. package/src/lib/legend/legend-data.ts +101 -0
  92. package/src/lib/map/MapShell.svelte +7 -6
  93. package/src/lib/map/basemap.ts +24 -0
  94. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +15 -2
  95. package/src/lib/map/integrations/map-layer-integration.svelte.ts +6 -3
  96. package/src/lib/map/map-style-state.svelte.ts +42 -0
  97. package/src/lib/map/map.svelte.ts +6 -0
  98. package/src/lib/monitors/monitor-groups.ts +20 -0
  99. package/src/lib/monitors/monitor-utils.ts +1 -5
  100. package/src/lib/monitors/monitors-icon-manager.svelte.ts +2 -8
  101. package/src/lib/monitors/monitors-map-integration.svelte.ts +39 -29
  102. package/src/lib/monitors/monitors.svelte.ts +22 -0
  103. package/src/lib/options/OptionsBar.svelte +96 -0
  104. package/src/lib/options/OptionsGroup.svelte +12 -0
  105. package/src/lib/options/OptionsMenu.svelte +71 -0
  106. package/src/lib/options/group-state.ts +16 -0
  107. package/src/lib/options/layout.ts +10 -0
  108. package/src/lib/options/map-option-defaults.ts +71 -0
  109. package/src/lib/options/menus/LayersMenu.svelte +47 -0
  110. package/src/lib/options/menus/MonitorsMenu.svelte +68 -0
  111. package/src/lib/options/menus/OverlaysMenu.svelte +18 -0
  112. package/src/lib/options/menus/PollutantMenu.svelte +89 -0
  113. package/src/lib/options/menus/SettingsMenu.svelte +73 -0
  114. package/src/lib/options/options-state.svelte.ts +48 -0
  115. package/src/lib/options/pollutant-param.ts +35 -0
  116. package/src/lib/options/reset-map-options.ts +28 -0
  117. package/src/lib/options/rows/CheckboxRow.svelte +51 -0
  118. package/src/lib/options/rows/GroupRow.svelte +43 -0
  119. package/src/lib/options/rows/MarkerIcon.svelte +10 -0
  120. package/src/lib/options/rows/RadioRow.svelte +33 -0
  121. package/src/lib/options/rows/SubmenuRow.svelte +22 -0
  122. package/src/lib/search/Search.svelte +6 -6
  123. package/dist/lib/MapLegend.svelte +0 -15
  124. package/dist/lib/collocation-sites/collocation-utils.d.ts +0 -3
  125. package/dist/lib/collocation-sites/collocation-utils.js +0 -30
  126. package/dist/lib/components/DisplayOption.svelte +0 -7
  127. package/dist/lib/components/DisplayOption.svelte.d.ts +0 -5
  128. package/dist/lib/components/MapLayersDisplayOptions.svelte +0 -42
  129. package/dist/lib/components/MapLayersDisplayOptions.svelte.d.ts +0 -18
  130. package/dist/lib/components/SegmentedControl.svelte +0 -52
  131. package/dist/lib/components/SegmentedControl.svelte.d.ts +0 -11
  132. package/dist/lib/components/ToggleSwitch.svelte +0 -93
  133. package/dist/lib/components/ToggleSwitch.svelte.d.ts +0 -9
  134. package/dist/lib/ev-stations/components/EvStationsDisplayOptions.svelte +0 -46
  135. package/dist/lib/ev-stations/components/EvStationsDisplayOptions.svelte.d.ts +0 -18
  136. package/dist/lib/hms/components/HMSFireLegend.svelte +0 -21
  137. package/dist/lib/map/MapStyleDisplayOptions.svelte +0 -61
  138. package/dist/lib/map/MapStyleDisplayOptions.svelte.d.ts +0 -3
  139. package/dist/lib/map/Menu.svelte +0 -65
  140. package/dist/lib/map/Menu.svelte.d.ts +0 -5
  141. package/dist/lib/monitors/components/MonitorMarkersLegend.svelte +0 -41
  142. package/dist/lib/monitors/components/MonitorMarkersLegend.svelte.d.ts +0 -3
  143. package/dist/lib/monitors/components/MonitorsDisplayOptions.svelte +0 -85
  144. package/dist/lib/monitors/components/MonitorsDisplayOptions.svelte.d.ts +0 -3
  145. package/src/lib/MapLegend.svelte +0 -15
  146. package/src/lib/collocation-sites/collocation-utils.ts +0 -41
  147. package/src/lib/components/DisplayOption.svelte +0 -7
  148. package/src/lib/components/MapLayersDisplayOptions.svelte +0 -42
  149. package/src/lib/components/SegmentedControl.svelte +0 -52
  150. package/src/lib/components/ToggleSwitch.svelte +0 -93
  151. package/src/lib/ev-stations/components/EvStationsDisplayOptions.svelte +0 -46
  152. package/src/lib/hms/components/HMSFireLegend.svelte +0 -21
  153. package/src/lib/map/MapStyleDisplayOptions.svelte +0 -61
  154. package/src/lib/map/Menu.svelte +0 -65
  155. package/src/lib/monitors/components/MonitorMarkersLegend.svelte +0 -41
  156. package/src/lib/monitors/components/MonitorsDisplayOptions.svelte +0 -85
package/README.md CHANGED
@@ -6,7 +6,7 @@ SDK. It ships two ways:
6
6
  - **A code-split widget** (`npm run build`), embedded via a `<div id="SJVAirMonitorMap">`
7
7
  in a Django template on sjvair.com.
8
8
  - **A component library** (`npm run build:lib`, published to npm as
9
- `@sjvair/monitor-map`), imported by the `v3-mobile` Capacitor app and by other SJVAir
9
+ `@sjvair/monitor-map`), imported by the `sjvair-mobile` Capacitor app and by other SJVAir
10
10
  projects (e.g. `data-dashboard`) that want to embed the map or reuse its map-display
11
11
  logic against their own data.
12
12
 
@@ -47,7 +47,7 @@ export const { route, navigate, p, isActive, Router } = createRouter({
47
47
  <Router />
48
48
  ```
49
49
 
50
- This is what the sjvair.com widget and `v3-mobile` use today, via `MonitorMapLayout`
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
53
  ### Embedding `MapShell` directly, with a reduced integration set
@@ -1,10 +1,25 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { onDestroy, untrack } from "svelte";
4
+ import { searchParams } from "sv-router";
4
5
  import MapShell from "./map/MapShell.svelte";
5
- import MonitorsDisplayOptions from "./monitors/components/MonitorsDisplayOptions.svelte";
6
- import MapLayersDisplayOptions from "./components/MapLayersDisplayOptions.svelte";
7
- import MapStyleDisplayOptions from "./map/MapStyleDisplayOptions.svelte";
6
+ import OptionsGroup from "./options/OptionsGroup.svelte";
7
+ import PollutantMenu from "./options/menus/PollutantMenu.svelte";
8
+ import MonitorsMenu from "./options/menus/MonitorsMenu.svelte";
9
+ import LayersMenu from "./options/menus/LayersMenu.svelte";
10
+ import OverlaysMenu from "./options/menus/OverlaysMenu.svelte";
11
+ import SettingsMenu from "./options/menus/SettingsMenu.svelte";
12
+ import {
13
+ applyPollutantSelection,
14
+ currentSelection,
15
+ parsePollutantParam
16
+ } from "./options/pollutant-param";
17
+ import Legend from "./legend/Legend.svelte";
18
+ import {
19
+ fireSmokeLegendSections,
20
+ pollutantLegendSection,
21
+ type LegendSection
22
+ } from "./legend/legend-data";
8
23
  import { monitorsManager } from "./monitors/monitors.svelte";
9
24
  import { monitorsMapIntegration } from "./monitors/monitors-map-integration.svelte";
10
25
  import { windMapIntegration } from "./wind/wind.svelte";
@@ -12,14 +27,12 @@
12
27
  import type { SomeMapIntegration } from "./map/integrations/types";
13
28
  import { collocationSitesManager } from "./collocation-sites/collocations.svelte";
14
29
  import { collocationSitesMapIntegration } from "./collocation-sites/collocations-map-integration.svelte";
15
- import EvStationsDisplayOptions from "./ev-stations/components/EvStationsDisplayOptions.svelte";
16
30
  import { evStationsMapIntegration } from "./ev-stations/ev-stations-map-integration.svelte";
17
31
  import { hmsManager } from "./hms/hms.svelte";
18
32
  import { hmsFireMapIntegration } from "./hms/hms-fire-map-integration.svelte";
19
- import { hmsSmokeMapIntegration } from "./hms/hms-smoke-map-integration.svelte";
20
- import MapLegend from "./MapLegend.svelte";
33
+ import { FIRE_LEGEND_CATEGORIES } from "./hms/hms-fire-icon-manager";
34
+ import { hmsSmokeMapIntegration, SMOKE_LEGEND } from "./hms/hms-smoke-map-integration.svelte";
21
35
  import Search from "./search/Search.svelte";
22
- import { searchParams } from "sv-router";
23
36
  import { useMonitorMapRouter } from "./router-context";
24
37
 
25
38
  interface Props {
@@ -40,6 +53,14 @@
40
53
  evStationsMapIntegration
41
54
  ];
42
55
 
56
+ const pollutantTargets = { manager: monitorsManager, integration: monitorsMapIntegration };
57
+
58
+ // "?pollutant=none" must disable monitors before the effects below first run, so the
59
+ // state->URL effect never sees enabled monitors and overwrites the param.
60
+ if (parsePollutantParam(route.search.pollutant) === "none") {
61
+ monitorsMapIntegration.enabled = false;
62
+ }
63
+
43
64
  monitorsManager.init(route.search.pollutant);
44
65
  collocationSitesManager.init();
45
66
  hmsManager.init();
@@ -49,30 +70,30 @@
49
70
 
50
71
  let panelOpen = $derived(route.pathname.startsWith(`${basePath}/monitor/`));
51
72
 
52
- // Keep monitorsManager.pollutant and the "pollutant" URL param in sync, both directions.
53
- // init() only seeds pollutant on the manager's first-ever initialization; this effect is what
54
- // applies a later "?pollutant=" change (e.g. navigating in from elsewhere) to an already-running manager.
73
+ // Keep the Pollutant menu's selection (pm25 / o3 / none) and the "pollutant" URL param in
74
+ // sync, both directions. init() only seeds the pollutant on the manager's first-ever
75
+ // initialization; this effect applies a later "?pollutant=" change (e.g. navigating in from
76
+ // elsewhere) to an already-running map.
55
77
  $effect(() => {
56
- const urlPollutant = route.search.pollutant;
57
- // Read monitorsManager.pollutant via untrack: this effect must only react to the URL
58
- // changing (e.g. navigating in from elsewhere), not to monitorsManager.pollutant itself.
59
- // Tracking it here would make a UI-driven pollutant change (e.g. the display-options
60
- // toggle) re-trigger this effect before the manager->URL effect below can sync the URL,
61
- // so this effect would see the still-stale URL and immediately revert the user's change.
62
- if (
63
- monitorsManager.initialized &&
64
- (urlPollutant === "pm25" || urlPollutant === "o3") &&
65
- untrack(() => monitorsManager.pollutant) !== urlPollutant
66
- ) {
67
- monitorsManager.pollutant = urlPollutant;
68
- }
78
+ const selection = parsePollutantParam(route.search.pollutant);
79
+ if (!monitorsManager.initialized || !selection) return;
80
+ // Read current state via untrack: this effect must only react to the URL changing. Tracking
81
+ // state here would make a UI-driven change re-trigger this effect before the effect below
82
+ // syncs the URL, so it would see the stale URL and revert the user's change.
83
+ untrack(() => {
84
+ if (
85
+ currentSelection(monitorsManager.pollutant, monitorsMapIntegration.enabled) !== selection
86
+ ) {
87
+ applyPollutantSelection(selection, pollutantTargets);
88
+ }
89
+ });
69
90
  });
70
91
 
71
- // ...and the reverse: reflect UI-driven pollutant changes (e.g. the display-options toggle) back to the URL.
92
+ // ...and the reverse: reflect UI-driven changes back to the URL.
72
93
  $effect(() => {
73
- const pollutant = monitorsManager.pollutant;
74
- if (pollutant && searchParams.get("pollutant") !== pollutant) {
75
- searchParams.set("pollutant", pollutant);
94
+ const selection = currentSelection(monitorsManager.pollutant, monitorsMapIntegration.enabled);
95
+ if (selection && searchParams.get("pollutant") !== selection) {
96
+ searchParams.set("pollutant", selection);
76
97
  }
77
98
  });
78
99
 
@@ -82,6 +103,18 @@
82
103
  monitorsMapIntegration.selectedMonitorId = null;
83
104
  });
84
105
 
106
+ const legendSections: Array<LegendSection> = $derived.by(() => {
107
+ const sections: Array<LegendSection> = [];
108
+ if (monitorsMapIntegration.enabled && monitorsManager.pollutant && monitorsManager.levels) {
109
+ const pollutant = pollutantLegendSection(monitorsManager.pollutant, monitorsManager.levels);
110
+ if (pollutant) sections.push(pollutant);
111
+ }
112
+ if (hmsFireMapIntegration.enabled && hmsSmokeMapIntegration.enabled) {
113
+ sections.push(...fireSmokeLegendSections(FIRE_LEGEND_CATEGORIES, SMOKE_LEGEND));
114
+ }
115
+ return sections;
116
+ });
117
+
85
118
  onDestroy(() => {
86
119
  monitorsManager.autoUpdate.stop();
87
120
  });
@@ -95,17 +128,22 @@
95
128
  {basePath}
96
129
  >
97
130
  {#snippet menu()}
98
- <MonitorsDisplayOptions />
99
- <EvStationsDisplayOptions />
100
- <MapLayersDisplayOptions />
101
- <MapStyleDisplayOptions />
131
+ <OptionsGroup>
132
+ <PollutantMenu />
133
+ <MonitorsMenu />
134
+ </OptionsGroup>
135
+ <OptionsGroup>
136
+ <LayersMenu />
137
+ <OverlaysMenu />
138
+ <SettingsMenu />
139
+ </OptionsGroup>
140
+ {/snippet}
141
+ {#snippet search()}
142
+ <Search />
102
143
  {/snippet}
103
144
  {#snippet overlays()}
104
- <div class="pointer-events-none absolute bottom-0 left-0 z-10">
105
- <MapLegend />
106
- </div>
107
- <div class="absolute top-4 left-20 z-10">
108
- <Search />
145
+ <div class="pointer-events-none absolute bottom-4 left-4 z-10">
146
+ <Legend sections={legendSections} />
109
147
  </div>
110
148
  {/snippet}
111
149
  {@render children()}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 15a6 6 0 0 1 12 0" stroke="#F2A93B" fill="none"/><path d="M20.37 13.52L21.85 13.26M17.46 8.49L18.43 7.34M12.00 6.50L12.00 5.00M6.54 8.49L5.57 7.34M3.63 13.52L2.15 13.26" stroke="#F2A93B" fill="none"/><path d="M2 15h20M5 18.5h14M8.5 22h7" stroke="#5B6770" fill="none"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2" fill="#6B5B4E"/><circle cx="16.5" cy="5" r="1.5" fill="#9C8878"/><circle cx="11" cy="12" r="2.5" fill="#6B5B4E"/><circle cx="5" cy="18" r="1.5" fill="#9C8878"/><circle cx="17.5" cy="17.5" r="2" fill="#6B5B4E"/></svg>
@@ -8,11 +8,14 @@ declare class CollocationSitesMapIntegration extends MapIconLayerIntegration<Col
8
8
  enabled: boolean;
9
9
  icons: CollocationIconManager;
10
10
  tooltipManager: TooltipManager;
11
+ /** The option only applies while monitors are shown with PM2.5. */
12
+ get available(): boolean;
11
13
  features: Array<CollocationSiteMapFeature>;
12
14
  mapLayer: Parameters<MaptilerMap["addLayer"]>[0];
13
15
  get mapSource(): Parameters<MaptilerMap["addSource"]>[1];
14
16
  constructor();
15
17
  apply(): void;
18
+ remove(): void;
16
19
  }
17
20
  export declare const collocationSitesMapIntegration: CollocationSitesMapIntegration;
18
21
  export type { CollocationSitesMapIntegration as MonitorsMapIntegration };
@@ -19,6 +19,10 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration {
19
19
  enabled = $state(false);
20
20
  icons = new CollocationIconManager(monitorsManager);
21
21
  tooltipManager = new TooltipManager();
22
+ /** The option only applies while monitors are shown with PM2.5. */
23
+ get available() {
24
+ return monitorsMapIntegration.enabled && monitorsManager.pollutant === "pm25";
25
+ }
22
26
  features = $derived.by(() => {
23
27
  if (!monitorsManager.meta ||
24
28
  !monitorsManager.pollutant ||
@@ -76,8 +80,10 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration {
76
80
  return;
77
81
  mapManager.setDataSource(this.referenceId, features);
78
82
  });
83
+ // Remove the layer while the option is unavailable (Ozone or Pollutant = None); restore
84
+ // it when it becomes available again if the user still has it checked.
79
85
  $effect(() => {
80
- if (monitorsManager.pollutant === "o3") {
86
+ if (!this.available) {
81
87
  untrack(() => this.remove());
82
88
  }
83
89
  else if (untrack(() => this.enabled)) {
@@ -87,6 +93,10 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration {
87
93
  });
88
94
  }
89
95
  apply() {
96
+ if (!this.available) {
97
+ this.remove();
98
+ return;
99
+ }
90
100
  if (!mapManager.map)
91
101
  return;
92
102
  if (!this.tooltipManager.has(this.referenceId)) {
@@ -95,5 +105,9 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration {
95
105
  this.tooltipManager.enable();
96
106
  super.apply();
97
107
  }
108
+ remove() {
109
+ this.tooltipManager.disable();
110
+ super.remove();
111
+ }
98
112
  }
99
113
  export const collocationSitesMapIntegration = new CollocationSitesMapIntegration();
@@ -11,15 +11,15 @@ import { EvStationsClusterRenderer } from "./ev-stations-cluster-renderer";
11
11
  import EvStationTooltip from "./components/EvStationTooltip.svelte";
12
12
  class EvStationsMapIntegration extends MapIconLayerIntegration {
13
13
  referenceId = "ev-stations";
14
- enabled = $state(true);
14
+ enabled = $state(false);
15
15
  clustered = $state(true);
16
16
  icons = new EvStationIconManager();
17
17
  tooltipManager = new TooltipManager();
18
18
  renderer = new EvStationsClusterRenderer(this);
19
19
  currentPopup = null;
20
20
  displayOptions = $derived.by(() => ({
21
- lvl2: new MapDisplayOption("Level 2", false, this.icons.get("ev-station-lvl2")),
22
- lvl3: new MapDisplayOption("Level 3", false, this.icons.get("ev-station-lvl3"))
21
+ lvl2: new MapDisplayOption("Level 2", true, this.icons.get("ev-station-lvl2")),
22
+ lvl3: new MapDisplayOption("Level 3", true, this.icons.get("ev-station-lvl3"))
23
23
  }));
24
24
  features = $derived.by(() => {
25
25
  const result = [];
@@ -97,22 +97,33 @@ class EvStationsMapIntegration extends MapIconLayerIntegration {
97
97
  constructor() {
98
98
  super();
99
99
  $effect.root(() => {
100
- // Lazy-fetch level data when integration is enabled and level is toggled on
100
+ // Lazy-fetch level data only once the integration is enabled and the level is on
101
101
  $effect(() => {
102
- if (this.displayOptions.lvl2.value && evStationsManager.lvl2Stations === undefined) {
102
+ if (this.enabled &&
103
+ this.displayOptions.lvl2.value &&
104
+ evStationsManager.lvl2Stations === undefined) {
103
105
  evStationsManager.loadLvl2Stations();
104
106
  }
105
107
  });
106
108
  $effect(() => {
107
- if (this.displayOptions.lvl3.value && evStationsManager.lvl3Stations === undefined) {
109
+ if (this.enabled &&
110
+ this.displayOptions.lvl3.value &&
111
+ evStationsManager.lvl3Stations === undefined) {
108
112
  evStationsManager.loadLvl3Stations();
109
113
  }
110
114
  });
115
+ // Every effect below early-returns while disabled so a data refresh can't re-add the layer.
116
+ // `enabled` is read untracked: the base class's own effect already applies/removes on
117
+ // enabled changes, and tracking it here would apply twice. The guard comes after each
118
+ // effect's tracked reads so its dependencies are always collected; returning before
119
+ // reading anything would leave the effect permanently dead.
111
120
  // Push filter changes to the unclustered layer
112
121
  $effect(() => {
113
122
  const filter = this.filters;
114
123
  if (!mapManager.map || this.clustered)
115
124
  return;
125
+ if (!untrack(() => this.enabled))
126
+ return;
116
127
  if (mapManager.map.getLayer(this.referenceId)) {
117
128
  mapManager.map.setFilter(this.referenceId, filter);
118
129
  }
@@ -122,6 +133,8 @@ class EvStationsMapIntegration extends MapIconLayerIntegration {
122
133
  const features = this.features;
123
134
  if (!mapManager.map || this.clustered)
124
135
  return;
136
+ if (!untrack(() => this.enabled))
137
+ return;
125
138
  mapManager.setDataSource(this.referenceId, features);
126
139
  });
127
140
  // Sync clustered sources when featuresByLevel changes
@@ -129,6 +142,8 @@ class EvStationsMapIntegration extends MapIconLayerIntegration {
129
142
  void this.featuresByLevel;
130
143
  if (!mapManager.map || !this.clustered)
131
144
  return;
145
+ if (!untrack(() => this.enabled))
146
+ return;
132
147
  this.renderer.syncFeatures();
133
148
  });
134
149
  // Re-apply when clustered mode switches or data first arrives
@@ -137,6 +152,8 @@ class EvStationsMapIntegration extends MapIconLayerIntegration {
137
152
  const hasFeatures = Object.keys(this.featuresByLevel).length > 0;
138
153
  if (!untrack(() => mapManager.map) || !hasFeatures)
139
154
  return;
155
+ if (!untrack(() => this.enabled))
156
+ return;
140
157
  untrack(() => this.apply());
141
158
  });
142
159
  });
@@ -153,6 +170,8 @@ class EvStationsMapIntegration extends MapIconLayerIntegration {
153
170
  this.tooltipManager.disable();
154
171
  this.renderer.remove();
155
172
  this.icons.loadIcons().then(() => {
173
+ if (mapManager.styleLoading)
174
+ return;
156
175
  this.renderer.apply(this.handleStationClick);
157
176
  this.tooltipManager.enable();
158
177
  });
@@ -163,6 +182,8 @@ class EvStationsMapIntegration extends MapIconLayerIntegration {
163
182
  clickManager.unregister([this.referenceId]);
164
183
  super.apply();
165
184
  this.icons.loadIcons().then(() => {
185
+ if (mapManager.styleLoading)
186
+ return;
166
187
  clickManager.register([this.referenceId], this.handleStationClick);
167
188
  this.tooltipManager.enable();
168
189
  });
@@ -1,4 +1,12 @@
1
1
  import { MapIconManager } from "../map/integrations/map-icon-manager";
2
+ import type { LegendCategory } from "../legend/legend-data";
3
+ export type FRPTier = "sm" | "md" | "lg" | "xl" | "xxl";
4
+ export declare const FRP_TIERS: Record<FRPTier, {
5
+ color: string;
6
+ size: number;
7
+ }>;
8
+ /** Fire legend blocks (MW), in tier order, using the same colors as the map icons. */
9
+ export declare const FIRE_LEGEND_CATEGORIES: Array<LegendCategory>;
2
10
  export declare function getTierIconId(frp: number): string;
3
11
  export declare class HMSFireIconManager extends MapIconManager {
4
12
  constructor();
@@ -1,12 +1,21 @@
1
1
  import { asDataURI, fireIcon } from "../map/icons";
2
2
  import { MapIconManager } from "../map/integrations/map-icon-manager";
3
- const FRP_TIERS = {
3
+ export const FRP_TIERS = {
4
4
  sm: { color: "#FFD700", size: 20 },
5
5
  md: { color: "#FF8C00", size: 24 },
6
6
  lg: { color: "#FF4500", size: 28 },
7
7
  xl: { color: "#DC143C", size: 32 },
8
8
  xxl: { color: "#8B0000", size: 36 }
9
9
  };
10
+ const FRP_TIER_LABELS = {
11
+ sm: "<10",
12
+ md: "10-49",
13
+ lg: "50-149",
14
+ xl: "150-349",
15
+ xxl: "≥350"
16
+ };
17
+ /** Fire legend blocks (MW), in tier order, using the same colors as the map icons. */
18
+ export const FIRE_LEGEND_CATEGORIES = Object.keys(FRP_TIERS).map((tier) => ({ color: FRP_TIERS[tier].color, label: FRP_TIER_LABELS[tier] }));
10
19
  export function getTierIconId(frp) {
11
20
  if (frp < 10)
12
21
  return "hms-fire-sm";
@@ -76,7 +76,7 @@ class HMSFireMapIntegration extends MapIconLayerIntegration {
76
76
  this.icons
77
77
  .loadIcons()
78
78
  .then(() => {
79
- if (!mapManager.map || !this.enabled)
79
+ if (!mapManager.map || !this.enabled || mapManager.styleLoading)
80
80
  return;
81
81
  this.remove();
82
82
  this.zoomedIn = mapManager.map.getZoom() >= ZOOM_THRESHOLD;
@@ -2,6 +2,26 @@ 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
4
  import { MapGeoJSONIntegration } from "../map/integrations/map-geojson-integration.svelte";
5
+ import type { SmokeDensityStyle } from "../legend/legend-data";
6
+ /** Smoke fill styles, shared by the map layer and the legend so they can't drift apart. */
7
+ export declare const SMOKE_DENSITY_STYLES: {
8
+ light: {
9
+ label: string;
10
+ color: string;
11
+ opacity: number;
12
+ };
13
+ medium: {
14
+ label: string;
15
+ color: string;
16
+ opacity: number;
17
+ };
18
+ heavy: {
19
+ label: string;
20
+ color: string;
21
+ opacity: number;
22
+ };
23
+ };
24
+ export declare const SMOKE_LEGEND: Array<SmokeDensityStyle>;
5
25
  type SmokeProperties = {
6
26
  id: string;
7
27
  density: HMSSmokeGeoJSON["density"];
@@ -1,6 +1,13 @@
1
1
  import { mapManager } from "../map/map.svelte";
2
2
  import { MapGeoJSONIntegration } from "../map/integrations/map-geojson-integration.svelte";
3
3
  import { hmsManager } from "./hms.svelte";
4
+ /** Smoke fill styles, shared by the map layer and the legend so they can't drift apart. */
5
+ export const SMOKE_DENSITY_STYLES = {
6
+ light: { label: "Light", color: "#bfc8c3", opacity: 0.2 },
7
+ medium: { label: "Medium", color: "#757b78", opacity: 0.3 },
8
+ heavy: { label: "Heavy", color: "#333634", opacity: 0.4 }
9
+ };
10
+ export const SMOKE_LEGEND = Object.values(SMOKE_DENSITY_STYLES);
4
11
  class HMSSmokeMapIntegration extends MapGeoJSONIntegration {
5
12
  referenceId = "hms-smoke";
6
13
  enabled = $state(true);
@@ -25,20 +32,28 @@ class HMSSmokeMapIntegration extends MapGeoJSONIntegration {
25
32
  "match",
26
33
  ["get", "density"],
27
34
  "light",
28
- "#bfc8c3",
35
+ SMOKE_DENSITY_STYLES.light.color,
29
36
  "medium",
30
- "#757b78",
31
- "#333634"
37
+ SMOKE_DENSITY_STYLES.medium.color,
38
+ SMOKE_DENSITY_STYLES.heavy.color
39
+ ],
40
+ "fill-opacity": [
41
+ "match",
42
+ ["get", "density"],
43
+ "light",
44
+ SMOKE_DENSITY_STYLES.light.opacity,
45
+ "medium",
46
+ SMOKE_DENSITY_STYLES.medium.opacity,
47
+ SMOKE_DENSITY_STYLES.heavy.opacity
32
48
  ],
33
- "fill-opacity": ["match", ["get", "density"], "light", 0.2, "medium", 0.3, 0.4],
34
49
  "fill-outline-color": [
35
50
  "match",
36
51
  ["get", "density"],
37
52
  "light",
38
- "#bfc8c3",
53
+ SMOKE_DENSITY_STYLES.light.color,
39
54
  "medium",
40
- "#757b78",
41
- "#333634"
55
+ SMOKE_DENSITY_STYLES.medium.color,
56
+ SMOKE_DENSITY_STYLES.heavy.color
42
57
  ]
43
58
  }
44
59
  };
@@ -45,15 +45,29 @@ export { hmsFireMapIntegration } from "./hms/hms-fire-map-integration.svelte";
45
45
  export { hmsSmokeMapIntegration } from "./hms/hms-smoke-map-integration.svelte";
46
46
  export { default as Map } from "./map/Map.svelte";
47
47
  export { default as MapShell, type MapShellProps } from "./map/MapShell.svelte";
48
- export { default as Menu } from "./map/Menu.svelte";
49
48
  export { default as LoadScreen, loadScreenState, enable as enableLoadScreen, disable as disableLoadScreen } from "./LoadScreen.svelte";
50
49
  export type { LoadScreenState } from "./LoadScreen.svelte";
51
- export { default as MonitorsDisplayOptions } from "./monitors/components/MonitorsDisplayOptions.svelte";
52
- export { default as EvStationsDisplayOptions } from "./ev-stations/components/EvStationsDisplayOptions.svelte";
53
- export { default as MapLayersDisplayOptions } from "./components/MapLayersDisplayOptions.svelte";
54
- export { default as MapStyleDisplayOptions } from "./map/MapStyleDisplayOptions.svelte";
55
- export { default as ToggleSwitch } from "./components/ToggleSwitch.svelte";
56
- export { default as SegmentedControl } from "./components/SegmentedControl.svelte";
50
+ export { default as OptionsBar } from "./options/OptionsBar.svelte";
51
+ export { default as OptionsGroup } from "./options/OptionsGroup.svelte";
52
+ export { default as OptionsMenu } from "./options/OptionsMenu.svelte";
53
+ export { default as CheckboxRow } from "./options/rows/CheckboxRow.svelte";
54
+ export { default as RadioRow } from "./options/rows/RadioRow.svelte";
55
+ export { default as SubmenuRow } from "./options/rows/SubmenuRow.svelte";
56
+ export { default as GroupRow } from "./options/rows/GroupRow.svelte";
57
+ export { default as MarkerIcon } from "./options/rows/MarkerIcon.svelte";
58
+ export { default as PollutantMenu } from "./options/menus/PollutantMenu.svelte";
59
+ export { default as MonitorsMenu } from "./options/menus/MonitorsMenu.svelte";
60
+ export { default as LayersMenu } from "./options/menus/LayersMenu.svelte";
61
+ export { default as OverlaysMenu } from "./options/menus/OverlaysMenu.svelte";
62
+ export { default as SettingsMenu } from "./options/menus/SettingsMenu.svelte";
63
+ export { resetMapOptions } from "./options/reset-map-options";
64
+ export { mapStyleState } from "./map/map-style-state.svelte.js";
65
+ export type { MapStyleState } from "./map/map-style-state.svelte.js";
66
+ export { default as Legend } from "./legend/Legend.svelte";
67
+ export { default as LegendBar } from "./legend/LegendBar.svelte";
68
+ export { pollutantLegendSection, fireSmokeLegendSections, type LegendSection, type LegendBarData, type LegendCategory } from "./legend/legend-data";
69
+ export { FIRE_LEGEND_CATEGORIES } from "./hms/hms-fire-icon-manager";
70
+ export { SMOKE_LEGEND } from "./hms/hms-smoke-map-integration.svelte";
57
71
  export { MapIntegration } from "./map/integrations/map-integration.svelte";
58
72
  export { MapLayerIntegration } from "./map/integrations/map-layer-integration.svelte";
59
73
  export { MapIconLayerIntegration as MapGeoJSONIntegration } from "./map/integrations/map-geojson-integration.svelte";
package/dist/lib/index.js CHANGED
@@ -43,14 +43,29 @@ export { hmsSmokeMapIntegration } from "./hms/hms-smoke-map-integration.svelte";
43
43
  // Components
44
44
  export { default as Map } from "./map/Map.svelte";
45
45
  export { default as MapShell } from "./map/MapShell.svelte";
46
- export { default as Menu } from "./map/Menu.svelte";
47
46
  export { default as LoadScreen, loadScreenState, enable as enableLoadScreen, disable as disableLoadScreen } from "./LoadScreen.svelte";
48
- export { default as MonitorsDisplayOptions } from "./monitors/components/MonitorsDisplayOptions.svelte";
49
- export { default as EvStationsDisplayOptions } from "./ev-stations/components/EvStationsDisplayOptions.svelte";
50
- export { default as MapLayersDisplayOptions } from "./components/MapLayersDisplayOptions.svelte";
51
- export { default as MapStyleDisplayOptions } from "./map/MapStyleDisplayOptions.svelte";
52
- export { default as ToggleSwitch } from "./components/ToggleSwitch.svelte";
53
- export { default as SegmentedControl } from "./components/SegmentedControl.svelte";
47
+ // Options toolbar / panel (compose these inside MapShell's `menu` snippet)
48
+ export { default as OptionsBar } from "./options/OptionsBar.svelte";
49
+ export { default as OptionsGroup } from "./options/OptionsGroup.svelte";
50
+ export { default as OptionsMenu } from "./options/OptionsMenu.svelte";
51
+ export { default as CheckboxRow } from "./options/rows/CheckboxRow.svelte";
52
+ export { default as RadioRow } from "./options/rows/RadioRow.svelte";
53
+ export { default as SubmenuRow } from "./options/rows/SubmenuRow.svelte";
54
+ export { default as GroupRow } from "./options/rows/GroupRow.svelte";
55
+ export { default as MarkerIcon } from "./options/rows/MarkerIcon.svelte";
56
+ export { default as PollutantMenu } from "./options/menus/PollutantMenu.svelte";
57
+ export { default as MonitorsMenu } from "./options/menus/MonitorsMenu.svelte";
58
+ export { default as LayersMenu } from "./options/menus/LayersMenu.svelte";
59
+ export { default as OverlaysMenu } from "./options/menus/OverlaysMenu.svelte";
60
+ export { default as SettingsMenu } from "./options/menus/SettingsMenu.svelte";
61
+ export { resetMapOptions } from "./options/reset-map-options";
62
+ export { mapStyleState } from "./map/map-style-state.svelte.js";
63
+ // Legend
64
+ export { default as Legend } from "./legend/Legend.svelte";
65
+ export { default as LegendBar } from "./legend/LegendBar.svelte";
66
+ export { pollutantLegendSection, fireSmokeLegendSections } from "./legend/legend-data";
67
+ export { FIRE_LEGEND_CATEGORIES } from "./hms/hms-fire-icon-manager";
68
+ export { SMOKE_LEGEND } from "./hms/hms-smoke-map-integration.svelte";
54
69
  // Integration base classes (extend these to add custom map features)
55
70
  export { MapIntegration } from "./map/integrations/map-integration.svelte";
56
71
  export { MapLayerIntegration } from "./map/integrations/map-layer-integration.svelte";
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ import { ChevronDownIcon, InfoIcon } from "@lucide/svelte";
3
+ import LegendBar from "./LegendBar.svelte";
4
+ import type { LegendSection } from "./legend-data";
5
+
6
+ let { sections }: { sections: Array<LegendSection> } = $props();
7
+
8
+ // Independent of which sections are active, so toggling a layer doesn't reset it.
9
+ let collapsed = $state(false);
10
+ </script>
11
+
12
+ {#if sections.length}
13
+ <div class="pointer-events-auto w-84 max-w-[calc(100vw-2rem)] rounded-md bg-white shadow">
14
+ <button
15
+ type="button"
16
+ class="flex w-full cursor-pointer items-center gap-2 px-4 py-2 font-semibold"
17
+ aria-expanded={!collapsed}
18
+ onclick={() => (collapsed = !collapsed)}
19
+ >
20
+ <InfoIcon size={18} class="text-gray-500" />
21
+ <span class="flex-1 text-left">Legend</span>
22
+ <ChevronDownIcon size={18} class="transition-transform {collapsed ? '-rotate-90' : ''}" />
23
+ </button>
24
+ {#if !collapsed}
25
+ <div class="flex flex-col gap-3 border-t border-gray-200 px-4 py-3">
26
+ {#each sections as section (section.id)}
27
+ <div>
28
+ <p class="mb-2 text-center text-sm font-semibold">
29
+ {section.title}
30
+ {#if section.unit}
31
+ <span class="text-xs font-normal text-gray-500">({section.unit})</span>
32
+ {/if}
33
+ </p>
34
+ <LegendBar bar={section.bar} />
35
+ </div>
36
+ {/each}
37
+ </div>
38
+ {/if}
39
+ </div>
40
+ {/if}
@@ -0,0 +1,7 @@
1
+ import type { LegendSection } from "./legend-data";
2
+ type $$ComponentProps = {
3
+ sections: Array<LegendSection>;
4
+ };
5
+ declare const Legend: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Legend = ReturnType<typeof Legend>;
7
+ export default Legend;
@@ -0,0 +1,45 @@
1
+ <script lang="ts">
2
+ import {
3
+ blocksBackground,
4
+ gradientBackground,
5
+ tickPosition,
6
+ type LegendBarData
7
+ } from "./legend-data";
8
+
9
+ let { bar }: { bar: LegendBarData } = $props();
10
+
11
+ const background = $derived(
12
+ bar.kind === "gradient"
13
+ ? gradientBackground(bar.colors)
14
+ : blocksBackground(bar.categories.map((category) => category.color))
15
+ );
16
+
17
+ // Labels placed on exact stop positions: gradient ticks, or block breakpoints.
18
+ const positioned = $derived(bar.kind === "gradient" ? bar.ticks : (bar.breakpoints ?? null));
19
+ </script>
20
+
21
+ <div class="h-3 rounded" style:background></div>
22
+ {#if positioned}
23
+ <div class="relative mt-1 h-4 text-xs">
24
+ {#each positioned as label, i (i)}
25
+ {@const isFirst = i === 0}
26
+ {@const isLast = i === positioned.length - 1}
27
+ <span
28
+ class={[
29
+ "absolute top-0 whitespace-nowrap",
30
+ isFirst && "left-0",
31
+ isLast && "right-0",
32
+ !isFirst && !isLast && "-translate-x-1/2"
33
+ ]}
34
+ style:left={!isFirst && !isLast ? `${tickPosition(i, positioned.length)}%` : undefined}
35
+ >{label}</span
36
+ >
37
+ {/each}
38
+ </div>
39
+ {:else if bar.kind === "blocks"}
40
+ <div class="mt-1 flex text-xs">
41
+ {#each bar.categories as category, i (i)}
42
+ <span class="min-w-0 flex-1 text-center break-words">{category.label ?? ""}</span>
43
+ {/each}
44
+ </div>
45
+ {/if}