@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.10

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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +713 -2
  3. package/dist/core/basemap.svelte.d.ts +38 -0
  4. package/dist/core/basemap.svelte.js +163 -0
  5. package/dist/core/context.d.ts +5 -0
  6. package/dist/core/context.js +13 -0
  7. package/dist/core/create-map.svelte.d.ts +274 -0
  8. package/dist/core/create-map.svelte.js +591 -0
  9. package/dist/core/gl.d.ts +97 -0
  10. package/dist/core/gl.js +1 -0
  11. package/dist/core/interactions.svelte.d.ts +50 -0
  12. package/dist/core/interactions.svelte.js +330 -0
  13. package/dist/core/plugin-host.svelte.d.ts +71 -0
  14. package/dist/core/plugin-host.svelte.js +445 -0
  15. package/dist/core/plugin.d.ts +190 -0
  16. package/dist/core/plugin.js +1 -0
  17. package/dist/core/scoped-context.d.ts +7 -0
  18. package/dist/core/scoped-context.js +34 -0
  19. package/dist/core/shape-view.d.ts +27 -0
  20. package/dist/core/shape-view.js +323 -0
  21. package/dist/data/format.d.ts +10 -0
  22. package/dist/data/format.js +37 -0
  23. package/dist/data/index.d.ts +9 -0
  24. package/dist/data/index.js +9 -0
  25. package/dist/data/messages/en.json +61 -0
  26. package/dist/data/messages.d.ts +10 -0
  27. package/dist/data/messages.js +17 -0
  28. package/dist/data/metadata.d.ts +22 -0
  29. package/dist/data/metadata.js +110 -0
  30. package/dist/data/refs.d.ts +18 -0
  31. package/dist/data/refs.js +61 -0
  32. package/dist/data/scale-types.d.ts +7 -0
  33. package/dist/data/scale-types.js +1 -0
  34. package/dist/data/scale.d.ts +13 -0
  35. package/dist/data/scale.js +27 -0
  36. package/dist/data/selection.d.ts +13 -0
  37. package/dist/data/selection.js +52 -0
  38. package/dist/data/static-source.d.ts +4 -0
  39. package/dist/data/static-source.js +3 -0
  40. package/dist/data/types.d.ts +74 -0
  41. package/dist/data/types.js +1 -0
  42. package/dist/index.d.ts +59 -0
  43. package/dist/index.js +42 -0
  44. package/dist/plugins/icons.d.ts +21 -0
  45. package/dist/plugins/icons.js +73 -0
  46. package/dist/plugins/points-clusters.d.ts +60 -0
  47. package/dist/plugins/points-clusters.js +93 -0
  48. package/dist/plugins/points.svelte.d.ts +105 -0
  49. package/dist/plugins/points.svelte.js +502 -0
  50. package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
  51. package/dist/plugins/scale-resolution.svelte.js +111 -0
  52. package/dist/plugins/shapes.svelte.d.ts +34 -0
  53. package/dist/plugins/shapes.svelte.js +325 -0
  54. package/dist/plugins/shared.d.ts +6 -0
  55. package/dist/plugins/shared.js +18 -0
  56. package/dist/plugins/style.d.ts +57 -0
  57. package/dist/plugins/style.js +193 -0
  58. package/dist/presets/CollocationTooltip.svelte +132 -0
  59. package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
  60. package/dist/presets/FireTooltip.svelte +70 -0
  61. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  62. package/dist/presets/MonitorClusterTooltip.svelte +168 -0
  63. package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
  64. package/dist/presets/MonitorReadingBox.svelte +58 -0
  65. package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
  66. package/dist/presets/MonitorTooltip.svelte +88 -0
  67. package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
  68. package/dist/presets/calheatscore.d.ts +24 -0
  69. package/dist/presets/calheatscore.js +22 -0
  70. package/dist/presets/cluster-area.d.ts +13 -0
  71. package/dist/presets/cluster-area.js +34 -0
  72. package/dist/presets/collocation-sites.d.ts +18 -0
  73. package/dist/presets/collocation-sites.js +67 -0
  74. package/dist/presets/fire-groups.d.ts +26 -0
  75. package/dist/presets/fire-groups.js +51 -0
  76. package/dist/presets/forecast-zones.d.ts +25 -0
  77. package/dist/presets/forecast-zones.js +23 -0
  78. package/dist/presets/hms-fire.d.ts +30 -0
  79. package/dist/presets/hms-fire.js +86 -0
  80. package/dist/presets/hms-smoke.d.ts +20 -0
  81. package/dist/presets/hms-smoke.js +22 -0
  82. package/dist/presets/monitors.d.ts +48 -0
  83. package/dist/presets/monitors.js +243 -0
  84. package/dist/presets/reactive-props.svelte.d.ts +4 -0
  85. package/dist/presets/reactive-props.svelte.js +7 -0
  86. package/dist/presets/regions.d.ts +24 -0
  87. package/dist/presets/regions.js +22 -0
  88. package/dist/stores/abort.d.ts +16 -0
  89. package/dist/stores/abort.js +52 -0
  90. package/dist/stores/collocation-store.svelte.d.ts +25 -0
  91. package/dist/stores/collocation-store.svelte.js +72 -0
  92. package/dist/stores/hms-stores.svelte.d.ts +38 -0
  93. package/dist/stores/hms-stores.svelte.js +97 -0
  94. package/dist/stores/index.d.ts +4 -0
  95. package/dist/stores/index.js +4 -0
  96. package/dist/stores/load-once.svelte.d.ts +25 -0
  97. package/dist/stores/load-once.svelte.js +93 -0
  98. package/dist/stores/monitors-store.svelte.d.ts +85 -0
  99. package/dist/stores/monitors-store.svelte.js +340 -0
  100. package/dist/theme.css +34 -0
  101. package/dist/ui/FeatureList.svelte +501 -0
  102. package/dist/ui/FeatureList.svelte.d.ts +14 -0
  103. package/dist/ui/Legend.svelte +214 -0
  104. package/dist/ui/Legend.svelte.d.ts +22 -0
  105. package/dist/ui/LegendBar.svelte +161 -0
  106. package/dist/ui/LegendBar.svelte.d.ts +11 -0
  107. package/dist/ui/MapControls.svelte +135 -0
  108. package/dist/ui/MapControls.svelte.d.ts +11 -0
  109. package/dist/ui/MapTooltip.svelte +397 -0
  110. package/dist/ui/MapTooltip.svelte.d.ts +37 -0
  111. package/dist/ui/PickNotice.svelte +56 -0
  112. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  113. package/dist/ui/SelectionBar.svelte +123 -0
  114. package/dist/ui/SelectionBar.svelte.d.ts +10 -0
  115. package/dist/ui/TooltipItem.svelte +66 -0
  116. package/dist/ui/TooltipItem.svelte.d.ts +11 -0
  117. package/dist/ui/ViewSummary.svelte +97 -0
  118. package/dist/ui/ViewSummary.svelte.d.ts +7 -0
  119. package/dist/ui/card.d.ts +29 -0
  120. package/dist/ui/card.js +1 -0
  121. package/dist/ui/controls.css +45 -0
  122. package/dist/ui/data-credits.svelte.d.ts +9 -0
  123. package/dist/ui/data-credits.svelte.js +52 -0
  124. package/dist/ui/feature-list.d.ts +46 -0
  125. package/dist/ui/feature-list.js +115 -0
  126. package/dist/ui/legend-data.d.ts +41 -0
  127. package/dist/ui/legend-data.js +40 -0
  128. package/dist/ui/level-chip.d.ts +11 -0
  129. package/dist/ui/level-chip.js +15 -0
  130. package/dist/ui/value-format.d.ts +8 -0
  131. package/dist/ui/value-format.js +20 -0
  132. package/dist/ui/view-summary.d.ts +11 -0
  133. package/dist/ui/view-summary.js +33 -0
  134. package/dist/view/MapView.svelte +443 -0
  135. package/dist/view/MapView.svelte.d.ts +51 -0
  136. package/dist/view/create-gl-map.d.ts +34 -0
  137. package/dist/view/create-gl-map.js +186 -0
  138. package/dist/view/list-placement.d.ts +21 -0
  139. package/dist/view/list-placement.js +13 -0
  140. package/package.json +104 -5
