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

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
@@ -374,6 +374,19 @@ function sitesPlugin(sites: Sites): MapPlugin {
374
374
  }
375
375
  ```
376
376
 
377
+ An item for a shape (a polygon or line) can carry its `geometry` and, optionally, its `bbox`
378
+ (`[west, south, east, north]`, from `geometryBbox(geometry)`). The list and the summary then count
379
+ it as in view when the shape overlaps the view, not only when its `lngLat` is inside it: a smoke
380
+ plume that fills the screen is listed even though its center is off-screen. Measure `bbox` once per
381
+ data load (both are tested on every move); without it the box is computed each time. Focusing such
382
+ a shape from the list while its `lngLat` is off-screen leaves the camera where it is, and its
383
+ tooltip pins at a point of the shape in view: the view's center (beside the list panel) when the
384
+ shape covers it, else a point inside its visible part. `map.focusedAnchor` is where the focused
385
+ tooltip pins, and `map.anchorFor(item)` gives that point for any item without moving the camera
386
+ (the hover tooltip uses it). `intersectsBounds(item, bounds)` is the in-view test. `shapesPlugin`
387
+ and the presets built on it set `geometry` and `bbox` already. Bounds across the antimeridian are
388
+ not handled.
389
+
377
390
  A plugin can also add a MapLibre custom layer, such as a wind layer from a library you add
378
391
  yourself, with `ctx.addCustomLayer(local, create)`. The host calls `create` with the namespaced id
379
392
  (the layer must use it) and adds the object it returns as is, in the plugin's draw-order slot. It is
@@ -440,7 +453,8 @@ and `getSelectionMode(id)` are reactive.
440
453
 
441
454
  `map.on("featureclick", ({ pluginId, ref, lngLat }) => …)` fires for every click on a feature,
442
455
  whatever the mode, and also for a pick in the feature list, so keyboard users get the same event;
443
- `lngLat` is the click point (the item's position for a list pick). It returns an unsubscribe
456
+ `lngLat` is the click point. For a list pick it's where the item's tooltip pins: the item's
457
+ position, or for a shape whose label point is off-screen, a point of the shape in view. It returns an unsubscribe
444
458
  function. For your own context menu, `map.featuresAt({ x, y })` returns the hits under
445
459
  a canvas point, topmost first, and `map.featureForElement(element)` returns the hit for a row of
446
460
  the feature list (an element with `data-mapsdk-ref`). Both return nothing while the map is
@@ -534,6 +548,36 @@ local sjvair.com stack at `http://localhost:8000`.
534
548
 
535
549
  ## Changes
536
550
 
551
+ ### 0.1.0-beta.6
552
+
553
+ - **A category reading is shown once.** When a feature's value is its category level's code, the
554
+ list row, the tooltip, and the row's accessible name show only the level label. Smoke rows read
555
+ "Smoke, Light" (was "light Light"), burn status "Burning Discouraged", and heat score
556
+ "Moderate" (the 0–4 number is no longer shown). Number scales such as PM2.5 and fire power are
557
+ unchanged, and a category value that matches no level is still shown.
558
+ - **New message key for hosts with their own catalog:** `mapsdk_list_row_level`
559
+ (`"{name}, {level}"`).
560
+
561
+ ### 0.1.0-beta.5
562
+
563
+ - **Shapes that cover the view are in view.** The feature list and the view summary count a shape
564
+ when it overlaps the view, not only when its center (label point) is inside it. A smoke plume or
565
+ a big region that fills the screen, with its center off-screen, now has a list row and is
566
+ counted. Points are unchanged.
567
+ - **Focusing such a shape doesn't move the map.** Before, focusing a row panned to center the
568
+ shape's label point (a plume's could be off Baja). Now the camera stays, and the tooltip pins at
569
+ a point of the shape in view, with no move event. A shape whose label point is on screen still
570
+ pans to it.
571
+ - **Hover tooltips** in `"pinned"` mode follow the same rule: a hovered shape whose label point is
572
+ off-screen pins its tooltip inside the view instead of at the map's edge. If the map moves while
573
+ the pointer rests on the shape, the pin is found again when the map settles.
574
+ - **`featureclick` from the list** (Enter on a row) reports that same in-view point for such a
575
+ shape, instead of its off-screen label point. A handler that pans to `lngLat` no longer sends
576
+ the map off to the shape's far-away center. Points and on-screen label points report their
577
+ position, as before.
578
+ - **New API (all optional):** `FeatureListItem.geometry` and `.bbox`, `MapContext.focusedAnchor`
579
+ and `.anchorFor(item)`, `geometryBbox`, `intersectsBounds`, and the `Bbox` type.
580
+
537
581
  ### 0.1.0-beta.4
