@sjvair/map-sdk 0.1.0-beta.3 → 0.1.0-beta.4

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.
package/README.md CHANGED
@@ -167,7 +167,22 @@ points only), as sjvair.com's map does today.
167
167
 
168
168
  `outline` on `shapesPlugin` is `"theme"` (default, a 1px `--foreground` edge), `"level"` (the edge
169
169
  in each level's color at the fill's opacity; choropleth only), or `"none"` (no outline layer).
170
- `hmsSmokePlugin` uses `"level"`.
170
+ `hmsSmokePlugin` uses `"level"`. `hoverable: false` on `shapesPlugin` sets the plugin's
171
+ `hoverable` (see [Writing a plugin](#writing-a-plugin)): hovering a shape shows no tooltip and
172
+ doesn't hide what is beneath it, while focusing it from the feature list still pins its tooltip.
173
+ `hmsSmokePlugin` uses it, so a plume, which can cover much of the map, never pops up a tooltip.
174
+
175
+ ```ts
176
+ map.addPlugin(
177
+ shapesPlugin({
178
+ id: "plumes",
179
+ source: staticSource(plumes, { dataset: "hms-smoke", entryType: "smoke-density" }),
180
+ layerLabel: "Plumes",
181
+ mode: "choropleth",
182
+ hoverable: false
183
+ })
184
+ );
185
+ ```
171
186
 
172
187
  `zoomRange: { min?, max? }` on `pointsPlugin` limits every layer it draws (points, clusters,
173
188
  selection) to camera zooms from `min` (inclusive) up to `max` (exclusive), switching live while
@@ -177,7 +192,12 @@ zooming. Two plugins whose ranges meet hand over with no gap, on the globe too.
177
192
  colored by FRP. With `hotspots`, it switches by itself while zooming, as sjvair.com's map does:
178
193
  `source` (the fire store's `groups`) below zoom 10 and `hotspots` from zoom 10. It stays one
179
194
  layer: one legend section, one feature list (the groups, so keyboard users reach every fire;
180
- hotspots aren't listed), and tooltips for both.
195
+ hotspots aren't listed), and tooltips for both. The tooltip is the monitors' reading box: "Fire
196
+ (MW)", the power in its level's color, beside the name ("3 fire detections", "1 fire detection"
197
+ for a group, "Fire detection" for one detection) and the level ("50–149 MW"); a group of two or
198
+ more says "Average". A feature with a `count` (`FireFeatureProperties`, which the fire store's
199
+ groups carry) is a group. Fire values are cut off at the display precision, not rounded, in the
200
+ tooltip, the list and its accessible names, so 9.96 MW reads "9.9" beside "<10".
181
201
 
182
202
  `legendDisplay: "gradient" | "blocks"` on `pointsPlugin` sets how the legend draws a range scale
183
203
  (default a gradient; `hmsFirePlugin` uses `"blocks"`, labeled with the levels' own labels). A blocks
@@ -204,6 +224,10 @@ charger") shown where "No level" would be: in the feature list (plain text, no s
204
224
  accessible name ("Fresno Station, Level 2 charger"), and the tooltip. A feature with a level
205
225
  always shows its level. A plugin of your own sets the same `detail` on its `FeatureListItem`s.
206
226
 
227
+ `truncateValues: true` on `pointsPlugin` cuts values off at the display precision instead of
228
+ rounding them (`truncate` on each `FeatureListItem`), in the list, its accessible names, and the
229
+ generic tooltip, so a value never reads as the next level up. `hmsFirePlugin` uses it.
230
+
207
231
  `collocationSitesPlugin({ id?, source, layerLabel })` draws collocation sites as sjvair.com does:
208
232
  each at its reference monitor, in that monitor's shape and level color (inactive gray, inside
209
233
  outlined), wrapped in a crosshair of `COLLOCATION_CROSSHAIR_COLOR` (`#4A5FC6`, a fixed color on
@@ -229,7 +253,12 @@ and reads "No level"; a level with an empty color draws neutral with its own lab
229
253
  source loads, fails, or is unavailable, the legend says so (a failure offers "Try again", which
230
254
  calls `status.retry()`). Values are shown with the metadata's display precision, except in the
231
255
  monitor, cluster, and collocation tooltips, which match monitor-map: the reading and a cluster's
232
- average are whole numbers, and a cluster's max and min are as reported.
256
+ average are whole numbers, and a cluster's max and min are as reported. Fire values are cut off
257
+ rather than rounded (see `truncateValues`). Times in the tooltips read like sjvair.com's map, in
258
+ Pacific time: "4:00 pm Friday Nov 06, 2026", with " PT" after them on a device in another zone.
259
+ The monitor cluster tooltip's count line gives the area the monitors are within, "3 monitors
260
+ within a 22.619 mi² area": the area of their convex hull, left out when it would read 0.000 (two
261
+ monitors, or all on one line or one spot).
233
262
 
234
263
  `createMonitorsStore()` (from `@sjvair/map-sdk/stores`) loads the monitors list and latest readings
235
264
  for an entry type, polls every two minutes, and exposes `dataSource`. `init()`, `update()`,
@@ -356,7 +385,10 @@ features are under the pointer, the hover tooltip shows the one whose plugin has
356
385
  `hoverPriority` (default 0), the topmost on a tie. Over a cluster bubble, the bubble's tooltip
357
386
  shows unless a feature beneath belongs to a plugin with a higher `hoverPriority` than the
358
387
  bubble's (collocation sites under monitor clusters). Clicks always go to the topmost, so a click
359
- on a bubble still zooms into it.
388
+ on a bubble still zooms into it. A plugin with `hoverable: false` never takes the hover: its
389
+ features show no hover tooltip or pointer cursor and are passed over as if they weren't drawn, so
390
+ whatever is beneath gets the hover. They are still clicked, returned by `featuresAt`, and pin
391
+ their tooltip when focused from the feature list.
360
392
 
361
393
  ```ts
362
394
  import type { CustomLayerInterface, MapPlugin, PluginContext } from "@sjvair/map-sdk";
@@ -500,6 +532,34 @@ local sjvair.com stack at `http://localhost:8000`.
500
532
  podman exec sjvaircom_web_1 python manage.py loaddata default-calibrations.yaml
501
533
  ```
502
534
 
535
+ ## Changes
536
+
537
+ ### 0.1.0-beta.4
538
+
539
+ Defaults from monitor-map 5.0's parity review against sjvair.com's map (4.3.0). Every map gets
540
+ them; none is an option.
541
+
542
+ - **Tooltip times** read like sjvair.com's map, in Pacific time: "4:00 pm Friday Nov 06, 2026"
543
+ (was "Nov 6, 2026, 4:00 PM"). A device in another zone still gets " PT" after the time.
544
+ - **The cluster area line is back.** The monitor cluster tooltip's count line reads "3 monitors
545
+ within a 22.619 mi² area". This reverses the 2026-11-04 parity decision of "no area line". The
546
+ wording is new (sjvair.com said "covering"), and the area is the convex hull of the cluster's
547
+ monitors, so clusters of four or more can differ from sjvair.com's number, which followed the
548
+ monitors' order and could cross itself. No line when the area would read 0.000.
549
+ - **Fire tooltips are the reading box**: "Fire (MW)", the power in its level's color, beside the
550
+ name and level; "Average" for a group of two or more. Fire values are cut off, not rounded, in
551
+ the tooltip, the list and its accessible names. Every group names its count, a group of 1
552
+ included ("1 fire detection").
553
+ - **Smoke plumes show no hover tooltip** and never take the hover from what is beneath them;
554
+ focusing a plume from the feature list still pins its tooltip. This reverses part of milestone
555
+ 2's accessibility additions ("tooltips on smoke and fire"): fire keeps a tooltip.
556
+ - **Breaking for hosts with their own message catalog:** `mapsdk_hms_fire_detections` is now
557
+ `mapsdk_hms_fire_detections_one` / `_other`. New keys: `mapsdk_hms_fire_box_header`,
558
+ `mapsdk_hms_fire_box_header_no_units`, `mapsdk_hms_fire_average`, `mapsdk_level_with_units`,
559
+ and `mapsdk_monitor_cluster_count_area_one` / `_other`.
560
+ - **New API:** `MapPlugin.hoverable`, `shapesPlugin`'s `hoverable`, `pointsPlugin`'s
561
+ `truncateValues`, `FeatureListItem.truncate`, and `FireFeatureProperties`.
562
+
503
563
  ## Development
504
564
 
505
565
  ```bash
@@ -11,21 +11,23 @@ export interface Hovered {
11
11
  * layers, so registrations can come and go without re-binding listeners. Layers nobody registered
12
12
  * are never hit. A blocking layer (e.g. a cluster bubble, which zooms instead) is never hit either,
13
13
  * and when it is topmost at a point nothing beneath it is hit; only a feature whose plugin
14
- * outranks it in `hoverPriority` is hovered through it.
14
+ * outranks it in `hoverPriority` is hovered through it. A non-hoverable plugin's features (e.g.
15
+ * smoke plumes) are left out of the hover and cursor as if they weren't rendered, but are still
16
+ * clicked and returned by lookups.
15
17
  */
16
18
  export declare class InteractionRouter {
17
19
  #private;
18
20
  /** The top hit under the pointer and where it is. Reactive, for the tooltip. */
19
21
  get hovered(): Hovered | null;
20
22
  /** Records the layers whose features `toHit` turns into hits. Returns an unregister function. */
21
- register(pluginId: string, layerIds: string[], toHit: (props: Record<string, unknown>) => FeatureHit | null, priority?: number): () => void;
23
+ register(pluginId: string, layerIds: string[], toHit: (props: Record<string, unknown>) => FeatureHit | null, priority?: number, hoverable?: boolean): () => void;
22
24
  /**
23
25
  * Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
24
26
  * nothing (calling `onClick` with that feature instead) and the hover is `toHover`'s hit for
25
27
  * it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
26
28
  * unregister function.
27
29
  */
28
- registerBlocking(pluginId: string, layerIds: string[], toHover?: (props: Record<string, unknown>) => FeatureHit | null, onClick?: (feature: BlockingClick) => void, priority?: number): () => void;
30
+ registerBlocking(pluginId: string, layerIds: string[], toHover?: (props: Record<string, unknown>) => FeatureHit | null, onClick?: (feature: BlockingClick) => void, priority?: number, hoverable?: boolean): () => void;
29
31
  /** Listens to `gl` from now on. */
30
32
  attach(gl: GlMapLike): void;
31
33
  /** Stops listening and clears `hovered`. */
@@ -5,7 +5,9 @@
5
5
  * layers, so registrations can come and go without re-binding listeners. Layers nobody registered
6
6
  * are never hit. A blocking layer (e.g. a cluster bubble, which zooms instead) is never hit either,
7
7
  * and when it is topmost at a point nothing beneath it is hit; only a feature whose plugin
8
- * outranks it in `hoverPriority` is hovered through it.
8
+ * outranks it in `hoverPriority` is hovered through it. A non-hoverable plugin's features (e.g.
9
+ * smoke plumes) are left out of the hover and cursor as if they weren't rendered, but are still
10
+ * clicked and returned by lookups.
9
11
  */
10
12
  export class InteractionRouter {
11
13
  // Bookkeeping only; nothing reads these reactively.
@@ -28,8 +30,15 @@ export class InteractionRouter {
28
30
  return this.#hovered;
29
31
  }
30
32
  /** Records the layers whose features `toHit` turns into hits. Returns an unregister function. */
31
- register(pluginId, layerIds, toHit, priority = 0) {
32
- return this.#add({ pluginId, layerIds: [...layerIds], toHit, blocking: false, priority });
33
+ register(pluginId, layerIds, toHit, priority = 0, hoverable = true) {
34
+ return this.#add({
35
+ pluginId,
36
+ layerIds: [...layerIds],
37
+ toHit,
38
+ blocking: false,
39
+ priority,
40
+ hoverable
41
+ });
33
42
  }
34
43
  /**
35
44
  * Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
@@ -37,14 +46,15 @@ export class InteractionRouter {
37
46
  * it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
38
47
  * unregister function.
39
48
  */
40
- registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0) {
49
+ registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0, hoverable = true) {
41
50
  return this.#add({
42
51
  pluginId,
43
52
  layerIds: [...layerIds],
44
53
  toHit: toHover ?? (() => null),
45
54
  blocking: true,
46
55
  onClick,
47
- priority
56
+ priority,
57
+ hoverable
48
58
  });
49
59
  }