@@ -0,0 +1,93 @@
1
+ import { untilAborted } from "./abort.js";
2
+ class LoadOnceCore {
3
+ status = $state.raw({ state: "loading" });
4
+ value = $state.raw(null);
5
+ #load;
6
+ #run = null;
7
+ #loaded = false;
8
+ #disposed = false;
9
+ constructor(load) {
10
+ this.#load = load;
11
+ }
12
+ init(options = {}) {
13
+ if (this.#loaded || this.#disposed)
14
+ return Promise.resolve();
15
+ const { signal } = options;
16
+ if (signal?.aborted)
17
+ return Promise.reject(signal.reason);
18
+ const run = (this.#run ??= this.#start());
19
+ if (!signal) {
20
+ run.unsignalled = true;
21
+ return run.promise;
22
+ }
23
+ run.signalled++;
24
+ const onAbort = () => {
25
+ run.aborted++;
26
+ if (!run.unsignalled && run.aborted === run.signalled)
27
+ this.#abort(run);
28
+ };
29
+ signal.addEventListener("abort", onAbort, { once: true });
30
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
31
+ run.promise.then(cleanup, cleanup);
32
+ return untilAborted(run.promise, signal);
33
+ }
34
+ #start() {
35
+ const controller = new AbortController();
36
+ if (this.status.state !== "loading")
37
+ this.status = { state: "loading" };
38
+ const run = {
39
+ // Cancelling rejects at once, even while a request that ignores the signal is pending.
40
+ promise: untilAborted(this.#fetch(controller.signal), controller.signal).then(() => {
41
+ if (this.#run === run)
42
+ this.#run = null;
43
+ }, (error) => {
44
+ if (this.#run === run)
45
+ this.#run = null;
46
+ if (!controller.signal.aborted) {
47
+ this.status = {
48
+ state: "error",
49
+ error,
50
+ retry: () => {
51
+ this.init().catch(() => { });
52
+ }
53
+ };
54
+ }
55
+ throw error;
56
+ }),
57
+ controller,
58
+ signalled: 0,
59
+ aborted: 0,
60
+ unsignalled: false
61
+ };
62
+ return run;
63
+ }
64
+ async #fetch(signal) {
65
+ const value = await this.#load(signal);
66
+ // Cancelled while a request that ignores the signal was settling: leave state to the next
67
+ // run.
68
+ if (signal.aborted)
69
+ throw signal.reason;
70
+ this.value = value;
71
+ this.status = { state: "ready" };
72
+ this.#loaded = true;
73
+ }
74
+ /**
75
+ * Cancels a run nobody is waiting on. It is detached at once, so a call arriving before its
76
+ * rejection settles starts a fresh run instead of joining the cancelled one.
77
+ */
78
+ #abort(run) {
79
+ if (this.#run !== run)
80
+ return;
81
+ this.#run = null;
82
+ run.controller.abort();
83
+ }
84
+ dispose() {
85
+ this.#disposed = true;
86
+ if (this.#run)
87
+ this.#abort(this.#run);
88
+ }
89
+ }
90
+ /** A loader that fetches once with `load`. Nothing is fetched until `init()`. */
91
+ export function createLoadOnce(load) {
92
+ return new LoadOnceCore(load);
93
+ }
@@ -0,0 +1,85 @@
1
+ import { type MonitorData, type MonitorsMeta } from "@sjvair/sdk";
2
+ import type { DataSource, MonitorFeatureProperties } from "../data/types.js";
3
+ import { type SignalOptions } from "./abort.js";
4
+ /** A monitor from the monitors list. */
5
+ export type MonitorsListItem = MonitorData;
6
+ /** A monitor's latest reading for the store's entry type. */
7
+ export interface MonitorReading {
8
+ /** Null when the server sent no numeric value. */
9
+ value: number | null;
10
+ /** When the reading was taken (ISO 8601). */
11
+ timestamp: string;
12
+ }
13
+ export interface MonitorsStoreOptions {
14
+ /** How often polling refreshes the data, in milliseconds. Default 120 000 (2 minutes). */
15
+ pollIntervalMs?: number;
16
+ }
17
+ export interface MonitorsInitOptions extends SignalOptions {
18
+ /**
19
+ * Start polling once the initial load finishes. Defaults to true; pass false to load the data
20
+ * without showing a map (`resumeAutoUpdate()` starts polling later).
21
+ */
22
+ autoUpdate?: boolean;
23
+ /**
24
+ * Stops waiting: the returned promise rejects with the signal's reason. The shared run keeps
25
+ * going for the other calls waiting on it, and is cancelled only when every call waiting on it
26
+ * passed a signal and all of those aborted. An aborted call's entry type and `autoUpdate` stay
27
+ * recorded. Ignored once initialized.
28
+ */
29
+ signal?: AbortSignal;
30
+ }
31
+ export interface MonitorsStore {
32
+ /** True once the first load finished. */
33
+ readonly initialized: boolean;
34
+ /** The entry type the readings are for, such as "pm25". Null before init. */
35
+ readonly entryType: string | null;
36
+ /** Every monitor, by id, from the monitors list. */
37
+ readonly monitors: ReadonlyMap<string, MonitorsListItem>;
38
+ /** Each monitor's latest reading for `entryType`, by monitor id. */
39
+ readonly latest: ReadonlyMap<string, MonitorReading>;
40
+ /** The entry types the server publishes on the map (monitors meta `available_pollutants`). */
41
+ readonly availableEntryTypes: readonly string[];
42
+ /** The monitors meta from init (the SDK's `MonitorsMeta`), or null before init. Read-only. */
43
+ readonly meta: MonitorsMeta | null;
44
+ /**
45
+ * Sets the entry type. Once initialized, an entry type the server doesn't list falls back to
46
+ * its default, and the readings blank at once (so markers never show the previous entry
47
+ * type's readings) and are fetched again. Before init it is replaced by the entry type init
48
+ * settles on: pass the entry type to `init` instead.
49
+ */
50
+ entryTypeFor(next: string): void;
51
+ /**
52
+ * Loads the monitors, their latest readings, and the meta. Safe to call from several places:
53
+ * overlapping calls share one run, and a failed run can be retried. An explicit entry type
54
+ * wins over a call without one, whichever came first; between two explicit values the most
55
+ * recent wins. An entry type the server doesn't list falls back to its default. Polling
56
+ * starts when the run finishes unless every call sharing it passed `autoUpdate: false`. A
57
+ * no-op once initialized, so it never restarts polling either: that is `resumeAutoUpdate()`.
58
+ */
59
+ init(entryType?: string, options?: MonitorsInitOptions): Promise<void>;
60
+ /**
61
+ * Refreshes the list and the latest readings. Starting an update aborts the previous
62
+ * in-flight one, which then resolves without touching state. Pass `signal` to cancel from
63
+ * the outside: that rejects with the signal's reason. A no-op before init.
64
+ */
65
+ update(options?: SignalOptions): Promise<void>;
66
+ /** Stops polling (for example when the last map showing the data goes away). */
67
+ pauseAutoUpdate(): void;
68
+ /**
69
+ * Starts polling when it is not running: after `pauseAutoUpdate()` or after an init that
70
+ * opted out of it. Refreshes first since the data may be stale. A no-op before init or while
71
+ * polling.
72
+ */
73
+ resumeAutoUpdate(): void;
74
+ /** The monitors and their readings for `monitorsPlugin`, with the entry type's scale. */
75
+ readonly dataSource: DataSource<MonitorFeatureProperties>;
76
+ /** Stops polling and cancels in-flight requests. A disposed store does nothing. */
77
+ dispose(): void;
78
+ }
79
+ /**
80
+ * The live monitors data for an app: the monitor list, the latest readings for one entry type,
81
+ * and polling every 2 minutes. One store can feed any number of maps and screens, or none: its
82
+ * `dataSource` goes straight to `monitorsPlugin`. Call `init()` to load, and `dispose()` when the
83
+ * app is done with it.
84
+ */
85
+ export declare function createMonitorsStore(options?: MonitorsStoreOptions): MonitorsStore;
@@ -0,0 +1,340 @@
1
+ import { getMonitors, getMonitorsList, getMonitorsMeta } from "@sjvair/sdk";
2
+ import { encodeRef } from "../data/refs.js";
3
+ import { anySignal, untilAborted } from "./abort.js";
4
+ const DEFAULT_POLL_INTERVAL_MS = 120_000;
5
+ const AIR_QUALITY = "air-quality";
6
+ /** Every monitor with its latest reading for `entryType`. */
7
+ async function getReadings(entryType, signal) {
8
+ // The SDK types entry types as a closed union; the server decides which exist.
9
+ const monitors = await getMonitors(entryType, undefined, { signal });
10
+ return monitors;
11
+ }
12
+ /** The reading's numeric value, or null. */
13
+ function readingValue(monitor) {
14
+ const raw = "value" in monitor.latest ? monitor.latest.value : null;
15
+ if (raw === null || raw === "")
16
+ return null;
17
+ const value = Number(raw);
18
+ return Number.isFinite(value) ? value : null;
19
+ }
20
+ function toCollection(readings) {
21
+ const features = [];
22
+ for (const monitor of readings) {
23
+ if (!monitor.position)
24
+ continue;
25
+ features.push({
26
+ type: "Feature",
27
+ geometry: { type: "Point", coordinates: monitor.position.coordinates },
28
+ properties: {
29
+ refKey: encodeRef({ kind: "monitor", id: monitor.id }),
30
+ name: monitor.name,
31
+ value: readingValue(monitor),
32
+ type: monitor.type,
33
+ isActive: monitor.is_active,
34
+ isSjvair: monitor.is_sjvair,
35
+ location: monitor.location === "inside" ? "inside" : "outside",
36
+ timestamp: monitor.latest.timestamp
37
+ }
38
+ });
39
+ }
40
+ return { type: "FeatureCollection", features };
41
+ }
42
+ class LiveMonitorsStore {
43
+ #meta = $state.raw(null);
44
+ #status = $state.raw({ state: "loading" });
45
+ initialized = $state(false);
46
+ entryType = $state(null);
47
+ monitors = $state.raw(new Map());
48
+ availableEntryTypes = $derived(this.#meta?.available_pollutants ?? []);
49
+ get meta() {
50
+ return this.#meta;
51
+ }
52
+ latest = $state.raw(new Map());
53
+ dataSource;
54
+ /** Null before init; built when the readings arrive, so every reader shares one object. */
55
+ #features = $state.raw(null);
56
+ /** True before init and while an entry type change refetches (the markers blank). */
57
+ #blank = true;
58
+ #scale = $derived(this.entryType === null ? null : { dataset: AIR_QUALITY, entryType: this.entryType });
59
+ #pollIntervalMs;
60
+ #pollTimer = null;
61
+ #initRun = null;
62
+ #requestedEntryType = null;
63
+ #requestedAutoUpdate = false;
64
+ #updateController = null;
65
+ #disposed = false;
66
+ constructor(options) {
67
+ this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
68
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- getters below read the store
69
+ const store = this;
70
+ this.dataSource = {
71
+ get status() {
72
+ return store.#status;
73
+ },
74
+ get features() {
75
+ return store.#features;
76
+ },
77
+ get scale() {
78
+ return store.#scale;
79
+ }
80
+ };
81
+ }
82
+ entryTypeFor(next) {
83
+ const meta = this.#meta;
84
+ if (this.initialized && meta && !meta.available_pollutants.includes(next)) {
85
+ next = meta.default_pollutant;
86
+ }
87
+ if (next === this.entryType)
88
+ return;
89
+ this.entryType = next;
90
+ if (!this.initialized || this.#disposed)
91
+ return;
92
+ this.#setReadings([]);
93
+ this.#blank = true;
94
+ this.#status = { state: "loading" };
95
+ this.#refresh();
96
+ }
97
+ init(entryType, options = {}) {
98
+ if (this.initialized || this.#disposed)
99
+ return Promise.resolve();
100
+ const { signal } = options;
101
+ // Nothing is recorded or started for a call that is already cancelled.
102
+ if (signal?.aborted)
103
+ return Promise.reject(signal.reason);
104
+ if (options.autoUpdate !== false)
105
+ this.#requestedAutoUpdate = true;
106
+ if (entryType)
107
+ this.#requestedEntryType = entryType;
108
+ const run = (this.#initRun ??= this.#startInitRun());
109
+ if (!signal) {
110
+ run.unsignalled = true;
111
+ return run.promise;
112
+ }
113
+ run.signalled++;
114
+ const onAbort = () => {
115
+ run.aborted++;
116
+ if (!run.unsignalled && run.aborted === run.signalled)
117
+ this.#abortInitRun(run);
118
+ };
119
+ signal.addEventListener("abort", onAbort, { once: true });
120
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
121
+ run.promise.then(cleanup, cleanup);
122
+ return untilAborted(run.promise, signal);
123
+ }
124
+ #startInitRun() {
125
+ const controller = new AbortController();
126
+ if (this.#status.state !== "loading")
127
+ this.#status = { state: "loading" };
128
+ const run = {
129
+ // Cancelling rejects at once, even while a request that ignores the signal is pending.
130
+ promise: untilAborted(this.#runInit(controller.signal), controller.signal).then(() => {
131
+ if (this.#initRun === run)
132
+ this.#clearInitRun();
133
+ }, (error) => {
134
+ // Recorded before clearing, so retry() repeats the failed call.
135
+ const entryType = this.#requestedEntryType ?? undefined;
136
+ const autoUpdate = this.#requestedAutoUpdate;
137
+ if (this.#initRun === run)
138
+ this.#clearInitRun();
139
+ if (!controller.signal.aborted) {
140
+ this.#status = {
141
+ state: "error",
142
+ error,
143
+ retry: () => {
144
+ this.init(entryType, { autoUpdate }).catch(() => { });
145
+ }
146
+ };
147
+ }
148
+ throw error;
149
+ }),
150
+ controller,
151
+ signalled: 0,
152
+ aborted: 0,
153
+ unsignalled: false
154
+ };
155
+ return run;
156
+ }
157
+ /**
158
+ * Cancels a run nobody is waiting on. It is detached at once, so a call arriving before its
159
+ * rejection settles starts a fresh run instead of joining the cancelled one.
160
+ */
161
+ #abortInitRun(run) {
162
+ if (this.#initRun !== run)
163
+ return;
164
+ this.#clearInitRun();
165
+ run.controller.abort();
166
+ }
167
+ #clearInitRun() {
168
+ this.#initRun = null;
169
+ this.#requestedEntryType = null;
170
+ this.#requestedAutoUpdate = false;
171
+ }
172
+ /** The requested entry type when the server lists it, else the server's default. */
173
+ #wantedEntryType(meta) {
174
+ const requested = this.#requestedEntryType;
175
+ return requested && meta.available_pollutants.includes(requested)
176
+ ? requested
177
+ : meta.default_pollutant;
178
+ }
179
+ async #runInit(signal) {
180
+ // The entry type the latest readings were requested for.
181
+ let fetchedEntryType = this.#requestedEntryType;
182
+ const metaRequest = getMonitorsMeta({ signal });
183
+ const listAndMeta = Promise.all([getMonitorsList({ signal }), metaRequest]).then(([list, meta]) => {
184
+ if (signal.aborted)
185
+ throw signal.reason;
186
+ this.monitors = new Map(list.map((monitor) => [monitor.id, monitor]));
187
+ this.#meta = meta;
188
+ return meta;
189
+ });
190
+ // The latest readings only need the entry type, so they never wait for the list. An
191
+ // explicit entry type may still arrive before meta does, in which case the default is never
192
+ // fetched. An explicit one is requested before meta can say whether the server lists it; if
193
+ // it doesn't, its failure is dropped and the default is fetched below.
194
+ const latestRequest = fetchedEntryType
195
+ ? getReadings(fetchedEntryType, signal).catch(async (error) => {
196
+ const meta = await metaRequest;
197
+ if (this.#wantedEntryType(meta) === fetchedEntryType || signal.aborted)
198
+ throw error;
199
+ // Nothing was fetched: if this entry type is wanted again, it is fetched again.
200
+ fetchedEntryType = null;
201
+ return null;
202
+ })
203
+ : metaRequest.then((meta) => {
204
+ const entryType = this.#wantedEntryType(meta);
205
+ fetchedEntryType = entryType;
206
+ return getReadings(entryType, signal);
207
+ });
208
+ // One Promise.all so a failure in either never leaves the other unhandled.
209
+ const [meta, firstReadings] = await Promise.all([listAndMeta, latestRequest]);
210
+ let readings = firstReadings;
211
+ // An explicit entry type may have arrived while the requests above were in flight.
212
+ let wanted = this.#wantedEntryType(meta);
213
+ while (wanted !== fetchedEntryType) {
214
+ readings = await getReadings(wanted, signal);
215
+ fetchedEntryType = wanted;
216
+ wanted = this.#wantedEntryType(meta);
217
+ }
218
+ // Cancelled while a request that ignores the signal was settling: leave state to the next run.
219
+ if (signal.aborted)
220
+ throw signal.reason;
221
+ this.entryType = fetchedEntryType;
222
+ // Non-null: the loop above leaves the readings of the entry type it settled on.
223
+ this.#setReadings(readings);
224
+ this.#status = { state: "ready" };
225
+ if (this.#requestedAutoUpdate)
226
+ this.#startPolling();
227
+ this.initialized = true;
228
+ }
229
+ #setReadings(readings) {
230
+ this.latest = new Map(readings.map((monitor) => [
231
+ monitor.id,
232
+ { value: readingValue(monitor), timestamp: monitor.latest.timestamp }
233
+ ]));
234
+ this.#features = toCollection(readings);
235
+ this.#blank = false;
236
+ }
237
+ #startPolling() {
238
+ if (this.#pollTimer !== null)
239
+ return;
240
+ this.#pollTimer = setInterval(() => this.#refresh(), this.#pollIntervalMs);
241
+ }
242
+ pauseAutoUpdate() {
243
+ if (this.#pollTimer === null)
244
+ return;
245
+ clearInterval(this.#pollTimer);
246
+ this.#pollTimer = null;
247
+ }
248
+ resumeAutoUpdate() {
249
+ if (!this.initialized || this.#disposed || this.#pollTimer !== null)
250
+ return;
251
+ this.#refresh();
252
+ this.#startPolling();
253
+ }
254
+ /** An update nobody awaits: polling, resuming, an entry type change, or a retry. */
255
+ #refresh() {
256
+ this.update().catch(console.error);
257
+ }
258
+ async update(options = {}) {
259
+ // Always set once initialized; the null check only narrows the type.
260
+ const entryType = this.entryType;
261
+ if (!this.initialized || this.#disposed || entryType === null)
262
+ return;
263
+ // Nothing has started yet, so an in-flight update is left alone.
264
+ if (options.signal?.aborted)
265
+ throw options.signal.reason;
266
+ const supersededInFlight = this.#updateController !== null;
267
+ this.#updateController?.abort();
268
+ const controller = new AbortController();
269
+ this.#updateController = controller;
270
+ const combined = anySignal(controller.signal, options.signal);
271
+ // The caller cancelled this update. If it is still the latest and it cut off an update that
272
+ // was in flight, that update's data is lost too, so refresh again without a signal.
273
+ const callerAborted = (signal) => {
274
+ if (supersededInFlight && this.#updateController === controller)
275
+ this.#refresh();
276
+ return signal.reason;
277
+ };
278
+ // Set once callerAborted has run, so the catch below does not run it a second time.
279
+ let abortedError;
280
+ try {
281
+ const [list, readings] = await Promise.all([
282
+ getMonitorsList({ signal: combined.signal }),
283
+ getReadings(entryType, combined.signal)
284
+ ]);
285
+ // Superseded or cancelled while the requests were settling.
286
+ if (options.signal?.aborted)
287
+ throw (abortedError = callerAborted(options.signal));
288
+ if (controller.signal.aborted)
289
+ return;
290
+ // The entry type changed while this was in flight: these readings belong to the previous
291
+ // one, and the change has already started its own update.
292
+ if (this.entryType !== entryType)
293
+ return;
294
+ this.monitors = new Map(list.map((monitor) => [monitor.id, monitor]));
295
+ this.#setReadings(readings);
296
+ this.#status = { state: "ready" };
297
+ }
298
+ catch (error) {
299
+ // The caller's own abort rejects with its reason. A superseded update resolves quietly.
300
+ if (options.signal?.aborted)
301
+ throw error === abortedError ? error : callerAborted(options.signal);
302
+ if (controller.signal.aborted)
303
+ return;
304
+ // With readings still shown, a failed refresh keeps them (polling tries again); with
305
+ // none (after an entry type change), the source reports the failure.
306
+ if (this.#blank) {
307
+ this.#status = {
308
+ state: "error",
309
+ error,
310
+ retry: () => {
311
+ this.#status = { state: "loading" };
312
+ this.#refresh();
313
+ }
314
+ };
315
+ }
316
+ throw error;
317
+ }
318
+ finally {
319
+ combined.dispose();
320
+ if (this.#updateController === controller)
321
+ this.#updateController = null;
322
+ }
323
+ }
324
+ dispose() {
325
+ this.#disposed = true;
326
+ this.pauseAutoUpdate();
327
+ this.#updateController?.abort();
328
+ if (this.#initRun)
329
+ this.#abortInitRun(this.#initRun);
330
+ }
331
+ }
332
+ /**
333
+ * The live monitors data for an app: the monitor list, the latest readings for one entry type,
334
+ * and polling every 2 minutes. One store can feed any number of maps and screens, or none: its
335
+ * `dataSource` goes straight to `monitorsPlugin`. Call `init()` to load, and `dispose()` when the
336
+ * app is done with it.
337
+ */
338
+ export function createMonitorsStore(options = {}) {
339
+ return new LiveMonitorsStore(options);
340
+ }
package/dist/theme.css ADDED
@@ -0,0 +1,34 @@
1
+ /*
2
+ * map-sdk's theme: import after Tailwind, e.g. `@import "@sjvair/map-sdk/theme.css";`.
3
+ *
4
+ * The components use the host's shadcn tokens (--background, --card, --primary, --border, …)
5
+ * and these family tokens. A host that defines a family token gets it; one that doesn't falls
6
+ * back to a standard shadcn token. Inline, so each utility reads the token where it is used and
7
+ * follows the host's dark mode.
8
+ */
9
+ @theme inline {
10
+ --color-highlight: var(--highlight, var(--primary));
11
+ --color-highlight-subtle: var(--highlight-subtle, var(--muted));
12
+ --color-highlight-foreground: var(--highlight-foreground, var(--primary-foreground));
13
+ --color-card-border: var(--card-border, var(--border));
14
+ --color-overlay: var(--overlay, var(--background));
15
+ --color-link: var(--link, var(--primary));
16
+ --color-ring-edge: var(--ring-edge, var(--background));
17
+ }
18
+
19
+ /* Utilities, not theme keys: a theme key named like the host token would emit a self-referencing
20
+ `--shadow-card: var(--shadow-card, …)` in the theme layer, overriding the host's value. */
21
+ @utility shadow-card {
22
+ box-shadow: var(--shadow-card, none);
23
+ }
24
+
25
+ @utility rounded-control {
26
+ border-radius: var(--radius-control, var(--radius));
27
+ }
28
+
29
+ @utility shadow-lifted {
30
+ box-shadow: var(--shadow-lifted, var(--shadow-card, none));
31
+ }
32
+
33
+ /* The motion, swatch-outline, and --ring-edge tokens are read with fallbacks where used (see
34
+ ui/controls.css and the components); they are never declared here. */