@trackunit/react-map 0.1.22 → 0.1.24

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
@@ -7598,7 +7625,7 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
7598
7625
  return result;
7599
7626
  };
7600
7627
  const computeFillTiling = (input) => {
7601
- const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7628
+ const { features, viewportBounds, resolveStackOrder, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7602
7629
  // Densify before any polygon/boundary math so clip geometry follows great-circle
7603
7630
  // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
7604
7631
  const featureCollection = {
@@ -7637,7 +7664,7 @@ const computeFillTiling = (input) => {
7637
7664
  featureToGroupKey.set(record.id, key);
7638
7665
  }
7639
7666
  // 1. Resting order: containment then smaller-area.
7640
- let order = resolveStackOrder(contended, { zoom });
7667
+ let order = resolveStackOrder(contended);
7641
7668
  // 2. Selection override: selected feature always takes front (highest priority).
7642
7669
  if (selectedFeatureId !== null && selectedFeatureId !== undefined) {
7643
7670
  order = promoteToFront(order, selectedFeatureId);
@@ -7743,8 +7770,11 @@ const useViewportContext = (api) => {
7743
7770
 
7744
7771
  const FALLBACK_CHAR_WIDTH = 6;
7745
7772
  const FALLBACK_PADDING = 16;
7773
+ // px-2 from cvaShapeLabel: 8px left + 8px right = 16px total
7774
+ const LABEL_HORIZONTAL_PADDING_PX = 16;
7746
7775
  const labelWidthCache = new Map();
7747
7776
  let probeElement = null;
7777
+ let canvasCtx = null;
7748
7778
  /**
7749
7779
  * Character-count fallback for environments where DOM measurement is unavailable
7750
7780
  * (SSR, jsdom, headless tests).
@@ -7771,30 +7801,67 @@ const getProbeElement = () => {
7771
7801
  return el;
7772
7802
  };
7773
7803
  /**
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`).
7804
+ * Returns a CanvasRenderingContext2D configured with the same font as ShapeLabelPill.
7805
+ * The font is read once from the probe element's computed style so canvas measurements
7806
+ * match the actual rendered text width. Returns null when canvas is unavailable.
7807
+ *
7808
+ * Canvas.measureText does not trigger a forced layout (reflow), unlike getBoundingClientRect.
7809
+ */
7810
+ const getCanvasCtx = (probe) => {
7811
+ if (canvasCtx !== null)
7812
+ return canvasCtx;
7813
+ if (typeof document === "undefined")
7814
+ return null;
7815
+ const canvas = document.createElement("canvas");
7816
+ const ctx = canvas.getContext("2d");
7817
+ if (ctx === null)
7818
+ return null;
7819
+ // Read the computed font from the probe element once. getComputedStyle forces
7820
+ // a style recalculation (cheap) but NOT a full layout pass (no reflow).
7821
+ const computed = window.getComputedStyle(probe);
7822
+ // `font` shorthand: "font-style font-variant font-weight font-size/line-height font-family"
7823
+ // Falls back to a safe approximation matching text-xs (12px) + font-medium (500).
7824
+ const font = computed.font || "500 12px ui-sans-serif, system-ui, sans-serif";
7825
+ ctx.font = font;
7826
+ canvasCtx = ctx;
7827
+ return ctx;
7828
+ };
7829
+ /**
7830
+ * Measure the rendered pixel width of label text using a Canvas context configured
7831
+ * with the same font as shape edge labels (`cvaShapeLabel` / `ShapeLabelPill`).
7776
7832
  *
7777
- * Shape edge labels and other layout utilities that need a DOM-measured width use this.
7833
+ * Results are cached per label text. Using Canvas.measureText avoids the forced
7834
+ * synchronous layout (reflow) that getBoundingClientRect triggers when called inside
7835
+ * a Google Maps onDraw callback — the previous implementation caused ~67ms of blocked
7836
+ * main-thread time per draw cycle during zoom (observed in Chrome DevTools trace).
7778
7837
  *
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).
7838
+ * Falls back to DOM measurement if canvas is unavailable, and to a character-count
7839
+ * estimate in environments without DOM (SSR, jsdom).
7782
7840
  */
7783
7841
  const measureLabelWidth = (label) => {
7784
7842
  const cached = labelWidthCache.get(label);
7785
7843
  if (cached !== undefined)
7786
7844
  return cached;
7787
7845
  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;
7846
+ if (probe !== null) {
7847
+ const ctx = getCanvasCtx(probe);
7848
+ if (ctx !== null) {
7849
+ const textWidth = ctx.measureText(label).width;
7850
+ const canvasWidth = textWidth + LABEL_HORIZONTAL_PADDING_PX;
7851
+ labelWidthCache.set(label, canvasWidth);
7852
+ return canvasWidth;
7853
+ }
7854
+ // Canvas unavailable — fall back to DOM measurement (triggers reflow, but only
7855
+ // for uncached labels and only when CanvasRenderingContext2D is missing).
7856
+ probe.textContent = label;
7857
+ const measured = probe.getBoundingClientRect().width;
7858
+ const domWidth = measured > 0 ? measured : fallbackLabelWidth(label);
7859
+ labelWidthCache.set(label, domWidth);
7860
+ return domWidth;
7861
+ }
7862
+ const fallback = fallbackLabelWidth(label);
7863
+ labelWidthCache.set(label, fallback);
7864
+ return fallback;
7798
7865
  };
7799
7866
 
7800
7867
  const DEG_TO_RAD$1 = Math.PI / 180;
@@ -7931,14 +7998,13 @@ const isShapeMatch = (entity, handleId, featureKey) => {
7931
7998
  return shape.handleId === handleId && shape.id === featureKey;
7932
7999
  };
7933
8000
  /**
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.
8001
+ * Build the per-feature, per-frame context passed to viewport-aware shape
8002
+ * callbacks. Pure: no React, no `api` reads — caller supplies the already-
8003
+ * projected viewport bits, the precomputed `shapesInViewport` count for the
8004
+ * layer, a lazy overlap count, and the current interaction state.
7938
8005
  *
7939
8006
  * 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.
8007
+ * `useShapeDecorations` once per feature, before any placement work runs.
7942
8008
  */
7943
8009
  const buildShapeLabelResolutionContext = (feature, args) => {
7944
8010
  const geometry = feature.geometry;
@@ -7951,7 +8017,9 @@ const buildShapeLabelResolutionContext = (feature, args) => {
7951
8017
  return {
7952
8018
  zoom: args.zoom,
7953
8019
  shapesInViewport: args.shapesInViewport,
7954
- overlappingShapesCount: args.overlappingShapesCount,
8020
+ get overlappingShapesCount() {
8021
+ return args.getOverlappingShapesCount();
8022
+ },
7955
8023
  geometryType: geometry.type,
7956
8024
  bboxPixelWidth: dims.widthPx,
7957
8025
  bboxPixelHeight: dims.heightPx,
@@ -8103,20 +8171,24 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8103
8171
  }
8104
8172
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8105
8173
  };
8106
- const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => {
8107
- const worldSize = tileSize * Math.pow(2, zoom);
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) => {
8108
8180
  const clampedLat = Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat));
8109
8181
  const x = ((lng + 180) / 360) * worldSize;
8110
8182
  const latRad = clampedLat * DEG_TO_RAD;
8111
8183
  const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + latRad / 2)) / (2 * Math.PI)) * worldSize;
8112
8184
  return [x, y];
8113
8185
  };
8114
- const webMercatorPxToLngLat = (px, py, zoom, tileSize = 256) => {
8115
- const worldSize = tileSize * Math.pow(2, zoom);
8186
+ const mercatorPxToLngLatWS = (px, py, worldSize) => {
8116
8187
  const lng = (px / worldSize) * 360 - 180;
8117
8188
  const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / worldSize))) * RAD_TO_DEG$1;
