@trackunit/react-map 0.1.24 → 0.1.27

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/index.cjs.js CHANGED
@@ -4470,6 +4470,122 @@ function MapMarkerIcon({ name, size, title }) {
4470
4470
  }, title: title, children: jsxRuntime.jsx(reactComponents.Icon, { ariaHidden: true, name: name, size: "small" }) }));
4471
4471
  }
4472
4472
 
4473
+ const EMPTY_SET = new Set();
4474
+ /**
4475
+ * Order-sensitive equality. The returned Set keeps a stable reference only when
4476
+ * both membership AND nearest-first ordering are identical. A nearest-first swap
4477
+ * within the same id set produces a new reference so consumers that cap to the
4478
+ * N closest via iteration order see the updated ranking.
4479
+ */
4480
+ const sameNearby = (a, b) => {
4481
+ if (a === b)
4482
+ return true;
4483
+ if (a.size !== b.size)
4484
+ return false;
4485
+ const ai = a.values();
4486
+ const bi = b.values();
4487
+ for (let i = 0; i < a.size; i++) {
4488
+ if (ai.next().value !== bi.next().value)
4489
+ return false;
4490
+ }
4491
+ return true;
4492
+ };
4493
+ const computeNearby = (cursor, entities, zoom, tileSize, radiusPx) => {
4494
+ if (entities.length === 0)
4495
+ return EMPTY_SET;
4496
+ // Project once per recompute: worldSize is constant for all entities at this
4497
+ // camera, so hoist Math.pow(2, zoom) out of the per-entity loop.
4498
+ const worldSize = tileSize * Math.pow(2, zoom);
4499
+ const [cursorPx, cursorPy] = geoJsonUtils.lngLatToMercatorPxWS(cursor[0], cursor[1], worldSize);
4500
+ const radiusSq = radiusPx * radiusPx;
4501
+ const hits = [];
4502
+ for (const entity of entities) {
4503
+ const [px, py] = geoJsonUtils.lngLatToMercatorPxWS(entity.position[0], entity.position[1], worldSize);
4504
+ const dx = px - cursorPx;
4505
+ const dy = py - cursorPy;
4506
+ // Shortest-path x-distance across the antimeridian: a cursor at 179.99° and
4507
+ // an entity at −179.99° are ~0.02° apart on screen but raw Mercator dx is
4508
+ // nearly worldSize. Taking min(|dx|, worldSize−|dx|) collapses that gap.
4509
+ const absDx = Math.abs(dx);
4510
+ const wrappedDx = Math.min(absDx, worldSize - absDx);
4511
+ const distSq = wrappedDx * wrappedDx + dy * dy;
4512
+ if (distSq <= radiusSq)
4513
+ hits.push({ id: entity.id, distSq });
4514
+ }
4515
+ if (hits.length === 0)
4516
+ return EMPTY_SET;
4517
+ // Nearest-first so a consumer can cap to the N closest by iteration order.
4518
+ hits.sort((a, b) => a.distSq - b.distSq);
4519
+ return new Set(hits.map(hit => hit.id));
4520
+ };
4521
+ /**
4522
+ * Reference/value equality for the recompute inputs. `entities` is compared by
4523
+ * reference (the caller memoises it), keeping the per-render change check O(1)
4524
+ * rather than deep-comparing the whole marker array on every frame.
4525
+ */
4526
+ const sameInputs = (a, b) => a.entities === b.entities && a.zoom === b.zoom && a.tileSize === b.tileSize && a.radiusPx === b.radiusPx;
4527
+ /**
4528
+ * The **Entities Near Cursor** primitive: returns a referentially-stable
4529
+ * `ReadonlySet<string>` of entity ids whose projected position is within
4530
+ * `radiusPx` CSS pixels of the projected cursor, nearest-first.
4531
+ *
4532
+ * It is the point-marker counterpart to **Shapes Under Cursor** and runs on the
4533
+ * same pure Web Mercator substrate (`@trackunit/geo-json-utils`) fed by
4534
+ * `useCameraState` + the RAF-throttled `pointermove` event — adapter-agnostic by
4535
+ * construction (see ADR-0025). The hook owns radius geometry only; consumers
4536
+ * decide why the ids are wanted (preload, highlight, …) and may cap the set to
4537
+ * the nearest N via iteration order.
4538
+ *
4539
+ * The returned Set keeps a stable identity until either its membership or its
4540
+ * nearest-first ordering changes, so wiring it straight into a memo or query
4541
+ * does not churn while the cursor drifts within the same cluster of dots at the
4542
+ * same relative distances. The host only re-renders when membership or ordering
4543
+ * actually changes: the cursor lives in a ref and recomputes commit through an
4544
+ * order-sensitive functional update that bails out (returns the previous Set)
4545
+ * when nothing changed.
4546
+ */
4547
+ const useEntitiesNearCursor = (api, options) => {
4548
+ const { entities, radiusPx } = options;
4549
+ const { zoom } = useCameraState(api);
4550
+ const { tileSize } = api.state;
4551
+ const [nearby, setNearby] = react.useState(EMPTY_SET);
4552
+ // Latest cursor position and recompute inputs, read by the stable subscription
4553
+ // and recompute without re-subscribing. Written only in effects / event
4554
+ // handlers — never during render.
4555
+ const cursorRef = react.useRef(null);
4556
+ const inputsRef = react.useRef({ entities, zoom, tileSize, radiusPx });
4557
+ const recompute = react.useCallback(() => {
4558
+ const cursor = cursorRef.current;
4559
+ const current = inputsRef.current;
4560
+ const next = cursor === null
4561
+ ? EMPTY_SET
4562
+ : computeNearby(cursor, current.entities, current.zoom, current.tileSize, current.radiusPx);
4563
+ // Membership-gated functional update: return the previous reference when the
4564
+ // id set is unchanged so React bails out of the re-render and the output
4565
+ // identity stays stable across cursor drift within the same cluster.
4566
+ setNearby(prev => (sameNearby(prev, next) ? prev : next));
4567
+ }, []);
4568
+ // Keep the recompute inputs fresh and recompute when the camera, entities, or
4569
+ // radius change while the cursor stays put (e.g. zooming under a stationary
4570
+ // pointer brings new dots into radius). `useWatch` owns the effect, so this
4571
+ // file never calls setState synchronously inside its own effect.
4572
+ reactComponents.useWatch({
4573
+ value: { entities, zoom, tileSize, radiusPx },
4574
+ immediate: true,
4575
+ isEqual: sameInputs,
4576
+ onChange: latest => {
4577
+ inputsRef.current = latest;
4578
+ recompute();
4579
+ },
4580
+ });
4581
+ // Track the cursor and recompute on each throttled move.
4582
+ react.useEffect(() => api.on("pointermove", event => {
4583
+ cursorRef.current = event.position;
4584
+ recompute();
4585
+ }), [api, recompute]);
4586
+ return nearby;
4587
+ };
4588
+
4473
4589
  const DEFAULT_DEBOUNCE_MS = 150;
