@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.esm.js CHANGED
@@ -3854,9 +3854,9 @@ const resolveMarkerDomSize = (size, direction, form) => {
3854
3854
  * both states. The inner indicator disc (`cvaMarkerIndicator` below) carries
3855
3855
  * its own per-size `h-* w-*`; the outer surface sizes intrinsically around it.
3856
3856
  *
3857
- * `transition-all` enables the morph; the duration and timing-function are
3858
- * applied inline from `MARKER_TUNING.animation` so the spring config remains
3859
- * the single source of truth (no stale `duration-200 ease-in-out` here).
3857
+ * `transitionProperty` is set inline in `MapMarker.tsx` to list only the
3858
+ * properties that need to animate, intentionally excluding `width` to prevent
3859
+ * non-composited layout thrashing. See `MarkerAnimatedSurface.tsx` for details.
3860
3860
  */
3861
3861
  const cvaMapMarker = cvaMerge([
3862
3862
  "inline-flex",
@@ -3865,7 +3865,6 @@ const cvaMapMarker = cvaMerge([
3865
3865
  "overflow-hidden",
3866
3866
  "select-none",
3867
3867
  "cursor-pointer",
3868
- "transition-all",
3869
3868
  "rounded-full",
3870
3869
  "outline-none",
3871
3870
  "focus-visible:ring-2",
@@ -3936,69 +3935,91 @@ const MarkerDiscContent = ({ icon, iconPx, isPill, dotPx, directionDisplay, }) =
3936
3935
  return null;
3937
3936
  };
3938
3937
 
3939
- // Single canonical morph transition shared by every slot inside the surface
3940
- // (disc + direction). Keeping disc and direction in lock-step means they
3941
- // always read as one synchronised motion rather than two independent fades.
3942
- // Easing comes from the shared morph easing so the CSS-driven slots match
3943
- // the spring's decelerating attack.
3944
- const slotTransition = (duration, easing) => {
3945
- return `transform ${duration}ms ${easing}, opacity ${duration}ms ${easing}, width ${duration}ms ${easing}`;
3946
- };
3938
+ /**
3939
+ * Compositable per-slot transition `transform` and `opacity` are GPU-composited
3940
+ * and do not trigger layout (reflow).
3941
+ *
3942
+ * `width` was intentionally omitted: animating `width` is a non-composited operation
3943
+ * that forces the browser to re-run layout on every animation frame. When many markers
3944
+ * transition simultaneously (e.g. after a zoom-driven refetch) this caused ~29 concurrent
3945
+ * non-composited width animations and a CLS score of 0.58. Space-collapse is now handled
3946
+ * via `grid-template-columns: 0fr ↔ 1fr` on the slot wrapper divs, which produces the
3947
+ * same visual result without blocking the compositor.
3948
+ */
3949
+ const slotContentTransition = (duration, easing) => `transform ${duration}ms ${easing}, opacity ${duration}ms ${easing}`;
3947
3950
  const MarkerAnimatedSurface = ({ colors, directionDisplay, duration, effectiveSize, icon, iconPx, isPill, labelText, showDisc, textPx, theme, }) => {
3948
- const circleD = MARKER_SIZE_MAP[effectiveSize].circle;
3949
3951
  const dotPx = MARKER_SIZE_MAP[effectiveSize].dot;
3950
3952
  const easing = MARKER_TUNING.animation.morphEasing;
3951
- const slotTx = slotTransition(duration, easing);
3953
+ const contentTx = slotContentTransition(duration, easing);
3952
3954
  const pillDirectionVisible = isPill && directionDisplay.show;
3953
- return (jsxs(Fragment, { children: [jsx("span", { "data-testid": "MapMarker-disc", style: {
3954
- alignItems: "center",
3955
- display: "inline-flex",
3955
+ // grid-template-columns 0fr 1fr is the compositable-friendly space-collapse
3956
+ // technique: the column animates its available-space fraction while overflow:clip
3957
+ // hides the partially-visible content. This avoids animating `width` directly.
3958
+ const gridTx = `grid-template-columns ${duration}ms ${easing}`;
3959
+ return (jsxs(Fragment, { children: [jsx("div", { "data-testid": "MapMarker-disc", style: {
3960
+ display: "grid",
3956
3961
  flexShrink: 0,
3957
- justifyContent: "center",
3958
- transition: slotTx,
3959
- width: showDisc ? circleD : 0,
3960
- }, children: jsx("span", { className: cvaMarkerIndicator({ size: effectiveSize, theme }), style: {
3961
- flexShrink: 0,
3962
- opacity: showDisc ? 1 : 0,
3963
- transform: showDisc ? "scale(1)" : "scale(0)",
3964
- transition: slotTx,
3965
- }, children: jsx(MarkerDiscContent, { directionDisplay: directionDisplay, dotPx: dotPx, icon: icon, iconPx: iconPx, isPill: isPill }) }) }), jsx("div", { style: {
3962
+ gridTemplateColumns: showDisc ? "1fr" : "0fr",
3963
+ overflow: "clip",
3964
+ transition: gridTx,
3965
+ }, children: jsx("span", { style: {
3966
+ alignItems: "center",
3967
+ display: "inline-flex",
3968
+ justifyContent: "center",
3969
+ // Override the default `min-width: auto` on grid items so the `0fr`
3970
+ // track can collapse to zero width instead of the child's min-content.
3971
+ minWidth: 0,
3972
+ }, children: jsx("span", { className: cvaMarkerIndicator({ size: effectiveSize, theme }), style: {
3973
+ flexShrink: 0,
3974
+ opacity: showDisc ? 1 : 0,
3975
+ transform: showDisc ? "scale(1)" : "scale(0)",
3976
+ transition: contentTx,
3977
+ }, children: jsx(MarkerDiscContent, { directionDisplay: directionDisplay, dotPx: dotPx, icon: icon, iconPx: iconPx, isPill: isPill }) }) }) }), jsx("div", { style: {
3966
3978
  display: "grid",
3967
3979
  gridTemplateColumns: isPill ? "1fr" : "0fr",
3968
3980
  overflowX: "clip",
3969
- transition: `grid-template-columns ${duration}ms ${easing}`,
3981
+ transition: gridTx,
3970
3982
  }, children: jsx("span", { style: {
3971
3983
  alignItems: "center",
3972
3984
  display: "flex",
3985
+ // Override the default `min-width: auto` on grid items so the `0fr`
3986
+ // track can collapse to zero width instead of the child's min-content.
3987
+ minWidth: 0,
3973
3988
  overflow: "hidden",
3974
3989
  }, children: labelText !== undefined ? (jsx("span", { className: cvaPillLabel(), style: {
3975
3990
  fontSize: textPx > 0 ? `${textPx}px` : undefined,
3976
3991
  maxWidth: MARKER_TUNING.pill.maxLabelWidthPx,
3977
3992
  opacity: isPill ? 1 : 0,
3978
3993
  transition: `opacity ${duration}ms ${easing}`,
3979
- }, children: labelText })) : null }) }), jsx("span", { style: {
3980
- alignItems: "center",
3981
- display: "inline-flex",
3994
+ }, children: labelText })) : null }) }), jsx("div", { style: {
3995
+ display: "grid",
3982
3996
  flexShrink: 0,
3983
- justifyContent: "center",
3984
- transition: slotTx,
3985
- width: isPill ? directionDisplay.pixelSize : 0,
3997
+ gridTemplateColumns: isPill ? "1fr" : "0fr",
3998
+ overflow: "clip",
3999
+ transition: gridTx,
3986
4000
  }, children: jsx("span", { style: {
3987
4001
  alignItems: "center",
3988
4002
  display: "inline-flex",
3989
- flexShrink: 0,
3990
4003
  justifyContent: "center",
3991
- opacity: pillDirectionVisible ? 1 : 0,
3992
- transform: pillDirectionVisible ? "scale(1)" : "scale(0)",
3993
- transformOrigin: "center center",
3994
- transition: slotTx,
3995
- }, children: directionDisplay.show ? (jsx(Icon, { ariaHidden: true, "data-testid": "MapMarker-direction-pill", name: "GpsArrowNorth", size: "small", style: {
3996
- color: colors.textColor,
4004
+ // Override the default `min-width: auto` on grid items so the `0fr`
4005
+ // track can collapse to zero width instead of the child's min-content.
4006
+ minWidth: 0,
4007
+ }, children: jsx("span", { style: {
4008
+ alignItems: "center",
4009
+ display: "inline-flex",
3997
4010
  flexShrink: 0,
3998
- height: directionDisplay.pixelSize,
3999
- rotate: `${directionDisplay.rotationDegrees}deg`,
4000
- width: directionDisplay.pixelSize,
4001
- } })) : null }) })] }));
4011
+ justifyContent: "center",
4012
+ opacity: pillDirectionVisible ? 1 : 0,
4013
+ transform: pillDirectionVisible ? "scale(1)" : "scale(0)",
4014
+ transformOrigin: "center center",
4015
+ transition: contentTx,
4016
+ }, children: directionDisplay.show ? (jsx(Icon, { ariaHidden: true, "data-testid": "MapMarker-direction-pill", name: "GpsArrowNorth", size: "small", style: {
4017
+ color: colors.textColor,
4018
+ flexShrink: 0,
4019
+ height: directionDisplay.pixelSize,
4020
+ rotate: `${directionDisplay.rotationDegrees}deg`,
4021
+ width: directionDisplay.pixelSize,
4022
+ } })) : null }) }) })] }));
4002
4023
  };
