@trackunit/react-map 0.1.5 → 0.1.13

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
@@ -672,6 +672,30 @@ const useLayerPort = () => {
672
672
  return react.useContext(MapLayerContext);
673
673
  };
674
674
 
675
+ /**
676
+ * Default visual styles for each shape type.
677
+ *
678
+ * These are a map consumer concern — injected into the adapter via
679
+ * `createMapComponent` so adapters remain style-agnostic.
680
+ */
681
+ const SHAPE_STYLE_DEFAULTS = {
682
+ polygon: {
683
+ fillOpacity: 0.05,
684
+ strokeWidth: 1,
685
+ strokeOpacity: 1,
686
+ },
687
+ line: {
688
+ strokeWidth: 1,
689
+ strokeOpacity: 1,
690
+ },
691
+ point: {
692
+ fillOpacity: 0.2,
693
+ strokeWidth: 1,
694
+ strokeOpacity: 1,
695
+ pointRadius: 5,
696
+ },
697
+ };
698
+
675
699
  /**
676
700
  * CSS `max-height` of the Panel shell — `min(320px, 40vh)`.
677
701
  *
@@ -964,8 +988,6 @@ const MapLoadingState = ({ isLoading, error = false, className, style }) => {
964
988
  }, children: [isLoading ? jsxRuntime.jsx(reactComponents.Spinner, { "aria-label": t("map.loading"), centering: "centered", size: "large" }) : null, error ? (jsxRuntime.jsx("span", { role: "alert", children: jsxRuntime.jsx(reactComponents.Text, { italicize: true, subtle: true, weight: "thick", children: t("map.loadingErrorMessage") }) })) : null] }));
965
989
  };
966
990
 
967
- const SafeAreaLayoutContext = react.createContext(null);
968
-
969
991
  /** Build padding style that combines spacing with safe area insets. */