8118
8189
  return [lng, lat];
8119
8190
  };
8191
+ const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => lngLatToMercatorPxWS(lng, lat, tileSize * Math.pow(2, zoom));
8120
8192
  /**
8121
8193
  * Determine which side of an edge the geometry interior lies on, in screen space.
8122
8194
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -8457,6 +8529,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8457
8529
  const [minLon, minLat, maxLon, maxLat] = viewportBounds;
8458
8530
  const edges = geoJsonUtils.extractEdges(features);
8459
8531
  const centroid = geoJsonUtils.computeGeometryCentroid(features);
8532
+ // Pre-compute once so the projection helpers below don't call Math.pow on every vertex.
8533
+ const worldSize = tileSize * Math.pow(2, zoom);
8460
8534
  const candidates = [];
8461
8535
  let nextCandidateId = 0;
8462
8536
  // Pre-compute pixel viewport bounds so we can clip in pixel space.
@@ -8465,8 +8539,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8465
8539
  // edge — causing labels to float away from the line at mid-edge positions.
8466
8540
  // Clipping in pixel space ensures clip endpoints lie exactly on the
8467
8541
  // 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);
8542
+ const [pxViewMin, pyViewMin] = lngLatToMercatorPxWS(minLon, maxLat, worldSize);
8543
+ const [pxViewMax, pyViewMax] = lngLatToMercatorPxWS(maxLon, minLat, worldSize);
8470
8544
  const viewportWidth = pxViewMax - pxViewMin;
8471
8545
  const viewportHeight = pyViewMax - pyViewMin;
8472
8546
  for (let edgeIdx = 0; edgeIdx < edges.length; edgeIdx++) {
@@ -8484,8 +8558,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8484
8558
  const segmentClips = [];
8485
8559
  for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
8486
8560
  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);
8561
+ const [startPxX, startPxY] = lngLatToMercatorPxWS(start[0], start[1], worldSize);
8562
+ const [endPxX, endPxY] = lngLatToMercatorPxWS(end[0], end[1], worldSize);
8489
8563
  const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8490
8564
  if (!clippedPx)
8491
8565
  continue;
@@ -8501,8 +8575,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8501
8575
  : [-rawDx / segPxLen, -rawDy / segPxLen];
8502
8576
  // Inverse-project pixel clip endpoints to geo for outward-side and
8503
8577
  // 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);
8578
+ const [ex0, ey0] = mercatorPxToLngLatWS(px0, py0, worldSize);
8579
+ const [ex1, ey1] = mercatorPxToLngLatWS(px1, py1, worldSize);
8506
8580
  const midLat = (ey0 + ey1) / 2;
8507
8581
  const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8508
8582
  if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
@@ -8639,7 +8713,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8639
8713
  for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
8640
8714
  if (matchingCandidate.pxLen <= 0)
8641
8715
  continue;
8642
- const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToWebMercatorPx(previousAnchorGeo[0], previousAnchorGeo[1], zoom, tileSize);
8716
+ const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8643
8717
  const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
8644
8718
  const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
8645
8719
  const dx = prevAnchorRelX - matchingCandidate.readingStartPx[0];
@@ -8709,7 +8783,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8709
8783
  // great-circle arc by computing the arc fraction via angular distance
8710
8784
  // from A. This places the label physically on the visible curved arc
8711
8785
  // rather than on the straight Mercator chord.
8712
- const [approxLng, approxLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8786
+ const [approxLng, approxLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8713
8787
  const tArc = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxLng, approxLat) / delta));
8714
8788
  const geoAnchor = reactMapAdapterShared.intermediatePoint(A, B, tArc, delta);
8715
8789
  position = [geoAnchor[0], geoAnchor[1]];
@@ -8723,13 +8797,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8723
8797
  const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
8724
8798
  const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
8725
8799
  const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
8726
- const [approxCenterLng, approxCenterLat] = webMercatorPxToLngLat(centerRelX + pxViewMin, centerRelY + pyViewMin, zoom, tileSize);
8800
+ const [approxCenterLng, approxCenterLat] = mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8727
8801
  const tArcCenter = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
8728
8802
  const TANGENT_EPS = 0.001;
8729
8803
  const p0 = reactMapAdapterShared.intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
8730
8804
  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);
8805
+ const [bx, by] = lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
8806
+ const [ax, ay] = lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
8733
8807
  const ddx = ax - bx;
8734
8808
  const ddy = ay - by;
8735
8809
  const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
@@ -8746,13 +8820,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8746
8820
  }
8747
8821
  else {
8748
8822
  // Co-located vertices — fall back to Mercator straight-line.
8749
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8823
+ const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8750
8824
  position = [anchorLng, anchorLat];
8751
8825
  resolvedDirectionPx = best.directionPx;
8752
8826
  }
8753
8827
  }
8754
8828
  else {
8755
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8829
+ const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8756
8830
  position = [anchorLng, anchorLat];
8757
8831
  resolvedDirectionPx = best.directionPx;
8758
8832
  }
@@ -9411,26 +9485,23 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9411
9485
  }
9412
9486
  return shapesInViewportCache;
9413
9487
  };
9414
- const overlappingCounts = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9488
+ // overlappingShapesCount is exposed to viewport-aware callbacks. Keep the
9489
+ // O(n²) overlap computation behind the context property getter so handles
9490
+ // that never read it pay nothing.
9491
+ let overlappingCountsCache = null;
9492
+ const getOverlappingCount = (feature) => {
9493
+ if (overlappingCountsCache === null) {
9494
+ overlappingCountsCache = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9495
+ }
9496
+ return overlappingCountsCache.get(feature) ?? 0;
9497
+ };
9415
9498
  handle.features.features.forEach((feature, index) => {
9416
9499
  const geometry = feature.geometry;
9417
9500
  if (geometry === null || geometry.type === "GeometryCollection")
9418
9501
  return;
9419
9502
  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);
9503
+ // Partition decorations before building the resolution context so we
9504
+ // can gate the overlap computation on edge-auto presence.
9434
9505
  const decorations = handle.getDecorations(feature);
9435
9506
  const resolvedStatic = [];
9436
9507
  const vertexAutoDecorations = [];
@@ -9452,6 +9523,20 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9452
9523
  }
9453
9524
  }
9454
9525
  }
9526
+ const resolutionContext = buildShapeLabelResolutionContext(feature, {
9527
+ handleId: handle.id,
9528
+ featureKey,
9529
+ viewportBounds: bounds,
9530
+ zoom,
9531
+ tileSize,
9532
+ shapesInViewport: shapesInViewport(),
9533
+ getOverlappingShapesCount: () => getOverlappingCount(feature),
9534
+ interaction: currentInteraction,
9535
+ });
9536
+ const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
9537
+ handleStyleOverrides.set(featureKey, featureStyle);
9538
+ const shapeType = reactMapAdapterShared.geometryTypeToShapeType(geometry.type);
9539
+ const strokeColors = reactMapAdapterShared.resolveStrokeColors(featureStyle, shapeType, theme);
9455
9540
  const makeEntity = () => ({
9456
9541
  type: "shape",
9457
9542
  id: featureKey,
@@ -9745,7 +9830,7 @@ const featureId = (shape) => String(shape.feature.id);
9745
9830
  * 2. **Smaller area on top** — stable tiebreak; inner shapes are typically
9746
9831
  * smaller, so this reinforces containment and keeps nested sites visible.
9747
9832
  */
