@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,40 @@
1
+ import type { SJVAirEntryLevel } from "@sjvair/sdk";
2
+ import type { MonitorsPollutant } from "../monitors/types";
3
+ export interface LegendCategory {
4
+ color: string;
5
+ label?: string;
6
+ }
7
+ export type LegendBarData = {
8
+ kind: "gradient";
9
+ colors: Array<string>;
10
+ ticks: Array<string>;
11
+ } | {
12
+ kind: "blocks";
13
+ categories: Array<LegendCategory>;
14
+ breakpoints?: Array<string>;
15
+ };
16
+ export interface LegendSection {
17
+ id: string;
18
+ title: string;
19
+ unit?: string;
20
+ bar: LegendBarData;
21
+ }
22
+ export interface SmokeDensityStyle {
23
+ label: string;
24
+ color: string;
25
+ opacity: number;
26
+ }
27
+ /**
28
+ * Pollutant legend built from the monitors meta levels — the same levels the map markers are
29
+ * colored by, so the two can never disagree. One tick per level, at that level's color stop.
30
+ */
31
+ export declare function pollutantLegendSection(pollutant: MonitorsPollutant, levels: ReadonlyArray<Pick<SJVAirEntryLevel, "color" | "range">>): LegendSection | null;
32
+ /** Fire & Smoke share one toggle, so they render as two independent bars. */
33
+ export declare function fireSmokeLegendSections(fireTiers: ReadonlyArray<LegendCategory>, smoke: ReadonlyArray<SmokeDensityStyle>): Array<LegendSection>;
34
+ /** Approximates how a translucent fill layer looks over a light basemap. */
35
+ export declare function swatchOverWhite(color: string, opacity: number): string;
36
+ export declare function gradientBackground(colors: ReadonlyArray<string>): string;
37
+ /** Hard-edged equal-width blocks: each color repeated at both edges of its own share. */
38
+ export declare function blocksBackground(colors: ReadonlyArray<string>): string;
39
+ /** Percent position of the index-th of `count` evenly spaced labels. */
40
+ export declare function tickPosition(index: number, count: number): number;
@@ -0,0 +1,65 @@
1
+ import { mix } from "color2k";
2
+ const POLLUTANT_TITLES = {
3
+ pm25: { title: "PM2.5", unit: "µg/m³" },
4
+ o3: { title: "Ozone", unit: "ppb" }
5
+ };
6
+ /**
7
+ * Pollutant legend built from the monitors meta levels — the same levels the map markers are
8
+ * colored by, so the two can never disagree. One tick per level, at that level's color stop.
9
+ */
10
+ export function pollutantLegendSection(pollutant, levels) {
11
+ if (levels.length === 0)
12
+ return null;
13
+ return {
14
+ id: "pollutant",
15
+ ...POLLUTANT_TITLES[pollutant],
16
+ bar: {
17
+ kind: "gradient",
18
+ colors: levels.map((level) => level.color),
19
+ ticks: levels.map((level) => String(Math.floor(level.range[0])))
20
+ }
21
+ };
22
+ }
23
+ /** Fire & Smoke share one toggle, so they render as two independent bars. */
24
+ export function fireSmokeLegendSections(fireTiers, smoke) {
25
+ return [
26
+ { id: "fire", title: "Fire", unit: "MW", bar: { kind: "blocks", categories: [...fireTiers] } },
27
+ {
28
+ id: "smoke",
29
+ title: "Smoke Density",
30
+ bar: {
31
+ kind: "blocks",
32
+ categories: smoke.map(({ label, color, opacity }) => ({
33
+ label,
34
+ color: swatchOverWhite(color, opacity)
35
+ }))
36
+ }
37
+ }
38
+ ];
39
+ }
40
+ /** Approximates how a translucent fill layer looks over a light basemap. */
41
+ export function swatchOverWhite(color, opacity) {
42
+ return mix("#ffffff", color, opacity);
43
+ }
44
+ export function gradientBackground(colors) {
45
+ if (colors.length === 0)
46
+ return "none";
47
+ if (colors.length === 1)
48
+ return colors[0];
49
+ return `linear-gradient(to right, ${colors.join(", ")})`;
50
+ }
51
+ /** Hard-edged equal-width blocks: each color repeated at both edges of its own share. */
52
+ export function blocksBackground(colors) {
53
+ const count = colors.length;
54
+ if (count === 0)
55
+ return "none";
56
+ const stops = colors.map((color, i) => `${color} ${percent(i / count)}, ${color} ${percent((i + 1) / count)}`);
57
+ return `linear-gradient(to right, ${stops.join(", ")})`;
58
+ }
59
+ /** Percent position of the index-th of `count` evenly spaced labels. */
60
+ export function tickPosition(index, count) {
61
+ return count <= 1 ? 0 : (index / (count - 1)) * 100;
62
+ }
63
+ function percent(fraction) {
64
+ return `${(fraction * 100).toFixed(2)}%`;
65
+ }
@@ -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 "../LoadScreen.svelte";
52
54
  import Map from "./Map.svelte";