970
992
  const padWithInsets = (availableSpace, insets) => {
971
993
  const base = availableSpace === "constrained" ? "var(--spacing-2)" : "var(--spacing-4)";
@@ -975,6 +997,8 @@ const padWithInsets = (availableSpace, insets) => {
975
997
  : { padding: base };
976
998
  };
977
999
 
1000
+ const SafeAreaLayoutContext = react.createContext(null);
1001
+
978
1002
  /**
979
1003
  * Renders the AnnotationStack when no external renderer (Controls) has
980
1004
  * claimed annotation rendering. Subscribes to the store's renderer-claim
@@ -1081,7 +1105,10 @@ const SafeAreaOverlay = ({ safeAreaInsets, safeAreaContentRef, children }) => {
1081
1105
  *
1082
1106
  * @internal
1083
1107
  */
1084
- const createMapComponent = ({ adapter, Renderer, adapterName, containerRefHolder, containerId, annotationStore, loadingIndicatorStore, panelStore, safeAreaInsets, }) => {
1108
+ const createMapComponent = ({ adapter, Renderer, adapterName, containerRefHolder, containerId, annotationStore, loadingIndicatorStore, panelStore, safeAreaInsets, shapeStyleDefaults = SHAPE_STYLE_DEFAULTS, }) => {
1109
+ // Inject consumer-owned shape style defaults into the adapter's layer port so
1110
+ // adapters don't need to hard-code visual defaults themselves.
1111
+ adapter.layers?.setShapeStyleDefaults(shapeStyleDefaults);
1085
1112
  // Preview maps (no containerRefHolder) — Renderer fills a relative wrapper; readiness overlay lives here (SAGA-374).
1086
1113
  // No `min-h-[200px]` on this shell: thumbnails (e.g. AppearancePreview 72×96) must respect consumer `style` /
1087
1114
  // `className`. The full-map branch keeps the floor on the inner map cell / Mapbox container.
@@ -6470,8 +6497,8 @@ const useShapes = (options) => {
6470
6497
  const mapEntry = featureKey !== undefined ? featureStyles.get(featureKey) : undefined;
6471
6498
  const resolvedStyle = mapEntry ?? resolveStyleRef.current(feature);
6472
6499
  const strokeColor = resolvedStyle.stroke ?? "#000";
6473
- const pointRadius = mapEntry?.pointRadius ?? resolvedStyle.pointRadius ?? reactMapAdapterShared.SHAPE_STYLE_DEFAULTS.point.pointRadius;
6474
- const strokeWidth = mapEntry?.strokeWidth ?? resolvedStyle.strokeWidth ?? reactMapAdapterShared.SHAPE_STYLE_DEFAULTS.point.strokeWidth;
6500
+ const pointRadius = mapEntry?.pointRadius ?? resolvedStyle.pointRadius ?? SHAPE_STYLE_DEFAULTS.point.pointRadius;
6501
+ const strokeWidth = mapEntry?.strokeWidth ?? resolvedStyle.strokeWidth ?? SHAPE_STYLE_DEFAULTS.point.strokeWidth;
6475
6502
  const auto = buildMultiPartDecorations(feature, strokeColor, pointRadius, strokeWidth);
6476
6503
  return auto.length > 0 ? [...custom, ...auto] : custom;
6477
6504
  }, [skipAutoDecorations, featureStyles]);
@@ -7404,10 +7431,19 @@ const degreesPerPixel = (zoom, tileSize) => 360 / (tileSize * Math.pow(2, zoom))
7404
7431
  * - Bbox-prefiltered for performance; results are in deterministic (feature-id) order.
7405
7432
  */
7406
7433
  const shapesUnderCursor = (position, features, options) => {
7407
- const { zoom, tileSize, strokeWidthFor, visibleFillFor } = options;
7434
+ const { zoom, tileSize, strokeWidthFor, visibleFillFor, layerGeodesic, featureStyles } = options;
7435
+ // Densify before boundary-distance and point-in-polygon math so stroke/fill
7436
+ // hit-tests follow great-circle arcs on large polygons.
7437
+ const featureCollection = {
7438
+ type: "FeatureCollection",
7439
+ features: Array.from(features),
7440
+ };
7441
+ const densifiedFeatures = layerGeodesic !== false
7442
+ ? reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7443
+ : features;
7408
7444
  const degPerPx = degreesPerPixel(zoom, tileSize);
7409
7445
  const hits = [];
7410
- for (const feature of features) {
7446
+ for (const feature of densifiedFeatures) {
7411
7447
  if (feature.id === undefined)
7412
7448
  continue;
7413
7449
  const geometry = polygonalGeometry(feature);
@@ -7515,9 +7551,17 @@ const promoteToFront = (order, id) => order.includes(id) ? [id, ...order.filter(
7515
7551
  * its full geometry — so peer-to-peer overlap ownership from resting tiling is
7516
7552
  * preserved and translucent fills do not compound under the winner.
7517
7553
  */
7518
- const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map()) => {
7554
+ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map(), layerGeodesic = undefined, featureStyles = undefined) => {
7555
+ // Densify before peer-vs-winner clip so promotion clips follow great-circle arcs.
7556
+ const featureCollection = {
7557
+ type: "FeatureCollection",
7558
+ features: Array.from(members),
7559
+ };
7560
+ const densifiedMembers = layerGeodesic !== false
7561
+ ? reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7562
+ : members;
7519
7563
  const geometries = new Map();
7520
- for (const feature of members) {
7564
+ for (const feature of densifiedMembers) {
7521
7565
  if (feature.id === undefined)
7522
7566
  continue;
7523
7567
  const geometry = polygonalGeometry(feature);
@@ -7553,9 +7597,18 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
7553
7597
  return result;
7554
7598
  };
7555
7599
  const computeFillTiling = (input) => {
7556
- const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds } = input;
7600
+ const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
7601
+ // Densify before any polygon/boundary math so clip geometry follows great-circle
7602
+ // arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
7603
+ const featureCollection = {
7604
+ type: "FeatureCollection",
7605
+ features: Array.from(features),
7606
+ };
7607
+ const densifiedFeatures = layerGeodesic !== false
7608
+ ? reactMapAdapterShared.densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
7609
+ : features;
7557
7610
  const records = [];
7558
- for (const feature of features) {
7611
+ for (const feature of densifiedFeatures) {
7559
7612
  if (feature.id === undefined)
7560
7613
  continue;
7561
7614
  const id = String(feature.id);
@@ -7990,6 +8043,8 @@ const RAD_TO_DEG$1 = 180 / Math.PI;
7990
8043
  const DEFAULT_EDGE_LABEL_INSET_PX = 6;
7991
8044
  const LABEL_HEIGHT_PX = 20;
7992
8045
  const MAX_WEB_MERCATOR_LAT = 85.05112878;
8046
+ const EARTH_RADIUS_KM = 6371;
8047
+ const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = reactMapAdapterShared.GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
7993
8048
  /**
7994
8049
  * Hysteresis override thresholds.
7995
8050
  *
@@ -8250,6 +8305,29 @@ const computeLabelBoundingBox = (anchorX, anchorY, cosT, sinT, labelAnchor, labe
8250
8305
  }
8251
8306
  return { minX, maxX, minY, maxY };
8252
8307
  };
8308
+ const getPlacementSegments = (start, end, geodesic) => {
8309
+ if (!geodesic) {
8310
+ return [{ start, end, isOriginalStart: true, isOriginalEnd: true }];
8311
+ }
8312
+ const delta = reactMapAdapterShared.angularDistance(start[0], start[1], end[0], end[1]);
8313
+ if (delta === 0) {
8314
+ return [{ start, end, isOriginalStart: true, isOriginalEnd: true }];
8315
+ }
8316
+ const segmentCount = Math.max(1, Math.ceil(delta / GEODESIC_PLACEMENT_MAX_SEGMENT_RAD));
8317
+ const segments = [];
8318
+ let segmentStart = start;
8319
+ for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex++) {
8320
+ const segmentEnd = segmentIndex === segmentCount ? end : reactMapAdapterShared.intermediatePoint(start, end, segmentIndex / segmentCount, delta);
8321
+ segments.push({
8322
+ start: segmentStart,
8323
+ end: segmentEnd,
8324
+ isOriginalStart: segmentIndex === 1,
8325
+ isOriginalEnd: segmentIndex === segmentCount,
8326
+ });
8327
+ segmentStart = segmentEnd;
8328
+ }
8329
+ return segments;
8330
+ };
8253
8331
  // ============================================================================
8254
8332
  // Default resolver
8255
8333
  // ============================================================================
@@ -8280,7 +8358,15 @@ const defaultEdgeLabelPlacementResolver = (context) => {
8280
8358
  : undefined;
8281
8359
  let best;
8282
8360
  if (previousEdgeIdentity !== undefined) {
8283
- const hysteresisCandidate = pool.find(c => c.labelFits && isSameEdge(previousEdgeIdentity, c.edge));
8361
+ // When a held anchor was projected onto a specific segment-candidate, lock onto
8362
+ // that same segment so the "don't slide back" hold fraction (measured in that
8363
+ // segment's reading frame) is applied to the matching candidate. Falling back to
8364
+ // the first fitting segment of the edge would apply the fraction in the wrong
8365
+ // reading frame, making the label jump back toward the edge start when panning.
8366
+ const heldCandidate = context.previousAnchorClippedCandidateId !== undefined
8367
+ ? pool.find(c => c.candidateId === context.previousAnchorClippedCandidateId && c.labelFits)
8368
+ : undefined;
8369
+ const hysteresisCandidate = heldCandidate ?? pool.find(c => c.labelFits && isSameEdge(previousEdgeIdentity, c.edge));
8284
8370
  if (hysteresisCandidate !== undefined) {
8285
8371
  const isHysteresisCandidateSteep = Math.abs(hysteresisCandidate.angleDeg) > STEEP_EDGE_THRESHOLD_DEG;
8286
8372
  if (isHysteresisCandidateSteep) {
@@ -8339,7 +8425,7 @@ const defaultEdgeLabelPlacementResolver = (context) => {
8339
8425
  // the center of the pill element to that geo position. Together they visually
8340
8426
  // center the label on the edge rather than pinning the left edge at the midpoint.
8341
8427
  if (isForced && !best.labelFits) {
8342
- return { edgeIdx: best.edgeIdx, side, anchorT: 0.5, anchor: "center" };
8428
+ return { edgeIdx: best.edgeIdx, candidateId: best.candidateId, side, anchorT: 0.5, anchor: "center" };
8343
8429
  }
8344
8430
  // "Don't slide back" — for left-anchored labels on the same edge as before,
8345
8431
  // once the viewport has pushed the anchor rightward (to stay in view), keep it
@@ -8357,20 +8443,21 @@ const defaultEdgeLabelPlacementResolver = (context) => {
8357
8443
  const maxSafeT = best.pxLen > 0 ? (best.pxLen - best.endInsetPx - context.labelPixelWidth) / best.pxLen : 0;
8358
8444
  const clampedT = Math.max(defaultLeftT, Math.min(maxSafeT, context.previousAnchorClippedT));
8359
8445
  if (clampedT > defaultLeftT) {
8360
- return { edgeIdx: best.edgeIdx, side, anchorT: clampedT };
8446
+ return { edgeIdx: best.edgeIdx, candidateId: best.candidateId, side, anchorT: clampedT };
8361
8447
  }
8362
8448
  }
8363
8449
  }
8364
- return { edgeIdx: best.edgeIdx, side };
8450
+ return { edgeIdx: best.edgeIdx, candidateId: best.candidateId, side };
8365
8451
  };
8366
8452
  // ============================================================================
8367
8453
  // Main function
8368
8454
  // ============================================================================
8369
- const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, labelPixelWidth, tileSize = 256, maxReadableAngleDeg, previousEdgeIdentity, previousAnchorGeo, previousLayoutSide, edgeInsets, labelAnchor = "left", labelPlacementResolver, isForced = false, }) => {
8455
+ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, labelPixelWidth, tileSize = 256, maxReadableAngleDeg, previousEdgeIdentity, previousAnchorGeo, previousLayoutSide, edgeInsets, labelAnchor = "left", labelPlacementResolver, isForced = false, geodesic = false, }) => {
8370
8456
  const [minLon, minLat, maxLon, maxLat] = viewportBounds;
8371
8457
  const edges = geoJsonUtils.extractEdges(features);
8372
8458
  const centroid = geoJsonUtils.computeGeometryCentroid(features);
8373
8459
  const candidates = [];
8460
+ let nextCandidateId = 0;
8374
8461
  // Pre-compute pixel viewport bounds so we can clip in pixel space.
8375
8462
  // Mercator y is non-linear in latitude, so clipping in geo space then
8376
8463
  // projecting gives clip points that are NOT on the rendered (pixel-space)
@@ -8385,75 +8472,159 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8385
8472
  const edge = edges[edgeIdx];
8386
8473
  if (edge === undefined)
8387
8474
  continue;
8388
- const [start, end] = edge;
8389
- // Project both endpoints to pixel space, then clip there.
8390
- const [startPxX, startPxY] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8391
- const [endPxX, endPxY] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
8392
- const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8393
- if (!clippedPx)
8475
+ const sourceEdge = edge;
8476
+ // Collect every clipped segment for this edge. For geodesic edges there
8477
+ // will be multiple short arc segments; for Mercator there is always one.
8478
+ // After collection we derive a single "chord" candidate that spans from the
8479
+ // reading-start of the first visible segment to the reading-end of the last
8480
+ // visible segment. Using the chord (straight-line) length — not the summed
8481
+ // arc length — ensures fit/visibility checks match what the label actually
8482
+ // occupies in screen space.
8483
+ const segmentClips = [];
8484
+ for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
8485
+ 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);
8488
+ const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
8489
+ if (!clippedPx)
8490
+ continue;
8491
+ const [px0, py0, px1, py1] = clippedPx;
8492
+ const rawDx = px1 - px0;
8493
+ const rawDy = py1 - py0;
8494
+ const segPxLen = Math.sqrt(rawDx * rawDx + rawDy * rawDy);
8495
+ if (segPxLen === 0)
8496
+ continue;
8497
+ const readsFromClippedStart = rawDx >= 0;
8498
+ const directionPx = readsFromClippedStart
8499
+ ? [rawDx / segPxLen, rawDy / segPxLen]
8500
+ : [-rawDx / segPxLen, -rawDy / segPxLen];
8501
+ // Inverse-project pixel clip endpoints to geo for outward-side and
8502
+ // 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);
8505
+ const midLat = (ey0 + ey1) / 2;
8506
+ const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8507
+ if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
8508
+ continue;
8509
+ const inset = edgeInsets?.[edgeIdx];
8510
+ const readingStartInset = readsFromClippedStart && segment.isOriginalStart
8511
+ ? inset?.startPx
8512
+ : !readsFromClippedStart && segment.isOriginalEnd
8513
+ ? inset?.endPx
8514
+ : 0;
8515
+ const readingEndInset = readsFromClippedStart && segment.isOriginalEnd
8516
+ ? inset?.endPx
8517
+ : !readsFromClippedStart && segment.isOriginalStart
8518
+ ? inset?.startPx
8519
+ : 0;
8520
+ const startInsetPx = Math.max(DEFAULT_EDGE_LABEL_INSET_PX, readingStartInset ?? 0);
8521
+ const endInsetPx = Math.max(DEFAULT_EDGE_LABEL_INSET_PX, readingEndInset ?? 0);
8522
+ const readingStartPx = readsFromClippedStart
8523
+ ? [px0 - pxViewMin, py0 - pyViewMin]
8524
+ : [px1 - pxViewMin, py1 - pyViewMin];
8525
+ const readingEndPx = readsFromClippedStart
8526
+ ? [px1 - pxViewMin, py1 - pyViewMin]
8527
+ : [px0 - pxViewMin, py0 - pyViewMin];
8528
+ segmentClips.push({
8529
+ segment,
8530
+ segmentPxLen: segPxLen,
8531
+ readsFromClippedStart,
8532
+ directionPx,
8533
+ readingStartPx,
8534
+ readingEndPx,
8535
+ outwardSide: edgeOutwardSide,
8536
+ startInsetPx,
8537
+ endInsetPx,
8538
+ });
8539
+ }
8540
+ if (segmentClips.length === 0)
8394
8541
  continue;
8395
- const [px0, py0, px1, py1] = clippedPx;
8396
- // Inverse-project pixel clip endpoints to geo for outward-side and
8397
- // polygon-interior checks (qualitative, so approximately correct geo is fine).
8398
- const [ex0, ey0] = webMercatorPxToLngLat(px0, py0, zoom, tileSize);
8399
- const [ex1, ey1] = webMercatorPxToLngLat(px1, py1, zoom, tileSize);
8400
- const midLat = (ey0 + ey1) / 2;
8401
- const rawDx = px1 - px0;
8402
- const rawDy = py1 - py0;
8403
- const pxLen = Math.sqrt(rawDx * rawDx + rawDy * rawDy);
8404
- if (pxLen === 0)
8542
+ // ── Build the chord spanning the full clipped arc ─────────────────────────
8543
+ //
8544
+ // For geodesic edges the label must fit in screen-space. The chord (straight
8545
+ // line from the first visible segment's reading start to the last visible
8546
+ // segment's reading end) is the actual screen-space extent of the arc.
8547
+ // Using chord length instead of the summed arc length prevents the label
8548
+ // from extending past a polygon corner even when the arc is longer than
8549
+ // the chord.
8550
+ //
8551
+ // For a right-to-left edge (readsFromClippedStart = false),
8552
+ // getPlacementSegments returns segments in geographic A→B order but the
8553
+ // reading direction is B→A, so the chord start is the LAST segment's
8554
+ // readingStartPx and the chord end is the FIRST segment's readingEndPx.
8555
+ // segmentClips is non-empty (checked above); TypeScript doesn't narrow that
8556
+ // from .length > 0, so we do a non-null assertion via the bang-free fallback.
8557
+ const firstClip = segmentClips[0];
8558
+ const lastClip = segmentClips[segmentClips.length - 1];
8559
+ if (firstClip === undefined || lastClip === undefined)
8405
8560
  continue;
8406
- const readsFromClippedStart = rawDx >= 0;
8407
- const directionPx = readsFromClippedStart
8408
- ? [rawDx / pxLen, rawDy / pxLen]
8409
- : [-rawDx / pxLen, -rawDy / pxLen];
8410
- const angleDeg = Math.atan2(directionPx[1], directionPx[0]) * RAD_TO_DEG$1;
8411
- if (pxLen < minPixelWidth)
8561
+ const readsFromStart = firstClip.readsFromClippedStart;
8562
+ const chordReadingStartPx = readsFromStart ? firstClip.readingStartPx : lastClip.readingStartPx;
8563
+ const chordReadingEndPx = readsFromStart ? lastClip.readingEndPx : firstClip.readingEndPx;
8564
+ const chordDx = chordReadingEndPx[0] - chordReadingStartPx[0];
8565
+ const chordDy = chordReadingEndPx[1] - chordReadingStartPx[1];
8566
+ const chordPxLen = Math.sqrt(chordDx * chordDx + chordDy * chordDy);
8567
+ if (chordPxLen === 0)
8412
8568
  continue;
8413
- const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
8414
- if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
8569
+ if (chordPxLen < minPixelWidth)
8415
8570
  continue;
8416
- const inset = edgeInsets?.[edgeIdx];
8417
- const startInsetPx = Math.max(DEFAULT_EDGE_LABEL_INSET_PX, (readsFromClippedStart ? inset?.startPx : inset?.endPx) ?? 0);
8418
- const endInsetPx = Math.max(DEFAULT_EDGE_LABEL_INSET_PX, (readsFromClippedStart ? inset?.endPx : inset?.startPx) ?? 0);
8419
- const availableWidthPx = Math.max(0, pxLen - startInsetPx - endInsetPx);
8420
- const labelFits = availableWidthPx >= labelPixelWidth;
8421
- // Viewport-relative reading start/end (subtract viewport origin so bbox
8422
- // checks can be done against [0, viewportWidth] × [0, viewportHeight]).
8423
- const readingStartPx = readsFromClippedStart
8424
- ? [px0 - pxViewMin, py0 - pyViewMin]
8425
- : [px1 - pxViewMin, py1 - pyViewMin];
8426
- const readingEndPx = readsFromClippedStart
8427
- ? [px1 - pxViewMin, py1 - pyViewMin]
8428
- : [px0 - pxViewMin, py0 - pyViewMin];
8429
- // Compute bbox anchor in viewport-relative coords.
8430
- const anchorOffsetForBbox = labelAnchor === "left"
8431
- ? startInsetPx
8432
- : labelAnchor === "center"
8433
- ? startInsetPx + availableWidthPx / 2
8434
- : pxLen - endInsetPx;
8435
- const anchorTForBbox = pxLen > 0 ? Math.max(0, Math.min(pxLen, anchorOffsetForBbox)) / pxLen : 0;
8436
- const bboxAnchorX = readingStartPx[0] + anchorTForBbox * (readingEndPx[0] - readingStartPx[0]);
8437
- const bboxAnchorY = readingStartPx[1] + anchorTForBbox * (readingEndPx[1] - readingStartPx[1]);
8438
- const outwardBbox = computeLabelBoundingBox(bboxAnchorX, bboxAnchorY, directionPx[0], directionPx[1], labelAnchor, labelPixelWidth, edgeOutwardSide);
8439
- const flippedSide = edgeOutwardSide === "above" ? "below" : "above";
8440
- const inwardBbox = computeLabelBoundingBox(bboxAnchorX, bboxAnchorY, directionPx[0], directionPx[1], labelAnchor, labelPixelWidth, flippedSide);
8441
- candidates.push({
8442
- edgeIdx,
8443
- edge: [start, end],
8444
- pxLen,
8445
- angleDeg,
8446
- availableWidthPx,
8447
- labelFits,
8448
- outwardSide: edgeOutwardSide,
8449
- startInsetPx,
8450
- endInsetPx,
8451
- directionPx,
8452
- readingStartPx,
8453
- readingEndPx,
8454
- outwardBbox,
8455
- inwardBbox,
8456
- });
8571
+ const chordDirectionPx = [chordDx / chordPxLen, chordDy / chordPxLen];
8572
+ const chordAngleDeg = Math.atan2(chordDy, chordDx) * RAD_TO_DEG$1;
8573
+ // Edge insets come from the chord-start and chord-end segment.
8574
+ const chordStartClip = readsFromStart ? firstClip : lastClip;
8575
+ const chordEndClip = readsFromStart ? lastClip : firstClip;
8576
+ const chordStartInsetPx = chordStartClip.startInsetPx;
8577
+ const chordEndInsetPx = chordEndClip.endInsetPx;
8578
+ const chordAvailableWidthPx = Math.max(0, chordPxLen - chordStartInsetPx - chordEndInsetPx);
8579
+ const chordLabelFits = chordAvailableWidthPx >= labelPixelWidth;
8580
+ // ── One candidate per visible segment, but with chord-level properties ──
8581
+ //
8582
+ // The chord properties (pxLen, available width, fit, reading direction,
8583
+ // readingStartPx/EndPx) govern the final anchor position and angle — using
8584
+ // the chord prevents protrusion past polygon corners caused by arc > chord.
8585
+ //
8586
+ // The bbox for each candidate is computed at that segment's own label anchor
8587
+ // position (using the segment's own available width), oriented along the
8588
+ // segment's own direction. This gives per-segment viewport-fit diversity so
8589
+ // narrow viewports where only part of the arc is horizontal enough still
8590
+ // produce at least one pool candidate.
8591
+ for (const clip of segmentClips) {
8592
+ // Per-segment bbox anchor at the segment's own label center / left / right.
8593
+ const segAvailableWidthPx = Math.max(0, clip.segmentPxLen - clip.startInsetPx - clip.endInsetPx);
8594
+ const segAnchorOffsetForBbox = labelAnchor === "left"
8595
+ ? clip.startInsetPx
8596
+ : labelAnchor === "center"
8597
+ ? clip.startInsetPx + segAvailableWidthPx / 2
8598
+ : clip.segmentPxLen - clip.endInsetPx;
8599
+ const segAnchorTForBbox = clip.segmentPxLen > 0
8600
+ ? Math.max(0, Math.min(clip.segmentPxLen, segAnchorOffsetForBbox)) / clip.segmentPxLen
8601
+ : 0;
8602
+ const bboxAnchorX = clip.readingStartPx[0] + segAnchorTForBbox * (clip.readingEndPx[0] - clip.readingStartPx[0]);
8603
+ const bboxAnchorY = clip.readingStartPx[1] + segAnchorTForBbox * (clip.readingEndPx[1] - clip.readingStartPx[1]);
8604
+ const outwardBbox = computeLabelBoundingBox(bboxAnchorX, bboxAnchorY, clip.directionPx[0], clip.directionPx[1], labelAnchor, labelPixelWidth, clip.outwardSide);
8605
+ const flippedSide = clip.outwardSide === "above" ? "below" : "above";
8606
+ const inwardBbox = computeLabelBoundingBox(bboxAnchorX, bboxAnchorY, clip.directionPx[0], clip.directionPx[1], labelAnchor, labelPixelWidth, flippedSide);
8607
+ candidates.push({
8608
+ candidateId: nextCandidateId,
8609
+ edgeIdx,
8610
+ edge: sourceEdge,
8611
+ // Chord-level: anchor, fit, resolver selection criteria
8612
+ pxLen: chordPxLen,
8613
+ angleDeg: chordAngleDeg,
8614
+ availableWidthPx: chordAvailableWidthPx,
8615
+ labelFits: chordLabelFits,
8616
+ startInsetPx: chordStartInsetPx,
8617
+ endInsetPx: chordEndInsetPx,
8618
+ directionPx: chordDirectionPx,
8619
+ readingStartPx: chordReadingStartPx,
8620
+ readingEndPx: chordReadingEndPx,
8621
+ // Per-segment: bbox viewport filtering
8622
+ outwardBbox,
8623
+ inwardBbox,
8624
+ outwardSide: clip.outwardSide,
8625
+ });
8626
+ nextCandidateId++;
8627
+ }
8457
8628
  }
8458
8629
  if (candidates.length === 0) {
8459
8630
  return null;
@@ -8462,9 +8633,11 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8462
8633
  // the reading direction of the matching previous-edge candidate so the resolver
8463
8634
  // can enforce "don't slide back left" without needing raw Mercator math.
8464
8635
  let previousAnchorClippedT;
8636
+ let previousAnchorClippedCandidateId;
8465
8637
  if (previousAnchorGeo !== undefined && previousEdgeIdentity !== undefined) {
8466
- const matchingCandidate = candidates.find(c => isSameEdge(previousEdgeIdentity, c.edge));
8467
- if (matchingCandidate !== undefined && matchingCandidate.pxLen > 0) {
8638
+ for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
8639
+ if (matchingCandidate.pxLen <= 0)
8640
+ continue;
8468
8641
  const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToWebMercatorPx(previousAnchorGeo[0], previousAnchorGeo[1], zoom, tileSize);
8469
8642
  const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
8470
8643
  const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
@@ -8477,8 +8650,10 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8477
8650
  // right of the current clip start (t >= 0). A negative t means the viewport has
8478
8651
  // scrolled so far right that the anchor is now behind the left clip boundary —
8479
8652
  // in that case the default left position is already as far right as we can go.
8480
- if (t >= 0) {
8481
- previousAnchorClippedT = Math.min(1, t);
8653
+ if (t >= 0 && t <= 1) {
8654
+ previousAnchorClippedT = t;
8655
+ previousAnchorClippedCandidateId = matchingCandidate.candidateId;
8656
+ break;
8482
8657
  }
8483
8658
  }
8484
8659
  }
@@ -8491,13 +8666,16 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8491
8666
  previousEdgeIdentity,
8492
8667
  isForced,
8493
8668
  previousAnchorClippedT,
8669
+ previousAnchorClippedCandidateId,
8494
8670
  previousLayoutSide,
8495
8671
  };
8496
8672
  const resolver = labelPlacementResolver ?? defaultEdgeLabelPlacementResolver;
8497
8673
  const decision = resolver(context);
8498
8674
  if (decision === null)
8499
8675
  return null;
8500
- const best = candidates.find(c => c.edgeIdx === decision.edgeIdx);
8676
+ const best = decision.candidateId !== undefined
8677
+ ? candidates.find(c => c.candidateId === decision.candidateId)
8678
+ : candidates.find(c => c.edgeIdx === decision.edgeIdx);
8501
8679
  if (best === undefined)
8502
8680
  return null;
8503
8681
  const fitMode = best.labelFits ? "fits" : "clipped";
@@ -8520,12 +8698,68 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
8520
8698
  // readingStartPx/readingEndPx are viewport-relative; add back viewport origin for absolute px.
8521
8699
  const anchorRelX = best.readingStartPx[0] + anchorT * (best.readingEndPx[0] - best.readingStartPx[0]);
8522
8700
  const anchorRelY = best.readingStartPx[1] + anchorT * (best.readingEndPx[1] - best.readingStartPx[1]);
8523
- const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8701
+ let position;
8702
+ let resolvedDirectionPx;
8703
+ if (geodesic) {
8704
+ const [A, B] = best.edge;
8705
+ const delta = reactMapAdapterShared.angularDistance(A[0], A[1], B[0], B[1]);
8706
+ if (delta > 0) {
8707
+ // Project the Mercator anchor pixel back to geo, then map it onto the
8708
+ // great-circle arc by computing the arc fraction via angular distance
8709
+ // from A. This places the label physically on the visible curved arc
8710
+ // rather than on the straight Mercator chord.
8711
+ const [approxLng, approxLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8712
+ const tArc = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxLng, approxLat) / delta));
8713
+ const geoAnchor = reactMapAdapterShared.intermediatePoint(A, B, tArc, delta);
8714
+ position = [geoAnchor[0], geoAnchor[1]];
8715
+ // Tangent: finite-difference at the visual center of the label (not the
8716
+ // anchor point). For a "left"-anchored label the anchor sits at the left
8717
+ // edge of the pill; evaluating the tangent there means the label hugs the
8718
+ // arc on the left but drifts on the right on curved edges. Computing the
8719
+ // tangent at the label's midpoint (anchor ± halfWidth along the reading
8720
+ // direction) keeps both sides equally aligned to the curve.
8721
+ const halfLabelPx = labelPixelWidth / 2;
8722
+ const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
8723
+ const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
8724
+ const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
8725
+ const [approxCenterLng, approxCenterLat] = webMercatorPxToLngLat(centerRelX + pxViewMin, centerRelY + pyViewMin, zoom, tileSize);
8726
+ const tArcCenter = Math.max(0, Math.min(1, reactMapAdapterShared.angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
8727
+ const TANGENT_EPS = 0.001;
8728
+ const p0 = reactMapAdapterShared.intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
8729
+ const p1 = reactMapAdapterShared.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);
8732
+ const ddx = ax - bx;
8733
+ const ddy = ay - by;
8734
+ const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
8735
+ if (tangentLen > 0) {
8736
+ const rawDir = [ddx / tangentLen, ddy / tangentLen];
8737
+ // Orient tangent to match the reading direction stored in best.directionPx.
8738
+ const dot = best.directionPx[0] * rawDir[0] + best.directionPx[1] * rawDir[1];
8739
+ const negDir = [-rawDir[0], -rawDir[1]];
8740
+ resolvedDirectionPx = dot >= 0 ? rawDir : negDir;
8741
+ }
8742
+ else {
8743
+ resolvedDirectionPx = best.directionPx;
8744
+ }
8745
+ }
8746
+ else {
8747
+ // Co-located vertices — fall back to Mercator straight-line.
8748
+ const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8749
+ position = [anchorLng, anchorLat];
8750
+ resolvedDirectionPx = best.directionPx;
8751
+ }
8752
+ }
8753
+ else {
8754
+ const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
8755
+ position = [anchorLng, anchorLat];
8756
+ resolvedDirectionPx = best.directionPx;
8757
+ }
8524
8758
  return {
8525
- position: [anchorLng, anchorLat],
8759
+ position,
8526
8760
  layout: {
8527
8761
  type: "edge",
8528
- directionPx: best.directionPx,
8762
+ directionPx: resolvedDirectionPx,
8529
8763
  anchor: resolvedAnchor,
8530
8764
  outwardSide: layoutOutwardSide,
8531
8765
  },
@@ -8581,7 +8815,7 @@ const placementToAnchor = (placement) => {
8581
8815
  * caller-managed annotation).
8582
8816
  */
8583
8817
  const computeEdgeAutoPlacement = (feature, label, viewportBounds, zoom, tileSize, options) => {
8584
- const { labelPixelWidth, mode = "auto", previousAnchorGeo, previousLayoutSide } = options;
8818
+ const { labelPixelWidth, mode = "auto", previousAnchorGeo, previousLayoutSide, geodesic = false } = options;
8585
8819
  const geomType = feature.geometry?.type;
8586
8820
  const isPointGeometry = geomType === "Point" || geomType === "MultiPoint";
8587
8821
  if (isPointGeometry) {
@@ -8623,6 +8857,7 @@ const computeEdgeAutoPlacement = (feature, label, viewportBounds, zoom, tileSize
8623
8857
  edgeInsets: options.edgeInsets,
8624
8858
  labelAnchor: options.labelAnchor,
8625
8859
  labelPlacementResolver: options.labelPlacementResolver,
8860
+ geodesic,
8626
8861
  });
8627
8862
  if (edgePlacement !== null) {
8628
8863
  return {
@@ -8650,6 +8885,7 @@ const computeEdgeAutoPlacement = (feature, label, viewportBounds, zoom, tileSize
8650
8885
  labelAnchor: options.labelAnchor,
8651
8886
  labelPlacementResolver: options.labelPlacementResolver,
8652
8887
  isForced: true,
8888
+ geodesic,
8653
8889
  });
8654
8890
  if (forcedPlacement !== null) {
8655
8891
  const fitMode = forcedPlacement.fitMode === "clipped" ? "overflow" : forcedPlacement.fitMode;
@@ -8688,12 +8924,36 @@ const isLineGeometry = (geometry) => {
8688
8924
  * For Polygons, `outwardSide` uses the cross product with the geometry centroid
8689
8925
  * to determine which side faces away from the interior. For LineStrings (no
8690
8926
  * interior), `outwardSide` defaults to `"above"`.
8927
+ *
8928
+ * When `geodesic` is `true`, `angleDeg` is derived from the arc tangent at
8929
+ * the edge midpoint (t = 0.5) via finite difference, so decorations are
8930
+ * rotated correctly relative to the visible curved edge.
8691
8931
  */
8692
- const computeEdgeProperties = (start, end, geometry, zoom, tileSize) => {
8932
+ const computeEdgeProperties = (start, end, geometry, zoom, tileSize, geodesic = false) => {
8693
8933
  const midLat = (start[1] + end[1]) / 2;
8694
- const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8695
- const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
8696
- const angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
8934
+ let angleDeg;
8935
+ if (geodesic) {
8936
+ const delta = reactMapAdapterShared.angularDistance(start[0], start[1], end[0], end[1]);
8937
+ if (delta > 0) {
8938
+ // Finite-difference tangent at the arc midpoint (t = 0.5).
8939
+ const TANGENT_EPS = 0.001;
8940
+ const p0 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 - TANGENT_EPS, delta);
8941
+ const p1 = reactMapAdapterShared.intermediatePoint(start, end, 0.5 + TANGENT_EPS, delta);
8942
+ const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
8943
+ const [ax, ay] = lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
8944
+ angleDeg = normalizeReadableAngle(Math.atan2(ay - by, ax - bx) * RAD_TO_DEG);
8945
+ }
8946
+ else {
8947
+ const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8948
+ const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
8949
+ angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
8950
+ }
8951
+ }
8952
+ else {
8953
+ const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
8954
+ const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
8955
+ angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
8956
+ }
8697
8957
  const pixelLength = geoJsonUtils.edgePixelLength(start[0], start[1], end[0], end[1], zoom, midLat, tileSize);
8698
8958
  let outwardSide = "above";
8699
8959
  if (!isLineGeometry(geometry)) {
@@ -8958,39 +9218,60 @@ const featureBbox = (feature) => {
8958
9218
  return [minLng, minLat, maxLng, maxLat];
8959
9219
  };
8960
9220
  const bboxesIntersect = (a, b) => a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3];
8961
- const countOverlappingShapes = (feature, featureBboxes, viewportBounds) => {
8962
- const bbox = featureBboxes.get(feature);
8963
- if (bbox === undefined || bbox === null)
8964
- return 0;
8965
- const geom = feature.geometry;
8966
- const isPolygonal = geom !== null && (geom.type === "Polygon" || geom.type === "MultiPolygon");
8967
- let count = 0;
8968
- for (const [otherFeature, otherBbox] of featureBboxes) {
8969
- if (otherFeature === feature || otherBbox === null)
9221
+ /**
9222
+ * Compute per-feature overlap counts for all features in one O() pass.
9223
+ *
9224
+ * Previously `countOverlappingShapes` was called once per feature, re-scanning
9225
+ * every other feature and re-running polygon intersection for the same pairs —
9226
+ * O(n³) intersection work per handle per pan frame. Each unordered pair is
9227
+ * evaluated at most once here.
9228
+ */
9229
+ const buildOverlappingShapesCountMap = (features, featureBboxes, viewportBounds) => {
9230
+ const counts = new Map();
9231
+ for (const feature of features) {
9232
+ counts.set(feature, 0);
9233
+ }
9234
+ for (let i = 0; i < features.length; i++) {
9235
+ const featureA = features[i];
9236
+ if (featureA === undefined)
8970
9237
  continue;
8971
- if (!bboxesIntersect(otherBbox, viewportBounds))
9238
+ const bboxA = featureBboxes.get(featureA);
9239
+ if (bboxA === undefined || bboxA === null)
8972
9240
  continue;
8973
- if (!bboxesIntersect(bbox, otherBbox))
9241
+ if (!bboxesIntersect(bboxA, viewportBounds))
8974
9242
  continue;
8975
- const otherGeom = otherFeature.geometry;
8976
- const otherIsPolygonal = otherGeom !== null && (otherGeom.type === "Polygon" || otherGeom.type === "MultiPolygon");
8977
- if (isPolygonal && otherIsPolygonal) {
8978
- // Precise polygon intersection — bbox was a prefilter only
8979
- if (geoJsonUtils.getGeoJsonPolygonIntersection(geom, otherGeom) !== null) {
8980
- // If this feature is fully contained inside the other, its fill does not
8981
- // obscure the base map layer below — only the outer's count increments.
8982
- if (geoJsonUtils.isFullyContainedInGeoJsonGeometry(geom, otherGeom) === true) {
8983
- continue;
9243
+ const geomA = featureA.geometry;
9244
+ const isPolygonalA = geomA !== null && (geomA.type === "Polygon" || geomA.type === "MultiPolygon");
9245
+ for (let j = i + 1; j < features.length; j++) {
9246
+ const featureB = features[j];
9247
+ if (featureB === undefined)
9248
+ continue;
9249
+ const bboxB = featureBboxes.get(featureB);
9250
+ if (bboxB === undefined || bboxB === null)
9251
+ continue;
9252
+ if (!bboxesIntersect(bboxB, viewportBounds))
9253
+ continue;
9254
+ if (!bboxesIntersect(bboxA, bboxB))
9255
+ continue;
9256
+ const geomB = featureB.geometry;
9257
+ const isPolygonalB = geomB !== null && (geomB.type === "Polygon" || geomB.type === "MultiPolygon");
9258
+ if (isPolygonalA && isPolygonalB) {
9259
+ if (geoJsonUtils.getGeoJsonPolygonIntersection(geomA, geomB) !== null) {
9260
+ if (geoJsonUtils.isFullyContainedInGeoJsonGeometry(geomA, geomB) !== true) {
9261
+ counts.set(featureA, (counts.get(featureA) ?? 0) + 1);
9262
+ }
9263
+ if (geoJsonUtils.isFullyContainedInGeoJsonGeometry(geomB, geomA) !== true) {
9264
+ counts.set(featureB, (counts.get(featureB) ?? 0) + 1);
9265
+ }
8984
9266
  }
8985
- count++;
8986
9267
  }
8987
- }
8988
- else {
8989
- // Points and lines keep bbox-only overlap (geometries have no fill area)
8990
- count++;
9268
+ else {
9269
+ counts.set(featureA, (counts.get(featureA) ?? 0) + 1);
9270
+ counts.set(featureB, (counts.get(featureB) ?? 0) + 1);
9271
+ }
8991
9272
  }
8992
9273
  }
8993
- return count;
9274
+ return counts;
8994
9275
  };
8995
9276
  /**
8996
9277
  * Value-based equality for the nested style overrides maps. Used to skip
@@ -9129,6 +9410,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9129
9410
  }
9130
9411
  return shapesInViewportCache;
9131
9412
  };
9413
+ const overlappingCounts = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
9132
9414
  handle.features.features.forEach((feature, index) => {
9133
9415
  const geometry = feature.geometry;
9134
9416
  if (geometry === null || geometry.type === "GeometryCollection")
@@ -9141,7 +9423,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9141
9423
  zoom,
9142
9424
  tileSize,
9143
9425
  shapesInViewport: shapesInViewport(),
9144
- overlappingShapesCount: countOverlappingShapes(feature, featureBboxes, bounds),
9426
+ overlappingShapesCount: overlappingCounts.get(feature) ?? 0,
9145
9427
  interaction: currentInteraction,
9146
9428
  });
9147
9429
  const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
@@ -9191,7 +9473,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9191
9473
  ...(decoration.sourceProperties ?? {}),
9192
9474
  };
9193
9475
  if (resolved.edgeEndpoints !== null) {
9194
- const edgeProps = computeEdgeProperties(resolved.edgeEndpoints.start, resolved.edgeEndpoints.end, geometry, zoom, tileSize);
9476
+ const edgeProps = computeEdgeProperties(resolved.edgeEndpoints.start, resolved.edgeEndpoints.end, geometry, zoom, tileSize, featureStyle.geodesic ?? true);
9195
9477
  properties = { ...properties, ...edgeProps };
9196
9478
  }
9197
9479
  newLayers.push({
@@ -9284,6 +9566,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
9284
9566
  labelAnchor: anchor.labelAnchor,
9285
9567
  labelPlacementResolver: anchor.labelPlacementResolver,
9286
9568
  mode,
9569
+ geodesic: featureStyle.geodesic ?? true,
9287
9570
  });
9288
9571
  switch (outcome.type) {
9289
9572
  case "placed": {
@@ -9747,7 +10030,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
9747
10030
  if (restingClip !== undefined)
9748
10031
  restingPeerFills.set(featureId, restingClip);
9749
10032
  }
9750
- const peerFills = computePromotionGroupFills(groupMembers, winnerId, restingPeerFills);
10033
+ const peerFills = computePromotionGroupFills(groupMembers, winnerId, restingPeerFills, handle?.style.geodesic, handle?.featureStyles);
9751
10034
  modifiedFills.delete(winnerId);
9752
10035
  for (const [featureId, clip] of peerFills) {
9753
10036
  modifiedFills.set(featureId, clip);
@@ -9800,12 +10083,28 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
9800
10083
  .sort(([a], [b]) => (a < b ? -1 : 1))
9801
10084
  .map(([handleId, ids]) => `${handleId}:${[...ids].sort().join(",")}`)
9802
10085
  .join(";");
10086
+ // Geodesic key: per-handle layer flag + sorted per-feature geodesic overrides.
10087
+ // Toggling geodesic (layer or per-feature) must recompute instead of serving
10088
+ // a stale clip (mirrors suppressedKey shape).
10089
+ const geodesicKey = currentHandles
10090
+ .map(handle => {
10091
+ const layerFlag = handle.style.geodesic;
10092
+ const featureFlags = handle.featureStyles
10093
+ ? [...handle.featureStyles.entries()]
10094
+ .filter(([, style]) => style.geodesic !== undefined)
10095
+ .sort(([a], [b]) => (a < b ? -1 : 1))
10096
+ .map(([id, style]) => `${id}:${String(style.geodesic)}`)
10097
+ .join(",")
10098
+ : "";
10099
+ return `${handle.id}:${String(layerFlag)}:${featureFlags}`;
10100
+ })
10101
+ .join(";");
9803
10102
  // Bounds are intentionally excluded from the inputKey. Clip geometry depends on
9804
10103
  // zoom (which affects stack order via resolveStackOrder) and feature geometry —
9805
10104
  // not on the visible viewport region. Using global bounds means all overlapping
9806
10105
  // features always have pre-computed clips, so polygons entering the viewport
9807
10106
  // during zoom-out never flash their unclipped fill.
9808
- const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}`;
10107
+ const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
9809
10108
  if (inputKey === lastComputeInputKeyRef.current) {
9810
10109
  return;
9811
10110
  }
@@ -9836,6 +10135,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
9836
10135
  zoom: currentViewport.zoom,
9837
10136
  selectedFeatureId: selectedFeatureIdRef.current,
9838
10137
  suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
10138
+ layerGeodesic: handle.style.geodesic,
10139
+ featureStyles: handle.featureStyles,
9839
10140
  });
9840
10141
  featureToGroupKeyByHandleRef.current.set(handle.id, new Map(featureToGroupKey));
9841
10142
  if (fillGeometries.size > 0) {
@@ -9952,6 +10253,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
9952
10253
  tileSize: currentViewport.tileSize,
9953
10254
  strokeWidthFor,
9954
10255
  visibleFillFor,
10256
+ layerGeodesic: handle.style.geodesic,
10257
+ featureStyles: handle.featureStyles,
9955
10258
  });
9956
10259
  handle.onShapesUnderCursor?.(hits, { position });
9957
10260
  settleHitsByHandle.set(handle.id, hits);
@@ -10109,6 +10412,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
10109
10412
  tileSize,
10110
10413
  strokeWidthFor,
10111
10414
  visibleFillFor,
10415
+ layerGeodesic: handle.style.geodesic,
10416
+ featureStyles: handle.featureStyles,
10112
10417
  });
10113
10418
  }, []);
10114
10419
  const setDecorationHoverActive = react.useCallback((active) => {