4474
4590
  /**
4475
4591
  * Fires `onShouldPreload` for the currently hovered entity after a debounce
@@ -7461,14 +7577,17 @@ const degreesPerPixel = (zoom, tileSize) => 360 / (tileSize * Math.pow(2, zoom))
7461
7577
  const shapesUnderCursor = (position, features, options) => {
7462
7578
  const { zoom, tileSize, strokeWidthFor, visibleFillFor, layerGeodesic, featureStyles } = options;
7463
7579
  // Densify before boundary-distance and point-in-polygon math so stroke/fill
7464
- // hit-tests follow great-circle arcs on large polygons.
7580
+ // hit-tests follow great-circle arcs on large polygons. Always defer to
7581
+ // densifyGeodesicFeatures' per-feature resolution (geodesic override > layer
7582
+ // default) rather than short-circuiting on layerGeodesic === false — that
7583
+ // matches the Mapbox render path, so the hit-test geometry never diverges
7584
+ // from the painted edge when a feature opts into geodesic under a non-geodesic
7585
+ // layer. The util returns the original collection when nothing is densified.
7465
7586
  const featureCollection = {
7466
7587
  type: "FeatureCollection",
7467
7588
  features: Array.from(features),
7468
7589
  };
7469
- const densifiedFeatures = layerGeodesic !== false
7470
- ? reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7471
- : features;
7590
+ const densifiedFeatures = reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features;
7472
7591
  const degPerPx = degreesPerPixel(zoom, tileSize);
7473
7592
  const hits = [];
7474
7593
  for (const feature of densifiedFeatures) {
@@ -7580,14 +7699,16 @@ const promoteToFront = (order, id) => order.includes(id) ? [id, ...order.filter(
7580
7699
  * preserved and translucent fills do not compound under the winner.
7581
7700
  */
