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

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 (38) hide show
  1. package/README.md +99 -5
  2. package/dist/core/create-map.svelte.d.ts +17 -2
  3. package/dist/core/create-map.svelte.js +108 -5
  4. package/dist/core/interactions.svelte.d.ts +5 -3
  5. package/dist/core/interactions.svelte.js +20 -7
  6. package/dist/core/plugin-host.svelte.js +2 -2
  7. package/dist/core/plugin.d.ts +25 -0
  8. package/dist/core/shape-view.d.ts +27 -0
  9. package/dist/core/shape-view.js +323 -0
  10. package/dist/data/format.d.ts +1 -1
  11. package/dist/data/format.js +16 -3
  12. package/dist/data/messages/en.json +8 -1
  13. package/dist/data/types.d.ts +5 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +1 -0
  16. package/dist/plugins/points.svelte.d.ts +5 -0
  17. package/dist/plugins/points.svelte.js +2 -1
  18. package/dist/plugins/shapes.svelte.d.ts +5 -0
  19. package/dist/plugins/shapes.svelte.js +20 -2
  20. package/dist/presets/FireTooltip.svelte +70 -0
  21. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  22. package/dist/presets/MonitorClusterTooltip.svelte +21 -2
  23. package/dist/presets/cluster-area.d.ts +13 -0
  24. package/dist/presets/cluster-area.js +34 -0
  25. package/dist/presets/hms-fire.d.ts +11 -7
  26. package/dist/presets/hms-fire.js +16 -17
  27. package/dist/presets/hms-smoke.d.ts +2 -1
  28. package/dist/presets/hms-smoke.js +4 -2
  29. package/dist/stores/hms-stores.svelte.d.ts +6 -3
  30. package/dist/stores/hms-stores.svelte.js +4 -4
  31. package/dist/ui/FeatureList.svelte +1 -1
  32. package/dist/ui/MapTooltip.svelte +17 -4
  33. package/dist/ui/TooltipItem.svelte +1 -1
  34. package/dist/ui/feature-list.d.ts +4 -1
  35. package/dist/ui/feature-list.js +9 -5
  36. package/dist/ui/value-format.d.ts +3 -2
  37. package/dist/ui/value-format.js +9 -2
  38. package/package.json +2 -1