4003
4024
 
4004
4025
  const isActivatingKey$2 = (key) => key === "Enter" || key === " ";
@@ -4138,6 +4159,9 @@ const STICK_FAN_RADIUS_PX = 56;
4138
4159
  /** Doughnut ring thickness as a percentage of the chart radius (matches the legacy cluster ring). */
4139
4160
  const PIE_LINE_WIDTH_PERCENT = 25;
4140
4161
  const PIE_START_ANGLE = -90;
4162
+ /** Stable empty sticks array — used as the default for the `sticks` prop so the useEffect
4163
+ * dependency does not change on every render when no sticks are passed. */
4164
+ const EMPTY_STICKS = [];
4141
4165
  const isActivatingKey$1 = (key) => key === "Enter" || key === " ";
4142
4166
  /** Fan angle (radians) for a given index, starting at top (-π/2) and spread around a full circle. */
4143
4167
  const computeFanAngle = (index, total) => {
@@ -4160,7 +4184,7 @@ const resolveBubbleBackground = (state, config) => {
4160
4184
  return config.expandedBackground;
4161
4185
  };
4162
4186
  const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref) {
4163
- const { count, segments, sticks = [], onStickClick, onClick, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, state = "default", theme, colorConfig, className, style, "data-testid": dataTestId, "aria-label": ariaLabel, } = props;
4187
+ 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;
4164
4188
  const formatted = formatClusterCount(count);
4165
4189
  const effectiveColorConfig = { ...defaultMarkerColorConfig(theme), ...colorConfig };
4166
4190
  const colors = resolveMarkerColors("transparent", state, theme, colorConfig);
@@ -4217,11 +4241,11 @@ const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref
4217
4241
  event.currentTarget.click();
4218
4242
  }
