@sjvair/map-sdk 0.1.0-beta.1 → 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 (52) hide show
  1. package/README.md +243 -17
  2. package/dist/core/create-map.svelte.d.ts +72 -5
  3. package/dist/core/create-map.svelte.js +229 -14
  4. package/dist/core/gl.d.ts +5 -0
  5. package/dist/core/interactions.svelte.d.ts +11 -3
  6. package/dist/core/interactions.svelte.js +128 -11
  7. package/dist/core/plugin-host.svelte.d.ts +30 -5
  8. package/dist/core/plugin-host.svelte.js +93 -14
  9. package/dist/core/plugin.d.ts +44 -2
  10. package/dist/core/shape-view.d.ts +27 -0
  11. package/dist/core/shape-view.js +323 -0
  12. package/dist/data/format.d.ts +1 -1
  13. package/dist/data/format.js +16 -3
  14. package/dist/data/messages/en.json +12 -2
  15. package/dist/data/selection.d.ts +6 -0
  16. package/dist/data/selection.js +21 -0
  17. package/dist/data/types.d.ts +5 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +1 -0
  20. package/dist/plugins/points.svelte.d.ts +14 -2
  21. package/dist/plugins/points.svelte.js +34 -5
  22. package/dist/plugins/shapes.svelte.d.ts +5 -0
  23. package/dist/plugins/shapes.svelte.js +20 -2
  24. package/dist/presets/FireTooltip.svelte +70 -0
  25. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  26. package/dist/presets/MonitorClusterTooltip.svelte +21 -2
  27. package/dist/presets/cluster-area.d.ts +13 -0
  28. package/dist/presets/cluster-area.js +34 -0
  29. package/dist/presets/hms-fire.d.ts +11 -7
  30. package/dist/presets/hms-fire.js +16 -17
  31. package/dist/presets/hms-smoke.d.ts +2 -1
  32. package/dist/presets/hms-smoke.js +4 -2
  33. package/dist/stores/hms-stores.svelte.d.ts +6 -3
  34. package/dist/stores/hms-stores.svelte.js +4 -4
  35. package/dist/ui/FeatureList.svelte +17 -17
  36. package/dist/ui/Legend.svelte +58 -16
  37. package/dist/ui/Legend.svelte.d.ts +9 -0
  38. package/dist/ui/MapTooltip.svelte +253 -14
  39. package/dist/ui/MapTooltip.svelte.d.ts +9 -0
  40. package/dist/ui/PickNotice.svelte +56 -0
  41. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  42. package/dist/ui/TooltipItem.svelte +12 -12
  43. package/dist/ui/card.d.ts +29 -0
  44. package/dist/ui/card.js +1 -0
  45. package/dist/ui/feature-list.d.ts +17 -2
  46. package/dist/ui/feature-list.js +45 -10
  47. package/dist/ui/value-format.d.ts +3 -2
  48. package/dist/ui/value-format.js +9 -2
  49. package/dist/view/MapView.svelte +72 -2
  50. package/dist/view/MapView.svelte.d.ts +8 -0
  51. package/dist/view/create-gl-map.js +4 -0
  52. 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();
@@ -8,6 +8,8 @@
8
8
  "mapsdk_list_row": "{name}, {value} {units}, {level}",
9
9
  "mapsdk_list_row_no_units": "{name}, {value}, {level}",
10
10
  "mapsdk_list_row_no_value": "{name}, no reading",
11
+ "mapsdk_list_row_level": "{name}, {level}",
12
+ "mapsdk_list_row_detail": "{name}, {detail}",
11
13
  "mapsdk_list_empty": "Nothing on the map in this view",
12
14
  "mapsdk_selected_count_one": "{count} selected",
13
15
  "mapsdk_selected_count_other": "{count} selected",
@@ -37,15 +39,23 @@
37
39
  "mapsdk_monitor_cluster_title_sjvair": "SJVAir {type} Monitors",
38
40
  "mapsdk_monitor_cluster_count_one": "{count} monitor",
39
41
  "mapsdk_monitor_cluster_count_other": "{count} monitors",
42
+ "mapsdk_monitor_cluster_count_area_one": "{count} monitor within a {area} mi² area",
43
+ "mapsdk_monitor_cluster_count_area_other": "{count} monitors within a {area} mi² area",
40
44
  "mapsdk_monitor_cluster_max": "Max: {value} {units}",
