@trackunit/react-map 0.1.22 → 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
@@ -3855,9 +3855,9 @@ const resolveMarkerDomSize = (size, direction, form) => {
3855
3855
  * both states. The inner indicator disc (`cvaMarkerIndicator` below) carries
3856
3856
  * its own per-size `h-* w-*`; the outer surface sizes intrinsically around it.
3857
3857
  *
3858
- * `transition-all` enables the morph; the duration and timing-function are
3859
- * applied inline from `MARKER_TUNING.animation` so the spring config remains
3860
- * the single source of truth (no stale `duration-200 ease-in-out` here).
3858
+ * `transitionProperty` is set inline in `MapMarker.tsx` to list only the
3859
+ * properties that need to animate, intentionally excluding `width` to prevent
3860
+ * non-composited layout thrashing. See `MarkerAnimatedSurface.tsx` for details.
3861
3861
  */
3862
3862
  const cvaMapMarker = cssClassVarianceUtilities.cvaMerge([
3863
3863
  "inline-flex",
@@ -3866,7 +3866,6 @@ const cvaMapMarker = cssClassVarianceUtilities.cvaMerge([
3866
3866
  "overflow-hidden",
3867
3867
  "select-none",
3868
3868
  "cursor-pointer",
3869
- "transition-all",
3870
3869
  "rounded-full",
3871
3870
  "outline-none",
3872
3871
  "focus-visible:ring-2",
@@ -3937,69 +3936,91 @@ const MarkerDiscContent = ({ icon, iconPx, isPill, dotPx, directionDisplay, }) =
3937
3936
  return null;
3938
3937
  };
3939
3938
 
3940
- // Single canonical morph transition shared by every slot inside the surface
3941
- // (disc + direction). Keeping disc and direction in lock-step means they
3942
- // always read as one synchronised motion rather than two independent fades.
3943
- // Easing comes from the shared morph easing so the CSS-driven slots match
3944
- // the spring's decelerating attack.
3945
- const slotTransition = (duration, easing) => {
3946
- return `transform ${duration}ms ${easing}, opacity ${duration}ms ${easing}, width ${duration}ms ${easing}`;
3947
- };
3939
+ /**
3940
+ * Compositable per-slot transition `transform` and `opacity` are GPU-composited
3941
+ * and do not trigger layout (reflow).
3942
+ *
3943
+ * `width` was intentionally omitted: animating `width` is a non-composited operation
3944
+ * that forces the browser to re-run layout on every animation frame. When many markers
3945
+ * transition simultaneously (e.g. after a zoom-driven refetch) this caused ~29 concurrent
3946
+ * non-composited width animations and a CLS score of 0.58. Space-collapse is now handled
3947
+ * via `grid-template-columns: 0fr ↔ 1fr` on the slot wrapper divs, which produces the
3948
+ * same visual result without blocking the compositor.
3949
+ */
3950
+ const slotContentTransition = (duration, easing) => `transform ${duration}ms ${easing}, opacity ${duration}ms ${easing}`;
3948
3951
  const MarkerAnimatedSurface = ({ colors, directionDisplay, duration, effectiveSize, icon, iconPx, isPill, labelText, showDisc, textPx, theme, }) => {
3949
- const circleD = MARKER_SIZE_MAP[effectiveSize].circle;
3950
3952
  const dotPx = MARKER_SIZE_MAP[effectiveSize].dot;
3951
3953
  const easing = MARKER_TUNING.animation.morphEasing;
3952
- const slotTx = slotTransition(duration, easing);
3954
+ const contentTx = slotContentTransition(duration, easing);
3953
3955
  const pillDirectionVisible = isPill && directionDisplay.show;
3954
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { "data-testid": "MapMarker-disc", style: {
3955
- alignItems: "center",
3956
- display: "inline-flex",
3956
+ // grid-template-columns 0fr 1fr is the compositable-friendly space-collapse
3957
+ // technique: the column animates its available-space fraction while overflow:clip
3958
+ // hides the partially-visible content. This avoids animating `width` directly.
3959
+ const gridTx = `grid-template-columns ${duration}ms ${easing}`;
3960
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { "data-testid": "MapMarker-disc", style: {
3961
+ display: "grid",
3957
3962
  flexShrink: 0,
3958
- justifyContent: "center",
3959
- transition: slotTx,
3960
- width: showDisc ? circleD : 0,
3961
- }, children: jsxRuntime.jsx("span", { className: cvaMarkerIndicator({ size: effectiveSize, theme }), style: {
3962
- flexShrink: 0,
3963
- opacity: showDisc ? 1 : 0,
3964
- transform: showDisc ? "scale(1)" : "scale(0)",
3965
- transition: slotTx,
3966
- }, children: jsxRuntime.jsx(MarkerDiscContent, { directionDisplay: directionDisplay, dotPx: dotPx, icon: icon, iconPx: iconPx, isPill: isPill }) }) }), jsxRuntime.jsx("div", { style: {
3963
+ gridTemplateColumns: showDisc ? "1fr" : "0fr",
3964
+ overflow: "clip",
3965
+ transition: gridTx,
3966
+ }, children: jsxRuntime.jsx("span", { style: {
3967
+ alignItems: "center",
3968
+ display: "inline-flex",
3969
+ justifyContent: "center",
3970
+ // Override the default `min-width: auto` on grid items so the `0fr`
3971
+ // track can collapse to zero width instead of the child's min-content.
3972
+ minWidth: 0,
3973
+ }, children: jsxRuntime.jsx("span", { className: cvaMarkerIndicator({ size: effectiveSize, theme }), style: {
3974
+ flexShrink: 0,
3975
+ opacity: showDisc ? 1 : 0,
3976
+ transform: showDisc ? "scale(1)" : "scale(0)",
3977
+ transition: contentTx,
3978
+ }, children: jsxRuntime.jsx(MarkerDiscContent, { directionDisplay: directionDisplay, dotPx: dotPx, icon: icon, iconPx: iconPx, isPill: isPill }) }) }) }), jsxRuntime.jsx("div", { style: {
3967
3979
  display: "grid",
3968
3980
  gridTemplateColumns: isPill ? "1fr" : "0fr",
3969
3981
  overflowX: "clip",
3970
- transition: `grid-template-columns ${duration}ms ${easing}`,
3982
+ transition: gridTx,
3971
3983
  }, children: jsxRuntime.jsx("span", { style: {
3972
3984
  alignItems: "center",
3973
3985
  display: "flex",
3986
+ // Override the default `min-width: auto` on grid items so the `0fr`
3987
+ // track can collapse to zero width instead of the child's min-content.
3988
+ minWidth: 0,
3974
3989
  overflow: "hidden",
3975
3990
  }, children: labelText !== undefined ? (jsxRuntime.jsx("span", { className: cvaPillLabel(), style: {
3976
3991
  fontSize: textPx > 0 ? `${textPx}px` : undefined,
3977
3992
  maxWidth: MARKER_TUNING.pill.maxLabelWidthPx,
3978
3993
  opacity: isPill ? 1 : 0,
3979
3994
  transition: `opacity ${duration}ms ${easing}`,
3980
- }, children: labelText })) : null }) }), jsxRuntime.jsx("span", { style: {
3981
- alignItems: "center",
3982
- display: "inline-flex",
3995
+ }, children: labelText })) : null }) }), jsxRuntime.jsx("div", { style: {
3996
+ display: "grid",
3983
3997
  flexShrink: 0,
3984
- justifyContent: "center",
3985
- transition: slotTx,
3986
- width: isPill ? directionDisplay.pixelSize : 0,
3998
+ gridTemplateColumns: isPill ? "1fr" : "0fr",
3999
+ overflow: "clip",
4000
+ transition: gridTx,
3987
4001
  }, children: jsxRuntime.jsx("span", { style: {
3988
4002
  alignItems: "center",
3989
4003
  display: "inline-flex",
3990
- flexShrink: 0,
3991
4004
  justifyContent: "center",
3992
- opacity: pillDirectionVisible ? 1 : 0,
3993
- transform: pillDirectionVisible ? "scale(1)" : "scale(0)",
3994
- transformOrigin: "center center",
3995
- transition: slotTx,
3996
- }, children: directionDisplay.show ? (jsxRuntime.jsx(reactComponents.Icon, { ariaHidden: true, "data-testid": "MapMarker-direction-pill", name: "GpsArrowNorth", size: "small", style: {
3997
- color: colors.textColor,
4005
+ // Override the default `min-width: auto` on grid items so the `0fr`
4006
+ // track can collapse to zero width instead of the child's min-content.
4007
+ minWidth: 0,
4008
+ }, children: jsxRuntime.jsx("span", { style: {
4009
+ alignItems: "center",
4010
+ display: "inline-flex",
3998
4011
  flexShrink: 0,
3999
- height: directionDisplay.pixelSize,
4000
- rotate: `${directionDisplay.rotationDegrees}deg`,
4001
- width: directionDisplay.pixelSize,
4002
- } })) : null }) })] }));
4012
+ justifyContent: "center",
4013
+ opacity: pillDirectionVisible ? 1 : 0,
4014
+ transform: pillDirectionVisible ? "scale(1)" : "scale(0)",
4015
+ transformOrigin: "center center",
4016
+ transition: contentTx,
4017
+ }, children: directionDisplay.show ? (jsxRuntime.jsx(reactComponents.Icon, { ariaHidden: true, "data-testid": "MapMarker-direction-pill", name: "GpsArrowNorth", size: "small", style: {
4018
+ color: colors.textColor,
4019
+ flexShrink: 0,
4020
+ height: directionDisplay.pixelSize,
4021
+ rotate: `${directionDisplay.rotationDegrees}deg`,
4022
+ width: directionDisplay.pixelSize,
4023
+ } })) : null }) }) })] }));
4003
4024
  };