7582
7701
  const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map(), layerGeodesic = undefined, featureStyles = undefined) => {
7583
- // Densify before peer-vs-winner clip so promotion clips follow great-circle arcs.
7702
+ // Densify before peer-vs-winner clip so promotion clips follow great-circle
7703
+ // arcs. Always defer to densifyGeodesicFeatures' per-feature resolution
7704
+ // (geodesic override > layer default) rather than short-circuiting on
7705
+ // layerGeodesic === false — keeps the clip geometry in step with the render
7706
+ // path. The util returns the original collection when nothing is densified.
7584
7707
  const featureCollection = {
7585
7708
  type: "FeatureCollection",
7586
7709
  features: Array.from(members),
7587
7710
  };
7588
- const densifiedMembers = layerGeodesic !== false
7589
- ? reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7590
- : members;
7711
+ const densifiedMembers = reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features;
7591
7712
  const geometries = new Map();
7592
7713
  for (const feature of densifiedMembers) {
7593
7714
  if (feature.id === undefined)
@@ -7627,14 +7748,16 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
7627
7748
  const computeFillTiling = (input) => {
7628
7749
  const { features, viewportBounds, resolveStackOrder, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7629
7750
  // Densify before any polygon/boundary math so clip geometry follows great-circle
7630
- // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
7751
+ // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels). Always
7752
+ // defer to densifyGeodesicFeatures' per-feature resolution (geodesic override >
7753
+ // layer default) rather than short-circuiting on layerGeodesic === false — keeps
7754
+ // the clip geometry in step with the Mapbox render path. The util returns the
7755
+ // original collection when nothing is densified.
7631
7756
  const featureCollection = {
7632
7757
  type: "FeatureCollection",
7633
7758
  features: Array.from(features),
7634
7759
  };
7635
- const densifiedFeatures = layerGeodesic !== false
7636
- ? reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7637
- : features;
7760
+ const densifiedFeatures = reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features;
7638
7761
  const records = [];
7639
7762
  for (const feature of densifiedFeatures) {
7640
7763
  if (feature.id === undefined)
@@ -8111,7 +8234,6 @@ const DEG_TO_RAD = Math.PI / 180;
8111
8234
  const RAD_TO_DEG$1 = 180 / Math.PI;
8112
8235
  const DEFAULT_EDGE_LABEL_INSET_PX = 6;
8113
8236
  const LABEL_HEIGHT_PX = 20;
8114
- const MAX_WEB_MERCATOR_LAT = 85.05112878;
8115
8237
  const EARTH_RADIUS_KM = 6371;
8116
8238
  const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = reactMapAdapterShared.GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
8117
8239
  /**
@@ -8171,24 +8293,6 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8171
8293
  }
8172
8294
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8173
8295
  };
8174
- /**
8175
- * Inner implementations that accept a pre-computed worldSize.
8176
- * Used by findBestEdgePosition to avoid recomputing Math.pow(2, zoom) on
8177
- * every vertex when projecting dozens of geodesic sub-segments per frame.
8178
- */
8179
- const lngLatToMercatorPxWS = (lng, lat, worldSize) => {
8180
- const clampedLat = Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat));
8181
- const x = ((lng + 180) / 360) * worldSize;
8182
- const latRad = clampedLat * DEG_TO_RAD;
8183
- const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + latRad / 2)) / (2 * Math.PI)) * worldSize;
8184
- return [x, y];
8185
- };
8186
- const mercatorPxToLngLatWS = (px, py, worldSize) => {
8187
- const lng = (px / worldSize) * 360 - 180;
8188
- const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / worldSize))) * RAD_TO_DEG$1;
8189
- return [lng, lat];
8190
- };
8191
- const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => lngLatToMercatorPxWS(lng, lat, tileSize * Math.pow(2, zoom));
8192
8296
  /**
8193
8297
  * Determine which side of an edge the geometry interior lies on, in screen space.
8194
8298
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -8539,8 +8643,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8539
8643
  // edge — causing labels to float away from the line at mid-edge positions.
8540
8644
  // Clipping in pixel space ensures clip endpoints lie exactly on the
8541
8645
  // rendered edge, which fixes the floating at the midpoint of long edges.
8542
- const [pxViewMin, pyViewMin] = lngLatToMercatorPxWS(minLon, maxLat, worldSize);
8543
- const [pxViewMax, pyViewMax] = lngLatToMercatorPxWS(maxLon, minLat, worldSize);
8646
+ const [pxViewMin, pyViewMin] = geoJsonUtils.lngLatToMercatorPxWS(minLon, maxLat, worldSize);
8647
+ const [pxViewMax, pyViewMax] = geoJsonUtils.lngLatToMercatorPxWS(maxLon, minLat, worldSize);
8544
8648
  const viewportWidth = pxViewMax - pxViewMin;
8545
8649
  const viewportHeight = pyViewMax - pyViewMin;
8546
8650
  for (let edgeIdx = 0; edgeIdx < edges.length; edgeIdx++) {
@@ -8558,8 +8662,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8558
8662
  const segmentClips = [];
8559
8663
  for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
8560
8664
  const { start, end } = segment;
8561
- const [startPxX, startPxY] = lngLatToMercatorPxWS(start[0], start[1], worldSize);
8562
- const [endPxX, endPxY] = lngLatToMercatorPxWS(end[0], end[1], worldSize);
8665
+ const [startPxX, startPxY] = geoJsonUtils.lngLatToMercatorPxWS(start[0], start[1], worldSize);
8666
+ const [endPxX, endPxY] = geoJsonUtils.lngLatToMercatorPxWS(end[0], end[1], worldSize);
8563
8667
  const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8564
8668
  if (!clippedPx)
8565
8669
  continue;
@@ -8575,8 +8679,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8575
8679
  : [-rawDx / segPxLen, -rawDy / segPxLen];
8576
8680
  // Inverse-project pixel clip endpoints to geo for outward-side and
8577
8681
  // polygon-interior checks (qualitative, so approximately correct geo is fine).
8578
- const [ex0, ey0] = mercatorPxToLngLatWS(px0, py0, worldSize);
8579
- const [ex1, ey1] = mercatorPxToLngLatWS(px1, py1, worldSize);
8682
+ const [ex0, ey0] = geoJsonUtils.mercatorPxToLngLatWS(px0, py0, worldSize);
8683
+ const [ex1, ey1] = geoJsonUtils.mercatorPxToLngLatWS(px1, py1, worldSize);
8580
8684
  const midLat = (ey0 + ey1) / 2;
8581
8685
  const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8582
8686
  if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
@@ -8713,7 +8817,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8713
8817
  for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
8714
8818
  if (matchingCandidate.pxLen <= 0)
8715
8819
  continue;
8716
- const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8820
+ const [prevAnchorAbsPx, prevAnchorAbsPy] = geoJsonUtils.lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8717
8821
  const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
8718
8822
  const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
8719
8823
  const dx = prevAnchorRelX - matchingCandidate.readingStartPx[0];
@@ -8783,7 +8887,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8783
8887
  // great-circle arc by computing the arc fraction via angular distance
8784
8888
  // from A. This places the label physically on the visible curved arc
8785
8889
  // rather than on the straight Mercator chord.
8786
- const [approxLng, approxLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8890
+ const [approxLng, approxLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8787
8891
  const tArc = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxLng, approxLat) / delta));
8788
8892
  const geoAnchor = reactMapAdapterShared.intermediatePoint(A, B, tArc, delta);
8789
8893
  position = [geoAnchor[0], geoAnchor[1]];
@@ -8797,13 +8901,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8797
8901
  const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
8798
8902
  const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
8799
8903
  const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
8800
- const [approxCenterLng, approxCenterLat] = mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8904
+ const [approxCenterLng, approxCenterLat] = geoJsonUtils.mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8801
8905
  const tArcCenter = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
8802
8906
  const TANGENT_EPS = 0.001;
8803
8907
  const p0 = reactMapAdapterShared.intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
8804
8908
  const p1 = reactMapAdapterShared.intermediatePoint(A, B, Math.min(1, tArcCenter + TANGENT_EPS), delta);
8805
- const [bx, by] = lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
8806
- const [ax, ay] = lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
8909
+ const [bx, by] = geoJsonUtils.lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
8910
+ const [ax, ay] = geoJsonUtils.lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
8807
8911
  const ddx = ax - bx;
8808
8912
  const ddy = ay - by;
8809
8913
  const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
@@ -8820,13 +8924,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8820
8924
  }
8821
8925
  else {
8822
8926
  // Co-located vertices — fall back to Mercator straight-line.
8823
- const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8927
+ const [anchorLng, anchorLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8824
8928
  position = [anchorLng, anchorLat];
8825
8929
  resolvedDirectionPx = best.directionPx;
8826
8930
  }
8827
8931
  }
8828
8932
  else {
8829
- const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8933
+ const [anchorLng, anchorLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8830
8934
  position = [anchorLng, anchorLat];
8831
8935
  resolvedDirectionPx = best.directionPx;
8832
8936
  }
@@ -9014,19 +9118,19 @@ const computeEdgeProperties = (start, end, geometry, zoom, tileSize, geodesic =
9014
9118
  const TANGENT_EPS = 0.001;
9015
9119
  const p0 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 - TANGENT_EPS, delta);
9016
9120
  const p1 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 + TANGENT_EPS, delta);
9017
- const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
9018
- const [ax, ay] = lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
9121
+ const [bx, by] = geoJsonUtils.lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
9122
+ const [ax, ay] = geoJsonUtils.lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
9019
9123
  angleDeg = normalizeReadableAngle(Math.atan2(ay - by, ax - bx) * RAD_TO_DEG);
9020
9124
  }
9021
9125
  else {
9022
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9023
- const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9126
+ const [startPx, startPy] = geoJsonUtils.lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9127
+ const [endPx, endPy] = geoJsonUtils.lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9024
9128
  angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
9025
9129
  }
9026
9130
  }
9027
9131
  else {
9028
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9029
- const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9132
+ const [startPx, startPy] = geoJsonUtils.lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9133
+ const [endPx, endPy] = geoJsonUtils.lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9030
9134
  angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
9031
9135
  }
9032
9136
  const pixelLength = geoJsonUtils.edgePixelLength(start[0], start[1], end[0], end[1], zoom, midLat, tileSize);
@@ -10031,6 +10135,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10031
10135
  }, [suppressedFillIdsByHandle]);
10032
10136
  const debounceTimerRef = react.useRef(null);
10033
10137
  const idleRecomputeTimerRef = react.useRef(null);
10138
+ /** Pending deferred selection re-tile (double-rAF handle); see selection effect. */
10139
+ const selectionRecomputeRafRef = react.useRef(null);
10034
10140
  // Currently emitted overrides — used for hit-testing visible fills in handleSettle.
10035
10141
  const prevFillOverridesRef = react.useRef(EMPTY_OVERRIDES);
10036
10142
  const prevZIndexOverridesRef = react.useRef(EMPTY_ZINDEX_OVERRIDES);
@@ -10285,8 +10391,30 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10285
10391
  return;
10286
10392
  recompute();
10287
10393
  }, [recompute, tilingContentKey]);
10394
+ // Selection forces the selected feature to the front of its overlap group,
10395
+ // which requires a resting re-tile (computeFillTiling depends on
10396
+ // selectedFeatureId via resolveStackOrder). For large layers that re-tile is
10397
+ // expensive (~600ms for 200 polygons over global bounds) and must NOT block
10398
+ // the interaction's first paint — e.g. a panel opening on the clicked feature.
10399
+ // Defer it past the next paint with a double rAF so the click commits and
10400
+ // paints immediately, then the selected polygon restacks one frame later.
10401
+ // Rapid selections coalesce: each schedule cancels the previous pending one.
10288
10402
  react.useEffect(() => {
10289
- recompute();
10403
+ if (selectionRecomputeRafRef.current !== null) {
10404
+ cancelAnimationFrame(selectionRecomputeRafRef.current);
10405
+ }
10406
+ selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10407
+ selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10408
+ selectionRecomputeRafRef.current = null;
10409
+ recompute();
10410
+ });
10411
+ });
10412
+ return () => {
10413
+ if (selectionRecomputeRafRef.current !== null) {
10414
+ cancelAnimationFrame(selectionRecomputeRafRef.current);
10415
+ selectionRecomputeRafRef.current = null;
10416
+ }
10417
+ };
10290
10418
  }, [recompute, selectedFeatureId]);