41
45
  "mapsdk_monitor_cluster_min": "Min: {value} {units}",
42
46
  "mapsdk_monitor_interval_avg": "({interval} avg)",
43
47
  "mapsdk_monitor_updated": "Last updated {when}",
44
48
  "mapsdk_hms_smoke_name": "Smoke",
45
49
  "mapsdk_hms_fire_detection": "Fire detection",
46
- "mapsdk_hms_fire_detections": "{count} fire detections",
50
+ "mapsdk_hms_fire_box_header": "Fire ({units})",
51
+ "mapsdk_hms_fire_box_header_no_units": "Fire",
52
+ "mapsdk_hms_fire_average": "Average",
53
+ "mapsdk_level_with_units": "{level} {units}",
54
+ "mapsdk_hms_fire_detections_one": "{count} fire detection",
55
+ "mapsdk_hms_fire_detections_other": "{count} fire detections",
47
56
  "mapsdk_collocation_reference": "Reference monitor",
48
57
  "mapsdk_collocation_colocated": "Collocated monitor",
49
58
  "mapsdk_collocation_interval_avg": "({interval} avg)",
50
- "mapsdk_collocation_updated": "Last updated {when}"
59
+ "mapsdk_collocation_updated": "Last updated {when}",
60
+ "mapsdk_card_close": "Close"
51
61
  }
@@ -4,4 +4,10 @@ export interface PickGesture {
4
4
  }
5
5
  export declare function selectionKind(ref: FeatureRef): "monitors" | "regions" | null;
6
6
  export declare function applyPick(current: SpatialSelection | null, ref: FeatureRef, mode: SelectionMode, gesture: PickGesture): SpatialSelection | null;
7
+ /**
8
+ * A modifier-click (or a card's Select/Remove): toggles `ref`. In `single` mode it is the
9
+ * selection, or `null` when it already is; in `multiple` mode it is added or removed (another
10
+ * kind starts a new selection, and removing the last id gives `null`).
11
+ */
12
+ export declare function toggleInSelection(current: SpatialSelection | null, ref: FeatureRef, mode: SelectionMode): SpatialSelection | null;
7
13
  export declare function selectionKeys(sel: SpatialSelection | null): Set<string>;
@@ -23,6 +23,27 @@ export function applyPick(current, ref, mode, gesture) {
23
23
  }
24
24
  return { kind, ids: [...current.ids, id] };
25
25
  }