@@ -0,0 +1,323 @@
1
+ /** The geometry's bounding box, or `null` when it has no coordinates. */
2
+ export function geometryBbox(geometry) {
3
+ let west = Infinity;
4
+ let south = Infinity;
5
+ let east = -Infinity;
6
+ let north = -Infinity;
7
+ const add = (p) => {
8
+ if (p[0] < west)
9
+ west = p[0];
10
+ if (p[0] > east)
11
+ east = p[0];
12
+ if (p[1] < south)
13
+ south = p[1];
14
+ if (p[1] > north)
15
+ north = p[1];
16
+ };
17
+ const walk = (g) => {
18
+ switch (g.type) {
19
+ case "Point":
20
+ add(g.coordinates);
21
+ break;
22
+ case "MultiPoint":
23
+ case "LineString":
24
+ g.coordinates.forEach(add);
25
+ break;
26
+ case "MultiLineString":
27
+ case "Polygon":
28
+ g.coordinates.forEach((ring) => ring.forEach(add));
29
+ break;
30
+ case "MultiPolygon":
31
+ g.coordinates.forEach((poly) => poly.forEach((ring) => ring.forEach(add)));
32
+ break;
33
+ case "GeometryCollection":
34
+ g.geometries.forEach(walk);
35
+ break;
36
+ }
37
+ };
38
+ walk(geometry);
39
+ return west <= east ? [west, south, east, north] : null;
40
+ }
41
+ const inRect = (p, b) => p[0] >= b.west && p[0] <= b.east && p[1] >= b.south && p[1] <= b.north;
42
+ /** The polygons of a geometry (each an outer ring and holes), or `null` for a non-polygon. */
43
+ function polygonsOf(g) {
44
+ if (g.type === "Polygon")
45
+ return [g.coordinates];
46
+ if (g.type === "MultiPolygon")
47
+ return g.coordinates;
48
+ return null;
49
+ }
50
+ function linesOf(g) {
51
+ if (g.type === "LineString")
52
+ return [g.coordinates];
53
+ if (g.type === "MultiLineString")
54
+ return g.coordinates;
55
+ return null;
56
+ }
57
+ /** Ring edges, closing an unclosed ring. Fewer than 3 points: no area, so no edges. */
58
+ function ringEdges(ring) {
59
+ const n = ring.length;
60
+ if (n < 3)
61
+ return [];
62
+ const edges = [];
63
+ for (let i = 0; i < n - 1; i++)
64
+ edges.push([ring[i], ring[i + 1]]);
65
+ const [a, z] = [ring[0], ring[n - 1]];
66
+ if (a[0] !== z[0] || a[1] !== z[1])
67
+ edges.push([z, a]);
68
+ return edges;
69
+ }
70
+ function inRing(p, ring) {
71
+ let inside = false;
72
+ for (const [a, b] of ringEdges(ring)) {
73
+ if (a[1] > p[1] !== b[1] > p[1]) {
74
+ const x = ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0];
75
+ if (p[0] < x)
76
+ inside = !inside;
77
+ }
78
+ }
79
+ return inside;
80
+ }
81
+ function inPolygon(p, polygon) {
82
+ const [outer, ...holes] = polygon;
83
+ return !!outer && inRing(p, outer) && !holes.some((hole) => inRing(p, hole));
84
+ }
85
+ /** Inside an outer ring and outside its holes, for any polygon of the geometry. */
86
+ export function containsPoint(geometry, p) {
87
+ return polygonsOf(geometry)?.some((polygon) => inPolygon(p, polygon)) ?? false;
88
+ }
89
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
90
+ const onSegment = (p, a, b) => Math.min(a[0], b[0]) <= p[0] &&
91
+ p[0] <= Math.max(a[0], b[0]) &&
92
+ Math.min(a[1], b[1]) <= p[1] &&
93
+ p[1] <= Math.max(a[1], b[1]);
94
+ /** Whether segments ab and cd meet, touching and collinear overlaps included. */
95
+ function segmentsMeet(a, b, c, d) {
96
+ const d1 = cross(c, d, a);
97
+ const d2 = cross(c, d, b);
98
+ const d3 = cross(a, b, c);
99
+ const d4 = cross(a, b, d);
100
+ if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
101
+ return true;
102
+ return ((d1 === 0 && onSegment(a, c, d)) ||
103
+ (d2 === 0 && onSegment(b, c, d)) ||
104
+ (d3 === 0 && onSegment(c, a, b)) ||
105
+ (d4 === 0 && onSegment(d, a, b)));
106
+ }
107
+ function rectCorners(b) {
108
+ return [
109
+ [b.west, b.south],
110
+ [b.east, b.south],
111
+ [b.east, b.north],
112
+ [b.west, b.north]
113
+ ];
114
+ }
115
+ function segmentHitsRect(a, b, r) {
116
+ if (inRect(a, r) || inRect(b, r))
117
+ return true;
118
+ const c = rectCorners(r);
119
+ return c.some((p, i) => segmentsMeet(a, b, p, c[(i + 1) % 4]));
120
+ }
121
+ function polygonHitsRect(polygon, r) {
122
+ const edges = polygon.flatMap(ringEdges);
123
+ if (edges.length === 0)
124
+ return false;
125
+ if (edges.some(([a]) => inRect(a, r)))
126
+ return true;
127
+ if (rectCorners(r).some((p) => inPolygon(p, polygon)))
128
+ return true;
129
+ return edges.some(([a, b]) => segmentHitsRect(a, b, r));
130
+ }
131
+ /**
132
+ * Whether the item is in view: a shape (`geometry`) when it intersects `bounds` (bbox pre-check,
133
+ * then the exact test; holes count), anything else when its `lngLat` is inside. Edges included.
134
+ */
135
+ export function intersectsBounds(item, bounds) {
136
+ const { geometry } = item;
137
+ const polygons = geometry && polygonsOf(geometry);
138
+ const lines = geometry && linesOf(geometry);
139
+ if (!geometry || (!polygons && !lines && geometry.type !== "Point")) {
140
+ return inRect(item.lngLat, bounds);
141
+ }
142
+ if (geometry.type === "Point")
143
+ return inRect(geometry.coordinates, bounds);
144
+ const box = item.bbox ?? geometryBbox(geometry);
145
+ if (!box ||
146
+ box[0] > bounds.east ||
147
+ box[2] < bounds.west ||
148
+ box[1] > bounds.north ||
149
+ box[3] < bounds.south) {
150
+ return false;
151
+ }
152
+ if (polygons)
153
+ return polygons.some((polygon) => polygonHitsRect(polygon, bounds));
154
+ return lines.some((line) => line.some((p, i) => i === 0 ? line.length === 1 && inRect(p, bounds) : segmentHitsRect(line[i - 1], p, bounds)));
155
+ }
156
+ /** Sutherland–Hodgman: the ring clipped to the rectangle (open, no repeated first point). */
157
+ function clipRing(ring, r) {
158
+ let pts = ringEdges(ring).map(([a]) => [a[0], a[1]]);
159
+ const sides = [
160
+ [(p) => p[0] >= r.west, (a, b) => atX(a, b, r.west)],
161
+ [(p) => p[0] <= r.east, (a, b) => atX(a, b, r.east)],
162
+ [(p) => p[1] >= r.south, (a, b) => atY(a, b, r.south)],
163
+ [(p) => p[1] <= r.north, (a, b) => atY(a, b, r.north)]
164
+ ];
165
+ for (const [keep, cut] of sides) {
166
+ const out = [];
167
+ for (let i = 0; i < pts.length; i++) {
168
+ const cur = pts[i];
169
+ const prev = pts[(i + pts.length - 1) % pts.length];
170
+ if (keep(cur)) {
171
+ if (!keep(prev))
172
+ out.push(cut(prev, cur));
173
+ out.push(cur);
174
+ }
175
+ else if (keep(prev)) {
176
+ out.push(cut(prev, cur));
177
+ }
178
+ }
179
+ pts = out;
180
+ if (pts.length === 0)
181
+ break;
182
+ }
183
+ return pts;
184
+ }
185
+ const atX = (a, b, x) => [
186
+ x,
187
+ a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0])
188
+ ];
189
+ const atY = (a, b, y) => [
190
+ a[0] + ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]),
191
+ y
192
+ ];
193
+ function openArea(pts) {
194
+ let area = 0;
195
+ for (let i = 0; i < pts.length; i++) {
196
+ const [a, b] = [pts[i], pts[(i + 1) % pts.length]];
197
+ area += a[0] * b[1] - b[0] * a[1];
198
+ }
199
+ return Math.abs(area) / 2;
200
+ }
201
+ /**
202
+ * A point well inside the clipped polygon (outer ring, then holes): the midpoint of the widest
203
+ * interior span of a horizontal line through its middle. When that line has none (a C shape,
204
+ * bands around a hole), the widest span among lines between neighboring vertex heights.
205
+ */
206
+ function polygonInteriorPoint(rings) {
207
+ const ys = [...new Set(rings.flatMap((ring) => ring.map((p) => p[1])))].sort((a, b) => a - b);
208
+ if (ys.length < 2)
209
+ return null;
210
+ const middle = widestSpan(rings, ys, [(ys[0] + ys[ys.length - 1]) / 2]);
211
+ if (middle)
212
+ return middle;
213
+ const gaps = [];
214
+ for (let i = 1; i < ys.length; i++)
215
+ gaps.push((ys[i - 1] + ys[i]) / 2);
216
+ return widestSpan(rings, ys, gaps);
217
+ }
218
+ /** The midpoint of the widest interior span on any of the horizontal `lines`. */
219
+ function widestSpan(rings, ys, lines) {
220
+ let best = null;
221
+ let width = 0;
222
+ for (let y of lines) {
223
+ // On a vertex height the crossings double up; step off it.
224
+ if (ys.includes(y))
225
+ y += (ys[ys.length - 1] - ys[0]) * 1e-9;
226
+ const xs = [];
227
+ for (const ring of rings) {
228
+ for (let i = 0; i < ring.length; i++) {
229
+ const [a, b] = [ring[i], ring[(i + 1) % ring.length]];
230
+ if (a[1] > y !== b[1] > y)
231
+ xs.push(atY(a, b, y)[0]);
232
+ }
233
+ }
234
+ xs.sort((a, b) => a - b);
235
+ for (let i = 0; i + 1 < xs.length; i += 2) {
236
+ if (xs[i + 1] - xs[i] > width) {
237
+ width = xs[i + 1] - xs[i];
238
+ best = [(xs[i] + xs[i + 1]) / 2, y];
239
+ }
240
+ }
241
+ }
242
+ return best;
243
+ }
244
+ /** Liang–Barsky: the segment clipped to the rectangle, or `null` when it misses. */
245
+ function clipSegment(a, b, r) {
246
+ const dx = b[0] - a[0];
247
+ const dy = b[1] - a[1];
248
+ let t0 = 0;
249
+ let t1 = 1;
250
+ const checks = [
251
+ [-dx, a[0] - r.west],
252
+ [dx, r.east - a[0]],
253
+ [-dy, a[1] - r.south],
254
+ [dy, r.north - a[1]]
255
+ ];
256
+ for (const [p, q] of checks) {
257
+ if (p === 0) {
258
+ if (q < 0)
259
+ return null;
260
+ }
261
+ else {
262
+ const t = q / p;
263
+ if (p < 0)
264
+ t0 = Math.max(t0, t);
265
+ else
266
+ t1 = Math.min(t1, t);
267
+ if (t0 > t1)
268
+ return null;
269
+ }
270
+ }
271
+ return [
272
+ [a[0] + t0 * dx, a[1] + t0 * dy],
273
+ [a[0] + t1 * dx, a[1] + t1 * dy]
274
+ ];
275
+ }
276
+ /**
277
+ * A point of the shape inside `view`: `center` when the shape contains it; otherwise a point well
278
+ * inside the shape's visible part (polygons: the largest clipped one, never in a hole; lines: the
279
+ * middle of the longest visible piece); `null` when nothing of it is visible.
280
+ */
281
+ export function shapeAnchor(geometry, view, center) {
282
+ const polygons = polygonsOf(geometry);
283
+ if (polygons) {
284
+ if (inRect(center, view) && containsPoint(geometry, center))
285
+ return center;
286
+ // Visible parts, largest first; the first that yields a point wins.
287
+ const parts = [];
288
+ for (const polygon of polygons) {
289
+ if (!polygon[0] || ringEdges(polygon[0]).length === 0)
290
+ continue;
291
+ const rings = polygon.map((ring) => clipRing(ring, view));
292
+ const area = rings[0].length >= 3 ? openArea(rings[0]) : 0;
293
+ if (area > 0)
294
+ parts.push({ rings: rings.filter((r, i) => i === 0 || r.length >= 3), area });
295
+ }
296
+ parts.sort((a, b) => b.area - a.area);
297
+ for (const { rings } of parts) {
298
+ const at = polygonInteriorPoint(rings);
299
+ if (at)
300
+ return at;
301
+ }
302
+ return null;
303
+ }
304
+ const lines = linesOf(geometry);
305
+ if (lines) {
306
+ let best = null;
307
+ let length = -1;
308
+ for (const line of lines) {
309
+ for (let i = 1; i < line.length; i++) {
310
+ const piece = clipSegment(line[i - 1], line[i], view);
311
+ if (!piece)
312
+ continue;
313
+ const len = Math.hypot(piece[1][0] - piece[0][0], piece[1][1] - piece[0][1]);
314
+ if (len > length) {
315
+ length = len;
316
+ best = [(piece[0][0] + piece[1][0]) / 2, (piece[0][1] + piece[1][1]) / 2];
317
+ }
318
+ }
319
+ }
320
+ return best;
321
+ }
322
+ return null;
323
+ }
@@ -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. */
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export { legendSection } from "./ui/legend-data.js";
12
12
  export type { LegendBar as LegendBarData, LegendSection } from "./ui/legend-data.js";