4004
4025
 
4005
4026
  const isActivatingKey$2 = (key) => key === "Enter" || key === " ";
@@ -4139,6 +4160,9 @@ const STICK_FAN_RADIUS_PX = 56;
4139
4160
  /** Doughnut ring thickness as a percentage of the chart radius (matches the legacy cluster ring). */
4140
4161
  const PIE_LINE_WIDTH_PERCENT = 25;
4141
4162
  const PIE_START_ANGLE = -90;
4163
+ /** Stable empty sticks array — used as the default for the `sticks` prop so the useEffect
4164
+ * dependency does not change on every render when no sticks are passed. */
4165
+ const EMPTY_STICKS = [];
4142
4166
  const isActivatingKey$1 = (key) => key === "Enter" || key === " ";
4143
4167
  /** Fan angle (radians) for a given index, starting at top (-π/2) and spread around a full circle. */
4144
4168
  const computeFanAngle = (index, total) => {
@@ -4161,7 +4185,7 @@ const resolveBubbleBackground = (state, config) => {
4161
4185
  return config.expandedBackground;
4162
4186
  };
4163
4187
  const ClusterMarkerInner = react.forwardRef(function ClusterMarkerComponent(props, ref) {
4164
- const { count, segments, sticks = [], onStickClick, onClick, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, state = "default", theme, colorConfig, className, style, "data-testid": dataTestId, "aria-label": ariaLabel, } = props;
4188
+ const { count, segments, sticks = EMPTY_STICKS, onStickClick, onClick, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, state = "default", theme, colorConfig, className, style, "data-testid": dataTestId, "aria-label": ariaLabel, } = props;
4165
4189
  const formatted = formatClusterCount(count);
4166
4190
  const effectiveColorConfig = { ...defaultMarkerColorConfig(theme), ...colorConfig };
4167
4191
  const colors = resolveMarkerColors("transparent", state, theme, colorConfig);
@@ -4218,11 +4242,11 @@ const ClusterMarkerInner = react.forwardRef(function ClusterMarkerComponent(prop
4218
4242
  event.currentTarget.click();
4219
4243
  }
4220
4244
  }, [onKeyDown]);
4221
- const pieData = segments.map((segment, index) => ({
4245
+ const pieData = react.useMemo(() => segments.map((segment, index) => ({
4222
4246
  color: segment.color,
4223
4247
  value: segment.weight,
4224
4248
  title: `segment-${index}`,
4225
- }));
4249
+ })), [segments]);
4226
4250
  const bubbleTestId = dataTestId !== undefined ? `${dataTestId}-bubble` : undefined;
4227
4251
  return (jsxRuntime.jsxs("div", { className: className, "data-testid": dataTestId, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, style: {
4228
4252
  position: "relative",
@@ -4387,6 +4411,9 @@ const MapMarkerInner = react.forwardRef(function MapMarkerComponent(props, ref)
4387
4411
  gap: isPill ? MARKER_TUNING.pill.gapPx : 0,
4388
4412
  borderWidth: showPillBorder ? 1 : 0,
4389
4413
  borderStyle: "solid",
4414
+ // Explicitly list only the properties that need to animate on the outer shell.
4415
+ // `width` is intentionally excluded — see MarkerAnimatedSurface for details.
4416
+ transitionProperty: "background-color, border-color, border-width, padding, gap, color",
4390
4417
  transitionDuration: `${morphDurationMs}ms`,
4391
4418
  transitionTimingFunction: MARKER_TUNING.animation.morphEasing,
4392
4419
  ...(isPill
@@ -4443,6 +4470,122 @@ function MapMarkerIcon({ name, size, title }) {
4443
4470
  }, title: title, children: jsxRuntime.jsx(reactComponents.Icon, { ariaHidden: true, name: name, size: "small" }) }));
4444
4471
  }
4445
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
+
4446
4589
  const DEFAULT_DEBOUNCE_MS = 150;
4447
4590
  /**
4448
4591
  * Fires `onShouldPreload` for the currently hovered entity after a debounce
@@ -7598,7 +7741,7 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
7598
7741
  return result;
7599
7742
  };
7600
7743
  const computeFillTiling = (input) => {
7601
- const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7744
+ const { features, viewportBounds, resolveStackOrder, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7602
7745
  // Densify before any polygon/boundary math so clip geometry follows great-circle
7603
7746
  // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
7604
7747
  const featureCollection = {
@@ -7637,7 +7780,7 @@ const computeFillTiling = (input) => {
7637
7780
  featureToGroupKey.set(record.id, key);
7638
7781
  }
7639
7782
  // 1. Resting order: containment then smaller-area.
7640
- let order = resolveStackOrder(contended, { zoom });
7783
+ let order = resolveStackOrder(contended);
7641
7784
  // 2. Selection override: selected feature always takes front (highest priority).
7642
7785
  if (selectedFeatureId !== null && selectedFeatureId !== undefined) {
7643
7786
  order = promoteToFront(order, selectedFeatureId);
@@ -7743,8 +7886,11 @@ const useViewportContext = (api) => {
7743
7886
 
7744
7887
  const FALLBACK_CHAR_WIDTH = 6;
7745
7888
  const FALLBACK_PADDING = 16;
7889
+ // px-2 from cvaShapeLabel: 8px left + 8px right = 16px total
7890
+ const LABEL_HORIZONTAL_PADDING_PX = 16;
7746
7891
  const labelWidthCache = new Map();
7747
7892
  let probeElement = null;
7893
+ let canvasCtx = null;
7748
7894
  /**
7749
7895
  * Character-count fallback for environments where DOM measurement is unavailable
7750
7896
  * (SSR, jsdom, headless tests).
@@ -7771,30 +7917,67 @@ const getProbeElement = () => {
7771
7917
  return el;
7772
7918
  };
7773
7919
  /**
7774
- * Measure the rendered pixel width of label text using an off-screen probe
7775
- * styled with the same CSS classes as shape edge labels (`cvaShapeLabel`).
7920
+ * Returns a CanvasRenderingContext2D configured with the same font as ShapeLabelPill.
7921
+ * The font is read once from the probe element's computed style so canvas measurements
7922
+ * match the actual rendered text width. Returns null when canvas is unavailable.
7776
7923
  *
7777
- * Shape edge labels and other layout utilities that need a DOM-measured width use this.
7924
+ * Canvas.measureText does not trigger a forced layout (reflow), unlike getBoundingClientRect.
7925
+ */
7926
+ const getCanvasCtx = (probe) => {
7927
+ if (canvasCtx !== null)
7928
+ return canvasCtx;
7929
+ if (typeof document === "undefined")
7930
+ return null;
7931
+ const canvas = document.createElement("canvas");
7932
+ const ctx = canvas.getContext("2d");
7933
+ if (ctx === null)
7934
+ return null;
7935
+ // Read the computed font from the probe element once. getComputedStyle forces
7936
+ // a style recalculation (cheap) but NOT a full layout pass (no reflow).
7937
+ const computed = window.getComputedStyle(probe);
7938
+ // `font` shorthand: "font-style font-variant font-weight font-size/line-height font-family"
7939
+ // Falls back to a safe approximation matching text-xs (12px) + font-medium (500).
7940
+ const font = computed.font || "500 12px ui-sans-serif, system-ui, sans-serif";
7941
+ ctx.font = font;
7942
+ canvasCtx = ctx;
7943
+ return ctx;
7944
+ };
7945
+ /**
7946
+ * Measure the rendered pixel width of label text using a Canvas context configured
7947
+ * with the same font as shape edge labels (`cvaShapeLabel` / `ShapeLabelPill`).
7948
+ *
7949
+ * Results are cached per label text. Using Canvas.measureText avoids the forced
7950
+ * synchronous layout (reflow) that getBoundingClientRect triggers when called inside
7951
+ * a Google Maps onDraw callback — the previous implementation caused ~67ms of blocked
7952
+ * main-thread time per draw cycle during zoom (observed in Chrome DevTools trace).
7778
7953
  *
7779
- * Results are cached per label text. In environments where DOM measurement is
7780
- * unavailable or returns zero (SSR, jsdom), falls back to a character-count
7781
- * estimate (label.length * 6 + 16).
7954
+ * Falls back to DOM measurement if canvas is unavailable, and to a character-count
7955
+ * estimate in environments without DOM (SSR, jsdom).
7782
7956
  */
7783
7957
  const measureLabelWidth = (label) => {
7784
7958
  const cached = labelWidthCache.get(label);
7785
7959
  if (cached !== undefined)
7786
7960
  return cached;
7787
7961
  const probe = getProbeElement();
7788
- if (probe === null) {
7789
- const fallback = fallbackLabelWidth(label);
7790
- labelWidthCache.set(label, fallback);
7791
- return fallback;
7792
- }
7793
- probe.textContent = label;
7794
- const measured = probe.getBoundingClientRect().width;
7795
- const width = measured > 0 ? measured : fallbackLabelWidth(label);
7796
- labelWidthCache.set(label, width);
7797
- return width;
7962
+ if (probe !== null) {
7963
+ const ctx = getCanvasCtx(probe);
7964
+ if (ctx !== null) {
7965
+ const textWidth = ctx.measureText(label).width;
7966
+ const canvasWidth = textWidth + LABEL_HORIZONTAL_PADDING_PX;
7967
+ labelWidthCache.set(label, canvasWidth);
7968
+ return canvasWidth;
7969
+ }
7970
+ // Canvas unavailable — fall back to DOM measurement (triggers reflow, but only
7971
+ // for uncached labels and only when CanvasRenderingContext2D is missing).
7972
+ probe.textContent = label;
7973
+ const measured = probe.getBoundingClientRect().width;
7974
+ const domWidth = measured > 0 ? measured : fallbackLabelWidth(label);
7975
+ labelWidthCache.set(label, domWidth);
7976
+ return domWidth;
7977
+ }
7978
+ const fallback = fallbackLabelWidth(label);
7979
+ labelWidthCache.set(label, fallback);
7980
+ return fallback;
7798
7981
  };
7799
7982
 
7800
7983
  const DEG_TO_RAD$1 = Math.PI / 180;
@@ -7931,14 +8114,13 @@ const isShapeMatch = (entity, handleId, featureKey) => {
7931
8114
  return shape.handleId === handleId && shape.id === featureKey;
7932
8115
  };
7933
8116
  /**
7934
- * Build the per-feature, per-frame context passed to an `edge-auto` anchor's
7935
- * `resolveLabel` callback. Pure: no React, no `api` reads — caller supplies the
7936
- * already-projected viewport bits, the precomputed `shapesInViewport` count for
7937
- * the layer, and the current interaction state.
8117
+ * Build the per-feature, per-frame context passed to viewport-aware shape
8118
+ * callbacks. Pure: no React, no `api` reads — caller supplies the already-
8119
+ * projected viewport bits, the precomputed `shapesInViewport` count for the
8120
+ * layer, a lazy overlap count, and the current interaction state.
7938
8121
  *
7939
8122
  * Lives next to `shapeLabelResolution.ts` and is consumed by
7940
- * `useShapeDecorations` once per feature that has at least one `edge-auto`
7941
- * decoration, before any placement work runs.
8123
+ * `useShapeDecorations` once per feature, before any placement work runs.
7942
8124
  */
7943
8125
  const buildShapeLabelResolutionContext = (feature, args) => {
7944
8126
  const geometry = feature.geometry;
@@ -7951,7 +8133,9 @@ const buildShapeLabelResolutionContext = (feature, args) => {
7951
8133
  return {
7952
8134
  zoom: args.zoom,
7953
8135
  shapesInViewport: args.shapesInViewport,
7954
- overlappingShapesCount: args.overlappingShapesCount,
8136
+ get overlappingShapesCount() {
8137
+ return args.getOverlappingShapesCount();
8138
+ },
7955
8139
  geometryType: geometry.type,
7956
8140
  bboxPixelWidth: dims.widthPx,
7957
8141
  bboxPixelHeight: dims.heightPx,
@@ -8043,7 +8227,6 @@ const DEG_TO_RAD = Math.PI / 180;
8043
8227
  const RAD_TO_DEG$1 = 180 / Math.PI;
8044
8228
  const DEFAULT_EDGE_LABEL_INSET_PX = 6;
8045
8229
  const LABEL_HEIGHT_PX = 20;
8046
- const MAX_WEB_MERCATOR_LAT = 85.05112878;
8047
8230
  const EARTH_RADIUS_KM = 6371;
8048
8231
  const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = reactMapAdapterShared.GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
8049
8232
  /**
@@ -8103,20 +8286,6 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8103
8286
  }
8104
8287
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8105
8288
  };
8106
- const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => {
8107
- const worldSize = tileSize * Math.pow(2, zoom);
8108
- const clampedLat = Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat));
8109
- const x = ((lng + 180) / 360) * worldSize;
8110
- const latRad = clampedLat * DEG_TO_RAD;
8111
- const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + latRad / 2)) / (2 * Math.PI)) * worldSize;
8112
- return [x, y];
8113
- };
8114
- const webMercatorPxToLngLat = (px, py, zoom, tileSize = 256) => {
8115
- const worldSize = tileSize * Math.pow(2, zoom);
8116
- const lng = (px / worldSize) * 360 - 180;
8117
- const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / worldSize))) * RAD_TO_DEG$1;
8118
- return [lng, lat];
8119
- };
8120
8289
  /**
8121
8290
  * Determine which side of an edge the geometry interior lies on, in screen space.
8122
8291
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -8457,6 +8626,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8457
8626
  const [minLon, minLat, maxLon, maxLat] = viewportBounds;
8458
8627
  const edges = geoJsonUtils.extractEdges(features);
8459
8628
  const centroid = geoJsonUtils.computeGeometryCentroid(features);
8629
+ // Pre-compute once so the projection helpers below don't call Math.pow on every vertex.
8630
+ const worldSize = tileSize * Math.pow(2, zoom);
8460
8631
  const candidates = [];
8461
8632
  let nextCandidateId = 0;
8462
8633
  // Pre-compute pixel viewport bounds so we can clip in pixel space.
@@ -8465,8 +8636,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8465
8636
  // edge — causing labels to float away from the line at mid-edge positions.
8466
8637
  // Clipping in pixel space ensures clip endpoints lie exactly on the
8467
8638
  // rendered edge, which fixes the floating at the midpoint of long edges.
8468
- const [pxViewMin, pyViewMin] = lngLatToWebMercatorPx(minLon, maxLat, zoom, tileSize);
8469
- const [pxViewMax, pyViewMax] = lngLatToWebMercatorPx(maxLon, minLat, zoom, tileSize);
8639
+ const [pxViewMin, pyViewMin] = geoJsonUtils.lngLatToMercatorPxWS(minLon, maxLat, worldSize);
8640
+ const [pxViewMax, pyViewMax] = geoJsonUtils.lngLatToMercatorPxWS(maxLon, minLat, worldSize);
8470
8641
  const viewportWidth = pxViewMax - pxViewMin;
8471
8642
  const viewportHeight = pyViewMax - pyViewMin;
8472
8643
  for (let edgeIdx = 0; edgeIdx < edges.length; edgeIdx++) {
@@ -8484,8 +8655,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8484
8655
  const segmentClips = [];
8485
8656
  for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
8486
8657
  const { start, end } = segment;
8487
- const [startPxX, startPxY] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8488
- const [endPxX, endPxY] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
8658
+ const [startPxX, startPxY] = geoJsonUtils.lngLatToMercatorPxWS(start[0], start[1], worldSize);
8659
+ const [endPxX, endPxY] = geoJsonUtils.lngLatToMercatorPxWS(end[0], end[1], worldSize);
8489
8660
  const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8490
8661
  if (!clippedPx)
8491
8662
  continue;
@@ -8501,8 +8672,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8501
8672
  : [-rawDx / segPxLen, -rawDy / segPxLen];
8502
8673
  // Inverse-project pixel clip endpoints to geo for outward-side and
8503
8674
  // polygon-interior checks (qualitative, so approximately correct geo is fine).
8504
- const [ex0, ey0] = webMercatorPxToLngLat(px0, py0, zoom, tileSize);
8505
- const [ex1, ey1] = webMercatorPxToLngLat(px1, py1, zoom, tileSize);
8675
+ const [ex0, ey0] = geoJsonUtils.mercatorPxToLngLatWS(px0, py0, worldSize);
8676
+ const [ex1, ey1] = geoJsonUtils.mercatorPxToLngLatWS(px1, py1, worldSize);
8506
8677
  const midLat = (ey0 + ey1) / 2;
8507
8678
  const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8508
8679
  if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
@@ -8639,7 +8810,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8639
8810
  for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
8640
8811
  if (matchingCandidate.pxLen <= 0)
8641
8812
  continue;
8642
- const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToWebMercatorPx(previousAnchorGeo[0], previousAnchorGeo[1], zoom, tileSize);
8813
+ const [prevAnchorAbsPx, prevAnchorAbsPy] = geoJsonUtils.lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8643
8814
  const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
8644
8815
  const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
8645
8816
  const dx = prevAnchorRelX - matchingCandidate.readingStartPx[0];
@@ -8709,7 +8880,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8709
8880
  // great-circle arc by computing the arc fraction via angular distance
8710
8881
  // from A. This places the label physically on the visible curved arc
8711
8882
  // rather than on the straight Mercator chord.
8712
- const [approxLng, approxLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8883
+ const [approxLng, approxLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8713
8884
  const tArc = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxLng, approxLat) / delta));
8714
8885
  const geoAnchor = reactMapAdapterShared.intermediatePoint(A, B, tArc, delta);
8715
8886
  position = [geoAnchor[0], geoAnchor[1]];
@@ -8723,13 +8894,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8723
8894
  const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
8724
8895
  const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
8725
8896
  const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
8726
- const [approxCenterLng, approxCenterLat] = webMercatorPxToLngLat(centerRelX + pxViewMin, centerRelY + pyViewMin, zoom, tileSize);
8897
+ const [approxCenterLng, approxCenterLat] = geoJsonUtils.mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8727
8898
  const tArcCenter = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
8728
8899
  const TANGENT_EPS = 0.001;
8729
8900
  const p0 = reactMapAdapterShared.intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
8730
8901
  const p1 = reactMapAdapterShared.intermediatePoint(A, B, Math.min(1, tArcCenter + TANGENT_EPS), delta);
8731
- const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
8732
- const [ax, ay] = lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
8902
+ const [bx, by] = geoJsonUtils.lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
8903
+ const [ax, ay] = geoJsonUtils.lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
8733
8904
  const ddx = ax - bx;
8734
8905
  const ddy = ay - by;
8735
8906
  const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
@@ -8746,13 +8917,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8746
8917
  }
8747
8918
  else {
8748
8919
  // Co-located vertices — fall back to Mercator straight-line.
8749
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8920
+ const [anchorLng, anchorLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8750
8921
  position = [anchorLng, anchorLat];
8751
8922
  resolvedDirectionPx = best.directionPx;
8752
8923
  }
8753
8924
  }
8754
8925
  else {
8755
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8926
+ const [anchorLng, anchorLat] = geoJsonUtils.mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8756
8927
  position = [anchorLng, anchorLat];
8757
8928
  resolvedDirectionPx = best.directionPx;
8758
8929
  }
@@ -8940,19 +9111,19 @@ const computeEdgeProperties = (start, end, geometry, zoom, tileSize, geodesic =
8940
9111
  const TANGENT_EPS = 0.001;
8941
9112
  const p0 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 - TANGENT_EPS, delta);
8942
9113
  const p1 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 + TANGENT_EPS, delta);
8943
- const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
8944
- 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);
8945
9116
  angleDeg = normalizeReadableAngle(Math.atan2(ay - by, ax - bx) * RAD_TO_DEG);
8946
9117
  }
8947
9118
  else {
8948
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8949
- 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);
8950
9121
  angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
8951
9122
  }
8952
9123
  }
8953
9124
  else {
8954
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8955
- 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);
8956
9127
  angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
8957
9128
  }
8958
9129
  const pixelLength = geoJsonUtils.edgePixelLength(start[0], start[1], end[0], end[1], zoom, midLat, tileSize);
@@ -9411,26 +9582,23 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9411
9582
  }
9412
9583
  return shapesInViewportCache;
9413
9584
  };
9414
- const overlappingCounts = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9585
+ // overlappingShapesCount is exposed to viewport-aware callbacks. Keep the
9586
+ // O(n²) overlap computation behind the context property getter so handles
9587
+ // that never read it pay nothing.
9588
+ let overlappingCountsCache = null;
9589
+ const getOverlappingCount = (feature) => {
9590
+ if (overlappingCountsCache === null) {
9591
+ overlappingCountsCache = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9592
+ }
9593
+ return overlappingCountsCache.get(feature) ?? 0;
9594
+ };
9415
9595
  handle.features.features.forEach((feature, index) => {
9416
9596
  const geometry = feature.geometry;
9417
9597
  if (geometry === null || geometry.type === "GeometryCollection")
9418
9598
  return;
9419
9599
  const featureKey = feature.id !== undefined ? String(feature.id) : String(index);
9420
- const resolutionContext = buildShapeLabelResolutionContext(feature, {
9421
- handleId: handle.id,
9422
- featureKey,
9423
- viewportBounds: bounds,
9424
- zoom,
9425
- tileSize,
9426
- shapesInViewport: shapesInViewport(),
9427
- overlappingShapesCount: overlappingCounts.get(feature) ?? 0,
9428
- interaction: currentInteraction,
9429
- });
9430
- const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
9431
- handleStyleOverrides.set(featureKey, featureStyle);
9432
- const shapeType = reactMapAdapterShared.geometryTypeToShapeType(geometry.type);
9433
- const strokeColors = reactMapAdapterShared.resolveStrokeColors(featureStyle, shapeType, theme);
9600
+ // Partition decorations before building the resolution context so we
9601
+ // can gate the overlap computation on edge-auto presence.
9434
9602
  const decorations = handle.getDecorations(feature);
9435
9603
  const resolvedStatic = [];
9436
9604
  const vertexAutoDecorations = [];
@@ -9452,6 +9620,20 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9452
9620
  }
9453
9621
  }
9454
9622
  }
9623
+ const resolutionContext = buildShapeLabelResolutionContext(feature, {
9624
+ handleId: handle.id,
9625
+ featureKey,
9626
+ viewportBounds: bounds,
9627
+ zoom,
9628
+ tileSize,
9629
+ shapesInViewport: shapesInViewport(),
9630
+ getOverlappingShapesCount: () => getOverlappingCount(feature),
9631
+ interaction: currentInteraction,
9632
+ });
9633
+ const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
9634
+ handleStyleOverrides.set(featureKey, featureStyle);
9635
+ const shapeType = reactMapAdapterShared.geometryTypeToShapeType(geometry.type);
9636
+ const strokeColors = reactMapAdapterShared.resolveStrokeColors(featureStyle, shapeType, theme);
9455
9637
  const makeEntity = () => ({
9456
9638
  type: "shape",
9457
9639
  id: featureKey,
@@ -9745,7 +9927,7 @@ const featureId = (shape) => String(shape.feature.id);
9745
9927
  * 2. **Smaller area on top** — stable tiebreak; inner shapes are typically
9746
9928
  * smaller, so this reinforces containment and keeps nested sites visible.
9747
9929
  */
9748
- const defaultShapeStackOrder = (contended, _ctx) => {
9930
+ const defaultShapeStackOrder = contended => {
9749
9931
  const idOf = new Map();
9750
9932
  for (const shape of contended) {
9751
9933
  idOf.set(shape, featureId(shape));
@@ -10073,7 +10255,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10073
10255
  }
10074
10256
  }, []);
10075
10257
  const recompute = react.useCallback(() => {
10076
- const currentViewport = viewportRef.current;
10077
10258
  const currentHandles = tilingHandlesRef.current;
10078
10259
  // Stable suppressed-key: per-handle sorted ids so suppression changes are
10079
10260
  // not deduped away by the inputKey guard.
@@ -10100,12 +10281,13 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10100
10281
  return `${handle.id}:${String(layerFlag)}:${featureFlags}`;
10101
10282
  })
10102
10283
  .join(";");
10103
- // Bounds are intentionally excluded from the inputKey. Clip geometry depends on
10104
- // zoom (which affects stack order via resolveStackOrder) and feature geometry —
10105
- // not on the visible viewport region. Using global bounds means all overlapping
10106
- // features always have pre-computed clips, so polygons entering the viewport
10107
- // during zoom-out never flash their unclipped fill.
10108
- const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
10284
+ // Bounds are intentionally excluded from the inputKey: clips use global bounds so
10285
+ // all overlapping features always have pre-computed clips, and polygons entering
10286
+ // the viewport during zoom-out never flash their unclipped fill.
10287
+ // Zoom is intentionally excluded too: fill-tiling stack order is zoom-invariant
10288
+ // by contract, so zooming can reuse the same resting clips and avoid a full
10289
+ // polygon-clipping pass for an identical result.
10290
+ const inputKey = `${tilingContentKeyRef.current}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
10109
10291
  if (inputKey === lastComputeInputKeyRef.current) {
10110
10292
  return;
10111
10293
  }
@@ -10133,7 +10315,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10133
10315
  features: handle.features.features,
10134
10316
  viewportBounds: GLOBAL_BOUNDS,
10135
10317
  resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
10136
- zoom: currentViewport.zoom,
10137
10318
  selectedFeatureId: selectedFeatureIdRef.current,
10138
10319
  suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
10139
10320
  layerGeodesic: handle.style.geodesic,
@@ -11587,6 +11768,7 @@ exports.useControlStack = useControlStack;
11587
11768
  exports.useControls = useControls;
11588
11769
  exports.useDefaultControls = useDefaultControls;
11589
11770
  exports.useDirectionIndicator = useDirectionIndicator;
11771
+ exports.useEntitiesNearCursor = useEntitiesNearCursor;
11590
11772
  exports.useExpandedIds = useExpandedIds;
11591
11773
  exports.useFitFeatureBounds = useFitFeatureBounds;
11592
11774
  exports.useFitToContent = useFitToContent;