@sjvair/monitor-map 3.6.3 → 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 +5 -3
  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
@@ -0,0 +1,20 @@
1
+ /** Monitor display-option keys shown under each Monitors menu group, in display order. */
2
+ export const REFERENCE_GRADE_TYPES = ["airnow", "aqlite", "aqview", "bam1022"] as const;
3
+ export const LOW_COST_TYPES = ["airgradient", "purpleair", "vozbox"] as const;
4
+
5
+ export type MonitorGroupType =
6
+ (typeof REFERENCE_GRADE_TYPES)[number] | (typeof LOW_COST_TYPES)[number];
7
+
8
+ /** The slice of MonitorsMeta needed to know which pollutants a monitor type reports. */
9
+ export interface MonitorTypeMeta {
10
+ monitors: Record<string, { entries: Record<string, unknown> } | undefined>;
11
+ }
12
+
13
+ export function typeSupportsPollutant(
14
+ meta: MonitorTypeMeta | null,
15
+ type: string,
16
+ pollutant: string | null
17
+ ): boolean {
18
+ const deviceMeta = meta?.monitors[type];
19
+ return !!deviceMeta && !!pollutant && pollutant in deviceMeta.entries;
20
+ }
@@ -40,18 +40,14 @@ export function getOrder(monitor: MonitorData): number {
40
40
 
41
41
  export function getTypeShape(type: string): string {
42
42
  switch (type) {
43
- case "airgradient":
44
- return "circle";
45
-
46
43
  case "airnow":
47
44
  case "aqview":
48
45
  case "bam1022":
49
46
  case "aqlite":
50
47
  return "triangle";
51
48
 
49
+ case "airgradient":
52
50
  case "purpleair":
53
- return "square";
54
-
55
51
  case "vozbox":
56
52
  return "circle";
57
53
 
@@ -13,23 +13,17 @@ export function getIconId<T extends MonitorData>(monitor: T, level: SJVAirEntryL
13
13
  const id = `${monitor.location}-${monitor.is_active ? level.name : "default"}`;
14
14
 
15
15
  switch (monitor.type) {
16
- case "airgradient":
17
- return `${id}-circle`;
18
-
19
16
  case "airnow":
20
17
  case "aqview":
21
18
  case "bam1022":
19
+ case "aqlite":
22
20
  return `${id}-triangle`;
23
21
 
22
+ case "airgradient":
24
23
  case "purpleair":
25
- return `${id}-${monitor.is_sjvair ? "circle" : "square"}`;
26
-
27
24
  case "vozbox":
28
25
  return `${id}-circle`;
29
26
 
30
- case "aqlite":
31
- return `${id}-triangle`;
32
-
33
27
  default:
34
28
  throw new Error(`Map icon for ${monitor.device} has not been set`);
35
29
  }
@@ -20,12 +20,6 @@ import type { MonitorMapFeature, MonitorMarkerProperties, MonitorsDataSource } f
20
20
  const filters = {
21
21
  monitor(deviceType: MonitorType): ExpressionSpecification {
22
22
  return ["==", ["get", "type"], deviceType];
23
- },
24
- purpleair(): ExpressionSpecification {
25
- return ["all", ["==", ["get", "type"], "purpleair"], ["==", ["get", "is_sjvair"], false]];
26
- },
27
- sjvPurpleair(): ExpressionSpecification {
28
- return ["all", ["==", ["get", "type"], "purpleair"], ["==", ["get", "is_sjvair"], true]];
29
23
  }
30
24
  };
31
25
 
@@ -39,13 +33,17 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
39
33
  private renderer: MonitorsClusterRenderer = new MonitorsClusterRenderer(this);
40
34
 
41
35
  displayOptions = $derived.by(() => ({
42
- purpleair: new MapDisplayOption("PurpleAir", true, this.icons.get("outside-display-square")),
43
- sjvair: new MapDisplayOption("SJVAir non-FEM", true, this.icons.get("outside-display-circle")),
44
- aqview: new MapDisplayOption("AQview", true, this.icons.get("outside-display-triangle")),
45
- bam1022: new MapDisplayOption("SJVAir FEM", true, this.icons.get("outside-display-triangle")),
46
36
  airnow: new MapDisplayOption("AirNow", true, this.icons.get("outside-display-triangle")),
47
- vozbox: new MapDisplayOption("VOZbox", true, this.icons.get("outside-display-circle")),
48
37
  aqlite: new MapDisplayOption("AQLite", true, this.icons.get("outside-display-triangle")),
38
+ aqview: new MapDisplayOption("AQview", true, this.icons.get("outside-display-triangle")),
39
+ bam1022: new MapDisplayOption("SJVAir", true, this.icons.get("outside-display-triangle")),
40
+ airgradient: new MapDisplayOption(
41
+ "AirGradient",
42
+ true,
43
+ this.icons.get("outside-display-circle")
44
+ ),
45
+ purpleair: new MapDisplayOption("PurpleAir", true, this.icons.get("outside-display-circle")),
46
+ vozbox: new MapDisplayOption("VOZbox", true, this.icons.get("outside-display-circle")),
49
47
  inactive: new MapDisplayOption("Inactive", false, this.icons.get("outside-default-square")),
50
48
  inside: new MapDisplayOption("Inside", false, this.icons.get("inside-display-square"))
51
49
  }));
@@ -115,14 +113,13 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
115
113
  ];
116
114
  const statusFilters: ExpressionSpecification = ["any", ["==", ["get", "is_active"], true]];
117
115
 
118
- if (this.displayOptions.purpleair.value) monitorFilters.push(filters.purpleair());
116
+ if (this.displayOptions.airnow.value) monitorFilters.push(filters.monitor("airnow"));
117
+ if (this.displayOptions.aqlite.value) monitorFilters.push(filters.monitor("aqlite"));
119
118
  if (this.displayOptions.aqview.value) monitorFilters.push(filters.monitor("aqview"));
120
119
  if (this.displayOptions.bam1022.value) monitorFilters.push(filters.monitor("bam1022"));
121
- if (this.displayOptions.airnow.value) monitorFilters.push(filters.monitor("airnow"));
120
+ if (this.displayOptions.airgradient.value) monitorFilters.push(filters.monitor("airgradient"));
121
+ if (this.displayOptions.purpleair.value) monitorFilters.push(filters.monitor("purpleair"));
122
122
  if (this.displayOptions.vozbox.value) monitorFilters.push(filters.monitor("vozbox"));
123
- if (this.displayOptions.aqlite.value) monitorFilters.push(filters.monitor("aqlite"));
124
- if (this.displayOptions.sjvair.value)
125
- monitorFilters.push(filters.sjvPurpleair(), filters.monitor("airgradient"));
126
123
  if (this.displayOptions.inside.value)
127
124
  locationFilters.push(["==", ["get", "location"], "inside"]);
128
125
  if (this.displayOptions.inactive.value) statusFilters.push(["==", ["get", "is_active"], false]);
@@ -131,8 +128,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
131
128
  });
132
129
 
133
130
  // Groups features by monitor type for per-type cluster sources, applying display option
134
- // filters so cluster aggregates only include visible monitors. sjvair purpleair is remapped
135
- // to "airgradient" since they share the same shape (circle).
131
+ // filters so cluster aggregates only include visible monitors.
136
132
  featuresByType: Record<string, MonitorMapFeature[]> = $derived.by(() => {
137
133
  const opts = this.displayOptions;
138
134
  const byType: Record<string, MonitorMapFeature[]> = {};
@@ -144,20 +140,19 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
144
140
  if (p.location === "inside" && !opts.inside.value) continue;
145
141
 
146
142
  const typeVisible: Partial<Record<MonitorType, boolean>> = {
147
- purpleair: p.is_sjvair ? opts.sjvair.value : opts.purpleair.value,
148
- airgradient: opts.sjvair.value,
143
+ airnow: opts.airnow.value,
144
+ aqlite: opts.aqlite.value,
149
145
  aqview: opts.aqview.value,
150
146
  bam1022: opts.bam1022.value,
151
- airnow: opts.airnow.value,
152
- vozbox: opts.vozbox.value,
153
- aqlite: opts.aqlite.value
147
+ airgradient: opts.airgradient.value,
148
+ purpleair: opts.purpleair.value,
149
+ vozbox: opts.vozbox.value
154
150
  };
155
151
  if (!(typeVisible[p.type] ?? true)) continue;
156
152
 
157
- const key = p.type === "purpleair" && p.is_sjvair ? "airgradient" : p.type;
158
- const arr = byType[key] ?? [];
153
+ const arr = byType[p.type] ?? [];
159
154
  arr.push(feat);
160
- byType[key] = arr;
155
+ byType[p.type] = arr;
161
156
  }
162
157
  return byType;
163
158
  });