26
+ /**
27
+ * A modifier-click (or a card's Select/Remove): toggles `ref`. In `single` mode it is the
28
+ * selection, or `null` when it already is; in `multiple` mode it is added or removed (another
29
+ * kind starts a new selection, and removing the last id gives `null`).
30
+ */
31
+ export function toggleInSelection(current, ref, mode) {
32
+ const kind = selectionKind(ref);
33
+ const id = selectionId(ref);
34
+ if (mode === "none" || kind === null || id === null)
35
+ return current;
36
+ const has = current?.kind === kind && current.ids.includes(id);
37
+ if (mode === "single")
38
+ return has ? null : { kind, ids: [id] };
39
+ if (!current || current.kind !== kind)
40
+ return { kind, ids: [id] };
41
+ if (has) {
42
+ const ids = current.ids.filter((x) => x !== id);
43
+ return ids.length ? { kind, ids } : null;
44
+ }
45
+ return { kind, ids: [...current.ids, id] };
46
+ }
26
47
  export function selectionKeys(sel) {
27
48
  if (!sel)
28
49
  return new Set();
@@ -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";
@@ -54,3 +56,4 @@ export type { FireDetection, FireGroup } from "./presets/fire-groups.js";
54
56
  export { collocationSitesPlugin, COLLOCATION_CROSSHAIR_COLOR } from "./presets/collocation-sites.js";
55
57
  export type { CollocationSitesPluginOptions } from "./presets/collocation-sites.js";
56
58
  export { circle, CROSSHAIR_ICON_SIZE, square, triangle, withCrosshair } from "./plugins/icons.js";
59
+ export type { CardContext, CardOptions } from "./ui/card.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";
@@ -60,8 +60,9 @@ export interface PointsOptions<P extends MapFeatureProperties> {
60
60
  sortKey?(props: P): number;
61
61
  /**
62
62
  * Limits every layer the plugin draws (points, clusters, selection) to a zoom range: visible
63
- * from `min` (inclusive) up to `max` (exclusive). Set on the layers, so it switches live while
64
- * zooming. Default: all zooms.
63
+ * from `min` (inclusive) up to `max` (exclusive) of the camera zoom, switching live while
64
+ * zooming (layer visibility, not layer zoom ranges, so ranges that meet leave no gap on a
65
+ * globe). Default: all zooms.
65
66
  */
66
67
  zoomRange?: {
67
68
  min?: number;
@@ -75,6 +76,17 @@ export interface PointsOptions<P extends MapFeatureProperties> {
75
76
  * it loads.
76
77
  */
77
78
  legendTitle?: "layer" | "entry-type";
79
+ /**
80
+ * A short text for a feature with no level (e.g. "Level 2 charger"), shown in the feature list,
81
+ * its accessible name, and the generic tooltip where "No level" would be. Ignored for a
82
+ * feature with a level.
83
+ */
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;
78
90
  /** Whether `features()` lists this plugin's features. Default true. */
79
91
  listed?: boolean;
80
92
  /** Tooltip content for a feature, below its name; `props` are its source properties. */
@@ -93,6 +93,7 @@ export function pointsPlugin(options) {
93
93
  if (!ref)
94
94
  continue;
95
95
  const [lng, lat] = geometry.coordinates;
96
+ const detail = options.detail?.(props)?.trim();
96
97
  items.push({
97
98
  refKey: props.refKey,
98
99
  ref,
@@ -102,7 +103,9 @@ export function pointsPlugin(options) {
102
103
  units: scale?.units ?? "",
103
104
  precision,
104
105
  lngLat: [lng, lat],
105
- selectable: selectable && selectionId(ref) !== null
106
+ selectable: selectable && selectionId(ref) !== null,
107
+ ...(detail ? { detail } : {}),
108
+ ...(options.truncateValues ? { truncate: true } : {})
106
109
  });
107
110
  }
108
111
  return items;
@@ -209,15 +212,39 @@ export function pointsPlugin(options) {
209
212
  }
210
213
  return spec;
211
214
  }
212
- /** The spec limited to the plugin's zoom range. */
215
+ // The zoom range shows and hides the layers by the camera zoom, not with layer
216
+ // minzoom/maxzoom: on a globe MapLibre uses the next zoom's tiles a little before the camera
217
+ // gets there and leaves a layer out of tiles at or above its maxzoom, so two plugins whose
218
+ // ranges meet (fire groups below 10, hotspots from 10) would leave a gap where neither drew.
219
+ const { min, max } = options.zoomRange ?? {};
220
+ const ranged = min !== undefined || max !== undefined;
221
+ const inRange = () => {
222
+ const zoom = gl.getZoom();
223
+ return (min === undefined || zoom >= min) && (max === undefined || zoom < max);
224
+ };
225
+ let shown = ranged ? inRange() : true;
226
+ /** The spec, hidden while the camera is outside the plugin's zoom range. */
213
227
  function zoomed(spec) {
214
- const { min, max } = options.zoomRange ?? {};
228
+ if (!ranged)
229
+ return spec;
230
+ const layout = spec.layout;
215
231
  return {
216
232
  ...spec,
217
- ...(min !== undefined ? { minzoom: min } : {}),
218
- ...(max !== undefined ? { maxzoom: max } : {})
233
+ layout: { ...layout, visibility: shown ? "visible" : "none" }
219
234
  };
220
235
  }
236
+ const onZoom = () => {
237
+ if (dead || inRange() === shown)
238
+ return;
239
+ shown = !shown;
240
+ for (const layerId of layers) {
241
+ if (gl.getLayer(layerId)) {
242
+ gl.setLayoutProperty(layerId, "visibility", shown ? "visible" : "none");
243
+ }
244
+ }
245
+ };
246
+ if (ranged)
247
+ gl.on("zoom", onZoom);
221
248
  function removeLayers() {
222
249
  for (const layerId of layers.toReversed()) {
223
250
  if (gl.getLayer(layerId))
@@ -447,6 +474,8 @@ export function pointsPlugin(options) {
447
474
  });
448
475
  return () => {
449
476
  dead = true;
477
+ if (ranged)
478
+ gl.off("zoom", onZoom);
450
479
  providers = null;
451
480
  live = null;
452
481
  };
@@ -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
  }