10291
10419
  // Suppression changes must trigger a recompute so EMPTY_FILL entries are
10292
10420
  // added / removed even when the map is idle (covers the decorations-vs-tiling
@@ -10538,7 +10666,24 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
10538
10666
  const [viewportStyleOverrides, setViewportStyleOverrides] = react.useState(new Map());
10539
10667
  const [fillGeometryOverrides, setFillGeometryOverrides] = react.useState(new Map());
10540
10668
  const [zIndexOverrides, setZIndexOverrides] = react.useState(new Map());
10669
+ // Mirror the authoritative hovered entity into a ref. The settle channel fires
10670
+ // on a ~120ms debounce (outside React's render cycle) and needs to read the
10671
+ // current hover synchronously.
10672
+ const hoveredEntityRef = react.useRef(layers.interaction.hoveredEntity);
10673
+ react.useEffect(() => {
10674
+ hoveredEntityRef.current = layers.interaction.hoveredEntity;
10675
+ }, [layers.interaction.hoveredEntity]);
10541
10676
  const onSettledHover = react.useCallback((handleId, featureId) => {
10677
+ // Z-order truth: markers and clusters render above shapes (DOM/symbol portals
10678
+ // over the polygon canvas). When a point entity is the current hovered entity
10679
+ // it is the authoritative top hit under the cursor, so the shape settle — which
10680
+ // is not itself position-gated against the marker layer — must not overwrite it.
10681
+ // Without this guard, moving onto an asset pill that overlaps a site would leave
10682
+ // the covered site as `hoveredEntity` (a stale cross-channel hover write). We read
10683
+ // the single source of truth (`hoveredEntity`) rather than a parallel guard flag.
10684
+ const hovered = hoveredEntityRef.current;
10685
+ if (hovered !== null && (hovered.type === "marker" || hovered.type === "cluster"))
10686
+ return;
10542
10687
  // Fill tiling only fires on polygonal features — shapeType is always "polygon".
10543
10688
  layers.hover({ type: "shape", id: featureId, handleId, shapeType: "polygon" });
10544
10689
  }, [layers]);
@@ -10600,17 +10745,27 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
10600
10745
  });
10601
10746
  }, [layers.handles, adaptiveByLayer, viewportStyleOverrides, fillGeometryOverrides, zIndexOverrides, queryAt]);
10602
10747
  const { renderedSourceIds, hasSourceReadiness, readyRevision } = useLayerHandleSync(layerPort, enrichedHandles, layers.interaction, decorationLayers);
10603
- const hoveredEntityRef = react.useRef(layers.interaction.hoveredEntity);
10604
- react.useEffect(() => {
10605
- hoveredEntityRef.current = layers.interaction.hoveredEntity;
10606
- }, [layers.interaction.hoveredEntity]);
10607
10748
  const coordinatedSelect = react.useCallback((entity) => {
10608
- if (entity !== null && hoveredEntityRef.current !== null) {
10609
- layers.select(hoveredEntityRef.current);
10610
- }
10611
- else {
10749
+ const hovered = hoveredEntityRef.current;
10750
+ if (entity === null || hovered === null) {
10612
10751
  layers.select(entity);
10752
+ return;
10753
+ }
10754
+ // Default to the entity the click actually carried — it is the freshest,
10755
+ // most direct signal of what the user hit, and it hit-tests per element
10756
+ // for markers/clusters/routes. The single exception is a shape-on-shape
10757
+ // conflict: the adapter resolves polygon clicks with its own z-order and
10758
+ // full (unclipped) hit areas, so it cannot honor the fill-tiling ownership
10759
+ // model that decides which overlapping site is visually on top (ADR-0021).
10760
+ // There the settle-resolved hovered shape is the precise winner, so we
10761
+ // trust it instead. This is also why an asset pill overlapping a site
10762
+ // selects the asset: a marker click is never a shape, so it bypasses the
10763
+ // exception even when a stale site shape is still the hovered entity.
10764
+ if (entity.type === "shape" && hovered.type === "shape") {
10765
+ layers.select(hovered);
10766
+ return;
10613
10767
  }
10768
+ layers.select(entity);
10614
10769
  }, [layers]);
10615
10770
  const fitOnDblClick = useDblClickFit(api, layers.handles);
10616
10771
  const handleDblClick = react.useCallback((entity) => {
@@ -11671,6 +11826,7 @@ exports.useControlStack = useControlStack;
11671
11826
  exports.useControls = useControls;
11672
11827
  exports.useDefaultControls = useDefaultControls;
11673
11828
  exports.useDirectionIndicator = useDirectionIndicator;
11829
+ exports.useEntitiesNearCursor = useEntitiesNearCursor;
11674
11830
  exports.useExpandedIds = useExpandedIds;
11675
11831
  exports.useFitFeatureBounds = useFitFeatureBounds;
11676
11832
  exports.useFitToContent = useFitToContent;
package/index.esm.js CHANGED
@@ -15,7 +15,7 @@ import { useModal, Modal as Modal$1, ModalHeader, ModalBody } from '@trackunit/r
15
15
  import { z } from 'zod';
16
16
  import { PieChart } from 'react-minimal-pie-chart';
17
17
  import { tailwindPalette } from '@trackunit/ui-design-tokens';
18
- import { validateBboxWithFallback, extractPositionsFromGeometry, geoJsonPositionSchema, validateFeatureCollection, EMPTY_FEATURE_COLLECTION, validateBbox, projectPolygonalToWebMercator, geoJsonPolygonDifference, unprojectPolygonalFromWebMercator, distanceToGeoJsonPolygonBoundary, getGeoJsonPolygonIntersection, isFullyContainedInGeoJsonGeometry, isGeoJsonPointInPolygon, isBboxInsideFeatureCollection, extractEdges, computeGeometryCentroid, isPositionInsideRing, edgePixelLength } from '@trackunit/geo-json-utils';
18
+ import { lngLatToMercatorPxWS, validateBboxWithFallback, extractPositionsFromGeometry, geoJsonPositionSchema, validateFeatureCollection, EMPTY_FEATURE_COLLECTION, validateBbox, projectPolygonalToWebMercator, geoJsonPolygonDifference, unprojectPolygonalFromWebMercator, distanceToGeoJsonPolygonBoundary, getGeoJsonPolygonIntersection, isFullyContainedInGeoJsonGeometry, isGeoJsonPointInPolygon, isBboxInsideFeatureCollection, extractEdges, computeGeometryCentroid, mercatorPxToLngLatWS, isPositionInsideRing, lngLatToWebMercatorPx, edgePixelLength } from '@trackunit/geo-json-utils';
19
19
  import { darkenColor, lightenColor } from '@trackunit/react-map-color-utils';
20
20
 
21
21
  var defaultTranslations = {
@@ -4469,6 +4469,122 @@ function MapMarkerIcon({ name, size, title }) {
4469
4469
  }, title: title, children: jsx(Icon, { ariaHidden: true, name: name, size: "small" }) }));
4470
4470
  }
