@sjvair/monitor-map 3.6.2 → 4.0.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 (152) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/MonitorMapLayout.svelte +74 -36
  3. package/dist/lib/collocation-sites/collocations-map-integration.svelte.d.ts +3 -0
  4. package/dist/lib/collocation-sites/collocations-map-integration.svelte.js +15 -1
  5. package/dist/lib/ev-stations/ev-stations-map-integration.svelte.js +27 -6
  6. package/dist/lib/hms/hms-fire-icon-manager.d.ts +8 -0
  7. package/dist/lib/hms/hms-fire-icon-manager.js +10 -1
  8. package/dist/lib/hms/hms-fire-map-integration.svelte.js +1 -1
  9. package/dist/lib/hms/hms-smoke-map-integration.svelte.d.ts +20 -0
  10. package/dist/lib/hms/hms-smoke-map-integration.svelte.js +22 -7
  11. package/dist/lib/index.d.ts +21 -7
  12. package/dist/lib/index.js +22 -7
  13. package/dist/lib/legend/Legend.svelte +40 -0
  14. package/dist/lib/legend/Legend.svelte.d.ts +7 -0
  15. package/dist/lib/legend/LegendBar.svelte +45 -0
  16. package/dist/lib/legend/LegendBar.svelte.d.ts +7 -0
  17. package/dist/lib/legend/legend-data.d.ts +40 -0
  18. package/dist/lib/legend/legend-data.js +65 -0
  19. package/dist/lib/map/MapShell.svelte +7 -6
  20. package/dist/lib/map/MapShell.svelte.d.ts +3 -1
  21. package/dist/lib/map/basemap.d.ts +14 -0
  22. package/dist/lib/map/basemap.js +10 -0
  23. package/dist/lib/map/integrations/map-geojson-integration.svelte.d.ts +6 -0
  24. package/dist/lib/map/integrations/map-geojson-integration.svelte.js +15 -2
  25. package/dist/lib/map/integrations/map-layer-integration.svelte.js +7 -3
  26. package/dist/lib/map/map-style-state.svelte.d.ts +13 -0
  27. package/dist/lib/map/map-style-state.svelte.js +35 -0
  28. package/dist/lib/map/map.svelte.d.ts +6 -0
  29. package/dist/lib/map/map.svelte.js +6 -0
  30. package/dist/lib/monitors/monitor-groups.d.ts +11 -0
  31. package/dist/lib/monitors/monitor-groups.js +7 -0
  32. package/dist/lib/monitors/monitor-utils.js +1 -3
  33. package/dist/lib/monitors/monitors-icon-manager.svelte.js +2 -5
  34. package/dist/lib/monitors/monitors-map-integration.svelte.d.ts +4 -4
  35. package/dist/lib/monitors/monitors-map-integration.svelte.js +43 -30
  36. package/dist/lib/monitors/monitors.svelte.d.ts +1 -0
  37. package/dist/lib/monitors/monitors.svelte.js +22 -0
  38. package/dist/lib/options/OptionsBar.svelte +96 -0
  39. package/dist/lib/options/OptionsBar.svelte.d.ts +10 -0
  40. package/dist/lib/options/OptionsGroup.svelte +12 -0
  41. package/dist/lib/options/OptionsGroup.svelte.d.ts +7 -0
  42. package/dist/lib/options/OptionsMenu.svelte +71 -0
  43. package/dist/lib/options/OptionsMenu.svelte.d.ts +15 -0
  44. package/dist/lib/options/group-state.d.ts +7 -0
  45. package/dist/lib/options/group-state.js +15 -0
  46. package/dist/lib/options/layout.d.ts +7 -0
  47. package/dist/lib/options/layout.js +9 -0
  48. package/dist/lib/options/map-option-defaults.d.ts +56 -0
  49. package/dist/lib/options/map-option-defaults.js +47 -0
  50. package/dist/lib/options/menus/LayersMenu.svelte +47 -0
  51. package/dist/lib/options/menus/LayersMenu.svelte.d.ts +3 -0
  52. package/dist/lib/options/menus/MonitorsMenu.svelte +68 -0
  53. package/dist/lib/options/menus/MonitorsMenu.svelte.d.ts +3 -0
  54. package/dist/lib/options/menus/OverlaysMenu.svelte +18 -0
  55. package/dist/lib/{MapLegend.svelte.d.ts → options/menus/OverlaysMenu.svelte.d.ts} +3 -3
  56. package/dist/lib/options/menus/PollutantMenu.svelte +79 -0
  57. package/dist/lib/options/menus/PollutantMenu.svelte.d.ts +3 -0
  58. package/dist/lib/options/menus/SettingsMenu.svelte +73 -0
  59. package/dist/lib/{hms/components/HMSFireLegend.svelte.d.ts → options/menus/SettingsMenu.svelte.d.ts} +3 -3
  60. package/dist/lib/options/options-state.svelte.d.ts +12 -0
  61. package/dist/lib/options/options-state.svelte.js +42 -0
  62. package/dist/lib/options/pollutant-param.d.ts +17 -0
  63. package/dist/lib/options/pollutant-param.js +14 -0
  64. package/dist/lib/options/reset-map-options.d.ts +2 -0
  65. package/dist/lib/options/reset-map-options.js +24 -0
  66. package/dist/lib/options/rows/CheckboxRow.svelte +51 -0
  67. package/dist/lib/options/rows/CheckboxRow.svelte.d.ts +13 -0
  68. package/dist/lib/options/rows/GroupRow.svelte +43 -0
  69. package/dist/lib/options/rows/GroupRow.svelte.d.ts +17 -0
  70. package/dist/lib/options/rows/MarkerIcon.svelte +10 -0
  71. package/dist/lib/options/rows/MarkerIcon.svelte.d.ts +8 -0
  72. package/dist/lib/options/rows/RadioRow.svelte +33 -0
  73. package/dist/lib/options/rows/RadioRow.svelte.d.ts +13 -0
  74. package/dist/lib/options/rows/SubmenuRow.svelte +22 -0
  75. package/dist/lib/options/rows/SubmenuRow.svelte.d.ts +11 -0
  76. package/dist/lib/search/Search.svelte +6 -6
  77. package/package.json +6 -4
  78. package/src/lib/MonitorMapLayout.svelte +74 -36
  79. package/src/lib/collocation-sites/collocations-map-integration.svelte.ts +17 -1
  80. package/src/lib/ev-stations/ev-stations-map-integration.svelte.ts +26 -6
  81. package/src/lib/hms/hms-fire-icon-manager.ts +16 -2
  82. package/src/lib/hms/hms-fire-map-integration.svelte.ts +1 -1
  83. package/src/lib/hms/hms-smoke-map-integration.svelte.ts +25 -7
  84. package/src/lib/index.ts +31 -7
  85. package/src/lib/legend/Legend.svelte +40 -0
  86. package/src/lib/legend/LegendBar.svelte +45 -0
  87. package/src/lib/legend/legend-data.ts +101 -0
  88. package/src/lib/map/MapShell.svelte +7 -6
  89. package/src/lib/map/basemap.ts +24 -0
  90. package/src/lib/map/integrations/map-geojson-integration.svelte.ts +15 -2
  91. package/src/lib/map/integrations/map-layer-integration.svelte.ts +6 -3
  92. package/src/lib/map/map-style-state.svelte.ts +42 -0
  93. package/src/lib/map/map.svelte.ts +6 -0
  94. package/src/lib/monitors/monitor-groups.ts +20 -0
  95. package/src/lib/monitors/monitor-utils.ts +1 -5
  96. package/src/lib/monitors/monitors-icon-manager.svelte.ts +2 -8
  97. package/src/lib/monitors/monitors-map-integration.svelte.ts +37 -27
  98. package/src/lib/monitors/monitors.svelte.ts +22 -0
  99. package/src/lib/options/OptionsBar.svelte +96 -0
  100. package/src/lib/options/OptionsGroup.svelte +12 -0
  101. package/src/lib/options/OptionsMenu.svelte +71 -0
  102. package/src/lib/options/group-state.ts +16 -0
  103. package/src/lib/options/layout.ts +10 -0
  104. package/src/lib/options/map-option-defaults.ts +71 -0
  105. package/src/lib/options/menus/LayersMenu.svelte +47 -0
  106. package/src/lib/options/menus/MonitorsMenu.svelte +68 -0
  107. package/src/lib/options/menus/OverlaysMenu.svelte +18 -0
  108. package/src/lib/options/menus/PollutantMenu.svelte +79 -0
  109. package/src/lib/options/menus/SettingsMenu.svelte +73 -0
  110. package/src/lib/options/options-state.svelte.ts +48 -0
  111. package/src/lib/options/pollutant-param.ts +35 -0
  112. package/src/lib/options/reset-map-options.ts +28 -0
  113. package/src/lib/options/rows/CheckboxRow.svelte +51 -0
  114. package/src/lib/options/rows/GroupRow.svelte +43 -0
  115. package/src/lib/options/rows/MarkerIcon.svelte +10 -0
  116. package/src/lib/options/rows/RadioRow.svelte +33 -0
  117. package/src/lib/options/rows/SubmenuRow.svelte +22 -0
  118. package/src/lib/search/Search.svelte +6 -6
  119. package/dist/lib/MapLegend.svelte +0 -15
  120. package/dist/lib/collocation-sites/collocation-utils.d.ts +0 -3
  121. package/dist/lib/collocation-sites/collocation-utils.js +0 -30
  122. package/dist/lib/components/DisplayOption.svelte +0 -7
  123. package/dist/lib/components/DisplayOption.svelte.d.ts +0 -5
  124. package/dist/lib/components/MapLayersDisplayOptions.svelte +0 -42
  125. package/dist/lib/components/MapLayersDisplayOptions.svelte.d.ts +0 -18
  126. package/dist/lib/components/SegmentedControl.svelte +0 -52
  127. package/dist/lib/components/SegmentedControl.svelte.d.ts +0 -11
  128. package/dist/lib/components/ToggleSwitch.svelte +0 -93
  129. package/dist/lib/components/ToggleSwitch.svelte.d.ts +0 -9
  130. package/dist/lib/ev-stations/components/EvStationsDisplayOptions.svelte +0 -46
  131. package/dist/lib/ev-stations/components/EvStationsDisplayOptions.svelte.d.ts +0 -18
  132. package/dist/lib/hms/components/HMSFireLegend.svelte +0 -21
  133. package/dist/lib/map/MapStyleDisplayOptions.svelte +0 -61
  134. package/dist/lib/map/MapStyleDisplayOptions.svelte.d.ts +0 -3
  135. package/dist/lib/map/Menu.svelte +0 -65
  136. package/dist/lib/map/Menu.svelte.d.ts +0 -5
  137. package/dist/lib/monitors/components/MonitorMarkersLegend.svelte +0 -41
  138. package/dist/lib/monitors/components/MonitorMarkersLegend.svelte.d.ts +0 -3
  139. package/dist/lib/monitors/components/MonitorsDisplayOptions.svelte +0 -85
  140. package/dist/lib/monitors/components/MonitorsDisplayOptions.svelte.d.ts +0 -3
  141. package/src/lib/MapLegend.svelte +0 -15
  142. package/src/lib/collocation-sites/collocation-utils.ts +0 -41
  143. package/src/lib/components/DisplayOption.svelte +0 -7
  144. package/src/lib/components/MapLayersDisplayOptions.svelte +0 -42
  145. package/src/lib/components/SegmentedControl.svelte +0 -52
  146. package/src/lib/components/ToggleSwitch.svelte +0 -93
  147. package/src/lib/ev-stations/components/EvStationsDisplayOptions.svelte +0 -46
  148. package/src/lib/hms/components/HMSFireLegend.svelte +0 -21
  149. package/src/lib/map/MapStyleDisplayOptions.svelte +0 -61
  150. package/src/lib/map/Menu.svelte +0 -65
  151. package/src/lib/monitors/components/MonitorMarkersLegend.svelte +0 -41
  152. package/src/lib/monitors/components/MonitorsDisplayOptions.svelte +0 -85