53
- import Menu from "./Menu.svelte";
55
+ import OptionsBar from "../options/OptionsBar.svelte";
54
56
  import { mapManager } from "./map.svelte";
55
57
  import { useMonitorMapRouter } from "../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
@@ -17,8 +17,10 @@ export interface MapShellProps {
17
17
  * trigger the escape-hatch full-page navigation below.
18
18
  */
19
19
  knownRoutes?: Array<string>;
20
- /** Rendered inside the display-options menu toggle */
20
+ /** Menus (OptionsGroup/OptionsMenu) rendered inside the options toolbar/panel */
21
21
  menu?: Snippet;
22
+ /** Rendered last in the toolbar (wide) or beside the menu button (narrow) */
23
+ search?: Snippet;
22
24
  /** Freeform absolutely-positioned content over the map (legend, search, etc.) */
23
25
  overlays?: Snippet;
24
26
  /** The routed detail panel content */
@@ -0,0 +1,14 @@
1
+ /** The slices of MapTiler's MapStyleVariant / ReferenceMapStyle this module needs. */
2
+ export interface StyleVariantLike {
3
+ getType(): string;
4
+ }
5
+ export interface ReferenceStyleLike<V extends StyleVariantLike> {
6
+ hasVariant(type: string): boolean;
7
+ getVariant(type: string): V;
8
+ getDefaultVariant(): V;
9
+ }
10
+ /**
11
+ * Variant to show after switching basemaps: the same variant type (e.g. "DARK") when the new
12
+ * basemap offers it, otherwise the new basemap's default.
13
+ */
14
+ export declare function variantForStyle<V extends StyleVariantLike>(style: ReferenceStyleLike<V>, previous: StyleVariantLike | null): V;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Variant to show after switching basemaps: the same variant type (e.g. "DARK") when the new
3
+ * basemap offers it, otherwise the new basemap's default.
4
+ */
5
+ export function variantForStyle(style, previous) {
6
+ if (previous && style.hasVariant(previous.getType())) {
7
+ return style.getVariant(previous.getType());
8
+ }
9
+ return style.getDefaultVariant();
10
+ }
@@ -6,6 +6,12 @@ export declare abstract class MapGeoJSONIntegration<T = Record<string, unknown>>
6
6
  abstract features: Array<Feature<Geometry, T>>;
7
7
  abstract mapSource: Parameters<MaptilerMap["addSource"]>[1];
8
8
  remove(): void;
9
+ /**
10
+ * Removes only this integration's own layer and source. Non-virtual on purpose: `apply()`
11
+ * paths call it to clear stale map state before re-adding, without triggering a subclass's
12
+ * `remove()` override (which also tears down tooltips/click handlers that apply just set up).
13
+ */
14
+ protected removeLayerAndSource(): void;
9
15
  }