9748
- const defaultShapeStackOrder = (contended, _ctx) => {
9833
+ const defaultShapeStackOrder = contended => {
9749
9834
  const idOf = new Map();
9750
9835
  for (const shape of contended) {
9751
9836
  idOf.set(shape, featureId(shape));
@@ -10073,7 +10158,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10073
10158
  }
10074
10159
  }, []);
10075
10160
  const recompute = react.useCallback(() => {
10076
- const currentViewport = viewportRef.current;
10077
10161
  const currentHandles = tilingHandlesRef.current;
10078
10162
  // Stable suppressed-key: per-handle sorted ids so suppression changes are
10079
10163
  // not deduped away by the inputKey guard.
@@ -10100,12 +10184,13 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10100
10184
  return `${handle.id}:${String(layerFlag)}:${featureFlags}`;
10101
10185
  })
10102
10186
  .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}`;
10187
+ // Bounds are intentionally excluded from the inputKey: clips use global bounds so
10188
+ // all overlapping features always have pre-computed clips, and polygons entering
10189
+ // the viewport during zoom-out never flash their unclipped fill.
10190
+ // Zoom is intentionally excluded too: fill-tiling stack order is zoom-invariant
10191
+ // by contract, so zooming can reuse the same resting clips and avoid a full
10192
+ // polygon-clipping pass for an identical result.
10193
+ const inputKey = `${tilingContentKeyRef.current}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
10109
10194
  if (inputKey === lastComputeInputKeyRef.current) {
10110
10195
  return;
10111
10196
  }
@@ -10133,7 +10218,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10133
10218
  features: handle.features.features,
10134
10219
  viewportBounds: GLOBAL_BOUNDS,
10135
10220
  resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
10136
- zoom: currentViewport.zoom,
10137
10221
  selectedFeatureId: selectedFeatureIdRef.current,
10138
10222
  suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
10139
10223
  layerGeodesic: handle.style.geodesic,