@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
@@ -19,8 +19,10 @@
19
19
  * trigger the escape-hatch full-page navigation below.
20
20
  */
21
21
  knownRoutes?: Array<string>;
22
- /** Rendered inside the display-options menu toggle */
22
+ /** Menus (OptionsGroup/OptionsMenu) rendered inside the options toolbar/panel */
23
23
  menu?: Snippet;
24
+ /** Rendered last in the toolbar (wide) or beside the menu button (narrow) */
25
+ search?: Snippet;
24
26
  /** Freeform absolutely-positioned content over the map (legend, search, etc.) */
25
27
  overlays?: Snippet;
26
28
  /** The routed detail panel content */
@@ -50,7 +52,7 @@
50
52
  <script lang="ts">
51
53
  import LoadScreen, { disable as disableLoadScreen } from "$lib/LoadScreen.svelte";
52
54
  import Map from "$lib/map/Map.svelte";
53
- import Menu from "$lib/map/Menu.svelte";
55
+ import OptionsBar from "$lib/options/OptionsBar.svelte";
54
56
  import { mapManager } from "$lib/map/map.svelte";
55
57
  import { useMonitorMapRouter } from "$lib/router-context";
56
58
 
@@ -60,6 +62,7 @@
60
62
  panelOpen,
61
63
  knownRoutes = [],
62
64
  menu,
65
+ search,
63
66
  overlays,
64
67
  children,
65
68
  basePath: providedBasePath,
@@ -124,10 +127,8 @@
124
127
  <div class="relative flex-1 overflow-hidden">
125
128
  <Map {integrations} />
126
129
  {@render overlays?.()}