50
60
  #add(registration) {
@@ -94,7 +104,8 @@ export class InteractionRouter {
94
104
  const found = this.#lookup(point);
95
105
  return found.blocked ? [] : found.hits;
96
106
  }
97
- #lookup(point) {
107
+ /** What is under one point; with `forHover`, non-hoverable features are left out first. */
108
+ #lookup(point, forHover = false) {
98
109
  const none = { hits: [], hover: null, blocked: false, blocker: null, blockedBy: null };
99
110
  const gl = this.#gl;
100
111
  if (!gl)
@@ -103,6 +114,8 @@ export class InteractionRouter {
103
114
  /* eslint-disable svelte/prefer-svelte-reactivity */
104
115
  const byLayer = new Map();
105
116
  for (const registration of this.#registrations) {
117
+ if (forHover && !registration.hoverable)
118
+ continue;
106
119
  for (const id of registration.layerIds) {
107
120
  // MapLibre errors on querying a missing layer (e.g. mid basemap change).
108
121
  if (!byLayer.has(id) && gl.getLayer(id))
@@ -210,7 +223,7 @@ export class InteractionRouter {
210
223
  }
211
224
  };
212
225
  #onMove = (e) => {
213
- const found = this.#lookup([e.point.x, e.point.y]);
226
+ const found = this.#lookup([e.point.x, e.point.y], true);
214
227
  const hit = found.blocked ? (found.hover ?? found.blocker) : found.hover;
215
228
  this.#hovered = hit ? { hit, lngLat: [e.lngLat.lng, e.lngLat.lat] } : null;
216
229
  this.#setPointer(found.blocked || hit !== null);
@@ -406,12 +406,12 @@ export class PluginHost {
406
406
  interactive(layerIds, toHit) {
407
407
  if (running.dead)
408
408
  return;
409
- running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0));
409
+ running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0, running.plugin.hoverable ?? true));
410
410
  },