@@ -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 "$lib/map/MapShell.svelte";
5
- import MonitorsDisplayOptions from "$lib/monitors/components/MonitorsDisplayOptions.svelte";
6
- import MapLayersDisplayOptions from "$lib/components/MapLayersDisplayOptions.svelte";
7
- import MapStyleDisplayOptions from "$lib/map/MapStyleDisplayOptions.svelte";
6
+ import OptionsGroup from "$lib/options/OptionsGroup.svelte";
7
+ import PollutantMenu from "$lib/options/menus/PollutantMenu.svelte";
8
+ import MonitorsMenu from "$lib/options/menus/MonitorsMenu.svelte";
9
+ import LayersMenu from "$lib/options/menus/LayersMenu.svelte";
10
+ import OverlaysMenu from "$lib/options/menus/OverlaysMenu.svelte";
11
+ import SettingsMenu from "$lib/options/menus/SettingsMenu.svelte";
12
+ import {
13
+ applyPollutantSelection,
14
+ currentSelection,
15
+ parsePollutantParam
16
+ } from "$lib/options/pollutant-param";
17
+ import Legend from "$lib/legend/Legend.svelte";
18
+ import {
19
+ fireSmokeLegendSections,
20
+ pollutantLegendSection,
21
+ type LegendSection
22
+ } from "$lib/legend/legend-data";
8
23
  import { monitorsManager } from "$lib/monitors/monitors.svelte";