4471
4471
 
4472
+ const EMPTY_SET = new Set();
4473
+ /**
4474
+ * Order-sensitive equality. The returned Set keeps a stable reference only when
4475
+ * both membership AND nearest-first ordering are identical. A nearest-first swap
4476
+ * within the same id set produces a new reference so consumers that cap to the
4477
+ * N closest via iteration order see the updated ranking.
4478
+ */
4479
+ const sameNearby = (a, b) => {
4480
+ if (a === b)
4481
+ return true;
4482
+ if (a.size !== b.size)
4483
+ return false;
4484
+ const ai = a.values();
4485
+ const bi = b.values();
4486
+ for (let i = 0; i < a.size; i++) {
4487
+ if (ai.next().value !== bi.next().value)
4488
+ return false;
4489
+ }
4490
+ return true;
4491
+ };
4492
+ const computeNearby = (cursor, entities, zoom, tileSize, radiusPx) => {
4493
+ if (entities.length === 0)
4494
+ return EMPTY_SET;
4495
+ // Project once per recompute: worldSize is constant for all entities at this
4496
+ // camera, so hoist Math.pow(2, zoom) out of the per-entity loop.
4497
+ const worldSize = tileSize * Math.pow(2, zoom);
4498
+ const [cursorPx, cursorPy] = lngLatToMercatorPxWS(cursor[0], cursor[1], worldSize);
4499
+ const radiusSq = radiusPx * radiusPx;
4500
+ const hits = [];
4501
+ for (const entity of entities) {
4502
+ const [px, py] = lngLatToMercatorPxWS(entity.position[0], entity.position[1], worldSize);
4503
+ const dx = px - cursorPx;
4504
+ const dy = py - cursorPy;
4505
+ // Shortest-path x-distance across the antimeridian: a cursor at 179.99° and
4506
+ // an entity at −179.99° are ~0.02° apart on screen but raw Mercator dx is
4507
+ // nearly worldSize. Taking min(|dx|, worldSize−|dx|) collapses that gap.
4508
+ const absDx = Math.abs(dx);
4509
+ const wrappedDx = Math.min(absDx, worldSize - absDx);
4510
+ const distSq = wrappedDx * wrappedDx + dy * dy;
4511
+ if (distSq <= radiusSq)
4512
+ hits.push({ id: entity.id, distSq });
4513
+ }
4514
+ if (hits.length === 0)
4515
+ return EMPTY_SET;
4516
+ // Nearest-first so a consumer can cap to the N closest by iteration order.
4517
+ hits.sort((a, b) => a.distSq - b.distSq);
4518
+ return new Set(hits.map(hit => hit.id));
4519
+ };
4520
+ /**
4521
+ * Reference/value equality for the recompute inputs. `entities` is compared by
4522
+ * reference (the caller memoises it), keeping the per-render change check O(1)
4523
+ * rather than deep-comparing the whole marker array on every frame.
4524
+ */
4525
+ const sameInputs = (a, b) => a.entities === b.entities && a.zoom === b.zoom && a.tileSize === b.tileSize && a.radiusPx === b.radiusPx;
4526
+ /**
4527
+ * The **Entities Near Cursor** primitive: returns a referentially-stable
4528
+ * `ReadonlySet<string>` of entity ids whose projected position is within
4529
+ * `radiusPx` CSS pixels of the projected cursor, nearest-first.
4530
+ *
4531
+ * It is the point-marker counterpart to **Shapes Under Cursor** and runs on the
4532
+ * same pure Web Mercator substrate (`@trackunit/geo-json-utils`) fed by
4533
+ * `useCameraState` + the RAF-throttled `pointermove` event — adapter-agnostic by
4534
+ * construction (see ADR-0025). The hook owns radius geometry only; consumers
4535
+ * decide why the ids are wanted (preload, highlight, …) and may cap the set to
4536
+ * the nearest N via iteration order.
4537
+ *
4538
+ * The returned Set keeps a stable identity until either its membership or its
4539
+ * nearest-first ordering changes, so wiring it straight into a memo or query
4540
+ * does not churn while the cursor drifts within the same cluster of dots at the
4541
+ * same relative distances. The host only re-renders when membership or ordering
4542
+ * actually changes: the cursor lives in a ref and recomputes commit through an
4543
+ * order-sensitive functional update that bails out (returns the previous Set)
4544
+ * when nothing changed.
4545
+ */
4546
+ const useEntitiesNearCursor = (api, options) => {
4547
+ const { entities, radiusPx } = options;
4548
+ const { zoom } = useCameraState(api);
4549
+ const { tileSize } = api.state;
4550
+ const [nearby, setNearby] = useState(EMPTY_SET);
4551
+ // Latest cursor position and recompute inputs, read by the stable subscription
4552
+ // and recompute without re-subscribing. Written only in effects / event
4553
+ // handlers — never during render.
4554
+ const cursorRef = useRef(null);
4555
+ const inputsRef = useRef({ entities, zoom, tileSize, radiusPx });
4556
+ const recompute = useCallback(() => {
4557
+ const cursor = cursorRef.current;
4558
+ const current = inputsRef.current;
4559
+ const next = cursor === null
4560
+ ? EMPTY_SET
4561
+ : computeNearby(cursor, current.entities, current.zoom, current.tileSize, current.radiusPx);
4562
+ // Membership-gated functional update: return the previous reference when the
4563
+ // id set is unchanged so React bails out of the re-render and the output
4564
+ // identity stays stable across cursor drift within the same cluster.
4565
+ setNearby(prev => (sameNearby(prev, next) ? prev : next));
4566
+ }, []);
4567
+ // Keep the recompute inputs fresh and recompute when the camera, entities, or
4568
+ // radius change while the cursor stays put (e.g. zooming under a stationary
4569
+ // pointer brings new dots into radius). `useWatch` owns the effect, so this
4570
+ // file never calls setState synchronously inside its own effect.
4571
+ useWatch({
4572
+ value: { entities, zoom, tileSize, radiusPx },
4573
+ immediate: true,
4574
+ isEqual: sameInputs,
4575
+ onChange: latest => {
4576
+ inputsRef.current = latest;
4577
+ recompute();
4578
+ },
4579
+ });
4580
+ // Track the cursor and recompute on each throttled move.
4581
+ useEffect(() => api.on("pointermove", event => {
4582
+ cursorRef.current = event.position;
4583
+ recompute();
4584
+ }), [api, recompute]);
4585
+ return nearby;
4586
+ };
4587
+
4472
4588
  const DEFAULT_DEBOUNCE_MS = 150;