127
- {#if menu}
128
- <div class="absolute top-4 left-4 z-10">
129
- <Menu>{@render menu()}</Menu>
130
- </div>
130
+ {#if menu || search}
131
+ <OptionsBar {search}>{@render menu?.()}</OptionsBar>
131
132
  {/if}
132
133
  </div>
133
134
  <div
@@ -0,0 +1,24 @@
1
+ /** The slices of MapTiler's MapStyleVariant / ReferenceMapStyle this module needs. */
2
+ export interface StyleVariantLike {
3
+ getType(): string;
4
+ }
5
+
6
+ export interface ReferenceStyleLike<V extends StyleVariantLike> {
7
+ hasVariant(type: string): boolean;
8
+ getVariant(type: string): V;
9
+ getDefaultVariant(): V;
10
+ }
11
+
12
+ /**
13
+ * Variant to show after switching basemaps: the same variant type (e.g. "DARK") when the new
14
+ * basemap offers it, otherwise the new basemap's default.
15
+ */
16
+ export function variantForStyle<V extends StyleVariantLike>(
17
+ style: ReferenceStyleLike<V>,
18
+ previous: StyleVariantLike | null
19
+ ): V {
20
+ if (previous && style.hasVariant(previous.getType())) {
21
+ return style.getVariant(previous.getType());
22
+ }
23
+ return style.getDefaultVariant();
24
+ }
@@ -11,7 +11,18 @@ export abstract class MapGeoJSONIntegration<
11
11
  abstract mapSource: Parameters<MaptilerMap["addSource"]>[1];
12
12
 
13
13
  remove() {
14
- super.remove();
14
+ this.removeLayerAndSource();
15
+ }
16
+
17
+ /**
18
+ * Removes only this integration's own layer and source. Non-virtual on purpose: `apply()`
19
+ * paths call it to clear stale map state before re-adding, without triggering a subclass's
20
+ * `remove()` override (which also tears down tooltips/click handlers that apply just set up).
21
+ */
22
+ protected removeLayerAndSource(): void {
23
+ if (mapManager.map?.getLayer(this.referenceId)) {
24
+ mapManager.map.removeLayer(this.referenceId);
25
+ }
15
26
  if (mapManager.map?.getSource(this.referenceId)) {
16
27
  mapManager.map.removeSource(this.referenceId);
17
28
  }
@@ -27,7 +38,9 @@ export abstract class MapIconLayerIntegration<
27
38
  if (!mapManager.map) return;
28
39
 
29
40
  this.icons.loadIcons().then(() => {
30
- this.remove();
41
+ // A basemap change started while icons loaded; the refresh on "style.load" re-applies.
42
+ if (mapManager.styleLoading) return;
43
+ this.removeLayerAndSource();
31
44
  mapManager.map?.addSource(this.referenceId, this.mapSource);
32
45
  super.apply();
33
46
  });
@@ -14,12 +14,15 @@ export abstract class MapLayerIntegration extends MapIntegration {
14
14
  // Each integration manages its own lifecycle. Tracking both mapManager.map and
15
15
  // this.enabled means apply/remove fire automatically on map load and on enabled
16
16
  // changes. untrack on the call itself prevents reactive reads inside apply/remove
17
- // from leaking back into this effect's dependency graph.
17
+ // from leaking back into this effect's dependency graph. While a new basemap style is
18
+ // loading nothing is applied or removed: the refresh on "style.load" does both.
18
19
  $effect.root(() => {
19
20
  $effect(() => {
20
- if (mapManager.map && this.enabled) {
21
+ const enabled = this.enabled;
22
+ if (!mapManager.map || mapManager.styleLoading) return;
23
+ if (enabled) {
21
24
  untrack(() => this.apply());
22
- } else if (mapManager.map && !this.enabled) {
25
+ } else {
23
26
  untrack(() => this.remove());
24
27
  }
25
28
  });
@@ -0,0 +1,42 @@
1
+ import type { MapStyleVariant, ReferenceMapStyle } from "@maptiler/sdk";
2
+ import { DefaultMapStyle, mapManager } from "./map.svelte";
3
+ import { integrationsManager } from "./integrations/integrations-manager";
4
+ import { variantForStyle } from "./basemap";
5
+
6
+ /** Basemap + theme selection, shared so the Settings menu and Reset Map Options agree. */
7
+ class MapStyleState {
8
+ referenceStyle: ReferenceMapStyle = $state.raw(DefaultMapStyle.getReferenceStyle());
9
+ variant: MapStyleVariant = $state.raw(DefaultMapStyle);
10
+ private applied: MapStyleVariant = DefaultMapStyle;
11
+
12
+ selectReferenceStyle(style: ReferenceMapStyle): void {
13
+ this.referenceStyle = style;
14
+ this.selectVariant(variantForStyle(style, this.variant));
15
+ }
16
+
17
+ selectVariant(variant: MapStyleVariant): void {
18
+ this.variant = variant;
19
+ this.apply();
20
+ }
21
+
22
+ reset(): void {
23
+ this.referenceStyle = DefaultMapStyle.getReferenceStyle();
24
+ this.selectVariant(DefaultMapStyle);
25
+ }
26
+
27
+ private apply(): void {
28
+ const map = mapManager.map;
29
+ if (!map || this.variant === this.applied) return;
30
+ this.applied = this.variant;
31
+ // setStyle wipes every custom source/layer; re-add enabled integrations once it loads.
32
+ map.once("style.load", () => {
33
+ mapManager.styleLoading = false;
34
+ integrationsManager.refresh();
35
+ });
36
+ mapManager.styleLoading = true;
37
+ map.setStyle(this.variant);
38
+ }
39
+ }
40
+
41
+ export const mapStyleState = new MapStyleState();
42
+ export type { MapStyleState };
@@ -15,6 +15,12 @@ const excludeLayers = ["Sport", "Tourism", "Culture", "Shopping", "Food", "Trans
15
15
 
16
16
  class MapManager {
17
17
  map: MaptilerMap | null = $state(null);
18
+ /**
19
+ * True between `setStyle()` and the new style's "style.load". Plain (non-reactive) on purpose:
20
+ * integrations skip touching the style while it's set, and the `integrationsManager.refresh()`
21
+ * that runs on "style.load" applies/removes every integration afterwards.
22
+ */
23
+ styleLoading: boolean = false;
18
24
 
19
25
  setDataSource(sourceId: string, features: Array<Feature<Geometry, GeoJsonProperties>>): void {
20
26
  const source = mapManager.map?.getSource(sourceId);
@@ -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
+ }