4219
4243
  }, [onKeyDown]);
4220
- const pieData = segments.map((segment, index) => ({
4244
+ const pieData = useMemo(() => segments.map((segment, index) => ({
4221
4245
  color: segment.color,
4222
4246
  value: segment.weight,
4223
4247
  title: `segment-${index}`,
4224
- }));
4248
+ })), [segments]);
4225
4249
  const bubbleTestId = dataTestId !== undefined ? `${dataTestId}-bubble` : undefined;
4226
4250
  return (jsxs("div", { className: className, "data-testid": dataTestId, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, style: {
4227
4251
  position: "relative",
@@ -4386,6 +4410,9 @@ const MapMarkerInner = forwardRef(function MapMarkerComponent(props, ref) {
4386
4410
  gap: isPill ? MARKER_TUNING.pill.gapPx : 0,
4387
4411
  borderWidth: showPillBorder ? 1 : 0,
4388
4412
  borderStyle: "solid",
4413
+ // Explicitly list only the properties that need to animate on the outer shell.
4414
+ // `width` is intentionally excluded — see MarkerAnimatedSurface for details.
4415
+ transitionProperty: "background-color, border-color, border-width, padding, gap, color",
4389
4416
  transitionDuration: `${morphDurationMs}ms`,
4390
4417
  transitionTimingFunction: MARKER_TUNING.animation.morphEasing,
4391
4418
  ...(isPill
@@ -7597,7 +7624,7 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
7597
7624
  return result;
7598
7625
  };
7599
7626
  const computeFillTiling = (input) => {
7600
- const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7627
+ const { features, viewportBounds, resolveStackOrder, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7601
7628
  // Densify before any polygon/boundary math so clip geometry follows great-circle
7602
7629
  // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
7603
7630
  const featureCollection = {
@@ -7636,7 +7663,7 @@ const computeFillTiling = (input) => {
7636
7663
  featureToGroupKey.set(record.id, key);
7637
7664
  }
7638
7665
  // 1. Resting order: containment then smaller-area.
7639
- let order = resolveStackOrder(contended, { zoom });
7666
+ let order = resolveStackOrder(contended);
7640
7667
  // 2. Selection override: selected feature always takes front (highest priority).
7641
7668
  if (selectedFeatureId !== null && selectedFeatureId !== undefined) {
7642
7669
  order = promoteToFront(order, selectedFeatureId);
@@ -7742,8 +7769,11 @@ const useViewportContext = (api) => {
7742
7769
 
7743
7770
  const FALLBACK_CHAR_WIDTH = 6;
7744
7771
  const FALLBACK_PADDING = 16;
7772
+ // px-2 from cvaShapeLabel: 8px left + 8px right = 16px total
7773
+ const LABEL_HORIZONTAL_PADDING_PX = 16;
7745
7774
  const labelWidthCache = new Map();
7746
7775
  let probeElement = null;
7776
+ let canvasCtx = null;
7747
7777
  /**
7748
7778
  * Character-count fallback for environments where DOM measurement is unavailable
7749
7779
  * (SSR, jsdom, headless tests).
@@ -7770,30 +7800,67 @@ const getProbeElement = () => {
7770
7800
  return el;
7771
7801
  };
7772
7802
  /**
7773
- * Measure the rendered pixel width of label text using an off-screen probe
7774
- * styled with the same CSS classes as shape edge labels (`cvaShapeLabel`).
7803
+ * Returns a CanvasRenderingContext2D configured with the same font as ShapeLabelPill.
7804
+ * The font is read once from the probe element's computed style so canvas measurements
7805
+ * match the actual rendered text width. Returns null when canvas is unavailable.
7806
+ *
7807
+ * Canvas.measureText does not trigger a forced layout (reflow), unlike getBoundingClientRect.
7808
+ */
7809
+ const getCanvasCtx = (probe) => {
7810
+ if (canvasCtx !== null)
7811
+ return canvasCtx;
7812
+ if (typeof document === "undefined")
7813
+ return null;
7814
+ const canvas = document.createElement("canvas");
7815
+ const ctx = canvas.getContext("2d");
7816
+ if (ctx === null)
7817
+ return null;
7818
+ // Read the computed font from the probe element once. getComputedStyle forces
7819
+ // a style recalculation (cheap) but NOT a full layout pass (no reflow).
7820
+ const computed = window.getComputedStyle(probe);
7821
+ // `font` shorthand: "font-style font-variant font-weight font-size/line-height font-family"
7822
+ // Falls back to a safe approximation matching text-xs (12px) + font-medium (500).
7823
+ const font = computed.font || "500 12px ui-sans-serif, system-ui, sans-serif";
7824
+ ctx.font = font;
7825
+ canvasCtx = ctx;
7826
+ return ctx;
7827
+ };
7828
+ /**
7829
+ * Measure the rendered pixel width of label text using a Canvas context configured
7830
+ * with the same font as shape edge labels (`cvaShapeLabel` / `ShapeLabelPill`).
7775
7831
  *
7776
- * Shape edge labels and other layout utilities that need a DOM-measured width use this.
7832
+ * Results are cached per label text. Using Canvas.measureText avoids the forced
7833
+ * synchronous layout (reflow) that getBoundingClientRect triggers when called inside
7834
+ * a Google Maps onDraw callback — the previous implementation caused ~67ms of blocked
7835
+ * main-thread time per draw cycle during zoom (observed in Chrome DevTools trace).
7777
7836
  *
7778
- * Results are cached per label text. In environments where DOM measurement is
7779
- * unavailable or returns zero (SSR, jsdom), falls back to a character-count
7780
- * estimate (label.length * 6 + 16).
7837
+ * Falls back to DOM measurement if canvas is unavailable, and to a character-count
7838
+ * estimate in environments without DOM (SSR, jsdom).
7781
7839
  */
7782
7840
  const measureLabelWidth = (label) => {
7783
7841
  const cached = labelWidthCache.get(label);
7784
7842
  if (cached !== undefined)
7785
7843
  return cached;
7786
7844
  const probe = getProbeElement();
7787
- if (probe === null) {
7788
- const fallback = fallbackLabelWidth(label);
7789
- labelWidthCache.set(label, fallback);
7790
- return fallback;
7791
- }
7792
- probe.textContent = label;
7793
- const measured = probe.getBoundingClientRect().width;
7794
- const width = measured > 0 ? measured : fallbackLabelWidth(label);
7795
- labelWidthCache.set(label, width);
7796
- return width;
7845
+ if (probe !== null) {
7846
+ const ctx = getCanvasCtx(probe);
7847
+ if (ctx !== null) {
7848
+ const textWidth = ctx.measureText(label).width;
7849
+ const canvasWidth = textWidth + LABEL_HORIZONTAL_PADDING_PX;
7850
+ labelWidthCache.set(label, canvasWidth);
7851
+ return canvasWidth;
7852
+ }
7853
+ // Canvas unavailable — fall back to DOM measurement (triggers reflow, but only
7854
+ // for uncached labels and only when CanvasRenderingContext2D is missing).
7855
+ probe.textContent = label;
7856
+ const measured = probe.getBoundingClientRect().width;
7857
+ const domWidth = measured > 0 ? measured : fallbackLabelWidth(label);
7858
+ labelWidthCache.set(label, domWidth);
7859
+ return domWidth;
7860
+ }
7861
+ const fallback = fallbackLabelWidth(label);
7862
+ labelWidthCache.set(label, fallback);
7863
+ return fallback;
7797
7864
  };
7798
7865
 
7799
7866
  const DEG_TO_RAD$1 = Math.PI / 180;
@@ -7930,14 +7997,13 @@ const isShapeMatch = (entity, handleId, featureKey) => {
7930
7997
  return shape.handleId === handleId && shape.id === featureKey;
7931
7998
  };
7932
7999
  /**
7933
- * Build the per-feature, per-frame context passed to an `edge-auto` anchor's
7934
- * `resolveLabel` callback. Pure: no React, no `api` reads — caller supplies the
7935
- * already-projected viewport bits, the precomputed `shapesInViewport` count for
7936
- * the layer, and the current interaction state.
8000
+ * Build the per-feature, per-frame context passed to viewport-aware shape
8001
+ * callbacks. Pure: no React, no `api` reads — caller supplies the already-
8002
+ * projected viewport bits, the precomputed `shapesInViewport` count for the
8003
+ * layer, a lazy overlap count, and the current interaction state.
7937
8004
  *
7938
8005
  * Lives next to `shapeLabelResolution.ts` and is consumed by
7939
- * `useShapeDecorations` once per feature that has at least one `edge-auto`
7940
- * decoration, before any placement work runs.
8006
+ * `useShapeDecorations` once per feature, before any placement work runs.
7941
8007
  */
7942
8008
  const buildShapeLabelResolutionContext = (feature, args) => {
7943
8009
  const geometry = feature.geometry;
@@ -7950,7 +8016,9 @@ const buildShapeLabelResolutionContext = (feature, args) => {
7950
8016
  return {
7951
8017
  zoom: args.zoom,
7952
8018
  shapesInViewport: args.shapesInViewport,
7953
- overlappingShapesCount: args.overlappingShapesCount,
8019
+ get overlappingShapesCount() {
8020
+ return args.getOverlappingShapesCount();
8021
+ },
7954
8022
  geometryType: geometry.type,
7955
8023
  bboxPixelWidth: dims.widthPx,
7956
8024
  bboxPixelHeight: dims.heightPx,
@@ -8102,20 +8170,24 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
8102
8170
  }
8103
8171
  return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
8104
8172
  };
8105
- const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => {
8106
- const worldSize = tileSize * Math.pow(2, zoom);
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) => {
8107
8179
  const clampedLat = Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat));
8108
8180
  const x = ((lng + 180) / 360) * worldSize;
8109
8181
  const latRad = clampedLat * DEG_TO_RAD;
8110
8182
  const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + latRad / 2)) / (2 * Math.PI)) * worldSize;
8111
8183
  return [x, y];
8112
8184
  };
8113
- const webMercatorPxToLngLat = (px, py, zoom, tileSize = 256) => {
8114
- const worldSize = tileSize * Math.pow(2, zoom);
8185
+ const mercatorPxToLngLatWS = (px, py, worldSize) => {
8115
8186
  const lng = (px / worldSize) * 360 - 180;
8116
8187
  const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / worldSize))) * RAD_TO_DEG$1;
8117
8188
  return [lng, lat];
8118
8189
  };
8190
+ const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => lngLatToMercatorPxWS(lng, lat, tileSize * Math.pow(2, zoom));
8119
8191
  /**
8120
8192
  * Determine which side of an edge the geometry interior lies on, in screen space.
8121
8193
  * Returns "above" if the label should extend above the edge (interior is below),
@@ -8456,6 +8528,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8456
8528
  const [minLon, minLat, maxLon, maxLat] = viewportBounds;
8457
8529
  const edges = extractEdges(features);
8458
8530
  const centroid = computeGeometryCentroid(features);
8531
+ // Pre-compute once so the projection helpers below don't call Math.pow on every vertex.
8532
+ const worldSize = tileSize * Math.pow(2, zoom);
8459
8533
  const candidates = [];
8460
8534
  let nextCandidateId = 0;
8461
8535
  // Pre-compute pixel viewport bounds so we can clip in pixel space.
@@ -8464,8 +8538,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8464
8538
  // edge — causing labels to float away from the line at mid-edge positions.
8465
8539
  // Clipping in pixel space ensures clip endpoints lie exactly on the
8466
8540
  // rendered edge, which fixes the floating at the midpoint of long edges.
8467
- const [pxViewMin, pyViewMin] = lngLatToWebMercatorPx(minLon, maxLat, zoom, tileSize);
8468
- const [pxViewMax, pyViewMax] = lngLatToWebMercatorPx(maxLon, minLat, zoom, tileSize);
8541
+ const [pxViewMin, pyViewMin] = lngLatToMercatorPxWS(minLon, maxLat, worldSize);
8542
+ const [pxViewMax, pyViewMax] = lngLatToMercatorPxWS(maxLon, minLat, worldSize);
8469
8543
  const viewportWidth = pxViewMax - pxViewMin;
8470
8544
  const viewportHeight = pyViewMax - pyViewMin;
8471
8545
  for (let edgeIdx = 0; edgeIdx < edges.length; edgeIdx++) {
@@ -8483,8 +8557,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8483
8557
  const segmentClips = [];
8484
8558
  for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
8485
8559
  const { start, end } = segment;
8486
- const [startPxX, startPxY] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8487
- const [endPxX, endPxY] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
8560
+ const [startPxX, startPxY] = lngLatToMercatorPxWS(start[0], start[1], worldSize);
8561
+ const [endPxX, endPxY] = lngLatToMercatorPxWS(end[0], end[1], worldSize);
8488
8562
  const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8489
8563
  if (!clippedPx)
8490
8564
  continue;
@@ -8500,8 +8574,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8500
8574
  : [-rawDx / segPxLen, -rawDy / segPxLen];
8501
8575
  // Inverse-project pixel clip endpoints to geo for outward-side and
8502
8576
  // polygon-interior checks (qualitative, so approximately correct geo is fine).
8503
- const [ex0, ey0] = webMercatorPxToLngLat(px0, py0, zoom, tileSize);
8504
- const [ex1, ey1] = webMercatorPxToLngLat(px1, py1, zoom, tileSize);
8577
+ const [ex0, ey0] = mercatorPxToLngLatWS(px0, py0, worldSize);
8578
+ const [ex1, ey1] = mercatorPxToLngLatWS(px1, py1, worldSize);
8505
8579
  const midLat = (ey0 + ey1) / 2;
8506
8580
  const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8507
8581
  if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
@@ -8638,7 +8712,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8638
8712
  for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
8639
8713
  if (matchingCandidate.pxLen <= 0)
8640
8714
  continue;
8641
- const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToWebMercatorPx(previousAnchorGeo[0], previousAnchorGeo[1], zoom, tileSize);
8715
+ const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
8642
8716
  const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
8643
8717
  const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
8644
8718
  const dx = prevAnchorRelX - matchingCandidate.readingStartPx[0];
@@ -8708,7 +8782,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8708
8782
  // great-circle arc by computing the arc fraction via angular distance
8709
8783
  // from A. This places the label physically on the visible curved arc
8710
8784
  // rather than on the straight Mercator chord.
8711
- const [approxLng, approxLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8785
+ const [approxLng, approxLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8712
8786
  const tArc = Math.max(0, Math.min(1, angularDistance(A[0], A[1], approxLng, approxLat) / delta));
8713
8787
  const geoAnchor = intermediatePoint(A, B, tArc, delta);
8714
8788
  position = [geoAnchor[0], geoAnchor[1]];
@@ -8722,13 +8796,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8722
8796
  const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
8723
8797
  const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
8724
8798
  const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
8725
- const [approxCenterLng, approxCenterLat] = webMercatorPxToLngLat(centerRelX + pxViewMin, centerRelY + pyViewMin, zoom, tileSize);
8799
+ const [approxCenterLng, approxCenterLat] = mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
8726
8800
  const tArcCenter = Math.max(0, Math.min(1, angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
8727
8801
  const TANGENT_EPS = 0.001;
8728
8802
  const p0 = intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
8729
8803
  const p1 = intermediatePoint(A, B, Math.min(1, tArcCenter + TANGENT_EPS), delta);
8730
- const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
8731
- const [ax, ay] = lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
8804
+ const [bx, by] = lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
8805
+ const [ax, ay] = lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
8732
8806
  const ddx = ax - bx;
8733
8807
  const ddy = ay - by;
8734
8808
  const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
@@ -8745,13 +8819,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8745
8819
  }
8746
8820
  else {
8747
8821
  // Co-located vertices — fall back to Mercator straight-line.
8748
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8822
+ const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8749
8823
  position = [anchorLng, anchorLat];
8750
8824
  resolvedDirectionPx = best.directionPx;
8751
8825
  }
8752
8826
  }
8753
8827
  else {
8754
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8828
+ const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
8755
8829
  position = [anchorLng, anchorLat];
8756
8830
  resolvedDirectionPx = best.directionPx;
8757
8831
  }
@@ -9410,26 +9484,23 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9410
9484
  }
9411
9485
  return shapesInViewportCache;
9412
9486
  };
9413
- const overlappingCounts = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9487
+ // overlappingShapesCount is exposed to viewport-aware callbacks. Keep the
9488
+ // O(n²) overlap computation behind the context property getter so handles
9489
+ // that never read it pay nothing.
9490
+ let overlappingCountsCache = null;
9491
+ const getOverlappingCount = (feature) => {
9492
+ if (overlappingCountsCache === null) {
9493
+ overlappingCountsCache = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9494
+ }
9495
+ return overlappingCountsCache.get(feature) ?? 0;
9496
+ };
9414
9497
  handle.features.features.forEach((feature, index) => {
9415
9498
  const geometry = feature.geometry;
9416
9499
  if (geometry === null || geometry.type === "GeometryCollection")
9417
9500
  return;
9418
9501
  const featureKey = feature.id !== undefined ? String(feature.id) : String(index);
9419
- const resolutionContext = buildShapeLabelResolutionContext(feature, {
9420
- handleId: handle.id,
9421
- featureKey,
9422
- viewportBounds: bounds,
9423
- zoom,
9424
- tileSize,
9425
- shapesInViewport: shapesInViewport(),
9426
- overlappingShapesCount: overlappingCounts.get(feature) ?? 0,
9427
- interaction: currentInteraction,
9428
- });
9429
- const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
9430
- handleStyleOverrides.set(featureKey, featureStyle);
9431
- const shapeType = geometryTypeToShapeType(geometry.type);
9432
- const strokeColors = resolveStrokeColors(featureStyle, shapeType, theme);
9502
+ // Partition decorations before building the resolution context so we
9503
+ // can gate the overlap computation on edge-auto presence.
9433
9504
  const decorations = handle.getDecorations(feature);
9434
9505
  const resolvedStatic = [];
9435
9506
  const vertexAutoDecorations = [];
@@ -9451,6 +9522,20 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9451
9522
  }
9452
9523
  }
9453
9524
  }
9525
+ const resolutionContext = buildShapeLabelResolutionContext(feature, {
9526
+ handleId: handle.id,
9527
+ featureKey,
9528
+ viewportBounds: bounds,
9529
+ zoom,
9530
+ tileSize,
9531
+ shapesInViewport: shapesInViewport(),
9532
+ getOverlappingShapesCount: () => getOverlappingCount(feature),
9533
+ interaction: currentInteraction,
9534
+ });
9535
+ const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
9536
+ handleStyleOverrides.set(featureKey, featureStyle);
9537
+ const shapeType = geometryTypeToShapeType(geometry.type);
9538
+ const strokeColors = resolveStrokeColors(featureStyle, shapeType, theme);
9454
9539
  const makeEntity = () => ({
9455
9540
  type: "shape",
9456
9541
  id: featureKey,
@@ -9744,7 +9829,7 @@ const featureId = (shape) => String(shape.feature.id);
9744
9829
  * 2. **Smaller area on top** — stable tiebreak; inner shapes are typically
9745
9830
  * smaller, so this reinforces containment and keeps nested sites visible.
9746
9831
  */
9747
- const defaultShapeStackOrder = (contended, _ctx) => {
9832
+ const defaultShapeStackOrder = contended => {
9748
9833
  const idOf = new Map();
9749
9834
  for (const shape of contended) {
9750
9835
  idOf.set(shape, featureId(shape));
@@ -10072,7 +10157,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10072
10157
  }
10073
10158
  }, []);
10074
10159
  const recompute = useCallback(() => {
10075
- const currentViewport = viewportRef.current;
10076
10160
  const currentHandles = tilingHandlesRef.current;
10077
10161
  // Stable suppressed-key: per-handle sorted ids so suppression changes are
10078
10162
  // not deduped away by the inputKey guard.
@@ -10099,12 +10183,13 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10099
10183
  return `${handle.id}:${String(layerFlag)}:${featureFlags}`;
10100
10184
  })
10101
10185
  .join(";");
10102
- // Bounds are intentionally excluded from the inputKey. Clip geometry depends on
10103
- // zoom (which affects stack order via resolveStackOrder) and feature geometry —
10104
- // not on the visible viewport region. Using global bounds means all overlapping
10105
- // features always have pre-computed clips, so polygons entering the viewport
10106
- // during zoom-out never flash their unclipped fill.
10107
- const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
10186
+ // Bounds are intentionally excluded from the inputKey: clips use global bounds so
10187
+ // all overlapping features always have pre-computed clips, and polygons entering
10188
+ // the viewport during zoom-out never flash their unclipped fill.
10189
+ // Zoom is intentionally excluded too: fill-tiling stack order is zoom-invariant
10190
+ // by contract, so zooming can reuse the same resting clips and avoid a full
10191
+ // polygon-clipping pass for an identical result.
10192
+ const inputKey = `${tilingContentKeyRef.current}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
10108
10193
  if (inputKey === lastComputeInputKeyRef.current) {
10109
10194
  return;
10110
10195
  }
@@ -10132,7 +10217,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10132
10217
  features: handle.features.features,
10133
10218
  viewportBounds: GLOBAL_BOUNDS,
10134
10219
  resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
10135
- zoom: currentViewport.zoom,
10136
10220
  selectedFeatureId: selectedFeatureIdRef.current,
10137
10221
  suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
10138
10222
  layerGeodesic: handle.style.geodesic,
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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.33",
11
- "@trackunit/css-class-variance-utilities": "1.13.37",
12
- "@trackunit/react-form-components": "2.1.35",
13
- "@trackunit/react-core-hooks": "1.17.46",
14
- "@trackunit/geo-json-utils": "1.14.40",
15
- "@trackunit/i18n-library-translation": "2.0.34",
16
- "@trackunit/react-modal": "2.1.36",
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",
17
17
  "react-minimal-pie-chart": "^8.4.0",
18
- "@trackunit/react-map-adapter-shared": "0.0.21",
19
- "@trackunit/react-map-color-utils": "0.0.6",
20
- "@trackunit/ui-design-tokens": "1.13.37",
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",
21
21
  "@floating-ui/react": "^0.26.25",
22
22
  "es-toolkit": "^1.39.10",
23
23
  "tailwind-merge": "^2.0.0",
package/src/index.d.ts CHANGED
@@ -46,7 +46,7 @@ export { type AutoPanContext, type AutoPanResult } from "./panel/utils/autoPan";
46
46
  export type { DecorationAnchor, EdgeSide, ShapeDecoration } from "./layers/useShapes/shapeDecorations";
47
47
  export { type AnnotationContext, type ShapeLabelPolicy } from "./layers/useShapes/shapeLabelPolicy";
48
48
  export type { ResolveShapeLabel, ShapeLabelResolution, ShapeLabelResolutionContext, } from "./layers/useShapes/shapeLabelResolution";
49
- export { type ContendedShape, type ResolveShapeStackOrder, type ShapeStackContext, } from "./layers/useShapes/shapeStackOrder";
49
+ export { type ContendedShape, type ResolveShapeStackOrder } from "./layers/useShapes/shapeStackOrder";
50
50
  export { useImageOverlay, type UseImageOverlayOptions, type UseImageOverlayReturn, } from "./layers/image-overlay/useImageOverlay";
51
51
  export { buildExpandedIds, useExpandedIds, type MapFocus, type MapFocusTier, type MapFocusTierDisplay, } from "./layers/mapFocus";
52
52
  export { useRoute, type UseRouteOptions, type UseRouteReturn } from "./layers/routes/useRoute";
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Measure the rendered pixel width of label text using an off-screen probe
3
- * styled with the same CSS classes as shape edge labels (`cvaShapeLabel`).
2
+ * Measure the rendered pixel width of label text using a Canvas context configured
3
+ * with the same font as shape edge labels (`cvaShapeLabel` / `ShapeLabelPill`).
4
4
  *
5
- * Shape edge labels and other layout utilities that need a DOM-measured width use this.
5
+ * Results are cached per label text. Using Canvas.measureText avoids the forced
6
+ * synchronous layout (reflow) that getBoundingClientRect triggers when called inside
7
+ * a Google Maps onDraw callback — the previous implementation caused ~67ms of blocked
8
+ * main-thread time per draw cycle during zoom (observed in Chrome DevTools trace).
6
9
  *
7
- * Results are cached per label text. In environments where DOM measurement is
8
- * unavailable or returns zero (SSR, jsdom), falls back to a character-count
9
- * estimate (label.length * 6 + 16).
10
+ * Falls back to DOM measurement if canvas is unavailable, and to a character-count
11
+ * estimate in environments without DOM (SSR, jsdom).
10
12
  */
11
13
  export declare const measureLabelWidth: (label: string) => number;