538
582
 
539
583
  Defaults from monitor-map 5.0's parity review against sjvair.com's map (4.3.0). Every map gets
@@ -5,7 +5,7 @@ import type { FeatureRef, SelectionMode, SpatialSelection } from "../data/types.
5
5
  import { BasemapState, type BasemapConfig } from "./basemap.svelte.js";
6
6
  import type { GlMapLike } from "./gl.js";
7
7
  import { type Hovered } from "./interactions.svelte.js";
8
- import type { FeatureHit, MapPlugin } from "./plugin.js";
8
+ import type { FeatureHit, FeatureListItem, MapPlugin } from "./plugin.js";
9
9
  export interface CameraState {
10
10
  center: [number, number];
11
11
  zoom: number;
@@ -52,7 +52,10 @@ export interface PluginOptions {
52
52
  export type FeatureClickHandler = (e: {
53
53
  pluginId: string;
54
54
  ref: FeatureRef;
55
- /** The click point; for a pick in the feature list, the item's position. */
55
+ /**
56
+ * The click point. For a pick in the feature list, where the item's tooltip pins: its position,
57
+ * or for a shape whose label point is off-screen, a point of the shape in view.
58
+ */
56
59
  lngLat: [number, number];
57
60
  }) => void;
58
61
  /** Where a camera move ends up. `zoom` wins over `minZoom`. */
@@ -185,6 +188,18 @@ export interface MapContext {
185
188
  readonly focusedKey: string | null;
186
189
  /** Reactive. The plugin `focusFeature` was given with `focusedKey`, or `null`. */
187
190
  readonly focusedPluginId: string | null;
191
+ /**
192
+ * Reactive. Where the focused feature's tooltip pins, set by `focusFeature` (`null` without
193
+ * focus): its `lngLat`, or for a shape that overlaps the view with its `lngLat` off-screen, a
194
+ * point of the shape in the part of the view the list panel leaves clear. Fixed at focus
195
+ * time, so it stays on the feature if the map then moves.
196
+ */
197
+ readonly focusedAnchor: [number, number] | null;
198
+ /**
199
+ * Where `item`'s tooltip pins in the current view, without moving the camera: its `lngLat`,
200
+ * or for a shape (`geometry`) with its `lngLat` off-screen, a point of the shape in view.
201
+ */
202
+ anchorFor(item: FeatureListItem): [number, number];
188
203
  /** For MapView only: uses `gl` from now on. */
189
204
  attach(gl: GlMapLike): void;
190
205
  /** For MapView only: stops using the current GL map. */
@@ -7,6 +7,7 @@ import { applyPick, selectionKeys } from "../data/selection.js";
7
7
  import { BasemapState } from "./basemap.svelte.js";
8
8
  import { InteractionRouter } from "./interactions.svelte.js";
9
9
  import { PluginHost } from "./plugin-host.svelte.js";
10
+ import { intersectsBounds, shapeAnchor } from "./shape-view.js";
10
11
  /** Monitor-map's start view. */
11
12
  const DEFAULT_VIEW = {
12
13
  center: [-119.7987626619462, 36.76272050981146],
@@ -36,6 +37,7 @@ function sameSelection(a, b) {
36
37
  return a === b;
37
38
  return (a.kind === b.kind && a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]));
38
39
  }
40
+ const inRect = (p, b) => p[0] >= b.west && p[0] <= b.east && p[1] >= b.south && p[1] <= b.north;
39
41
  function prefersReducedMotion() {
40
42
  return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
41
43
  }
@@ -55,6 +57,7 @@ class MapContextImpl {
55
57
  #glState = $state.raw(null);
56
58
  #focusedKey = $state(null);
57
59
  #focusedPluginId = $state(null);
60
+ #focusedAnchor = $state.raw(null);
58
61
  #selections = new SvelteMap();
59
62
  #featureClickHandlers = new Set();
60
63
  #router = new InteractionRouter();
@@ -63,6 +66,11 @@ class MapContextImpl {
63
66
  #destroyed = false;
64
67
  #listInset = 0;
65
68
  #listInsetSide = "left";
69
+ /** An animated ease that `focusFeature` started is still running (cleared on `moveend`). */
70
+ #focusEasing = false;
71
+ #onMoveEnd = () => {
72
+ this.#focusEasing = false;
73
+ };
66
74
  constructor(options) {
67
75
  const initialView = copyView(options.config.initialView ?? DEFAULT_VIEW);
68
76
  this.config = {
@@ -123,6 +131,9 @@ class MapContextImpl {
123
131
  get focusedPluginId() {
124
132
  return this.#focusedPluginId;
125
133
  }
134
+ get focusedAnchor() {
135
+ return this.#focusedAnchor;
136
+ }
126
137
  get hovered() {
127
138
  return this.#router.hovered;
128
139
  }
@@ -178,9 +189,15 @@ class MapContextImpl {
178
189
  const found = untrack(() => this.#findItem(refKey, pluginId));
179
190
  if (!found)
180
191
  return;
181
- this.#emitClick(pluginId, found.item.ref, found.item.lngLat);
182
- if (found.item.selectable)
183
- this.#applyPick(pluginId, found.item.ref, additive);
192
+ const { item } = found;
193
+ // Where the row's tooltip pins: the focused anchor for the focused row, else a point of a
194
+ // shape in view (a point's own position for points and on-screen label points).
195
+ const focused = untrack(() => this.#focusedKey) === refKey &&
196
+ (untrack(() => this.#focusedPluginId) ?? pluginId) === pluginId;
197
+ const anchor = (focused && untrack(() => this.#focusedAnchor)) || this.anchorFor(item);
198
+ this.#emitClick(pluginId, item.ref, anchor);
199
+ if (item.selectable)
200
+ this.#applyPick(pluginId, item.ref, additive);
184
201
  }
185
202
  clearSelection(pluginId) {
186
203
  if (this.#destroyed)
@@ -225,19 +242,101 @@ class MapContextImpl {
225
242
  return;
226
243
  this.#focusedKey = refKey;
227
244
  this.#focusedPluginId = refKey === null ? null : (pluginId ?? null);
245
+ this.#focusedAnchor = null;
228
246
  const gl = this.#gl;
229
247
  if (!gl || refKey === null)
230
248
  return;
231
249
  const found = untrack(() => this.#findItem(refKey, pluginId));
232
250
  if (!found)
233
251
  return;
252
+ const { item } = found;
253
+ const place = this.#placeFor(item, gl);
254
+ this.#focusedAnchor = place.anchor;
255
+ if (!place.pan) {
256
+ // Stays where it is now: an earlier focus's ease that is still running stops here.
257
+ // With nothing running, no move at all.
258
+ if (this.#focusEasing) {
259
+ const { lng, lat } = gl.getCenter();
260
+ gl.easeTo({ center: [lng, lat], duration: 0 });
261
+ this.#focusEasing = false;
262
+ }
263
+ return;
264
+ }
265
+ const reduce = prefersReducedMotion();
234
266
  gl.easeTo({
235
- center: found.item.lngLat,
267
+ center: place.anchor,
236
268
  // The middle of the part the panel leaves clear. An offset, not padding: MapLibre keeps
237
269
  // padding for later moves (zooms, the reported center), an offset applies to this one.
238
270
  ...(this.#listInset > 0 && { offset: [this.#listOffsetX(), 0] }),
239
- ...(prefersReducedMotion() && { duration: 0 })
271
+ ...(reduce && { duration: 0 })
240
272
  });
273
+ // Set after the call: stopping an earlier move fires its `moveend` inside `easeTo`.
274
+ this.#focusEasing = !reduce;
275
+ }
276
+ anchorFor(item) {
277
+ const gl = this.#gl;
278
+ if (!gl || !item.geometry)
279
+ return item.lngLat;
280
+ const { live, clear, liveCenter, clearCenter } = this.#views(gl);
281
+ if (inRect(item.lngLat, live) || !intersectsBounds(item, live))
282
+ return item.lngLat;
283
+ return (shapeAnchor(item.geometry, clear, clearCenter) ??
284
+ shapeAnchor(item.geometry, live, liveCenter) ??
285
+ item.lngLat);
286
+ }
287
+ /** The live view, and the part of it the list panel leaves clear, with their centers. */
288
+ #views(gl) {
289
+ const b = gl.getBounds();
290
+ const live = {
291
+ west: b.getWest(),
292
+ south: b.getSouth(),
293
+ east: b.getEast(),
294
+ north: b.getNorth()
295
+ };
296
+ const center = gl.getCenter();
297
+ const width = untrack(() => gl.getCanvas().clientWidth) || 0;
298
+ const share = width > 0 ? Math.min(Math.max(this.#listInset / width, 0), 0.99) : 0;
299
+ const cut = share * (live.east - live.west);
300
+ const clear = this.#listInsetSide === "left"
301
+ ? { ...live, west: live.west + cut }
302
+ : { ...live, east: live.east - cut };
303
+ return {
304
+ live,
305
+ clear,
306
+ liveCenter: [center.lng, center.lat],
307
+ clearCenter: [(clear.west + clear.east) / 2, center.lat]
308
+ };
309
+ }
310
+ /**
311
+ * Where focus puts `item`: `pan: false` keeps the camera (a shape in view whose `lngLat` is
312
+ * off-screen, with a point in the clear part); otherwise ease to center `anchor`.
313
+ */
314
+ #placeFor(item, gl) {
315
+ const { geometry, lngLat } = item;
316
+ if (!geometry)
317
+ return { anchor: lngLat, pan: true };
318
+ const { live, clear, liveCenter, clearCenter } = this.#views(gl);
319
+ if (inRect(lngLat, live))
320
+ return { anchor: lngLat, pan: true };
321
+ if (intersectsBounds(item, live)) {
322
+ const inClear = shapeAnchor(geometry, clear, clearCenter);
323
+ if (inClear)
324
+ return { anchor: inClear, pan: false };
325
+ const inLive = shapeAnchor(geometry, live, liveCenter);
326
+ if (inLive)
327
+ return { anchor: inLive, pan: true };
328
+ }
329
+ const settled = untrack(() => this.#bounds);
330
+ if (settled && intersectsBounds(item, settled)) {
331
+ const middle = [
332
+ (settled.west + settled.east) / 2,
333
+ (settled.south + settled.north) / 2
334
+ ];
335
+ const inSettled = shapeAnchor(geometry, settled, middle);
336
+ if (inSettled)
337
+ return { anchor: inSettled, pan: true };
338
+ }
339
+ return { anchor: lngLat, pan: true };
241
340
  }
242
341
  #cameraOptions(target, gl) {
243
342
  const zoom = target.zoom ??
@@ -281,6 +380,7 @@ class MapContextImpl {
281
380
  this.#glState = gl;
282
381
  this.#host.attach(gl);
283
382
  this.#router.attach(gl);
383
+ gl.on("moveend", this.#onMoveEnd);
284
384
  this.basemap.attach(gl, () => this.#host.restyle(), () => {
285
385
  this.#ready = false;
286
386
  this.#readyRequested = false;
@@ -291,6 +391,8 @@ class MapContextImpl {
291
391
  detach() {
292
392
  if (!this.#gl)
293
393
  return;
394
+ this.#gl.off("moveend", this.#onMoveEnd);
395
+ this.#focusEasing = false;
294
396
  this.#router.detach();
295
397
  this.basemap.detach();
296
398
  this.#host.detach();
@@ -326,6 +428,7 @@ class MapContextImpl {
326
428
  this.#featureClickHandlers.clear();
327
429
  this.#focusedKey = null;
328
430
  this.#focusedPluginId = null;
431
+ this.#focusedAnchor = null;
329
432
  this.#destroyed = true;
330
433
  }
331
434
  /** A click on a feature: report it, then apply it to the top hit's plugin's selection. */
@@ -53,6 +53,19 @@ export interface FeatureListItem {
53
53
  */
54
54
  truncate?: boolean;
55
55
  lngLat: [number, number];
56
+ /**
57
+ * The feature's shape. When given, the item is in view (the feature list, the view summary)
58
+ * when the shape overlaps the view, not only when `lngLat` is inside it, and focusing or
59
+ * hovering it while `lngLat` is off-screen pins its tooltip at a point of the shape in view
60
+ * (focus then leaves the camera where it is).
61
+ */
62
+ geometry?: Geometry;
63
+ /**
64
+ * `geometry`'s bounding box, `[west, south, east, north]` (`geometryBbox`). Optional; computed
65
+ * from `geometry` when missing. A plugin listing many shapes should measure it once per data
66
+ * load, since the list and summary test every item on every move.
67
+ */
68
+ bbox?: [number, number, number, number];
56
69
  selectable: boolean;
57
70
  /**
58
71
  * A short text about a feature with no level (e.g. "Level 2 charger"), shown where its level
@@ -0,0 +1,27 @@
1
+ import type { Geometry } from "geojson";
2
+ import type { Bounds } from "./create-map.svelte.js";
3
+ /**
4
+ * Shapes against the view: a geometry's bounding box, whether it overlaps the view, and a point of
5
+ * it inside the view. Coordinates are planar lng/lat (as `labelPoint`); no antimeridian handling.
6
+ */
7
+ /** `[west, south, east, north]`. */
8
+ export type Bbox = [west: number, south: number, east: number, north: number];
9
+ /** The geometry's bounding box, or `null` when it has no coordinates. */
10
+ export declare function geometryBbox(geometry: Geometry): Bbox | null;
11
+ /** Inside an outer ring and outside its holes, for any polygon of the geometry. */
12
+ export declare function containsPoint(geometry: Geometry, p: [number, number]): boolean;
13
+ /**
14
+ * Whether the item is in view: a shape (`geometry`) when it intersects `bounds` (bbox pre-check,
15
+ * then the exact test; holes count), anything else when its `lngLat` is inside. Edges included.
16
+ */
17
+ export declare function intersectsBounds(item: {
18
+ lngLat: [number, number];
19
+ geometry?: Geometry;
20
+ bbox?: Bbox;
21
+ }, bounds: Bounds): boolean;
22
+ /**
23
+ * A point of the shape inside `view`: `center` when the shape contains it; otherwise a point well
24
+ * inside the shape's visible part (polygons: the largest clipped one, never in a hole; lines: the
25
+ * middle of the longest visible piece); `null` when nothing of it is visible.
26
+ */
27
+ export declare function shapeAnchor(geometry: Geometry, view: Bounds, center: [number, number]): [number, number] | null;
@@ -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
+ }
@@ -8,6 +8,7 @@
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}",
11
12
  "mapsdk_list_row_detail": "{name}, {detail}",
12
13
  "mapsdk_list_empty": "Nothing on the map in this view",
13
14
  "mapsdk_selected_count_one": "{count} selected",
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";
@@ -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
  }
@@ -11,11 +11,11 @@
11
11
  rowLabel,
12
12
  levelText,
13
13
  shownLevel,
14
+ shownValue,
14
15
  sortItems,
15
16
  typeaheadIndex,
16
17
  type SortBy
17
18
  } from "./feature-list.js";
18
- import { formatValue as formatNumber } from "./value-format.js";
19
19
 
20
20
  interface Props {
21
21
  map: MapContext;
@@ -267,13 +267,6 @@
267
267
  pick(rows[index], e.shiftKey || e.metaKey || e.ctrlKey);
268
268
  }
269
269
 
270
- function formatValue(row: Row): string {
271
- if (row.value === null) return "";
272
- return typeof row.value === "number"
273
- ? formatNumber(row.value, row.precision, map.format, row.truncate)
274
- : row.value;
275
- }
276
-
277
270
  onDestroy(() => {
278
271
  clearTimeout(typeaheadTimer);
279
272
  clearTimeout(menuSentTimer);
@@ -332,6 +325,7 @@
332
325
  {@const index = range.start + i}
333
326
  {@const level = shownLevel(row)}
334
327
  {@const levelPlace = levelText(row, map.messages)}
328
+ {@const value = shownValue(row, map.format)}
335
329
  {@const isOn = isSelected(row)}
336
330
  <!-- Options aren't focused: the listbox holds focus and handles keys (aria-activedescendant). -->
337
331
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->
@@ -359,7 +353,7 @@
359
353
  </span>
360
354
  <span class="mapsdk-list-name">{row.name}</span>
361
355
  <span class="mapsdk-list-value">
362
- {formatValue(row)}{#if row.units && row.value !== null}&nbsp;{row.units}{/if}
356
+ {value ?? ""}{#if row.units && value !== null}&nbsp;{row.units}{/if}
363
357
  </span>
364
358
  <span class="mapsdk-list-level text-xs">
365
359
  {#if levelPlace.kind !== "detail"}
@@ -70,11 +70,24 @@
70
70
  // A found item anchors the tooltip at its point (stable while the pointer moves over it).
71
71
  // In pointer mode a hovered hit is anchored at the pointer instead (a focused item stays
72
72
  // pinned).
73
- const anchor = $derived(
74
- mode === "pointer" && !map.focusedKey
75
- ? (map.hovered?.lngLat ?? null)
76
- : (item?.lngLat ?? map.hovered?.lngLat ?? null)
73
+ // A hovered shape whose point is off-screen pins at a point of it in view, found once per
74
+ // hovered feature (keyed on primitives), so pointer moves within it don't move the tooltip.
75
+ // It's found again when the map settles (`bounds`), so a pan or zoom under a resting pointer
76
+ // doesn't leave it off-screen.
77
+ const hoverKey = $derived(
78
+ !map.focusedKey && item && hitPluginId ? `${hitPluginId}\n${item.refKey}` : null
77
79
  );
80
+ const hoverAnchor = $derived.by(() => {
81
+ if (hoverKey === null) return null;
82
+ void map.bounds;
83
+ return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
84
+ });
85
+ const anchor = $derived.by((): [number, number] | null => {
86
+ if (mode === "pointer" && !map.focusedKey) return map.hovered?.lngLat ?? null;
87
+ // A focused feature that left the data has no item, so no tooltip.
88
+ if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
89
+ return hoverAnchor ?? item?.lngLat ?? map.hovered?.lngLat ?? null;
90
+ });
78
91
  const projected = $derived(anchor ? project(anchor) : null);
79
92
  // Centered above the point; flipped below it near the top edge; inside the container.
80
93
  const position = $derived.by(() => {
@@ -3,9 +3,8 @@
3
3
  import type { MessageProvider } from "../data/messages.js";
4
4
  import type { FeatureListItem } from "../core/plugin.js";
5
5
  import { NO_LEVEL } from "../data/scale.js";
6
- import { levelText } from "./feature-list.js";
6
+ import { levelText, shownValue } from "./feature-list.js";
7
7
  import { levelChip } from "./level-chip.js";
8
- import { formatValue } from "./value-format.js";
9
8
 
10
9
  // The generic tooltip body under a feature's name: its value and a chip with its level. Shared
11
10
  // by MapTooltip and by plugins that show it for features they don't list.
@@ -26,13 +25,8 @@
26
25
  const chip = levelChip(level?.color);
27
26
  return chip ? `background:${chip.background};color:${chip.text}` : "";
28
27
  });
29
- const valueText = $derived(
30
- item.value == null
31
- ? null
32
- : typeof item.value === "number"
33
- ? formatValue(item.value, item.precision, format, item.truncate)
34
- : item.value
35
- );
28
+ // A category reading shows only its level chip: its value is the level's code.
29
+ const valueText = $derived(shownValue(item, format));
36
30
  </script>
37
31
 
38
32
  <div class="mapsdk-tooltip-item">
@@ -16,13 +16,21 @@ export declare const MENU_EVENT_WINDOW_MS = 1000;
16
16
  * separator and different rows never share a fragment.
17
17
  */
18
18
  export declare function optionIdPart(pluginId: string, refKey: string): string;
19
- /** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
19
+ /**
20
+ * Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
21
+ * its point is inside. No antimeridian handling (the Valley).
22
+ */
20
23
  export declare function inView(items: FeatureListItem[], bounds: Bounds): FeatureListItem[];
21
24
  /**
22
25
  * The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
23
26
  * label on a neutral swatch.
24
27
  */
25
28
  export declare function shownLevel(item: FeatureListItem): Level | null;
29
+ /**
30
+ * The value as shown, or `null` for none. A category reading, whose value is its level's code
31
+ * (smoke's "light", the heat score's 2), shows no value: its level label already says it.
32
+ */
33
+ export declare function shownValue(item: FeatureListItem, format: FormatProvider): string | null;
26
34
  /** A sorted copy. `value`: highest first, null values and no level last; ties and `name` by name. */
27
35
  export declare function sortItems(items: FeatureListItem[], by: SortBy): FeatureListItem[];
28
36
  /** The first index at or after `from` (wrapping) whose name starts with `query`, any case; -1 if none. */
@@ -1,3 +1,4 @@
1
+ import { intersectsBounds } from "../core/shape-view.js";
1
2
  import { NO_LEVEL } from "../data/scale.js";
2
3
  import { formatValue } from "./value-format.js";
3
4
  /**
@@ -14,11 +15,12 @@ export function optionIdPart(pluginId, refKey) {
14
15
  const escape = (text) => text.replace(/[^A-Za-z0-9]/g, (c) => `_${c.codePointAt(0).toString(16)}_`);
15
16
  return `${escape(pluginId)}--${escape(refKey)}`;
16
17
  }
17
- /** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
18
+ /**
19
+ * Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
20
+ * its point is inside. No antimeridian handling (the Valley).
21
+ */
18
22
  export function inView(items, bounds) {
19
- return items.filter(({ lngLat: [lng, lat] }) => {
20
- return lng >= bounds.west && lng <= bounds.east && lat >= bounds.south && lat <= bounds.north;
21
- });
23
+ return items.filter((item) => intersectsBounds(item, bounds));
22
24
  }
23
25
  /**
24
26
  * The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
@@ -27,6 +29,20 @@ export function inView(items, bounds) {
27
29
  export function shownLevel(item) {
28
30
  return item.level !== NO_LEVEL ? item.level : null;
29
31
  }
32
+ /**
33
+ * The value as shown, or `null` for none. A category reading, whose value is its level's code
34
+ * (smoke's "light", the heat score's 2), shows no value: its level label already says it.
35
+ */
36
+ export function shownValue(item, format) {
37
+ if (item.value === null)
38
+ return null;
39
+ const level = shownLevel(item);
40
+ if (level && level.value !== null && level.value === item.value)
41
+ return null;
42
+ return typeof item.value === "number"
43
+ ? formatValue(item.value, item.precision, format, item.truncate)
44
+ : item.value;
45
+ }
30
46
  const collator = new Intl.Collator("en-US");
31
47
  /** A value's sort weight; `null` sorts last. Category values sort by their level's tier. */
32
48
  function weight(item) {
@@ -80,16 +96,18 @@ export function levelText(item, messages) {
80
96
  /** A row's accessible name: name, value, units, and level label (or detail). */
81
97
  export function rowLabel(item, messages, format) {
82
98
  const level = levelText(item, messages);
83
- if (item.value === null) {
99
+ const value = shownValue(item, format);
100
+ if (value === null) {
101
+ // A category reading: its level is its value.
102
+ if (item.value !== null && level.kind === "level")
103
+ return messages("mapsdk_list_row_level", { name: item.name, level: level.text });
84
104
  return level.kind === "detail"
85
105
  ? messages("mapsdk_list_row_detail", { name: item.name, detail: level.text })
86
106
  : messages("mapsdk_list_row_no_value", { name: item.name });
87
107
  }
88
108
  const params = {
89
109
  name: item.name,
90
- value: typeof item.value === "number"
91
- ? formatValue(item.value, item.precision, format, item.truncate)
92
- : item.value,
110
+ value,
93
111
  units: item.units,
94
112
  level: level.text
95
113
  };
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.4",
4
+ "version": "0.1.0-beta.6",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",