4473
4589
  /**
4474
4590
  * Fires `onShouldPreload` for the currently hovered entity after a debounce
@@ -7460,14 +7576,17 @@ const degreesPerPixel = (zoom, tileSize) => 360 / (tileSize * Math.pow(2, zoom))
7460
7576
  const shapesUnderCursor = (position, features, options) => {
7461
7577
  const { zoom, tileSize, strokeWidthFor, visibleFillFor, layerGeodesic, featureStyles } = options;
7462
7578
  // Densify before boundary-distance and point-in-polygon math so stroke/fill
7463
- // hit-tests follow great-circle arcs on large polygons.
7579
+ // hit-tests follow great-circle arcs on large polygons. Always defer to
7580
+ // densifyGeodesicFeatures' per-feature resolution (geodesic override > layer
7581
+ // default) rather than short-circuiting on layerGeodesic === false — that
7582
+ // matches the Mapbox render path, so the hit-test geometry never diverges
7583
+ // from the painted edge when a feature opts into geodesic under a non-geodesic
7584
+ // layer. The util returns the original collection when nothing is densified.
7464
7585
  const featureCollection = {
7465
7586
  type: "FeatureCollection",
7466
7587
  features: Array.from(features),
7467
7588
  };
7468
- const densifiedFeatures = layerGeodesic !== false
7469
- ? densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7470
- : features;
7589
+ const densifiedFeatures = densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features;
7471
7590
  const degPerPx = degreesPerPixel(zoom, tileSize);
7472
7591
  const hits = [];
7473
7592
  for (const feature of densifiedFeatures) {
@@ -7579,14 +7698,16 @@ const promoteToFront = (order, id) => order.includes(id) ? [id, ...order.filter(
7579
7698
  * preserved and translucent fills do not compound under the winner.
7580
7699
  */
7581
7700
  const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map(), layerGeodesic = undefined, featureStyles = undefined) => {
7582
- // Densify before peer-vs-winner clip so promotion clips follow great-circle arcs.
7701
+ // Densify before peer-vs-winner clip so promotion clips follow great-circle
7702
+ // arcs. Always defer to densifyGeodesicFeatures' per-feature resolution
7703
+ // (geodesic override > layer default) rather than short-circuiting on
7704
+ // layerGeodesic === false — keeps the clip geometry in step with the render
7705
+ // path. The util returns the original collection when nothing is densified.
7583
7706
  const featureCollection = {
7584
7707
  type: "FeatureCollection",
7585
7708
  features: Array.from(members),
7586
7709
  };
7587
- const densifiedMembers = layerGeodesic !== false
7588
- ? densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7589
- : members;
7710
+ const densifiedMembers = densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features;
7590
7711
  const geometries = new Map();
7591
7712
  for (const feature of densifiedMembers) {
7592
7713
  if (feature.id === undefined)
@@ -7626,14 +7747,16 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
7626
7747
  const computeFillTiling = (input) => {
7627
7748
  const { features, viewportBounds, resolveStackOrder, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7628
7749
  // Densify before any polygon/boundary math so clip geometry follows great-circle
7629
- // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
7750
+ // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels). Always
7751
+ // defer to densifyGeodesicFeatures' per-feature resolution (geodesic override >
7752
+ // layer default) rather than short-circuiting on layerGeodesic === false — keeps
7753
+ // the clip geometry in step with the Mapbox render path. The util returns the
7754
+ // original collection when nothing is densified.
7630
7755
  const featureCollection = {
7631
7756
  type: "FeatureCollection",
7632
7757
  features: Array.from(features),
7633
7758
  };
7634
- const densifiedFeatures = layerGeodesic !== false
7635
- ? densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7636
- : features;
7759
+ const densifiedFeatures = densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features;
7637
7760
  const records = [];
7638
7761
  for (const feature of densifiedFeatures) {
7639
7762
  if (feature.id === undefined)
@@ -8110,7 +8233,6 @@ const DEG_TO_RAD = Math.PI / 180;
8110
8233
  const RAD_TO_DEG$1 = 180 / Math.PI;
8111
8234
  const DEFAULT_EDGE_LABEL_INSET_PX = 6;
8112
8235
  const LABEL_HEIGHT_PX = 20;
8113
- const MAX_WEB_MERCATOR_LAT = 85.05112878;
8114
8236
  const EARTH_RADIUS_KM = 6371;
8115
8237
  const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
8116
8238
  /**
@@ -8170,24 +8292,6 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8170
8292
  }
8171
8293
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8172
8294
  };
8173
- /**
8174
- * Inner implementations that accept a pre-computed worldSize.
8175
- * Used by findBestEdgePosition to avoid recomputing Math.pow(2, zoom) on
8176
- * every vertex when projecting dozens of geodesic sub-segments per frame.
8177
- */
8178
- const lngLatToMercatorPxWS = (lng, lat, worldSize) => {
8179
- const clampedLat = Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat));
8180
- const x = ((lng + 180) / 360) * worldSize;
8181
- const latRad = clampedLat * DEG_TO_RAD;
8182
- const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + latRad / 2)) / (2 * Math.PI)) * worldSize;
8183
- return [x, y];
8184
- };
8185
- const mercatorPxToLngLatWS = (px, py, worldSize) => {
8186
- const lng = (px / worldSize) * 360 - 180;
8187
- const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / worldSize))) * RAD_TO_DEG$1;
8188
- return [lng, lat];
8189
- };
8190
- const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => lngLatToMercatorPxWS(lng, lat, tileSize * Math.pow(2, zoom));
8191
8295
  /**
8192
8296
  * Determine which side of an edge the geometry interior lies on, in screen space.
8193
8297
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -10030,6 +10134,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10030
10134
  }, [suppressedFillIdsByHandle]);
10031
10135
  const debounceTimerRef = useRef(null);
10032
10136
  const idleRecomputeTimerRef = useRef(null);
10137
+ /** Pending deferred selection re-tile (double-rAF handle); see selection effect. */
10138
+ const selectionRecomputeRafRef = useRef(null);
10033
10139
  // Currently emitted overrides — used for hit-testing visible fills in handleSettle.
10034
10140
  const prevFillOverridesRef = useRef(EMPTY_OVERRIDES);
10035
10141
  const prevZIndexOverridesRef = useRef(EMPTY_ZINDEX_OVERRIDES);
@@ -10284,8 +10390,30 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10284
10390
  return;
10285
10391
  recompute();
10286
10392
  }, [recompute, tilingContentKey]);