9
24
  import { monitorsMapIntegration } from "$lib/monitors/monitors-map-integration.svelte";
10
25
  import { windMapIntegration } from "$lib/wind/wind.svelte";
@@ -12,14 +27,12 @@
12
27
  import type { SomeMapIntegration } from "$lib/map/integrations/types";
13
28
  import { collocationSitesManager } from "$lib/collocation-sites/collocations.svelte";
14
29
  import { collocationSitesMapIntegration } from "$lib/collocation-sites/collocations-map-integration.svelte";
15
- import EvStationsDisplayOptions from "$lib/ev-stations/components/EvStationsDisplayOptions.svelte";
16
30
  import { evStationsMapIntegration } from "$lib/ev-stations/ev-stations-map-integration.svelte";
17
31
  import { hmsManager } from "$lib/hms/hms.svelte";
18
32
  import { hmsFireMapIntegration } from "$lib/hms/hms-fire-map-integration.svelte";
19
- import { hmsSmokeMapIntegration } from "$lib/hms/hms-smoke-map-integration.svelte";
20
- import MapLegend from "$lib/MapLegend.svelte";
33
+ import { FIRE_LEGEND_CATEGORIES } from "$lib/hms/hms-fire-icon-manager";
34
+ import { hmsSmokeMapIntegration, SMOKE_LEGEND } from "$lib/hms/hms-smoke-map-integration.svelte";
21
35
  import Search from "$lib/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()}