411
411
  blocking(layerIds, toHover, onClick) {
412
412
  if (running.dead)
413
413
  return;
414
- running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0));
414
+ running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0, running.plugin.hoverable ?? true));
415
415
  },
416
416
  on(type, layerId, listener) {
417
417
  if (running.dead)
@@ -46,6 +46,12 @@ export interface FeatureListItem {
46
46
  * metadata's display hint). Without it the value is formatted as given.
47
47
  */
48
48
  precision?: number;
49
+ /**
50
+ * Cut a numeric `value` off at `precision` instead of rounding it, so the number never reads
51
+ * as the next level (e.g. fire power: 9.96 MW shows "9.9", not "10.0", beside "<10"). Default
52
+ * false.
53
+ */
54
+ truncate?: boolean;
49
55
  lngLat: [number, number];
50
56
  selectable: boolean;
51
57
  /**
@@ -139,6 +145,12 @@ export interface MapPlugin {
139
145
  * Clicks ignore this (topmost first), and a blocking layer on top still blocks them.
140
146
  */
141
147
  hoverPriority?: number;
148
+ /**
149
+ * False: the plugin's features never take the hover (so no hover tooltip and no pointer
150
+ * cursor) and never hide a feature beneath from it, as if they weren't rendered. Clicks,
151
+ * `featuresAt`, and keyboard focus from the list are unchanged. Default true.
152
+ */
153
+ hoverable?: boolean;
142
154
  displayOptions?: Snippet;
143
155
  /** Richer tooltip content for one of this plugin's features, below its name. */
144
156
  tooltip?(item: FeatureListItem): Snippet | undefined;
@@ -1,6 +1,6 @@
1
1
  export interface FormatProvider {
2
2
  number(value: number, options?: Intl.NumberFormatOptions): string;
3
- /** Formats in Pacific time. */
3
+ /** Formats in Pacific time like sjvair.com's map: "4:00 pm Friday Nov 06, 2026". */
4
4
  dateTime(value: Date | string): string;
5
5
  /** "PT" only when the device zone isn't Pacific. */
6
6
  zoneLabel(): string | null;
@@ -2,13 +2,26 @@ const PACIFIC = "America/Los_Angeles";
2
2
  export function createDefaultFormat(deviceZone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
3
3
  const dateFormat = new Intl.DateTimeFormat("en-US", {
4
4
  timeZone: PACIFIC,
5
- dateStyle: "medium",
6
- timeStyle: "short"
5
+ hour: "numeric",
6
+ minute: "2-digit",
7
+ hour12: true,
8
+ weekday: "long",
9
+ month: "short",
10
+ day: "2-digit",
11
+ year: "numeric"
7
12
  });
13
+ // date-fns' "h:mm aaa EEEE MMM dd, yyyy", assembled from the parts.
14
+ const dateTime = (value) => {
15
+ const date = typeof value === "string" ? new Date(value) : value;
16
+ const part = {};
17
+ for (const { type, value: text } of dateFormat.formatToParts(date))
18
+ part[type] = text;
19
+ return `${part.hour}:${part.minute} ${part.dayPeriod?.toLowerCase()} ${part.weekday} ${part.month} ${part.day}, ${part.year}`;
20
+ };
8
21
  const relative = new Intl.RelativeTimeFormat("en-US", { numeric: "always" });
9
22
  return {
10
23
  number: (value, options) => new Intl.NumberFormat("en-US", options).format(value),
11
- dateTime: (value) => dateFormat.format(typeof value === "string" ? new Date(value) : value),
24
+ dateTime,
12
25
  zoneLabel: () => (deviceZone === PACIFIC ? null : "PT"),
13
26
  relativeTime: (value, now = new Date()) => {
14
27
  const ms = (typeof value === "string" ? new Date(value) : value).getTime() - now.getTime();
@@ -38,13 +38,20 @@
38
38
  "mapsdk_monitor_cluster_title_sjvair": "SJVAir {type} Monitors",
39
39
  "mapsdk_monitor_cluster_count_one": "{count} monitor",
40
40
  "mapsdk_monitor_cluster_count_other": "{count} monitors",
41
+ "mapsdk_monitor_cluster_count_area_one": "{count} monitor within a {area} mi² area",
42
+ "mapsdk_monitor_cluster_count_area_other": "{count} monitors within a {area} mi² area",
41
43
  "mapsdk_monitor_cluster_max": "Max: {value} {units}",
42
44
  "mapsdk_monitor_cluster_min": "Min: {value} {units}",
43
45
  "mapsdk_monitor_interval_avg": "({interval} avg)",
44
46
  "mapsdk_monitor_updated": "Last updated {when}",
45
47
  "mapsdk_hms_smoke_name": "Smoke",
46
48
  "mapsdk_hms_fire_detection": "Fire detection",
47
- "mapsdk_hms_fire_detections": "{count} fire detections",
49
+ "mapsdk_hms_fire_box_header": "Fire ({units})",
50
+ "mapsdk_hms_fire_box_header_no_units": "Fire",
51
+ "mapsdk_hms_fire_average": "Average",
52
+ "mapsdk_level_with_units": "{level} {units}",
53
+ "mapsdk_hms_fire_detections_one": "{count} fire detection",
54
+ "mapsdk_hms_fire_detections_other": "{count} fire detections",
48
55
  "mapsdk_collocation_reference": "Reference monitor",
49
56
  "mapsdk_collocation_colocated": "Collocated monitor",
50
57
  "mapsdk_collocation_interval_avg": "({interval} avg)",
@@ -21,6 +21,11 @@ export interface MapFeatureProperties {
21
21
  name: string;
22
22
  value: number | string | null;
23
23
  }
24
+ /** An HMS fire on the map: a group of nearby detections, or a single detection. */
25
+ export interface FireFeatureProperties extends MapFeatureProperties {
26
+ /** Set on a group: how many detections it averages. Absent on a single detection. */
27
+ count?: number;
28
+ }
24
29
  /** A monitor on the map: what the monitors preset draws and its tooltips show. */
25
30
  export interface MonitorFeatureProperties extends MapFeatureProperties {
26
31
  /** The server's monitor type (e.g. "purpleair", "airnow"); picks the marker shape. */
@@ -82,6 +82,11 @@ export interface PointsOptions<P extends MapFeatureProperties> {
82
82
  * feature with a level.
83
83
  */
84
84
  detail?(props: P): string | undefined;
85
+ /**
86
+ * Cut values off at the display precision instead of rounding them, in the list, its
87
+ * accessible names, and the generic tooltip (`FeatureListItem.truncate`). Default false.
88
+ */
89
+ truncateValues?: boolean;
85
90
  /** Whether `features()` lists this plugin's features. Default true. */
86
91
  listed?: boolean;
87
92
  /** Tooltip content for a feature, below its name; `props` are its source properties. */
@@ -104,7 +104,8 @@ export function pointsPlugin(options) {
104
104
  precision,
105
105
  lngLat: [lng, lat],
106
106
  selectable: selectable && selectionId(ref) !== null,
107
- ...(detail ? { detail } : {})
107
+ ...(detail ? { detail } : {}),
108
+ ...(options.truncateValues ? { truncate: true } : {})
108
109
  });
109
110
  }
110
111
  return items;
@@ -19,6 +19,11 @@ export interface ShapesOptions<P extends MapFeatureProperties> {
19
19
  * (so LineString geometries are not drawn either).
20
20
  */
21
21
  outline?: "theme" | "level" | "none";
22
+ /**
23
+ * False: hovering a shape shows no tooltip and doesn't hide what is beneath it
24
+ * (`MapPlugin.hoverable`); list focus still shows it. Default true.
25
+ */
26
+ hoverable?: boolean;
22
27
  }
23
28
  /** The point used to place a feature in lists: its largest ring's centroid, or a line's midpoint. */
24
29
  export declare function labelPoint(geometry: Geometry): [number, number] | null;
@@ -303,5 +303,7 @@ export function shapesPlugin(options) {
303
303
  };
304
304
  if (options.listed ?? true)
305
305
  plugin.features = features;
306
+ if (options.hoverable === false)
307
+ plugin.hoverable = false;
306
308
  return plugin;
307
309
  }
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import type { FeatureListItem } from "../core/plugin.js";
3
+ import type { FormatProvider } from "../data/format.js";
4
+ import type { MessageProvider } from "../data/messages.js";
5
+ import { NO_LEVEL, numericValue } from "../data/scale.js";
6
+ import { formatValue } from "../ui/value-format.js";
7
+ import MonitorReadingBox from "./MonitorReadingBox.svelte";
8
+
9
+ // A fire's power in the monitors' reading box, beside its name and level. The value is cut
10
+ // off, not rounded, so it never reads as the next level (9.96 MW shows 9.9 beside "<10").
11
+ // No detection time: a satellite pass isn't when the fire started or whether it still burns.
12
+
13
+ interface Props {
14
+ item: FeatureListItem;
15
+ /** How many detections a group averages; null for a single detection. */
16
+ count: number | null;
17
+ messages: MessageProvider;
18
+ format: FormatProvider;
19
+ }
20
+
21
+ let { item, count, messages, format }: Props = $props();
22
+
23
+ const level = $derived(item.level !== NO_LEVEL ? item.level : null);
24
+ const value = $derived.by(() => {
25
+ const n = numericValue(item.value);
26
+ return n === null ? null : formatValue(n, item.precision, format, true);
27
+ });
28
+ const header = $derived(
29
+ item.units
30
+ ? messages("mapsdk_hms_fire_box_header", { units: item.units })
31
+ : messages("mapsdk_hms_fire_box_header_no_units")
32
+ );
33
+ </script>
34
+
35
+ <div class="mapsdk-fire-tip mapsdk-tooltip-wide">
36
+ <MonitorReadingBox
37
+ color={level?.color || null}
38
+ {header}
39
+ value={value ?? messages("mapsdk_no_reading")}
40
+ large={value !== null}
41
+ subheading={count !== null && count >= 2 ? messages("mapsdk_hms_fire_average") : undefined}
42
+ />
43
+ <div class="mapsdk-fire-detail">
44
+ <span class="mapsdk-fire-name text-lg">{item.name}</span>
45
+ <span>
46
+ {level
47
+ ? item.units
48
+ ? messages("mapsdk_level_with_units", { level: level.label, units: item.units })
49
+ : level.label
50
+ : messages("mapsdk_no_level")}
51
+ </span>
52
+ </div>
53
+ </div>
54
+
55
+ <style>
56
+ .mapsdk-fire-tip {
57
+ display: flex;
58
+ gap: 0.5rem;
59
+ }
60
+
61
+ .mapsdk-fire-name {
62
+ font-weight: 700;
63
+ }
64
+
65
+ .mapsdk-fire-detail {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.25rem;
69
+ }
70
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { FeatureListItem } from "../core/plugin.js";
2
+ import type { FormatProvider } from "../data/format.js";
3
+ import type { MessageProvider } from "../data/messages.js";
4
+ interface Props {
5
+ item: FeatureListItem;
6
+ /** How many detections a group averages; null for a single detection. */
7
+ count: number | null;
8
+ messages: MessageProvider;
9
+ format: FormatProvider;
10
+ }
11
+ declare const FireTooltip: import("svelte").Component<Props, {}, "">;
12
+ type FireTooltip = ReturnType<typeof FireTooltip>;
13
+ export default FireTooltip;
@@ -7,12 +7,14 @@
7
7
  import type { MonitorFeatureProperties } from "../data/types.js";
8
8
  import type { ClusterInfo } from "../plugins/points-clusters.js";
9
9
  import { formatValue } from "../ui/value-format.js";
10
+ import { clusterAreaSqMiles } from "./cluster-area.js";
10
11
  import MonitorReadingBox from "./MonitorReadingBox.svelte";
11
12
 
12
13
  // Ported from monitor-map's MonitorClusterTooltip: the cluster's average in a level-colored
13
14
  // box beside its monitor type, count, and highest and lowest readings. The count and average
14
15
  // come from the bubble itself, so they show at once; the rest fills in from the cluster's
15
16
  // monitors. The average is a whole number and max and min are as reported, as monitor-map's.
17
+ // The count line adds the area the monitors are within (their convex hull) once they load.
16
18
 
17
19
  interface Props {
18
20
  cluster: ClusterInfo<MonitorFeatureProperties>;
@@ -29,6 +31,8 @@
29
31
  isSjvair: boolean;
30
32
  /** Readings only; monitors without one are left out. */
31
33
  values: number[];
34
+ /** Square miles of the monitors' convex hull, every monitor included. */
35
+ area: number;
32
36
  }
33
37
 
34
38
  let leaves = $state.raw<Leaves | null>(null);
@@ -60,7 +64,8 @@
60
64
  values: found.flatMap(({ props }) => {
61
65
  const n = numericValue(props.value);
62
66
  return n === null ? [] : [n];
63
- })
67
+ }),
68
+ area: clusterAreaSqMiles(found.map(({ lngLat }) => lngLat))
64
69
  };
65
70
  },
66
71
  () => {
@@ -87,6 +92,16 @@
87
92
  return found !== NO_LEVEL ? found : null;
88
93
  });