13
13
  export { rowLabel } from "./ui/feature-list.js";
14
14
  export type { SortBy } from "./ui/feature-list.js";
15
+ export { geometryBbox, intersectsBounds } from "./core/shape-view.js";
16
+ export type { Bbox } from "./core/shape-view.js";
15
17
  export { pointsPlugin } from "./plugins/points.svelte.js";
16
18
  export type { ClusterInfo, ClusterLayerArgs, PointsOptions, PointsTooltipEnv } from "./plugins/points.svelte.js";
17
19
  export type { ClusterLayers } from "./plugins/points-clusters.js";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { default as ViewSummary } from "./ui/ViewSummary.svelte";
10
10
  export { default as MapControls } from "./ui/MapControls.svelte";
11
11
  export { legendSection } from "./ui/legend-data.js";
12
12
  export { rowLabel } from "./ui/feature-list.js";
13
+ export { geometryBbox, intersectsBounds } from "./core/shape-view.js";
13
14
  export { pointsPlugin } from "./plugins/points.svelte.js";
14
15
  export { colorExpression, levelKeyExpression, opacityExpression } from "./plugins/style.js";
15
16
  export { shapesPlugin } from "./plugins/shapes.svelte.js";
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { geometryBbox } from "../core/shape-view.js";
1
2
  import { untrack } from "svelte";