@@ -25,6 +25,11 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration<Collocation
25
25
  icons: CollocationIconManager = new CollocationIconManager(monitorsManager);
26
26
  tooltipManager: TooltipManager = new TooltipManager();
27
27
 
28
+ /** The option only applies while monitors are shown with PM2.5. */
29
+ get available(): boolean {
30
+ return monitorsMapIntegration.enabled && monitorsManager.pollutant === "pm25";
31
+ }
32
+
28
33
  features: Array<CollocationSiteMapFeature> = $derived.by(() => {
29
34
  if (
30
35
  !monitorsManager.meta ||
@@ -93,8 +98,10 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration<Collocation
93
98
  mapManager.setDataSource(this.referenceId, features);
94
99
  });
95
100
 
101
+ // Remove the layer while the option is unavailable (Ozone or Pollutant = None); restore
102
+ // it when it becomes available again if the user still has it checked.
96
103
  $effect(() => {
97
- if (monitorsManager.pollutant === "o3") {
104
+ if (!this.available) {
98
105
  untrack(() => this.remove());
99
106
  } else if (untrack(() => this.enabled)) {
100
107
  untrack(() => this.apply());
@@ -104,6 +111,10 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration<Collocation
104
111
  }
105
112
 
106
113
  apply() {
114
+ if (!this.available) {
115
+ this.remove();
116
+ return;
117
+ }
107
118
  if (!mapManager.map) return;
108
119
 
109
120
  if (!this.tooltipManager.has(this.referenceId)) {
@@ -114,6 +125,11 @@ class CollocationSitesMapIntegration extends MapIconLayerIntegration<Collocation
114
125
 
115
126
  super.apply();
116
127
  }
128
+
129
+ remove() {
130
+ this.tooltipManager.disable();
131
+ super.remove();
132
+ }
117
133
  }
118
134
 
119
135
  export const collocationSitesMapIntegration = new CollocationSitesMapIntegration();
@@ -26,7 +26,7 @@ class EvStationsMapIntegration
26
26
  implements EvStationsClusterContext
27
27
  {
28
28
  referenceId: string = "ev-stations";
29
- enabled: boolean = $state(true);
29
+ enabled: boolean = $state(false);
30
30
  clustered: boolean = $state(true);
31
31
 
32
32
  icons: EvStationIconManager = new EvStationIconManager();
@@ -35,8 +35,8 @@ class EvStationsMapIntegration
35
35
  private currentPopup: Popup | null = null;
36
36
 
37
37
  displayOptions = $derived.by(() => ({
38
- lvl2: new MapDisplayOption("Level 2", false, this.icons.get("ev-station-lvl2")!),
39
- lvl3: new MapDisplayOption("Level 3", false, this.icons.get("ev-station-lvl3")!)
38
+ lvl2: new MapDisplayOption("Level 2", true, this.icons.get("ev-station-lvl2")!),
39
+ lvl3: new MapDisplayOption("Level 3", true, this.icons.get("ev-station-lvl3")!)
40
40
  }));
41
41
 
42
42
  features: Array<EvStationMapFeature> = $derived.by(() => {
@@ -115,23 +115,38 @@ class EvStationsMapIntegration
115
115
  super();
116
116
 
117
117
  $effect.root(() => {
118
- // Lazy-fetch level data when integration is enabled and level is toggled on
118
+ // Lazy-fetch level data only once the integration is enabled and the level is on
119
119
  $effect(() => {
120
- if (this.displayOptions.lvl2.value && evStationsManager.lvl2Stations === undefined) {
120
+ if (
121
+ this.enabled &&
122
+ this.displayOptions.lvl2.value &&
123
+ evStationsManager.lvl2Stations === undefined
124
+ ) {
121
125
  evStationsManager.loadLvl2Stations();
122
126
  }
123
127
  });
124
128
 
125
129
  $effect(() => {
126
- if (this.displayOptions.lvl3.value && evStationsManager.lvl3Stations === undefined) {
130
+ if (
131
+ this.enabled &&
132
+ this.displayOptions.lvl3.value &&
133
+ evStationsManager.lvl3Stations === undefined
134
+ ) {
127
135
  evStationsManager.loadLvl3Stations();
128
136
  }
129
137
  });
130
138
 
139
+ // Every effect below early-returns while disabled so a data refresh can't re-add the layer.
140
+ // `enabled` is read untracked: the base class's own effect already applies/removes on
141
+ // enabled changes, and tracking it here would apply twice. The guard comes after each
142
+ // effect's tracked reads so its dependencies are always collected; returning before
143
+ // reading anything would leave the effect permanently dead.
144
+
131
145
  // Push filter changes to the unclustered layer
132
146
  $effect(() => {
133
147
  const filter = this.filters;
134
148
  if (!mapManager.map || this.clustered) return;
149
+ if (!untrack(() => this.enabled)) return;
135
150
  if (mapManager.map.getLayer(this.referenceId)) {
136
151
  mapManager.map.setFilter(this.referenceId, filter);
137
152
  }
@@ -141,6 +156,7 @@ class EvStationsMapIntegration
141
156
  $effect(() => {
142
157
  const features = this.features;
143
158
  if (!mapManager.map || this.clustered) return;
159
+ if (!untrack(() => this.enabled)) return;
144
160
  mapManager.setDataSource(this.referenceId, features);
145
161
  });
146
162
 
@@ -148,6 +164,7 @@ class EvStationsMapIntegration
148
164
  $effect(() => {
149
165
  void this.featuresByLevel;
150
166
  if (!mapManager.map || !this.clustered) return;
167
+ if (!untrack(() => this.enabled)) return;
151
168
  this.renderer.syncFeatures();
152
169
  });
153
170
 
@@ -156,6 +173,7 @@ class EvStationsMapIntegration
156
173
  void this.clustered;
157
174
  const hasFeatures = Object.keys(this.featuresByLevel).length > 0;
158
175
  if (!untrack(() => mapManager.map) || !hasFeatures) return;
176
+ if (!untrack(() => this.enabled)) return;
159
177
  untrack(() => this.apply());
160
178
  });
161
179
  });
@@ -171,6 +189,7 @@ class EvStationsMapIntegration
171
189
  this.tooltipManager.disable();
172
190
  this.renderer.remove();
173
191
  this.icons.loadIcons().then(() => {
192
+ if (mapManager.styleLoading) return;
174
193
  this.renderer.apply(this.handleStationClick);
175
194
  this.tooltipManager.enable();
176
195
  });
@@ -180,6 +199,7 @@ class EvStationsMapIntegration
180
199
  clickManager.unregister([this.referenceId]);
181
200
  super.apply();
182
201
  this.icons.loadIcons().then(() => {
202
+ if (mapManager.styleLoading) return;
183
203
  clickManager.register([this.referenceId], this.handleStationClick);
184
204
  this.tooltipManager.enable();
185
205
  });
@@ -1,9 +1,10 @@
1
1
  import { asDataURI, fireIcon } from "$lib/map/icons";
2
2
  import { MapIconManager } from "$lib/map/integrations/map-icon-manager";
3
+ import type { LegendCategory } from "$lib/legend/legend-data";
3
4
 
4
- type FRPTier = "sm" | "md" | "lg" | "xl" | "xxl";
5
+ export type FRPTier = "sm" | "md" | "lg" | "xl" | "xxl";
5
6
 
6
- const FRP_TIERS: Record<FRPTier, { color: string; size: number }> = {
7
+ export const FRP_TIERS: Record<FRPTier, { color: string; size: number }> = {
7
8
  sm: { color: "#FFD700", size: 20 },
8
9
  md: { color: "#FF8C00", size: 24 },
9
10
  lg: { color: "#FF4500", size: 28 },
@@ -11,6 +12,19 @@ const FRP_TIERS: Record<FRPTier, { color: string; size: number }> = {
11
12
  xxl: { color: "#8B0000", size: 36 }
12
13
  };
13
14
 
15
+ const FRP_TIER_LABELS: Record<FRPTier, string> = {
16
+ sm: "<10",
17
+ md: "10-49",
18
+ lg: "50-149",
19
+ xl: "150-349",
20
+ xxl: "≥350"
21
+ };
22
+
23
+ /** Fire legend blocks (MW), in tier order, using the same colors as the map icons. */
24
+ export const FIRE_LEGEND_CATEGORIES: Array<LegendCategory> = (
25
+ Object.keys(FRP_TIERS) as Array<FRPTier>
26
+ ).map((tier) => ({ color: FRP_TIERS[tier].color, label: FRP_TIER_LABELS[tier] }));
27
+
14
28
  export function getTierIconId(frp: number): string {
15
29
  if (frp < 10) return "hms-fire-sm";
16
30
  if (frp < 50) return "hms-fire-md";
@@ -89,7 +89,7 @@ class HMSFireMapIntegration extends MapIconLayerIntegration<FireProperties> {
89
89
  this.icons
90
90
  .loadIcons()
91
91
  .then(() => {
92
- if (!mapManager.map || !this.enabled) return;
92
+ if (!mapManager.map || !this.enabled || mapManager.styleLoading) return;
93
93
  this.remove();
94
94
  this.zoomedIn = mapManager.map.getZoom() >= ZOOM_THRESHOLD;
95
95
  mapManager.map.addSource(this.referenceId, this.mapSource);
@@ -4,6 +4,16 @@ import type { HMSSmokeGeoJSON } from "@sjvair/sdk/hms";
4
4
  import { mapManager } from "$lib/map/map.svelte";
5
5
  import { MapGeoJSONIntegration } from "$lib/map/integrations/map-geojson-integration.svelte";
6
6
  import { hmsManager } from "./hms.svelte";
7
+ import type { SmokeDensityStyle } from "$lib/legend/legend-data";
8
+
9
+ /** Smoke fill styles, shared by the map layer and the legend so they can't drift apart. */
10
+ export const SMOKE_DENSITY_STYLES = {
11
+ light: { label: "Light", color: "#bfc8c3", opacity: 0.2 },
12
+ medium: { label: "Medium", color: "#757b78", opacity: 0.3 },
13
+ heavy: { label: "Heavy", color: "#333634", opacity: 0.4 }
14
+ } satisfies Record<string, SmokeDensityStyle>;
15
+
16
+ export const SMOKE_LEGEND: Array<SmokeDensityStyle> = Object.values(SMOKE_DENSITY_STYLES);
7
17
 
8
18
  type SmokeProperties = {
9
19
  id: string;
@@ -36,20 +46,28 @@ class HMSSmokeMapIntegration extends MapGeoJSONIntegration<SmokeProperties> {
36
46
  "match",
37
47
  ["get", "density"],
38
48
  "light",
39
- "#bfc8c3",
49
+ SMOKE_DENSITY_STYLES.light.color,
50
+ "medium",
51
+ SMOKE_DENSITY_STYLES.medium.color,
52
+ SMOKE_DENSITY_STYLES.heavy.color
53
+ ],
54
+ "fill-opacity": [
55
+ "match",
56
+ ["get", "density"],
57
+ "light",
58
+ SMOKE_DENSITY_STYLES.light.opacity,
40
59
  "medium",
41
- "#757b78",
42
- "#333634"
60
+ SMOKE_DENSITY_STYLES.medium.opacity,
61
+ SMOKE_DENSITY_STYLES.heavy.opacity
43
62
  ],
44
- "fill-opacity": ["match", ["get", "density"], "light", 0.2, "medium", 0.3, 0.4],
45
63
  "fill-outline-color": [
46
64
  "match",
47
65
  ["get", "density"],
48
66
  "light",
49
- "#bfc8c3",
67
+ SMOKE_DENSITY_STYLES.light.color,
50
68
  "medium",
51
- "#757b78",
52
- "#333634"
69
+ SMOKE_DENSITY_STYLES.medium.color,
70
+ SMOKE_DENSITY_STYLES.heavy.color
53
71
  ]
54
72
  }
55
73
  };
package/src/lib/index.ts CHANGED
@@ -59,7 +59,6 @@ export { hmsSmokeMapIntegration } from "./hms/hms-smoke-map-integration.svelte";
59
59
  // Components
60
60
  export { default as Map } from "./map/Map.svelte";
61
61
  export { default as MapShell, type MapShellProps } from "./map/MapShell.svelte";
62
- export { default as Menu } from "./map/Menu.svelte";
63
62
  export {
64
63
  default as LoadScreen,
65
64
  loadScreenState,
@@ -67,12 +66,37 @@ export {
67
66
  disable as disableLoadScreen
68
67
  } from "./LoadScreen.svelte";
69
68
  export type { LoadScreenState } from "./LoadScreen.svelte";
70
- export { default as MonitorsDisplayOptions } from "./monitors/components/MonitorsDisplayOptions.svelte";
71
- export { default as EvStationsDisplayOptions } from "./ev-stations/components/EvStationsDisplayOptions.svelte";
72
- export { default as MapLayersDisplayOptions } from "./components/MapLayersDisplayOptions.svelte";
73
- export { default as MapStyleDisplayOptions } from "./map/MapStyleDisplayOptions.svelte";
74
- export { default as ToggleSwitch } from "./components/ToggleSwitch.svelte";
75
- export { default as SegmentedControl } from "./components/SegmentedControl.svelte";
69
+
70
+ // Options toolbar / panel (compose these inside MapShell's `menu` snippet)
71
+ export { default as OptionsBar } from "./options/OptionsBar.svelte";
72
+ export { default as OptionsGroup } from "./options/OptionsGroup.svelte";
73
+ export { default as OptionsMenu } from "./options/OptionsMenu.svelte";
74
+ export { default as CheckboxRow } from "./options/rows/CheckboxRow.svelte";
75
+ export { default as RadioRow } from "./options/rows/RadioRow.svelte";
76
+ export { default as SubmenuRow } from "./options/rows/SubmenuRow.svelte";
77
+ export { default as GroupRow } from "./options/rows/GroupRow.svelte";
78
+ export { default as MarkerIcon } from "./options/rows/MarkerIcon.svelte";
79
+ export { default as PollutantMenu } from "./options/menus/PollutantMenu.svelte";
80
+ export { default as MonitorsMenu } from "./options/menus/MonitorsMenu.svelte";
81
+ export { default as LayersMenu } from "./options/menus/LayersMenu.svelte";
82
+ export { default as OverlaysMenu } from "./options/menus/OverlaysMenu.svelte";
83
+ export { default as SettingsMenu } from "./options/menus/SettingsMenu.svelte";
84
+ export { resetMapOptions } from "./options/reset-map-options";
85
+ export { mapStyleState } from "./map/map-style-state.svelte.js";
86
+ export type { MapStyleState } from "./map/map-style-state.svelte.js";
87
+
88
+ // Legend
89
+ export { default as Legend } from "./legend/Legend.svelte";
90
+ export { default as LegendBar } from "./legend/LegendBar.svelte";
91
+ export {
92
+ pollutantLegendSection,
93
+ fireSmokeLegendSections,
94
+ type LegendSection,
95
+ type LegendBarData,
96
+ type LegendCategory
97
+ } from "./legend/legend-data";
98
+ export { FIRE_LEGEND_CATEGORIES } from "./hms/hms-fire-icon-manager";
99
+ export { SMOKE_LEGEND } from "./hms/hms-smoke-map-integration.svelte";
76
100
 
77
101
  // Integration base classes (extend these to add custom map features)
78
102
  export { MapIntegration } from "./map/integrations/map-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,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}
@@ -0,0 +1,101 @@
1
+ import type { SJVAirEntryLevel } from "@sjvair/sdk";
2
+ import { mix } from "color2k";
3
+ import type { MonitorsPollutant } from "$lib/monitors/types";
4
+
5
+ export interface LegendCategory {
6
+ color: string;
7
+ label?: string;
8
+ }
9
+
10
+ export type LegendBarData =
11
+ | { kind: "gradient"; colors: Array<string>; ticks: Array<string> }
12
+ | { kind: "blocks"; categories: Array<LegendCategory>; breakpoints?: Array<string> };
13
+
14
+ export interface LegendSection {
15
+ id: string;
16
+ title: string;
17
+ unit?: string;
18
+ bar: LegendBarData;
19
+ }
20
+
21
+ export interface SmokeDensityStyle {
22
+ label: string;
23
+ color: string;
24
+ opacity: number;
25
+ }
26
+
27
+ const POLLUTANT_TITLES: Record<MonitorsPollutant, { title: string; unit: string }> = {
28
+ pm25: { title: "PM2.5", unit: "µg/m³" },
29
+ o3: { title: "Ozone", unit: "ppb" }
30
+ };
31
+
32
+ /**
33
+ * Pollutant legend built from the monitors meta levels — the same levels the map markers are
34
+ * colored by, so the two can never disagree. One tick per level, at that level's color stop.
35
+ */
36
+ export function pollutantLegendSection(
37
+ pollutant: MonitorsPollutant,
38
+ levels: ReadonlyArray<Pick<SJVAirEntryLevel, "color" | "range">>
39
+ ): LegendSection | null {
40
+ if (levels.length === 0) return null;
41
+ return {
42
+ id: "pollutant",
43
+ ...POLLUTANT_TITLES[pollutant],
44
+ bar: {
45
+ kind: "gradient",
46
+ colors: levels.map((level) => level.color),
47
+ ticks: levels.map((level) => String(Math.floor(level.range[0])))
48
+ }
49
+ };
50
+ }
51
+
52
+ /** Fire & Smoke share one toggle, so they render as two independent bars. */
53
+ export function fireSmokeLegendSections(
54
+ fireTiers: ReadonlyArray<LegendCategory>,
55
+ smoke: ReadonlyArray<SmokeDensityStyle>
56
+ ): Array<LegendSection> {
57
+ return [
58
+ { id: "fire", title: "Fire", unit: "MW", bar: { kind: "blocks", categories: [...fireTiers] } },
59
+ {
60
+ id: "smoke",
61
+ title: "Smoke Density",
62
+ bar: {
63
+ kind: "blocks",
64
+ categories: smoke.map(({ label, color, opacity }) => ({
65
+ label,
66
+ color: swatchOverWhite(color, opacity)
67
+ }))
68
+ }
69
+ }
70
+ ];
71
+ }
72
+
73
+ /** Approximates how a translucent fill layer looks over a light basemap. */
74
+ export function swatchOverWhite(color: string, opacity: number): string {
75
+ return mix("#ffffff", color, opacity);
76
+ }
77
+
78
+ export function gradientBackground(colors: ReadonlyArray<string>): string {
79
+ if (colors.length === 0) return "none";
80
+ if (colors.length === 1) return colors[0];
81
+ return `linear-gradient(to right, ${colors.join(", ")})`;
82
+ }
83
+
84
+ /** Hard-edged equal-width blocks: each color repeated at both edges of its own share. */
85
+ export function blocksBackground(colors: ReadonlyArray<string>): string {
86
+ const count = colors.length;
87
+ if (count === 0) return "none";
88
+ const stops = colors.map(
89
+ (color, i) => `${color} ${percent(i / count)}, ${color} ${percent((i + 1) / count)}`
90
+ );
91
+ return `linear-gradient(to right, ${stops.join(", ")})`;
92
+ }
93
+
94
+ /** Percent position of the index-th of `count` evenly spaced labels. */
95
+ export function tickPosition(index: number, count: number): number {
96
+ return count <= 1 ? 0 : (index / (count - 1)) * 100;
97
+ }
98
+
99
+ function percent(fraction: number): string {
100
+ return `${(fraction * 100).toFixed(2)}%`;
101
+ }