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