10393
+ // Selection forces the selected feature to the front of its overlap group,
10394
+ // which requires a resting re-tile (computeFillTiling depends on
10395
+ // selectedFeatureId via resolveStackOrder). For large layers that re-tile is
10396
+ // expensive (~600ms for 200 polygons over global bounds) and must NOT block
10397
+ // the interaction's first paint — e.g. a panel opening on the clicked feature.
10398
+ // Defer it past the next paint with a double rAF so the click commits and
10399
+ // paints immediately, then the selected polygon restacks one frame later.
10400
+ // Rapid selections coalesce: each schedule cancels the previous pending one.
10287
10401
  useEffect(() => {
10288
- recompute();
10402
+ if (selectionRecomputeRafRef.current !== null) {
10403
+ cancelAnimationFrame(selectionRecomputeRafRef.current);
10404
+ }
10405
+ selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10406
+ selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10407
+ selectionRecomputeRafRef.current = null;
10408
+ recompute();
10409
+ });
10410
+ });
10411
+ return () => {
10412
+ if (selectionRecomputeRafRef.current !== null) {
10413
+ cancelAnimationFrame(selectionRecomputeRafRef.current);
10414
+ selectionRecomputeRafRef.current = null;
10415
+ }
10416
+ };
10289
10417
  }, [recompute, selectedFeatureId]);
10290
10418
  // Suppression changes must trigger a recompute so EMPTY_FILL entries are
10291
10419
  // added / removed even when the map is idle (covers the decorations-vs-tiling
@@ -10537,7 +10665,24 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
10537
10665
  const [viewportStyleOverrides, setViewportStyleOverrides] = useState(new Map());
10538
10666
  const [fillGeometryOverrides, setFillGeometryOverrides] = useState(new Map());
10539
10667
  const [zIndexOverrides, setZIndexOverrides] = useState(new Map());
10668
+ // Mirror the authoritative hovered entity into a ref. The settle channel fires
10669
+ // on a ~120ms debounce (outside React's render cycle) and needs to read the
10670
+ // current hover synchronously.
10671
+ const hoveredEntityRef = useRef(layers.interaction.hoveredEntity);
10672
+ useEffect(() => {
10673
+ hoveredEntityRef.current = layers.interaction.hoveredEntity;
10674
+ }, [layers.interaction.hoveredEntity]);
10540
10675
  const onSettledHover = useCallback((handleId, featureId) => {
10676
+ // Z-order truth: markers and clusters render above shapes (DOM/symbol portals
10677
+ // over the polygon canvas). When a point entity is the current hovered entity
10678
+ // it is the authoritative top hit under the cursor, so the shape settle — which
10679
+ // is not itself position-gated against the marker layer — must not overwrite it.
10680
+ // Without this guard, moving onto an asset pill that overlaps a site would leave
10681
+ // the covered site as `hoveredEntity` (a stale cross-channel hover write). We read
10682
+ // the single source of truth (`hoveredEntity`) rather than a parallel guard flag.
10683
+ const hovered = hoveredEntityRef.current;
10684
+ if (hovered !== null && (hovered.type === "marker" || hovered.type === "cluster"))
10685
+ return;
10541
10686
  // Fill tiling only fires on polygonal features — shapeType is always "polygon".
10542
10687
  layers.hover({ type: "shape", id: featureId, handleId, shapeType: "polygon" });
10543
10688
  }, [layers]);
@@ -10599,17 +10744,27 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
10599
10744
  });
10600
10745
  }, [layers.handles, adaptiveByLayer, viewportStyleOverrides, fillGeometryOverrides, zIndexOverrides, queryAt]);
10601
10746
  const { renderedSourceIds, hasSourceReadiness, readyRevision } = useLayerHandleSync(layerPort, enrichedHandles, layers.interaction, decorationLayers);