89
94
  const units = $derived(scale?.units ?? "");
95
+ /** The area to 3 decimals, or null when there is none to show (it would read 0.000). */
96
+ const area = $derived.by(() => {
97
+ // Decided on the number, so a host's own number format can't change what's hidden.
98
+ if (!leaves || Math.round(leaves.area * 1000) === 0) return null;
99
+ return format.number(leaves.area, {
100
+ minimumFractionDigits: 3,
101
+ maximumFractionDigits: 3,
102
+ useGrouping: false
103
+ });
104
+ });
90
105
  const max = $derived(leaves?.values.length ? Math.max(...leaves.values) : null);
91
106
  const min = $derived(leaves?.values.length ? Math.min(...leaves.values) : null);
92
107
  </script>
@@ -108,7 +123,11 @@
108
123
  )}
109
124
  </span>
110
125
  {/if}
111
- <span>{messages("mapsdk_monitor_cluster_count", { count: cluster.pointCount })}</span>
126
+ <span>
127
+ {area
128
+ ? messages("mapsdk_monitor_cluster_count_area", { count: cluster.pointCount, area })
129
+ : messages("mapsdk_monitor_cluster_count", { count: cluster.pointCount })}
130
+ </span>
112
131
  <span>{level ? level.label : messages("mapsdk_no_level")}</span>