@@ -200,12 +195,20 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
200
195
  this.icons = new MonitorsIconManager(dataSource);
201
196
 
202
197
  $effect.root(() => {
198
+ // Every effect below early-returns while disabled (Pollutant = None) so a data refresh
199
+ // can't re-add the layer. `enabled` is read untracked: the base class's own effect
200
+ // already applies/removes on enabled changes, and tracking it here would apply twice.
201
+ // The guard comes after each effect's tracked reads so its dependencies are always
202
+ // collected; returning before reading anything would leave the effect permanently dead.
203
+
203
204
  // Push filter changes to the active layer(s) imperatively
204
205
  $effect(() => {
205
206
  const filter = this.filters;
207
+ const clustered = this.clustered;
206
208
  if (!mapManager.map) return;
209
+ if (!untrack(() => this.enabled)) return;
207
210
 
208
- if (this.clustered) {
211
+ if (clustered) {
209
212
  this.renderer.syncFilter();
210
213
  } else {
211
214
  if (mapManager.map.getLayer(this.referenceId)) {
@@ -218,6 +221,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
218
221
  $effect(() => {
219
222
  const features = this.features;
220
223
  if (!mapManager.map || this.clustered) return;
224
+ if (!untrack(() => this.enabled)) return;
221
225
  mapManager.setDataSource(this.referenceId, features);
222
226
  });
223
227
 
@@ -225,6 +229,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
225
229
  $effect(() => {
226
230
  void this.featuresByType;
227
231
  if (!mapManager.map || !this.clustered) return;
232
+ if (!untrack(() => this.enabled)) return;
228
233
  this.renderer.syncFeatures();
229
234
  });
230
235
 
@@ -232,6 +237,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
232
237
  $effect(() => {
233
238
  const thresholds = this.clusterIconThresholds;
234
239
  if (!mapManager.map || !this.clustered || !thresholds.length) return;
240
+ if (!untrack(() => this.enabled)) return;
235
241
  this.renderer.syncThresholds();
236
242
  });
237
243
 
@@ -242,6 +248,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
242
248
  void this.clustered;
243
249
  const hasFeatures = Object.keys(this.featuresByType).length > 0;
244
250
  if (!untrack(() => mapManager.map) || !hasFeatures) return;
251
+ if (!untrack(() => this.enabled)) return;
245
252
  untrack(() => this.apply());
246
253
  });
247
254
 
@@ -268,6 +275,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
268
275
  this.tooltipManager.disable();
269
276
  this.renderer.remove();
270
277
  this.icons.loadIcons().then(() => {
278
+ if (mapManager.styleLoading) return;
271
279
  this.renderer.apply(this.handleMonitorClick);
272
280
  this.tooltipManager.enable();
273
281
  this.applySelectedState();
@@ -279,6 +287,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
279
287
  clickManager.unregister([this.referenceId]);
280
288
  super.apply();
281
289
  this.icons.loadIcons().then(() => {
290
+ if (mapManager.styleLoading) return;
282
291
  clickManager.register([this.referenceId], this.handleMonitorClick);
283
292
  this.applySelectedState();
284
293
  });
@@ -286,6 +295,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMarkerProper
286
295
  }
287
296
 
288
297
  remove() {
298
+ this.tooltipManager.disable();
289
299
  clickManager.unregister([this.referenceId]);
290
300
  this.renderer.remove();
291
301
  super.remove();
@@ -9,6 +9,7 @@ import {
9
9
  } from "@sjvair/sdk";
10
10
  import { XMap } from "@tstk/builtin-extensions";
11
11
  import { Interval } from "@tstk/utils";
12
+ import { untrack } from "svelte";
12
13
  import type { MonitorsDataSource } from "./types";
13
14
 
14
15
  class MonitorsManager implements MonitorsDataSource {
@@ -26,6 +27,27 @@ class MonitorsManager implements MonitorsDataSource {
26
27
  this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null
27
28
  );
28
29
 
30
+ constructor() {
31
+ // Refetch monitors when the pollutant changes after the initial load, wherever the change
32
+ // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
33
+ // instead of briefly coloring them with the previous pollutant's readings.
34
+ let fetchedPollutant: string | null = null;
35
+ $effect.root(() => {
36
+ $effect(() => {
37
+ const pollutant = this.pollutant;
38
+ if (!this.initialized) return;
39
+ if (fetchedPollutant !== null && fetchedPollutant !== pollutant) {
40
+ untrack(() => {
41
+ this.list = [];
42
+ this.latest = null;
43
+ this.update();
44
+ });
45
+ }
46
+ fetchedPollutant = pollutant;
47
+ });
48
+ });
49
+ }
50
+
29
51
  async init(urlPollutant?: string | number | boolean): Promise<void> {
30
52
  if (this.initialized) return;
31
53
 
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { Attachment } from "svelte/attachments";
4
+ import { MenuIcon, XIcon } from "@lucide/svelte";
5
+ import { provideOptionsMenus } from "./options-state.svelte.js";
6
+ import { isWideLayout, WIDE_LAYOUT_QUERY } from "./layout";
7
+
8
+ interface Props {
9
+ /** OptionsGroup / OptionsMenu content */
10
+ children?: Snippet;
11
+ /** Rendered last in the toolbar (wide) or beside the menu button (narrow) */
12
+ search?: Snippet;
13
+ }
14
+
15
+ let { children, search }: Props = $props();
16
+
17
+ const menus = provideOptionsMenus();
18
+ const panelId = "monitor-map-options-panel";
19
+ let panelOpen = $state(false);
20
+
21
+ // Toolbar only: clicks outside close any click-opened dropdown; clicks inside never do.
22
+ const closeOnOutsideClick: Attachment<HTMLElement> = (node) => {
23
+ function handler(e: MouseEvent) {
24
+ if (isWideLayout() && !node.contains(e.target as Node)) menus.closeAll();
25
+ }
26
+ document.addEventListener("click", handler);
27
+ return () => document.removeEventListener("click", handler);
28
+ };
29
+
30
+ // Open menus mean different things in the toolbar (dropdowns) and the panel (expanded
31
+ // sections), so crossing the breakpoint (e.g. rotating a phone) starts from a closed state.
32
+ $effect(() => {
33
+ const query = window.matchMedia(WIDE_LAYOUT_QUERY);
34
+ function onchange() {
35
+ menus.closeAll();
36
+ panelOpen = false;
37
+ }
38
+ query.addEventListener("change", onchange);
39
+ return () => query.removeEventListener("change", onchange);
40
+ });
41
+
42
+ function onkeydown(e: KeyboardEvent) {
43
+ if (e.key !== "Escape") return;
44
+ panelOpen = false;
45
+ if (isWideLayout()) menus.closeAll();
46
+ }
47
+ </script>
48
+
49
+ <svelte:window {onkeydown} />
50
+
51
+ <!-- Covers the map area so the narrow panel can fill it; passes pointer events through. -->
52
+ <div class="pointer-events-none absolute inset-0 z-10">
53
+ <div
54
+ {@attach closeOnOutsideClick}
55
+ class="md:absolute md:top-4 md:right-16 md:left-4 md:flex md:flex-wrap md:items-start md:gap-2"
56
+ >
57
+ <div
58
+ class="pointer-events-auto flex items-start gap-2 max-md:absolute max-md:top-4 max-md:left-4 md:contents"
59
+ >
60
+ <button
61
+ type="button"
62
+ class="flex size-10 shrink-0 items-center justify-center rounded-md bg-white shadow md:hidden"
63
+ aria-label="Open map options"
64
+ aria-expanded={panelOpen}
65
+ aria-controls={panelId}
66
+ onclick={() => (panelOpen = true)}
67
+ >
68
+ <MenuIcon size={22} />
69
+ </button>
70
+ {#if search}
71
+ <div class="md:order-last">{@render search()}</div>
72
+ {/if}
73
+ </div>
74
+ <div
75
+ id={panelId}
76
+ class={[
77
+ "pointer-events-auto md:contents",
78
+ "max-md:absolute max-md:inset-0 max-md:z-20 max-md:flex max-md:flex-col max-md:gap-2 max-md:overflow-y-auto max-md:bg-white max-md:p-4 max-md:transition-[translate,visibility] max-md:duration-200",
79
+ !panelOpen && "max-md:invisible max-md:-translate-x-full"
80
+ ]}
81
+ >
82
+ <div class="mb-2 flex items-center justify-between md:hidden">
83
+ <p class="text-lg font-semibold">Map Options</p>
84
+ <button
85
+ type="button"
86
+ class="flex size-9 items-center justify-center rounded-md hover:bg-gray-100"
87
+ aria-label="Close map options"
88
+ onclick={() => (panelOpen = false)}
89
+ >
90
+ <XIcon size={20} />
91
+ </button>
92
+ </div>
93
+ {@render children?.()}
94
+ </div>
95
+ </div>
96
+ </div>
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ let { children }: { children: Snippet } = $props();
5
+ </script>
6
+
7
+ <!-- Joined button cluster on the toolbar; a plain stack of sections in the panel. -->
8
+ <div
9
+ class="flex flex-col gap-2 md:flex-row md:gap-0 md:divide-x md:divide-gray-200 md:rounded-md md:bg-white md:shadow"
10
+ >
11
+ {@render children()}
12
+ </div>
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { ChevronDownIcon } from "@lucide/svelte";
4
+ import { useOptionsMenus } from "./options-state.svelte.js";
5
+ import { isWideLayout } from "./layout";
6
+
7
+ interface Props {
8
+ id: string;
9
+ label: string;
10
+ icon: Snippet;
11
+ children: Snippet;
12
+ disabled?: boolean;
13
+ /** Anchor the toolbar dropdown to the trigger's right edge */
14
+ alignRight?: boolean;
15
+ /** Show only the icon on the toolbar; the panel section still shows the label */
16
+ iconOnly?: boolean;
17
+ }
18
+
19
+ let {
20
+ id,
21
+ label,
22
+ icon,
23
+ children,
24
+ disabled = false,
25
+ alignRight = false,
26
+ iconOnly = false
27
+ }: Props = $props();
28
+
29
+ const menus = useOptionsMenus();
30
+ const contentId = $derived(`monitor-map-options-${id}`);
31
+ const open = $derived(menus.isOpen(id) && !disabled);
32
+ </script>
33
+
34
+ <div
35
+ role="presentation"
36
+ class="group/menu relative md:first:[&>button]:rounded-l-md md:last:[&>button]:rounded-r-md"
37
+ onmouseenter={() => {
38
+ if (isWideLayout()) menus.closeOthers(id);
39
+ }}
40
+ >
41
+ <button
42
+ type="button"
43
+ {disabled}
44
+ aria-expanded={open}
45
+ aria-controls={contentId}
46
+ aria-label={iconOnly ? label : undefined}
47
+ class="flex h-10 w-full items-center gap-2 px-3 text-sm font-medium whitespace-nowrap select-none disabled:cursor-not-allowed max-md:rounded-md max-md:border max-md:border-gray-200 max-md:bg-white md:w-auto md:hover:bg-gray-100 md:disabled:hover:bg-transparent"
48
+ onclick={() => menus.toggle(id)}
49
+ >
50
+ <span class={["flex size-5 items-center justify-center", disabled && "opacity-50"]}>
51
+ {@render icon()}
52
+ </span>
53
+ <span class={[disabled && "opacity-50", iconOnly && "md:hidden"]}>{label}</span>
54
+ <span class={["ml-auto", disabled && "opacity-50", iconOnly && "md:hidden"]}>
55
+ <ChevronDownIcon size={16} />
56
+ </span>
57
+ </button>
58
+ <div
59
+ id={contentId}
60
+ class={[
61
+ "max-md:pt-1 md:absolute md:top-full md:z-20 md:min-w-64 md:pt-1",
62
+ alignRight ? "md:right-0" : "md:left-0",
63
+ open ? "block" : "hidden",
64
+ !disabled && "md:group-hover/menu:block md:group-has-[:focus-visible]/menu:block"
65
+ ]}
66
+ >
67
+ <div class="py-1 md:max-h-[69vh] md:overflow-y-auto md:rounded-md md:bg-white md:shadow-lg">
68
+ {@render children()}
69
+ </div>
70
+ </div>
71
+ </div>
@@ -0,0 +1,16 @@
1
+ export type GroupState = "checked" | "unchecked" | "indeterminate" | "empty";
2
+
3
+ /** Select-all state of a group, computed only from its currently visible rows. */
4
+ export function groupState(values: ReadonlyArray<boolean>): GroupState {
5
+ if (values.length === 0) return "empty";
6
+ const checkedCount = values.filter(Boolean).length;
7
+ if (checkedCount === 0) return "unchecked";
8
+ return checkedCount === values.length ? "checked" : "indeterminate";
9
+ }
10
+
11
+ /** Applies a select-all toggle to the given (visible) rows only. */
12
+ export function setGroup(options: ReadonlyArray<{ value: boolean }>, value: boolean): void {
13
+ for (const option of options) {
14
+ option.value = value;
15
+ }
16
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Tailwind's default `md` breakpoint (48rem). Layout itself is pure CSS (`md:` / `max-md:`);
3
+ * this is only read at event time for the few behaviors that differ between the toolbar and
4
+ * the options panel.
5
+ */
6
+ export const WIDE_LAYOUT_QUERY = "(min-width: 48rem)";
7
+
8
+ export function isWideLayout(): boolean {
9
+ return window.matchMedia(WIDE_LAYOUT_QUERY).matches;
10
+ }
@@ -0,0 +1,71 @@
1
+ import type { MonitorsPollutant } from "$lib/monitors/types";
2
+ import { applyPollutantSelection, type PollutantTargets } from "./pollutant-param";
3
+
4
+ /**
5
+ * Defaults restored by "Reset Map Options". Keep in sync with the constructor defaults in each
6
+ * integration (monitors/EV display options, `enabled`, `clustered`) so a reset matches a fresh load.
7
+ */
8
+ export const MAP_OPTION_DEFAULTS = {
9
+ monitorTypes: {
10
+ airnow: true,
11
+ aqlite: true,
12
+ aqview: true,
13
+ bam1022: true,
14
+ airgradient: true,
15
+ purpleair: true,
16
+ vozbox: true,
17
+ inactive: false,
18
+ inside: false
19
+ },
20
+ clustered: true,
21
+ collocationSites: false,
22
+ fireAndSmoke: true,
23
+ evStations: false,
24
+ evLevels: { lvl2: true, lvl3: true },
25
+ wind: false
26
+ } as const;
27
+
28
+ type Toggle = { enabled: boolean };
29
+ type Option = { value: boolean };
30
+
31
+ export interface MapOptionTargets extends PollutantTargets {
32
+ integration: PollutantTargets["integration"] & {
33
+ clustered: boolean;
34
+ displayOptions: Record<string, Option>;
35
+ };
36
+ collocationSites: Toggle;
37
+ hmsFire: Toggle;
38
+ hmsSmoke: Toggle;
39
+ evStations: Toggle & { clustered: boolean; displayOptions: { lvl2: Option; lvl3: Option } };
40
+ wind: Toggle;
41
+ mapStyle: { reset(): void };
42
+ }
43
+
44
+ export function applyMapOptionDefaults(
45
+ targets: MapOptionTargets,
46
+ defaultPollutant: MonitorsPollutant | null
47
+ ): void {
48
+ if (defaultPollutant) {
49
+ applyPollutantSelection(defaultPollutant, targets);
50
+ } else {
51
+ targets.integration.enabled = true;
52
+ }
53
+
54
+ for (const [key, value] of Object.entries(MAP_OPTION_DEFAULTS.monitorTypes)) {
55
+ const option = targets.integration.displayOptions[key];
56
+ if (option) option.value = value;
57
+ }
58
+ targets.integration.clustered = MAP_OPTION_DEFAULTS.clustered;
59
+
60
+ targets.collocationSites.enabled = MAP_OPTION_DEFAULTS.collocationSites;
61
+ targets.hmsFire.enabled = MAP_OPTION_DEFAULTS.fireAndSmoke;
62
+ targets.hmsSmoke.enabled = MAP_OPTION_DEFAULTS.fireAndSmoke;
63
+
64
+ targets.evStations.enabled = MAP_OPTION_DEFAULTS.evStations;
65
+ targets.evStations.clustered = MAP_OPTION_DEFAULTS.clustered;
66
+ targets.evStations.displayOptions.lvl2.value = MAP_OPTION_DEFAULTS.evLevels.lvl2;
67
+ targets.evStations.displayOptions.lvl3.value = MAP_OPTION_DEFAULTS.evLevels.lvl3;
68
+
69
+ targets.wind.enabled = MAP_OPTION_DEFAULTS.wind;
70
+ targets.mapStyle.reset();
71
+ }
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import { FlameIcon, LayersIcon, EvChargerIcon } from "@lucide/svelte";
3
+ import OptionsMenu from "../OptionsMenu.svelte";
4
+ import CheckboxRow from "../rows/CheckboxRow.svelte";
5
+ import SubmenuRow from "../rows/SubmenuRow.svelte";
6
+ import MarkerIcon from "../rows/MarkerIcon.svelte";
7
+ import { hmsFireMapIntegration } from "$lib/hms/hms-fire-map-integration.svelte";
8
+ import { hmsSmokeMapIntegration } from "$lib/hms/hms-smoke-map-integration.svelte";
9
+ import { evStationsMapIntegration } from "$lib/ev-stations/ev-stations-map-integration.svelte";
10
+
11
+ const levels = $derived(evStationsMapIntegration.displayOptions);
12
+ </script>
13
+
14
+ <OptionsMenu id="layers" label="Layers">
15
+ {#snippet icon()}
16
+ <LayersIcon size={20} color="#708090" />
17
+ {/snippet}
18
+ <CheckboxRow
19
+ label="Fire & Smoke"
20
+ bind:checked={
21
+ () => hmsFireMapIntegration.enabled && hmsSmokeMapIntegration.enabled,
22
+ (value) => {
23
+ hmsFireMapIntegration.enabled = value;
24
+ hmsSmokeMapIntegration.enabled = value;
25
+ }
26
+ }
27
+ >
28
+ {#snippet icon()}
29
+ <FlameIcon size={18} color="#ff4500" />
30
+ {/snippet}
31
+ </CheckboxRow>
32
+ <SubmenuRow label="EV Chargers" bind:checked={evStationsMapIntegration.enabled}>
33
+ {#snippet icon()}
34
+ <EvChargerIcon size={18} color="#708090" />
35
+ {/snippet}
36
+ <CheckboxRow label="Level 2" bind:checked={evStationsMapIntegration.displayOptions.lvl2.value}>
37
+ {#snippet icon()}
38
+ <MarkerIcon icon={levels.lvl2.icon} class="w-5" />
39
+ {/snippet}
40
+ </CheckboxRow>
41
+ <CheckboxRow label="Level 3" bind:checked={evStationsMapIntegration.displayOptions.lvl3.value}>
42
+ {#snippet icon()}
43
+ <MarkerIcon icon={levels.lvl3.icon} class="w-5" />
44
+ {/snippet}
45
+ </CheckboxRow>
46
+ </SubmenuRow>
47
+ </OptionsMenu>
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import { CrosshairIcon, RadioTowerIcon } from "@lucide/svelte";
3
+ import OptionsMenu from "../OptionsMenu.svelte";
4
+ import CheckboxRow from "../rows/CheckboxRow.svelte";
5
+ import GroupRow from "../rows/GroupRow.svelte";
6
+ import MarkerIcon from "../rows/MarkerIcon.svelte";
7
+ import { monitorsManager } from "$lib/monitors/monitors.svelte";
8
+ import { monitorsMapIntegration } from "$lib/monitors/monitors-map-integration.svelte";
9
+ import { collocationSitesMapIntegration } from "$lib/collocation-sites/collocations-map-integration.svelte";
10
+ import {
11
+ LOW_COST_TYPES,
12
+ REFERENCE_GRADE_TYPES,
13
+ typeSupportsPollutant,
14
+ type MonitorGroupType
15
+ } from "$lib/monitors/monitor-groups";
16
+
17
+ // Rows for types that don't report the current pollutant are hidden (meta-driven).
18
+ function visibleRows(types: ReadonlyArray<MonitorGroupType>) {
19
+ const displayOptions = monitorsMapIntegration.displayOptions;
20
+ return types
21
+ .filter((type) =>
22
+ typeSupportsPollutant(monitorsManager.meta, type, monitorsManager.pollutant)
23
+ )
24
+ .map((type) => ({
25
+ key: type,
26
+ label: displayOptions[type].label,
27
+ option: displayOptions[type]
28
+ }));
29
+ }
30
+
31
+ const referenceGrade = $derived(visibleRows(REFERENCE_GRADE_TYPES));
32
+ const lowCost = $derived(visibleRows(LOW_COST_TYPES));
33
+ const icons = monitorsMapIntegration.icons;
34
+ </script>
35
+
36
+ <OptionsMenu id="monitors" label="Monitors" disabled={!monitorsMapIntegration.enabled}>
37
+ {#snippet icon()}
38
+ <RadioTowerIcon size={20} color="#4682b4" />
39
+ {/snippet}
40
+ <GroupRow label="Reference-grade" options={referenceGrade}>
41
+ {#snippet icon()}
42
+ <MarkerIcon icon={icons.get("outside-display-triangle")} />
43
+ {/snippet}
44
+ </GroupRow>
45
+ <GroupRow label="Low-cost sensors" options={lowCost}>
46
+ {#snippet icon()}
47
+ <MarkerIcon icon={icons.get("outside-display-circle")} />
48
+ {/snippet}
49
+ </GroupRow>
50
+ <hr class="my-1 border-gray-200" />
51
+ <CheckboxRow label="Inactive" bind:checked={monitorsMapIntegration.displayOptions.inactive.value}>
52
+ {#snippet icon()}
53
+ <MarkerIcon icon={monitorsMapIntegration.displayOptions.inactive.icon} />
54
+ {/snippet}
55
+ </CheckboxRow>
56
+ <CheckboxRow label="Inside" bind:checked={monitorsMapIntegration.displayOptions.inside.value}>
57
+ {#snippet icon()}
58
+ <MarkerIcon icon={monitorsMapIntegration.displayOptions.inside.icon} />
59
+ {/snippet}
60
+ </CheckboxRow>
61
+ {#if monitorsManager.pollutant === "pm25"}
62
+ <CheckboxRow label="Collocation Sites" bind:checked={collocationSitesMapIntegration.enabled}>
63
+ {#snippet icon()}
64
+ <CrosshairIcon size={16} color="#4A5FC6" />
65
+ {/snippet}
66
+ </CheckboxRow>
67
+ {/if}
68
+ </OptionsMenu>