10602
- const hoveredEntityRef = useRef(layers.interaction.hoveredEntity);
10603
- useEffect(() => {
10604
- hoveredEntityRef.current = layers.interaction.hoveredEntity;
10605
- }, [layers.interaction.hoveredEntity]);
10606
10747
  const coordinatedSelect = useCallback((entity) => {
10607
- if (entity !== null && hoveredEntityRef.current !== null) {
10608
- layers.select(hoveredEntityRef.current);
10609
- }
10610
- else {
10748
+ const hovered = hoveredEntityRef.current;
10749
+ if (entity === null || hovered === null) {
10611
10750
  layers.select(entity);
10751
+ return;
10752
+ }
10753
+ // Default to the entity the click actually carried — it is the freshest,
10754
+ // most direct signal of what the user hit, and it hit-tests per element
10755
+ // for markers/clusters/routes. The single exception is a shape-on-shape
10756
+ // conflict: the adapter resolves polygon clicks with its own z-order and
10757
+ // full (unclipped) hit areas, so it cannot honor the fill-tiling ownership
10758
+ // model that decides which overlapping site is visually on top (ADR-0021).
10759
+ // There the settle-resolved hovered shape is the precise winner, so we
10760
+ // trust it instead. This is also why an asset pill overlapping a site
10761
+ // selects the asset: a marker click is never a shape, so it bypasses the
10762
+ // exception even when a stale site shape is still the hovered entity.
10763
+ if (entity.type === "shape" && hovered.type === "shape") {
10764
+ layers.select(hovered);
10765
+ return;
10612
10766
  }
10767
+ layers.select(entity);
10613
10768
  }, [layers]);
10614
10769
  const fitOnDblClick = useDblClickFit(api, layers.handles);
10615
10770
  const handleDblClick = useCallback((entity) => {
@@ -11589,4 +11744,4 @@ const mockMapApi = (overrides) => {
11589
11744
  */
11590
11745
  setupLibraryTranslations();
11591
11746
 
11592
- export { ClusterMarker, ClusterStick, Controls, DEFAULT_MARKER_SIZE_BREAKPOINTS, DefaultControls, Layers, MARKER_DARK_PILL, MARKER_DISC_BORDER_WIDTH_PX, MARKER_LIGHT_PILL, MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_SIZE_MAP, MARKER_TUNING, MapLoadingState, MapMarker, MapMarkerIcon, ShapeAnnotationLabel, buildExpandedIds, cvaMapMarker, cvaMarkerIndicator, mockMapApi, useAdaptiveMarkerHelpers, useAutoPanResolver, useCameraIdle, useCameraState, useClusterCountFormat, useControlStack, useControls, useDefaultControls, useDirectionIndicator, useExpandedIds, useFitFeatureBounds, useFitToContent, useImageOverlay, useLayers, useMap, useMapAnnotation, useMapAnnotations, useMapAppearanceControls, useMapKeyboardNavigation, useMarkerColors, useMarkerStateResolvers, useMarkers, usePanel, usePanelPreload, usePreviewMap, useRoute, useShapeLabelHelpers, useShapes, useViewportContext };
11747
+ export { ClusterMarker, ClusterStick, Controls, DEFAULT_MARKER_SIZE_BREAKPOINTS, DefaultControls, Layers, MARKER_DARK_PILL, MARKER_DISC_BORDER_WIDTH_PX, MARKER_LIGHT_PILL, MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_SIZE_MAP, MARKER_TUNING, MapLoadingState, MapMarker, MapMarkerIcon, ShapeAnnotationLabel, buildExpandedIds, cvaMapMarker, cvaMarkerIndicator, mockMapApi, useAdaptiveMarkerHelpers, useAutoPanResolver, useCameraIdle, useCameraState, useClusterCountFormat, useControlStack, useControls, useDefaultControls, useDirectionIndicator, useEntitiesNearCursor, useExpandedIds, useFitFeatureBounds, useFitToContent, useImageOverlay, useLayers, useMap, useMapAnnotation, useMapAnnotations, useMapAppearanceControls, useMapKeyboardNavigation, useMarkerColors, useMarkerStateResolvers, useMarkers, usePanel, usePanelPreload, usePreviewMap, useRoute, useShapeLabelHelpers, useShapes, useViewportContext };
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.1.24",
3
+ "version": "0.1.27",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-components": "2.1.35",
11
- "@trackunit/css-class-variance-utilities": "1.13.39",
12
- "@trackunit/react-form-components": "2.1.37",
13
- "@trackunit/react-core-hooks": "1.17.48",
14
- "@trackunit/geo-json-utils": "1.14.42",
15
- "@trackunit/i18n-library-translation": "2.0.36",
16
- "@trackunit/react-modal": "2.1.38",
10
+ "@trackunit/react-components": "2.1.38",
11
+ "@trackunit/css-class-variance-utilities": "1.13.42",
12
+ "@trackunit/react-form-components": "2.1.40",
13
+ "@trackunit/react-core-hooks": "1.17.51",
14
+ "@trackunit/geo-json-utils": "1.14.45",
15
+ "@trackunit/i18n-library-translation": "2.0.39",
16
+ "@trackunit/react-modal": "2.1.41",
17
17
  "react-minimal-pie-chart": "^8.4.0",
18
- "@trackunit/react-map-adapter-shared": "0.0.23",
19
- "@trackunit/react-map-color-utils": "0.0.8",
20
- "@trackunit/ui-design-tokens": "1.13.39",
18
+ "@trackunit/react-map-adapter-shared": "0.0.26",
19
+ "@trackunit/react-map-color-utils": "0.0.11",
20
+ "@trackunit/ui-design-tokens": "1.13.42",
21
21
  "@floating-ui/react": "^0.26.25",
22
22
  "es-toolkit": "^1.39.10",
23
23
  "tailwind-merge": "^2.0.0",
@@ -0,0 +1,36 @@
1
+ import type { GeoJsonPosition, MapApi } from "../core/types";
2
+ /**
3
+ * A point entity that can be tested against the cursor. `position` is the
4
+ * geographic anchor as `[lng, lat]`.
5
+ */
6
+ export type EntityNearCursor = Readonly<{
7
+ id: string;
8
+ position: GeoJsonPosition;
9
+ }>;
10
+ export type UseEntitiesNearCursorOptions = Readonly<{
11
+ /** The point entities to hit-test against the cursor on every move. */
12
+ entities: ReadonlyArray<EntityNearCursor>;
13
+ /** Hit radius around the cursor, in CSS pixels. */
14
+ radiusPx: number;
15
+ }>;
16
+ /**
17
+ * The **Entities Near Cursor** primitive: returns a referentially-stable
18
+ * `ReadonlySet<string>` of entity ids whose projected position is within
19
+ * `radiusPx` CSS pixels of the projected cursor, nearest-first.
20
+ *
21
+ * It is the point-marker counterpart to **Shapes Under Cursor** and runs on the
22
+ * same pure Web Mercator substrate (`@trackunit/geo-json-utils`) fed by
23
+ * `useCameraState` + the RAF-throttled `pointermove` event — adapter-agnostic by
24
+ * construction (see ADR-0025). The hook owns radius geometry only; consumers
25
+ * decide why the ids are wanted (preload, highlight, …) and may cap the set to
26
+ * the nearest N via iteration order.
27
+ *
28
+ * The returned Set keeps a stable identity until either its membership or its
29
+ * nearest-first ordering changes, so wiring it straight into a memo or query
30
+ * does not churn while the cursor drifts within the same cluster of dots at the
31
+ * same relative distances. The host only re-renders when membership or ordering
32
+ * actually changes: the cursor lives in a ref and recomputes commit through an
33
+ * order-sensitive functional update that bails out (returns the previous Set)
34
+ * when nothing changed.
35
+ */
36
+ export declare const useEntitiesNearCursor: (api: MapApi, options: UseEntitiesNearCursorOptions) => ReadonlySet<string>;
package/src/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export { MARKER_TUNING } from "./markers/model/markerTuningParams";
38
38
  export { cvaMapMarker, cvaMarkerIndicator } from "./markers/shared/mapMarkerVariants";
39
39
  export { MARKER_DARK_PILL, MARKER_LIGHT_PILL } from "./markers/shared/markerColors";
40
40
  export type { MarkerColorConfig, MarkerColorCssVars, ResolvedMarkerColors } from "./markers/shared/markerColors";
41
+ export { useEntitiesNearCursor, type EntityNearCursor, type UseEntitiesNearCursorOptions, } from "./cursor/useEntitiesNearCursor";
41
42
  export { type HoverPanelPreloadInitiator, type PanelPreloadInitiator, type PanelPreloadInitiatorType, type ProximityPanelPreloadInitiator, } from "./panel/preload/preloadInitiators";
42
43
  export { usePanelPreload, type UsePanelPreloadOptions } from "./panel/preload/usePanelPreload";
43
44
  export { type PanelStore } from "./panel/store/panels";
@@ -39,8 +39,6 @@ export type EdgeInsets = Readonly<{
39
39
  export declare const clipSegmentToRect: (x0: number, y0: number, x1: number, y1: number, xMin: number, yMin: number, xMax: number, yMax: number) => ClippedSegment | null;
40
40
  export { extractEdges } from "@trackunit/geo-json-utils";
41
41
  export { computeGeometryCentroid } from "@trackunit/geo-json-utils";
42
- export declare const lngLatToWebMercatorPx: (lng: number, lat: number, zoom: number, tileSize?: number) => readonly [number, number];
43
- export declare const webMercatorPxToLngLat: (px: number, py: number, zoom: number, tileSize?: number) => readonly [number, number];
44
42
  /**
45
43
  * Compute the screen-space angle (in degrees) of a line segment, accounting for
46
44
  * Web Mercator distortion. Normalized to [-90, 90] so text reads left-to-right.
@@ -60,11 +58,6 @@ export declare const edgeScreenAngleDeg: (x0: number, y0: number, x1: number, y1
60
58
  * Latitude pixels scale by sec(lat).
61
59
  */
62
60
  export declare const edgePixelLength: (x0: number, y0: number, x1: number, y1: number, zoom: number, midLatDeg: number, tileSize?: number) => number;
63
- /**
64
- * Convert a pixel distance to latitude degrees at a given zoom level,
65
- * accounting for Web Mercator latitude distortion.
66
- */
67
- export declare const pixelsToLatDegrees: (pixels: number, zoom: number, latDeg: number, tileSize?: number) => number;
68
61
  /**
69
62
  * Determine which side of an edge the geometry interior lies on, in screen space.
70
63
  * Returns "above" if the label should extend above the edge (interior is below),