10
16
  export declare abstract class MapIconLayerIntegration<T = Record<string, unknown>> extends MapGeoJSONIntegration<T> {
11
17
  abstract icons: MapIconManager;
@@ -2,7 +2,17 @@ import { MapLayerIntegration } from "./map-layer-integration.svelte";
2
2
  import { mapManager } from "../map.svelte";
3
3
  export class MapGeoJSONIntegration extends MapLayerIntegration {
4
4
  remove() {
5
- super.remove();
5
+ this.removeLayerAndSource();
6
+ }
7
+ /**
8
+ * Removes only this integration's own layer and source. Non-virtual on purpose: `apply()`
9
+ * paths call it to clear stale map state before re-adding, without triggering a subclass's
10
+ * `remove()` override (which also tears down tooltips/click handlers that apply just set up).
11
+ */
12
+ removeLayerAndSource() {
13
+ if (mapManager.map?.getLayer(this.referenceId)) {
14
+ mapManager.map.removeLayer(this.referenceId);
15
+ }
6
16
  if (mapManager.map?.getSource(this.referenceId)) {
7
17
  mapManager.map.removeSource(this.referenceId);
8
18
  }
@@ -13,7 +23,10 @@ export class MapIconLayerIntegration extends MapGeoJSONIntegration {
13
23
  if (!mapManager.map)
14
24
  return;
15
25
  this.icons.loadIcons().then(() => {
16
- this.remove();
26
+ // A basemap change started while icons loaded; the refresh on "style.load" re-applies.
27
+ if (mapManager.styleLoading)
28
+ return;
29
+ this.removeLayerAndSource();
17
30
  mapManager.map?.addSource(this.referenceId, this.mapSource);
18
31
  super.apply();
19
32
  });
@@ -8,13 +8,17 @@ export class MapLayerIntegration extends MapIntegration {
8
8
  // Each integration manages its own lifecycle. Tracking both mapManager.map and
9
9
  // this.enabled means apply/remove fire automatically on map load and on enabled
10
10
  // changes. untrack on the call itself prevents reactive reads inside apply/remove
11
- // from leaking back into this effect's dependency graph.
11
+ // from leaking back into this effect's dependency graph. While a new basemap style is
12
+ // loading nothing is applied or removed: the refresh on "style.load" does both.
12
13
  $effect.root(() => {
13
14
  $effect(() => {
14
- if (mapManager.map && this.enabled) {
15
+ const enabled = this.enabled;
16
+ if (!mapManager.map || mapManager.styleLoading)
17
+ return;
18
+ if (enabled) {
15
19
  untrack(() => this.apply());
16
20
  }
17
- else if (mapManager.map && !this.enabled) {
21
+ else {
18
22
  untrack(() => this.remove());
19
23
  }
20
24
  });
@@ -0,0 +1,13 @@
1
+ import type { MapStyleVariant, ReferenceMapStyle } from "@maptiler/sdk";
2
+ /** Basemap + theme selection, shared so the Settings menu and Reset Map Options agree. */
3
+ declare class MapStyleState {
4
+ referenceStyle: ReferenceMapStyle;
5
+ variant: MapStyleVariant;
6
+ private applied;
7
+ selectReferenceStyle(style: ReferenceMapStyle): void;
8
+ selectVariant(variant: MapStyleVariant): void;
9
+ reset(): void;
10
+ private apply;
11
+ }
12
+ export declare const mapStyleState: MapStyleState;
13
+ export type { MapStyleState };
@@ -0,0 +1,35 @@
1
+ import { DefaultMapStyle, mapManager } from "./map.svelte";
2
+ import { integrationsManager } from "./integrations/integrations-manager";
3
+ import { variantForStyle } from "./basemap";
4
+ /** Basemap + theme selection, shared so the Settings menu and Reset Map Options agree. */
5
+ class MapStyleState {
6
+ referenceStyle = $state.raw(DefaultMapStyle.getReferenceStyle());
7
+ variant = $state.raw(DefaultMapStyle);
8
+ applied = DefaultMapStyle;
9
+ selectReferenceStyle(style) {
10
+ this.referenceStyle = style;
11
+ this.selectVariant(variantForStyle(style, this.variant));
12
+ }
13
+ selectVariant(variant) {
14
+ this.variant = variant;
15
+ this.apply();
16
+ }
17
+ reset() {
18
+ this.referenceStyle = DefaultMapStyle.getReferenceStyle();
19
+ this.selectVariant(DefaultMapStyle);
20
+ }
21
+ apply() {
22
+ const map = mapManager.map;
23
+ if (!map || this.variant === this.applied)
24
+ return;
25
+ this.applied = this.variant;
26
+ // setStyle wipes every custom source/layer; re-add enabled integrations once it loads.
27
+ map.once("style.load", () => {
28
+ mapManager.styleLoading = false;
29
+ integrationsManager.refresh();
30
+ });
31
+ mapManager.styleLoading = true;
32
+ map.setStyle(this.variant);
33
+ }
34
+ }
35
+ export const mapStyleState = new MapStyleState();
@@ -4,6 +4,12 @@ import type { Feature, GeoJsonProperties, Geometry } from "geojson";
4
4
  export declare const DefaultMapStyle: MapStyleVariant;
5
5
  declare class MapManager {
6
6
  map: MaptilerMap | null;
7
+ /**
8
+ * True between `setStyle()` and the new style's "style.load". Plain (non-reactive) on purpose:
9
+ * integrations skip touching the style while it's set, and the `integrationsManager.refresh()`
10
+ * that runs on "style.load" applies/removes every integration afterwards.
11
+ */
12
+ styleLoading: boolean;
7
13
  setDataSource(sourceId: string, features: Array<Feature<Geometry, GeoJsonProperties>>): void;
8
14
  refreshMap(): void;
9
15
  removeExcludedLayers(): void;
@@ -5,6 +5,12 @@ export const DefaultMapStyle = MapStyle.STREETS_V4.DEFAULT;
5
5
  const excludeLayers = ["Sport", "Tourism", "Culture", "Shopping", "Food", "Transport"];
6
6
  class MapManager {
7
7
  map = $state(null);
8
+ /**
9
+ * True between `setStyle()` and the new style's "style.load". Plain (non-reactive) on purpose:
10
+ * integrations skip touching the style while it's set, and the `integrationsManager.refresh()`
11
+ * that runs on "style.load" applies/removes every integration afterwards.
12
+ */
13
+ styleLoading = false;
8
14
  setDataSource(sourceId, features) {
9
15
  const source = mapManager.map?.getSource(sourceId);
10
16
  if (isGeoJSONSource(source)) {
@@ -0,0 +1,11 @@
1
+ /** Monitor display-option keys shown under each Monitors menu group, in display order. */
2
+ export declare const REFERENCE_GRADE_TYPES: readonly ["airnow", "aqlite", "aqview", "bam1022"];
3
+ export declare const LOW_COST_TYPES: readonly ["airgradient", "purpleair", "vozbox"];
4
+ export type MonitorGroupType = (typeof REFERENCE_GRADE_TYPES)[number] | (typeof LOW_COST_TYPES)[number];
5
+ /** The slice of MonitorsMeta needed to know which pollutants a monitor type reports. */
6
+ export interface MonitorTypeMeta {
7
+ monitors: Record<string, {
8
+ entries: Record<string, unknown>;
9
+ } | undefined>;
10
+ }
11
+ export declare function typeSupportsPollutant(meta: MonitorTypeMeta | null, type: string, pollutant: string | null): boolean;
@@ -0,0 +1,7 @@
1
+ /** Monitor display-option keys shown under each Monitors menu group, in display order. */
2
+ export const REFERENCE_GRADE_TYPES = ["airnow", "aqlite", "aqview", "bam1022"];
3
+ export const LOW_COST_TYPES = ["airgradient", "purpleair", "vozbox"];
4
+ export function typeSupportsPollutant(meta, type, pollutant) {
5
+ const deviceMeta = meta?.monitors[type];
6
+ return !!deviceMeta && !!pollutant && pollutant in deviceMeta.entries;
7
+ }
@@ -26,15 +26,13 @@ export function getOrder(monitor) {
26
26
  }
27
27
  export function getTypeShape(type) {
28
28
  switch (type) {
29
- case "airgradient":
30
- return "circle";
31
29
  case "airnow":
32
30
  case "aqview":
33
31
  case "bam1022":
34
32
  case "aqlite":
35
33
  return "triangle";
34
+ case "airgradient":
36
35
  case "purpleair":
37
- return "square";
38
36
  case "vozbox":
39
37
  return "circle";
40
38
  default:
@@ -8,18 +8,15 @@ const MONITOR_ICON_DISPLAY_COLOR = "#48c78e"; // light gray
8
8
  export function getIconId(monitor, level) {
9
9
  const id = `${monitor.location}-${monitor.is_active ? level.name : "default"}`;
10
10
  switch (monitor.type) {
11
- case "airgradient":
12
- return `${id}-circle`;
13
11
  case "airnow":
14
12
  case "aqview":
15
13
  case "bam1022":
14
+ case "aqlite":
16
15
  return `${id}-triangle`;
16
+ case "airgradient":
17
17
  case "purpleair":
18
- return `${id}-${monitor.is_sjvair ? "circle" : "square"}`;
19
18
  case "vozbox":
20
19
  return `${id}-circle`;
21
- case "aqlite":
22
- return `${id}-triangle`;
23
20
  default:
24
21
  throw new Error(`Map icon for ${monitor.device} has not been set`);
25
22
  }
@@ -14,13 +14,13 @@ declare class MonitorsMapIntegration extends MapIconLayerIntegration<MonitorMark
14
14
  tooltipManager: TooltipManager;
15
15
  private renderer;
16
16
  displayOptions: {
17
- purpleair: MapDisplayOption;
18
- sjvair: MapDisplayOption;
17
+ airnow: MapDisplayOption;
18
+ aqlite: MapDisplayOption;
19
19
  aqview: MapDisplayOption;
20
20
  bam1022: MapDisplayOption;
21
- airnow: MapDisplayOption;
21
+ airgradient: MapDisplayOption;
22
+ purpleair: MapDisplayOption;
22
23
  vozbox: MapDisplayOption;
23
- aqlite: MapDisplayOption;
24
24
  inactive: MapDisplayOption;
25
25
  inside: MapDisplayOption;
26
26
  };
@@ -11,12 +11,6 @@ import { MonitorsClusterRenderer, monitorTooltip } from "./monitors-cluster-rend
11
11
  const filters = {
12
12
  monitor(deviceType) {
13
13
  return ["==", ["get", "type"], deviceType];
14
- },
15
- purpleair() {
16
- return ["all", ["==", ["get", "type"], "purpleair"], ["==", ["get", "is_sjvair"], false]];
17
- },
18
- sjvPurpleair() {
19
- return ["all", ["==", ["get", "type"], "purpleair"], ["==", ["get", "is_sjvair"], true]];
20
14
  }
21
15
  };
22
16
  class MonitorsMapIntegration extends MapIconLayerIntegration {
@@ -28,13 +22,13 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
28
22
  tooltipManager = new TooltipManager();
29
23
  renderer = new MonitorsClusterRenderer(this);
30
24
  displayOptions = $derived.by(() => ({
31
- purpleair: new MapDisplayOption("PurpleAir", true, this.icons.get("outside-display-square")),
32
- sjvair: new MapDisplayOption("SJVAir non-FEM", true, this.icons.get("outside-display-circle")),
33
- aqview: new MapDisplayOption("AQview", true, this.icons.get("outside-display-triangle")),
34
- bam1022: new MapDisplayOption("SJVAir FEM", true, this.icons.get("outside-display-triangle")),
35
25
  airnow: new MapDisplayOption("AirNow", true, this.icons.get("outside-display-triangle")),
36
- vozbox: new MapDisplayOption("VOZbox", true, this.icons.get("outside-display-circle")),
37
26
  aqlite: new MapDisplayOption("AQLite", true, this.icons.get("outside-display-triangle")),
27
+ aqview: new MapDisplayOption("AQview", true, this.icons.get("outside-display-triangle")),
28
+ bam1022: new MapDisplayOption("SJVAir", true, this.icons.get("outside-display-triangle")),
29
+ airgradient: new MapDisplayOption("AirGradient", true, this.icons.get("outside-display-circle")),
30
+ purpleair: new MapDisplayOption("PurpleAir", true, this.icons.get("outside-display-circle")),
31
+ vozbox: new MapDisplayOption("VOZbox", true, this.icons.get("outside-display-circle")),
38
32
  inactive: new MapDisplayOption("Inactive", false, this.icons.get("outside-default-square")),
39
33
  inside: new MapDisplayOption("Inside", false, this.icons.get("inside-display-square"))
40
34
  }));
@@ -95,20 +89,20 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
95
89
  ["==", ["get", "location"], "outside"]
96
90
  ];
97
91
  const statusFilters = ["any", ["==", ["get", "is_active"], true]];
98
- if (this.displayOptions.purpleair.value)
99
- monitorFilters.push(filters.purpleair());
92
+ if (this.displayOptions.airnow.value)
93
+ monitorFilters.push(filters.monitor("airnow"));
94
+ if (this.displayOptions.aqlite.value)
95
+ monitorFilters.push(filters.monitor("aqlite"));
100
96
  if (this.displayOptions.aqview.value)
101
97
  monitorFilters.push(filters.monitor("aqview"));
102
98
  if (this.displayOptions.bam1022.value)
103
99
  monitorFilters.push(filters.monitor("bam1022"));
104
- if (this.displayOptions.airnow.value)
105
- monitorFilters.push(filters.monitor("airnow"));
100
+ if (this.displayOptions.airgradient.value)
101
+ monitorFilters.push(filters.monitor("airgradient"));
102
+ if (this.displayOptions.purpleair.value)
103
+ monitorFilters.push(filters.monitor("purpleair"));
106
104
  if (this.displayOptions.vozbox.value)
107
105
  monitorFilters.push(filters.monitor("vozbox"));
108
- if (this.displayOptions.aqlite.value)
109
- monitorFilters.push(filters.monitor("aqlite"));
110
- if (this.displayOptions.sjvair.value)
111
- monitorFilters.push(filters.sjvPurpleair(), filters.monitor("airgradient"));
112
106
  if (this.displayOptions.inside.value)
113
107
  locationFilters.push(["==", ["get", "location"], "inside"]);
114
108
  if (this.displayOptions.inactive.value)
@@ -116,8 +110,7 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
116
110
  return ["all", monitorFilters, locationFilters, statusFilters];
117
111
  });
118
112
  // Groups features by monitor type for per-type cluster sources, applying display option
119
- // filters so cluster aggregates only include visible monitors. sjvair purpleair is remapped
120
- // to "airgradient" since they share the same shape (circle).
113
+ // filters so cluster aggregates only include visible monitors.
121
114
  featuresByType = $derived.by(() => {
122
115
  const opts = this.displayOptions;
123
116
  const byType = {};
@@ -128,20 +121,19 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
128
121
  if (p.location === "inside" && !opts.inside.value)
129
122
  continue;
130
123
  const typeVisible = {
131
- purpleair: p.is_sjvair ? opts.sjvair.value : opts.purpleair.value,
132
- airgradient: opts.sjvair.value,
124
+ airnow: opts.airnow.value,
125
+ aqlite: opts.aqlite.value,
133
126
  aqview: opts.aqview.value,
134
127
  bam1022: opts.bam1022.value,
135
- airnow: opts.airnow.value,
136
- vozbox: opts.vozbox.value,
137
- aqlite: opts.aqlite.value
128
+ airgradient: opts.airgradient.value,
129
+ purpleair: opts.purpleair.value,
130
+ vozbox: opts.vozbox.value
138
131
  };
139
132
  if (!(typeVisible[p.type] ?? true))
140
133
  continue;
141
- const key = p.type === "purpleair" && p.is_sjvair ? "airgradient" : p.type;
142
- const arr = byType[key] ?? [];
134
+ const arr = byType[p.type] ?? [];
143
135
  arr.push(feat);
144
- byType[key] = arr;
136
+ byType[p.type] = arr;
145
137
  }
146
138
  return byType;
147
139
  });
@@ -181,12 +173,20 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
181
173
  this.dataSource = dataSource;
182
174
  this.icons = new MonitorsIconManager(dataSource);
183
175
  $effect.root(() => {
176
+ // Every effect below early-returns while disabled (Pollutant = None) so a data refresh
177
+ // can't re-add the layer. `enabled` is read untracked: the base class's own effect
178
+ // already applies/removes on enabled changes, and tracking it here would apply twice.
179
+ // The guard comes after each effect's tracked reads so its dependencies are always
180
+ // collected; returning before reading anything would leave the effect permanently dead.
184
181
  // Push filter changes to the active layer(s) imperatively
185
182
  $effect(() => {
186
183
  const filter = this.filters;
184
+ const clustered = this.clustered;
187
185
  if (!mapManager.map)
188
186
  return;
189
- if (this.clustered) {
187
+ if (!untrack(() => this.enabled))
188
+ return;
189
+ if (clustered) {
190
190
  this.renderer.syncFilter();
191
191
  }
192
192
  else {
@@ -200,6 +200,8 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
200
200
  const features = this.features;
201
201
  if (!mapManager.map || this.clustered)
202
202
  return;
203
+ if (!untrack(() => this.enabled))
204
+ return;
203
205
  mapManager.setDataSource(this.referenceId, features);
204
206
  });
205
207
  // Keep cluster source data in sync when features or display options change
@@ -207,6 +209,8 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
207
209
  void this.featuresByType;
208
210
  if (!mapManager.map || !this.clustered)
209
211
  return;
212
+ if (!untrack(() => this.enabled))
213
+ return;
210
214
  this.renderer.syncFeatures();
211
215
  });
212
216
  // Push updated icon expressions to cluster layers when the pollutant changes
@@ -214,6 +218,8 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
214
218
  const thresholds = this.clusterIconThresholds;
215
219
  if (!mapManager.map || !this.clustered || !thresholds.length)
216
220
  return;
221
+ if (!untrack(() => this.enabled))
222
+ return;
217
223
  this.renderer.syncThresholds();
218
224
  });
219
225
  // Re-apply when clustered mode switches or when data first arrives (cold load).
@@ -224,6 +230,8 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
224
230
  const hasFeatures = Object.keys(this.featuresByType).length > 0;
225
231
  if (!untrack(() => mapManager.map) || !hasFeatures)
226
232
  return;
233
+ if (!untrack(() => this.enabled))
234
+ return;
227
235
  untrack(() => this.apply());
228
236
  });
229
237
  // Sync selected icon scale via feature state
@@ -250,6 +258,8 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
250
258
  this.tooltipManager.disable();
251
259
  this.renderer.remove();
252
260
  this.icons.loadIcons().then(() => {
261
+ if (mapManager.styleLoading)
262
+ return;
253
263
  this.renderer.apply(this.handleMonitorClick);
254
264
  this.tooltipManager.enable();
255
265
  this.applySelectedState();
@@ -262,12 +272,15 @@ class MonitorsMapIntegration extends MapIconLayerIntegration {
262
272
  clickManager.unregister([this.referenceId]);
263
273
  super.apply();
264
274
  this.icons.loadIcons().then(() => {
275
+ if (mapManager.styleLoading)
276
+ return;
265
277
  clickManager.register([this.referenceId], this.handleMonitorClick);
266
278
  this.applySelectedState();
267
279
  });
268
280
  }
269
281
  }
270
282
  remove() {
283
+ this.tooltipManager.disable();
271
284
  clickManager.unregister([this.referenceId]);
272
285
  this.renderer.remove();
273
286
  super.remove();
@@ -10,6 +10,7 @@ declare class MonitorsManager implements MonitorsDataSource {
10
10
  meta: MonitorsMeta | null;
11
11
  pollutant: "pm25" | "o3" | null;
12
12
  levels: Array<SJVAirEntryLevel> | null;
13
+ constructor();
13
14
  init(urlPollutant?: string | number | boolean): Promise<void>;
14
15
  update(): Promise<void>;
15
16
  }
@@ -1,6 +1,7 @@
1
1
  import { getMonitors, getMonitorsList, getMonitorsMeta } from "@sjvair/sdk";
2
2
  import { XMap } from "@tstk/builtin-extensions";
3
3
  import { Interval } from "@tstk/utils";
4
+ import { untrack } from "svelte";
4
5
  class MonitorsManager {
5
6
  autoUpdate = new Interval(async () => await this.update(), 2 * 60 * 1000);
6
7
  initialized = $state(false);
@@ -9,6 +10,27 @@ class MonitorsManager {
9
10
  meta = $state(null);
10
11
  pollutant = $state(null);
11
12
  levels = $derived(this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null);
13
+ constructor() {
14
+ // Refetch monitors when the pollutant changes after the initial load, wherever the change
15
+ // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
16
+ // instead of briefly coloring them with the previous pollutant's readings.
17
+ let fetchedPollutant = null;
18
+ $effect.root(() => {
19
+ $effect(() => {
20
+ const pollutant = this.pollutant;
21
+ if (!this.initialized)
22
+ return;
23
+ if (fetchedPollutant !== null && fetchedPollutant !== pollutant) {
24
+ untrack(() => {
25
+ this.list = [];
26
+ this.latest = null;
27
+ this.update();
28
+ });
29
+ }
30
+ fetchedPollutant = pollutant;
31
+ });
32
+ });
33
+ }
12
34
  async init(urlPollutant) {
13
35
  if (this.initialized)
14
36
  return;