@trackunit/react-map 0.1.24 → 0.1.25

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
@@ -8111,7 +8227,6 @@ const DEG_TO_RAD = Math.PI / 180;
8111
8227
  const RAD_TO_DEG$1 = 180 / Math.PI;
8112
8228
  const DEFAULT_EDGE_LABEL_INSET_PX = 6;
8113
8229
  const LABEL_HEIGHT_PX = 20;
8114
- const MAX_WEB_MERCATOR_LAT = 85.05112878;
8115
8230
  const EARTH_RADIUS_KM = 6371;
8116
8231
  const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = reactMapAdapterShared.GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
8117
8232
  /**
@@ -8171,24 +8286,6 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8171
8286
  }
8172
8287
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8173
8288
  };
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
8289
  /**
8193
8290
  * Determine which side of an edge the geometry interior lies on, in screen space.
8194
8291
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -8539,8 +8636,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8539
8636
  // edge — causing labels to float away from the line at mid-edge positions.
8540
8637
  // Clipping in pixel space ensures clip endpoints lie exactly on the
8541
8638
  // 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);
8639
+ const [pxViewMin, pyViewMin] = geoJsonUtils.lngLatToMercatorPxWS(minLon, maxLat, worldSize);
8640
+ const [pxViewMax, pyViewMax] = geoJsonUtils.lngLatToMercatorPxWS(maxLon, minLat, worldSize);
8544
8641
  const viewportWidth = pxViewMax - pxViewMin;
8545
8642
  const viewportHeight = pyViewMax - pyViewMin;
8546
8643
  for (let edgeIdx = 0; edgeIdx < edges.length; edgeIdx++) {
@@ -8558,8 +8655,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8558
8655
  const segmentClips = [];
8559
8656
  for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
8560
8657
  const { start, end } = segment;
8561
- const [startPxX, startPxY] = lngLatToMercatorPxWS(start[0], start[1], worldSize);
8562
- const [endPxX, endPxY] = lngLatToMercatorPxWS(end[0], end[1], worldSize);
8658
+ const [startPxX, startPxY] = geoJsonUtils.lngLatToMercatorPxWS(start[0], start[1], worldSize);
8659
+ const [endPxX, endPxY] = geoJsonUtils.lngLatToMercatorPxWS(end[0], end[1], worldSize);
8563
8660
  const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8564
8661
  if (!clippedPx)
8565
8662
  continue;
@@ -8575,8 +8672,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8575
8672
  : [-rawDx / segPxLen, -rawDy / segPxLen];
8576
8673
  // Inverse-project pixel clip endpoints to geo for outward-side and
8577
8674
  // 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);
8675
+ const [ex0, ey0] = geoJsonUtils.mercatorPxToLngLatWS(px0, py0, worldSize);
8676
+ const [ex1, ey1] = geoJsonUtils.mercatorPxToLngLatWS(px1, py1, worldSize);
8580
8677
  const midLat = (ey0 + ey1) / 2;
8581
8678
  const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8582
8679
  if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
@@ -8713,7 +8810,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8713
8810
  for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
8714
8811
  if (matchingCandidate.pxLen <= 0)
8715
8812
  continue;
8716
- const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8813
+ const [prevAnchorAbsPx, prevAnchorAbsPy] = geoJsonUtils.lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8717
8814
  const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
8718
8815
  const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
8719
8816
  const dx = prevAnchorRelX - matchingCandidate.readingStartPx[0];
@@ -8783,7 +8880,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8783
8880
  // great-circle arc by computing the arc fraction via angular distance
8784
8881
  // from A. This places the label physically on the visible curved arc
8785
8882
  // rather than on the straight Mercator chord.
8786
- const [approxLng, approxLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8883
+ const [approxLng, approxLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8787
8884
  const tArc = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxLng, approxLat) / delta));
8788
8885
  const geoAnchor = reactMapAdapterShared.intermediatePoint(A, B, tArc, delta);
8789
8886
  position = [geoAnchor[0], geoAnchor[1]];
@@ -8797,13 +8894,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8797
8894
  const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
8798
8895
  const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
8799
8896
  const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
8800
- const [approxCenterLng, approxCenterLat] = mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8897
+ const [approxCenterLng, approxCenterLat] = geoJsonUtils.mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8801
8898
  const tArcCenter = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
8802
8899
  const TANGENT_EPS = 0.001;
8803
8900
  const p0 = reactMapAdapterShared.intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
8804
8901
  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);
8902
+ const [bx, by] = geoJsonUtils.lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
8903
+ const [ax, ay] = geoJsonUtils.lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
8807
8904
  const ddx = ax - bx;
8808
8905
  const ddy = ay - by;
8809
8906
  const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
@@ -8820,13 +8917,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8820
8917
  }
8821
8918
  else {
8822
8919
  // Co-located vertices — fall back to Mercator straight-line.
8823
- const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8920
+ const [anchorLng, anchorLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8824
8921
  position = [anchorLng, anchorLat];
8825
8922
  resolvedDirectionPx = best.directionPx;
8826
8923
  }
8827
8924
  }
8828
8925
  else {
8829
- const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8926
+ const [anchorLng, anchorLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8830
8927
  position = [anchorLng, anchorLat];
8831
8928
  resolvedDirectionPx = best.directionPx;
8832
8929
  }
@@ -9014,19 +9111,19 @@ const computeEdgeProperties = (start, end, geometry, zoom, tileSize, geodesic =
9014
9111
  const TANGENT_EPS = 0.001;
9015
9112
  const p0 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 - TANGENT_EPS, delta);
9016
9113
  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);
9114
+ const [bx, by] = geoJsonUtils.lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
9115
+ const [ax, ay] = geoJsonUtils.lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
9019
9116
  angleDeg = normalizeReadableAngle(Math.atan2(ay - by, ax - bx) * RAD_TO_DEG);
9020
9117
  }
9021
9118
  else {
9022
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9023
- const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9119
+ const [startPx, startPy] = geoJsonUtils.lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9120
+ const [endPx, endPy] = geoJsonUtils.lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9024
9121
  angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
9025
9122
  }
9026
9123
  }
9027
9124
  else {
9028
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9029
- const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9125
+ const [startPx, startPy] = geoJsonUtils.lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
9126
+ const [endPx, endPy] = geoJsonUtils.lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
9030
9127
  angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
9031
9128
  }
9032
9129
  const pixelLength = geoJsonUtils.edgePixelLength(start[0], start[1], end[0], end[1], zoom, midLat, tileSize);
@@ -11671,6 +11768,7 @@ exports.useControlStack = useControlStack;
11671
11768
  exports.useControls = useControls;
11672
11769
  exports.useDefaultControls = useDefaultControls;
11673
11770
  exports.useDirectionIndicator = useDirectionIndicator;
11771
+ exports.useEntitiesNearCursor = useEntitiesNearCursor;
11674
11772
  exports.useExpandedIds = useExpandedIds;
11675
11773
  exports.useFitFeatureBounds = useFitFeatureBounds;
11676
11774
  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
@@ -8110,7 +8226,6 @@ const DEG_TO_RAD = Math.PI / 180;
8110
8226
  const RAD_TO_DEG$1 = 180 / Math.PI;
8111
8227
  const DEFAULT_EDGE_LABEL_INSET_PX = 6;
8112
8228
  const LABEL_HEIGHT_PX = 20;
8113
- const MAX_WEB_MERCATOR_LAT = 85.05112878;
8114
8229
  const EARTH_RADIUS_KM = 6371;
8115
8230
  const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
8116
8231
  /**
@@ -8170,24 +8285,6 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8170
8285
  }
8171
8286
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8172
8287
  };
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
8288
  /**
8192
8289
  * Determine which side of an edge the geometry interior lies on, in screen space.
8193
8290
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -11589,4 +11686,4 @@ const mockMapApi = (overrides) => {
11589
11686
  */
11590
11687
  setupLibraryTranslations();
11591
11688
 
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 };
11689
+ 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.25",
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.36",
11
+ "@trackunit/css-class-variance-utilities": "1.13.40",
12
+ "@trackunit/react-form-components": "2.1.38",
13
+ "@trackunit/react-core-hooks": "1.17.49",
14
+ "@trackunit/geo-json-utils": "1.14.43",
15
+ "@trackunit/i18n-library-translation": "2.0.37",
16
+ "@trackunit/react-modal": "2.1.39",
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.24",
19
+ "@trackunit/react-map-color-utils": "0.0.9",
20
+ "@trackunit/ui-design-tokens": "1.13.40",
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),