2
3
  import { decodeRef, selectionId } from "../data/refs.js";
3
4
  import { levelFor, NO_LEVEL } from "../data/scale.js";
@@ -88,6 +89,17 @@ export function shapesPlugin(options) {
88
89
  /** Plain mode ignores the source's scale. */
89
90
  const currentScale = () => (choropleth ? scales.current() : NO_SCALE);
90
91
  const legend = () => (choropleth ? scales.legend() : null);
92
+ // Each feature's label point and bbox, measured once per feature object (once per data load):
93
+ // `features()` runs on every move. Per plugin instance, so two maps share nothing.
94
+ const measured = new WeakMap();
95
+ const measure = (feature) => {
96
+ let m = measured.get(feature);
97
+ if (!m) {
98
+ m = { lngLat: labelPoint(feature.geometry), bbox: geometryBbox(feature.geometry) };
99
+ measured.set(feature, m);
100
+ }
101
+ return m;
102
+ };
91
103
  function features() {
92
104
  const collection = source.features;
93
105
  if (!collection)
@@ -101,8 +113,10 @@ export function shapesPlugin(options) {
101
113
  if (!props || !feature.geometry)
102
114
  continue;
103
115
  const ref = decodeRef(props.refKey);
104
- const lngLat = labelPoint(feature.geometry);
105
- if (!ref || !lngLat)
116
+ if (!ref)
117
+ continue;
118
+ const { lngLat, bbox } = measure(feature);
119
+ if (!lngLat)
106
120
  continue;
107
121
  items.push({
108
122
  refKey: props.refKey,
@@ -113,6 +127,8 @@ export function shapesPlugin(options) {
113
127
  units: scale?.units ?? "",
114
128
  precision,
115
129
  lngLat,
130
+ geometry: feature.geometry,
131
+ ...(bbox && { bbox }),
116
132
  selectable: selectable && selectionId(ref) !== null
117
133
  });
118
134
  }
@@ -303,5 +319,7 @@ export function shapesPlugin(options) {
303
319
  };
304
320
  if (options.listed ?? true)
305
321
  plugin.features = features;
322
+ if (options.hoverable === false)
323
+ plugin.hoverable = false;
306
324
  return plugin;
307
325
  }
@@ -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 {};