113
132
  {#if max !== null && min !== null}
114
133
  <span class="mapsdk-monitor-extremes text-base">
@@ -0,0 +1,13 @@
1
+ type Point = [number, number];
2
+ /**
3
+ * The convex hull of lng/lat points (monotone chain): counter-clockwise, without a repeated
4
+ * closing point, inner and collinear points dropped. Fewer than 3 points means no area.
5
+ */
6
+ export declare function convexHull(points: Point[]): Point[];
7
+ /**
8
+ * The area, in square miles, of the smallest shape around a cluster's monitors (their convex
9
+ * hull), as sjvair.com's map computed area: `@turf/area`, then `convertArea`. 0 for fewer than 3
10
+ * points not on one line.
11
+ */
12
+ export declare function clusterAreaSqMiles(points: Point[]): number;
13
+ export {};
@@ -0,0 +1,34 @@
1
+ import area from "@turf/area";
2
+ import { convertArea, polygon } from "@turf/helpers";
3
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
4
+ /**
5
+ * The convex hull of lng/lat points (monotone chain): counter-clockwise, without a repeated
6
+ * closing point, inner and collinear points dropped. Fewer than 3 points means no area.
7
+ */
8
+ export function convexHull(points) {
9
+ const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
10
+ if (sorted.length < 3)
11
+ return sorted;
12
+ const half = (from) => {
13
+ const chain = [];
14
+ for (const p of from) {
15
+ while (chain.length >= 2 && cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0)
16
+ chain.pop();
17
+ chain.push(p);
18
+ }
19
+ chain.pop();
20
+ return chain;
21
+ };
22
+ return [...half(sorted), ...half([...sorted].reverse())];
23
+ }
24
+ /**
25
+ * The area, in square miles, of the smallest shape around a cluster's monitors (their convex
26
+ * hull), as sjvair.com's map computed area: `@turf/area`, then `convertArea`. 0 for fewer than 3
27
+ * points not on one line.
28
+ */
29
+ export function clusterAreaSqMiles(points) {
30
+ const hull = convexHull(points);
31
+ if (hull.length < 3)
32
+ return 0;
33
+ return convertArea(area(polygon([[...hull, hull[0]]])), "meters", "miles");
34
+ }
@@ -1,12 +1,16 @@
1
1
  import type { MapPlugin } from "../core/plugin.js";
2
- import type { DataSource } from "../data/types.js";
2
+ import type { DataSource, FireFeatureProperties } from "../data/types.js";
3
3
  export interface HmsFirePluginOptions {
4
4
  /** Default "hms-fire". */
5
5
  id?: string;
6
- /** The fires, or with `hotspots` the groups shown below zoom 10 (listed). */
7
- source: DataSource;
6
+ /**
7
+ * The fires, or with `hotspots` the groups shown below zoom 10 (listed). A feature with a
8
+ * `count` is a group (its tooltip says "Average" from 2 detections); one without is a single
9
+ * detection.
10
+ */
11
+ source: DataSource<FireFeatureProperties>;
8
12
  /** Single detections shown from zoom 10 instead of `source` (not listed). Default: none. */
9
- hotspots?: DataSource;
13
+ hotspots?: DataSource<FireFeatureProperties>;
10
14
  /** Already-translated layer name for the list and summary. */
11
15
  layerLabel: string;
12
16
  }
@@ -16,9 +20,9 @@ export interface HmsFirePluginOptions {
16
20
  *
17
21
  * With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
18
22
  * while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
19
- * (each detection) from zoom 10. Both are hit-tested and show the same tooltip (name, value,
20
- * level);
21
- * only the groups are listed.
23
+ * (each detection) from zoom 10. Both are hit-tested and show the same tooltip: the power in a
24
+ * reading box colored by its level, beside the name and level; only the groups are listed. Values
25
+ * are cut off at the display precision, not rounded, in the tooltip, list, and accessible names.
22
26
  *
23
27
  * The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
24
28
  * "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
@@ -1,7 +1,7 @@
1
1
  import { scopedContext } from "../core/scoped-context.js";
2
2
  import { fireIcon } from "../plugins/icons.js";
3
3
  import { pointsPlugin } from "../plugins/points.svelte.js";
4
- import TooltipItem from "../ui/TooltipItem.svelte";
4
+ import FireTooltip from "./FireTooltip.svelte";
5
5
  import { componentSnippet } from "./monitors.js";
6
6
  /** The zoom where `hotspots` replace the groups (inclusive), as sjvair.com's map does. */
7
7
  const HOTSPOT_ZOOM = 10;
@@ -22,6 +22,14 @@ function firePoints(id, source, layerLabel, zoomRange) {
22
22
  selectable: false,
23
23
  zoomRange,
24
24
  legendDisplay: "blocks",
25
+ truncateValues: true,
26
+ tooltipShowsName: true,
27
+ tooltip: (item, props, env) => componentSnippet(FireTooltip, {
28
+ item,
29
+ count: typeof props.count === "number" ? props.count : null,
30
+ messages: env.messages,
31
+ format: env.format
32
+ }),
25
33
  icon: {
26
34
  category: () => "fire",
27
35
  draw: (_category, color, _theme, levelIndex) => drawFire(color, levelIndex),
@@ -35,9 +43,9 @@ function firePoints(id, source, layerLabel, zoomRange) {
35
43
  *
36
44
  * With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
37
45
  * while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
38
- * (each detection) from zoom 10. Both are hit-tested and show the same tooltip (name, value,
39
- * level);
40
- * only the groups are listed.
46
+ * (each detection) from zoom 10. Both are hit-tested and show the same tooltip: the power in a
47
+ * reading box colored by its level, beside the name and level; only the groups are listed. Values
48
+ * are cut off at the display precision, not rounded, in the tooltip, list, and accessible names.
41
49
  *
42
50
  * The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
43
51
  * "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
@@ -50,8 +58,6 @@ export function hmsFirePlugin(options) {
50
58
  // Both inner plugins share the outer id, so their hits are this plugin's.
51
59
  const groups = firePoints(id, source, layerLabel, { max: HOTSPOT_ZOOM });
52
60
  const singles = firePoints(id, hotspots, layerLabel, { min: HOTSPOT_ZOOM });
53
- // The providers of the running setup, for the hotspots' tooltips.
54
- let ctxNow = null;
55
61
  return {
56
62
  id,
57
63
  label: layerLabel,
@@ -60,28 +66,21 @@ export function hmsFirePlugin(options) {
60
66
  },
61
67
  selectable: false,
62
68
  setup(ctx) {
63
- ctxNow = ctx;
64
69
  const teardownGroups = groups.setup(scopedContext(ctx, "groups:"));
65
70
  const teardownSingles = singles.setup(scopedContext(ctx, "hotspots:"));
66
71
  return () => {
67
- ctxNow = null;
68
72
  teardownSingles();
69
73
  teardownGroups();
70
74
  };
71
75
  },
72
76
  legend: groups.legend,
73
- // Only the groups are listed; a hovered hotspot shows what a group's hover does: its name
74
- // (the generic header) with its value and level.
77
+ // Only the groups are listed; a hovered hotspot shows the same box as a group.
75
78
  features: groups.features,
79
+ tooltip: groups.tooltip,
80
+ tooltipShowsName: true,
76
81
  hoverTooltip(hit) {
77
82
  const item = singles.features?.().find((f) => f.refKey === hit.refKey);
78
- if (!item || !ctxNow)
79
- return undefined;
80
- return componentSnippet(TooltipItem, {
81
- item,
82
- messages: ctxNow.messages,
83
- format: ctxNow.format
84
- });
83
+ return item && singles.tooltip?.(item);
85
84
  }
86
85
  };
87
86
  }
@@ -10,7 +10,8 @@ export interface HmsSmokePluginOptions {
10
10
  /**
11
11
  * NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
12
12
  * level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
13
- * can be reached without a mouse.
13
+ * can be reached without a mouse. Hovering a plume shows no tooltip and never hides another
14
+ * layer's (`hoverable: false`); focusing one from the list still pins its tooltip.
14
15
  *
15
16
  * The source's `scale` is expected to be the ScaleRef
16
17
  * `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
@@ -2,7 +2,8 @@ import { shapesPlugin } from "../plugins/shapes.svelte.js";
2
2
  /**
3
3
  * NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
4
4
  * level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
5
- * can be reached without a mouse.
5
+ * can be reached without a mouse. Hovering a plume shows no tooltip and never hides another
6
+ * layer's (`hoverable: false`); focusing one from the list still pins its tooltip.
6
7
  *
7
8
  * The source's `scale` is expected to be the ScaleRef
8
9
  * `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
@@ -15,6 +16,7 @@ export function hmsSmokePlugin(options) {
15
16
  layerLabel: options.layerLabel,
16
17
  mode: "choropleth",
17
18
  selectable: false,
18
- outline: "level"
19
+ outline: "level",
20
+ hoverable: false
19
21
  });
20
22
  }
@@ -1,5 +1,5 @@
1
1
  import { type MessageProvider } from "../data/messages.js";
2
- import type { DataSource, DataStatus } from "../data/types.js";
2
+ import type { DataSource, DataStatus, FireFeatureProperties } from "../data/types.js";
3
3
  import type { SignalOptions } from "./abort.js";
4
4
  export interface HmsStoreOptions {
5
5
  /** Names features ("Smoke", "Fire detection"). Default: the built-in English messages. */
@@ -22,8 +22,11 @@ export interface HmsSmokeStore {
22
22
  export interface HmsFireStore {
23
23
  /** `loading` until loaded, then `ready` (also when today has no fires) or `error`. */
24
24
  readonly status: DataStatus;
25
- /** Nearby detections merged (`groupFires`), for `hmsFirePlugin`; `value` is the mean FRP. */
26
- readonly groups: DataSource;
25
+ /**
26
+ * Nearby detections merged (`groupFires`), for `hmsFirePlugin`; `value` is the mean FRP and
27
+ * `count` how many detections it merges.
28
+ */
29
+ readonly groups: DataSource<FireFeatureProperties>;
27
30
  /** Each detection (`cleanFires`), for `hmsFirePlugin`; `value` is its FRP. */
28
31
  readonly hotspots: DataSource;
29
32
  /** As `HmsSmokeStore.init`. */
@@ -69,15 +69,15 @@ export function createHmsFireStore(options = {}) {
69
69
  value: fire.frp
70
70
  }
71
71
  }));
72
+ // Every group names its count, a group of 1 included ("1 fire detection").
72
73
  const groups = groupFires(detections).map((group) => ({
73
74
  type: "Feature",
74
75
  geometry: { type: "Point", coordinates: group.coordinates },
75
76
  properties: {
76
77
  refKey: encodeRef({ kind: "feature", id: group.id }),
77
- name: group.count === 1
78
- ? detection
79
- : messages("mapsdk_hms_fire_detections", { count: group.count }),
80
- value: group.avgFrp
78
+ name: messages("mapsdk_hms_fire_detections", { count: group.count }),
79
+ value: group.avgFrp,
80
+ count: group.count
81
81
  }
82
82
  }));
83
83
  return {
@@ -270,7 +270,7 @@
270
270
  function formatValue(row: Row): string {
271
271
  if (row.value === null) return "";
272
272
  return typeof row.value === "number"
273
- ? formatNumber(row.value, row.precision, map.format)
273
+ ? formatNumber(row.value, row.precision, map.format, row.truncate)
274
274
  : row.value;
275
275
  }
276
276
 
@@ -30,7 +30,7 @@
30
30
  item.value == null
31
31
  ? null
32
32
  : typeof item.value === "number"
33
- ? formatValue(item.value, item.precision, format)
33
+ ? formatValue(item.value, item.precision, format, item.truncate)
34
34
  : item.value
35
35
  );
36
36
  </script>
@@ -87,7 +87,9 @@ export function rowLabel(item, messages, format) {
87
87
  }
88
88
  const params = {
89
89
  name: item.name,
90
- value: typeof item.value === "number" ? formatValue(item.value, item.precision, format) : item.value,
90
+ value: typeof item.value === "number"
91
+ ? formatValue(item.value, item.precision, format, item.truncate)
92
+ : item.value,
91
93
  units: item.units,
92
94
  level: level.text
93
95
  };
@@ -2,6 +2,7 @@ import type { FormatProvider } from "../data/format.js";
2
2
  /**
3
3
  * A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
4
4
  * there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
5
- * so they all agree.
5
+ * so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
6
+ * precision 1 is "9.9"), so the number never reads as the next level up.
6
7
  */
7
- export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider): string;
8
+ export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider, truncate?: boolean): string;
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
3
3
  * there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
4
- * so they all agree.
4
+ * so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
5
+ * precision 1 is "9.9"), so the number never reads as the next level up.
5
6
  */
6
- export function formatValue(value, precision, format) {
7
+ export function formatValue(value, precision, format, truncate = false) {
7
8
  if (precision === undefined)
8
9
  return format.number(value);
10
+ if (truncate) {
11
+ const factor = 10 ** precision;
12
+ // toPrecision drops float noise first (4.35 * 100 is 434.99999999999994); 15 digits keep
13
+ // any real value from rounding up.
14
+ value = Math.trunc(Number((value * factor).toPrecision(15))) / factor;
15
+ }
9
16
  return format.number(value, {
10
17
  minimumFractionDigits: precision,
11
18
  maximumFractionDigits: precision
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjvair/map-sdk",
3
3
  "description": "Svelte 5 map components for SJVAir air-quality data",
4
- "version": "0.1.0-beta.3",
4
+ "version": "0.1.0-beta.4",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -63,6 +63,7 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@maptiler/sdk": "~4.2.0",
66
+ "@turf/area": "^7.4.0",
66
67
  "@turf/clusters-dbscan": "^7.4.0",
67
68
  "@turf/helpers": "^7.4.0",
68
69
  "@types/geojson": "^7946.0.16",