@trackunit/react-map 0.1.5 → 0.1.6
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 +397 -119
- package/index.esm.js +398 -120
- package/package.json +2 -2
- package/src/layers/useShapes/edge/computeEdgeAutoPlacement.d.ts +7 -0
- package/src/layers/useShapes/edge/computeEdgeProperties.d.ts +5 -1
- package/src/layers/useShapes/edge/findBestEdgePosition.d.ts +20 -1
- package/src/layers/useShapes/shapeFillTiling.d.ts +24 -2
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, SHAPE_STYLE_DEFAULTS, 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';
|
|
@@ -7403,10 +7403,19 @@ const degreesPerPixel = (zoom, tileSize) => 360 / (tileSize * Math.pow(2, zoom))
|
|
|
7403
7403
|
* - Bbox-prefiltered for performance; results are in deterministic (feature-id) order.
|
|
7404
7404
|
*/
|
|
7405
7405
|
const shapesUnderCursor = (position, features, options) => {
|
|
7406
|
-
const { zoom, tileSize, strokeWidthFor, visibleFillFor } = options;
|
|
7406
|
+
const { zoom, tileSize, strokeWidthFor, visibleFillFor, layerGeodesic, featureStyles } = options;
|
|
7407
|
+
// Densify before boundary-distance and point-in-polygon math so stroke/fill
|
|
7408
|
+
// hit-tests follow great-circle arcs on large polygons.
|
|
7409
|
+
const featureCollection = {
|
|
7410
|
+
type: "FeatureCollection",
|
|
7411
|
+
features: Array.from(features),
|
|
7412
|
+
};
|
|
7413
|
+
const densifiedFeatures = layerGeodesic !== false
|
|
7414
|
+
? densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
|
|
7415
|
+
: features;
|
|
7407
7416
|
const degPerPx = degreesPerPixel(zoom, tileSize);
|
|
7408
7417
|
const hits = [];
|
|
7409
|
-
for (const feature of
|
|
7418
|
+
for (const feature of densifiedFeatures) {
|
|
7410
7419
|
if (feature.id === undefined)
|
|
7411
7420
|
continue;
|
|
7412
7421
|
const geometry = polygonalGeometry(feature);
|
|
@@ -7514,9 +7523,17 @@ const promoteToFront = (order, id) => order.includes(id) ? [id, ...order.filter(
|
|
|
7514
7523
|
* its full geometry — so peer-to-peer overlap ownership from resting tiling is
|
|
7515
7524
|
* preserved and translucent fills do not compound under the winner.
|
|
7516
7525
|
*/
|
|
7517
|
-
const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map()) => {
|
|
7526
|
+
const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map(), layerGeodesic = undefined, featureStyles = undefined) => {
|
|
7527
|
+
// Densify before peer-vs-winner clip so promotion clips follow great-circle arcs.
|
|
7528
|
+
const featureCollection = {
|
|
7529
|
+
type: "FeatureCollection",
|
|
7530
|
+
features: Array.from(members),
|
|
7531
|
+
};
|
|
7532
|
+
const densifiedMembers = layerGeodesic !== false
|
|
7533
|
+
? densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
|
|
7534
|
+
: members;
|
|
7518
7535
|
const geometries = new Map();
|
|
7519
|
-
for (const feature of
|
|
7536
|
+
for (const feature of densifiedMembers) {
|
|
7520
7537
|
if (feature.id === undefined)
|
|
7521
7538
|
continue;
|
|
7522
7539
|
const geometry = polygonalGeometry(feature);
|
|
@@ -7552,9 +7569,18 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
|
|
|
7552
7569
|
return result;
|
|
7553
7570
|
};
|
|
7554
7571
|
const computeFillTiling = (input) => {
|
|
7555
|
-
const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds } = input;
|
|
7572
|
+
const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
|
|
7573
|
+
// Densify before any polygon/boundary math so clip geometry follows great-circle
|
|
7574
|
+
// arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
|
|
7575
|
+
const featureCollection = {
|
|
7576
|
+
type: "FeatureCollection",
|
|
7577
|
+
features: Array.from(features),
|
|
7578
|
+
};
|
|
7579
|
+
const densifiedFeatures = layerGeodesic !== false
|
|
7580
|
+
? densifyGeodesicFeatures(featureCollection, layerGeodesic ?? true, featureStyles).features
|
|
7581
|
+
: features;
|
|
7556
7582
|
const records = [];
|
|
7557
|
-
for (const feature of
|
|
7583
|
+
for (const feature of densifiedFeatures) {
|
|
7558
7584
|
if (feature.id === undefined)
|
|
7559
7585
|
continue;
|
|
7560
7586
|
const id = String(feature.id);
|
|
@@ -7989,6 +8015,8 @@ const RAD_TO_DEG$1 = 180 / Math.PI;
|
|
|
7989
8015
|
const DEFAULT_EDGE_LABEL_INSET_PX = 6;
|
|
7990
8016
|
const LABEL_HEIGHT_PX = 20;
|
|
7991
8017
|
const MAX_WEB_MERCATOR_LAT = 85.05112878;
|
|
8018
|
+
const EARTH_RADIUS_KM = 6371;
|
|
8019
|
+
const GEODESIC_PLACEMENT_MAX_SEGMENT_RAD = GEODESIC_MAX_SEGMENT_KM / EARTH_RADIUS_KM;
|
|
7992
8020
|
/**
|
|
7993
8021
|
* Hysteresis override thresholds.
|
|
7994
8022
|
*
|
|
@@ -8249,6 +8277,29 @@ const computeLabelBoundingBox = (anchorX, anchorY, cosT, sinT, labelAnchor, labe
|
|
|
8249
8277
|
}
|
|
8250
8278
|
return { minX, maxX, minY, maxY };
|
|
8251
8279
|
};
|
|
8280
|
+
const getPlacementSegments = (start, end, geodesic) => {
|
|
8281
|
+
if (!geodesic) {
|
|
8282
|
+
return [{ start, end, isOriginalStart: true, isOriginalEnd: true }];
|
|
8283
|
+
}
|
|
8284
|
+
const delta = angularDistance(start[0], start[1], end[0], end[1]);
|
|
8285
|
+
if (delta === 0) {
|
|
8286
|
+
return [{ start, end, isOriginalStart: true, isOriginalEnd: true }];
|
|
8287
|
+
}
|
|
8288
|
+
const segmentCount = Math.max(1, Math.ceil(delta / GEODESIC_PLACEMENT_MAX_SEGMENT_RAD));
|
|
8289
|
+
const segments = [];
|
|
8290
|
+
let segmentStart = start;
|
|
8291
|
+
for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex++) {
|
|
8292
|
+
const segmentEnd = segmentIndex === segmentCount ? end : intermediatePoint(start, end, segmentIndex / segmentCount, delta);
|
|
8293
|
+
segments.push({
|
|
8294
|
+
start: segmentStart,
|
|
8295
|
+
end: segmentEnd,
|
|
8296
|
+
isOriginalStart: segmentIndex === 1,
|
|
8297
|
+
isOriginalEnd: segmentIndex === segmentCount,
|
|
8298
|
+
});
|
|
8299
|
+
segmentStart = segmentEnd;
|
|
8300
|
+
}
|
|
8301
|
+
return segments;
|
|
8302
|
+
};
|
|
8252
8303
|
// ============================================================================
|
|
8253
8304
|
// Default resolver
|
|
8254
8305
|
// ============================================================================
|
|
@@ -8279,7 +8330,15 @@ const defaultEdgeLabelPlacementResolver = (context) => {
|
|
|
8279
8330
|
: undefined;
|
|
8280
8331
|
let best;
|
|
8281
8332
|
if (previousEdgeIdentity !== undefined) {
|
|
8282
|
-
|
|
8333
|
+
// When a held anchor was projected onto a specific segment-candidate, lock onto
|
|
8334
|
+
// that same segment so the "don't slide back" hold fraction (measured in that
|
|
8335
|
+
// segment's reading frame) is applied to the matching candidate. Falling back to
|
|
8336
|
+
// the first fitting segment of the edge would apply the fraction in the wrong
|
|
8337
|
+
// reading frame, making the label jump back toward the edge start when panning.
|
|
8338
|
+
const heldCandidate = context.previousAnchorClippedCandidateId !== undefined
|
|
8339
|
+
? pool.find(c => c.candidateId === context.previousAnchorClippedCandidateId && c.labelFits)
|
|
8340
|
+
: undefined;
|
|
8341
|
+
const hysteresisCandidate = heldCandidate ?? pool.find(c => c.labelFits && isSameEdge(previousEdgeIdentity, c.edge));
|
|
8283
8342
|
if (hysteresisCandidate !== undefined) {
|
|
8284
8343
|
const isHysteresisCandidateSteep = Math.abs(hysteresisCandidate.angleDeg) > STEEP_EDGE_THRESHOLD_DEG;
|
|
8285
8344
|
if (isHysteresisCandidateSteep) {
|
|
@@ -8338,7 +8397,7 @@ const defaultEdgeLabelPlacementResolver = (context) => {
|
|
|
8338
8397
|
// the center of the pill element to that geo position. Together they visually
|
|
8339
8398
|
// center the label on the edge rather than pinning the left edge at the midpoint.
|
|
8340
8399
|
if (isForced && !best.labelFits) {
|
|
8341
|
-
return { edgeIdx: best.edgeIdx, side, anchorT: 0.5, anchor: "center" };
|
|
8400
|
+
return { edgeIdx: best.edgeIdx, candidateId: best.candidateId, side, anchorT: 0.5, anchor: "center" };
|
|
8342
8401
|
}
|
|
8343
8402
|
// "Don't slide back" — for left-anchored labels on the same edge as before,
|
|
8344
8403
|
// once the viewport has pushed the anchor rightward (to stay in view), keep it
|
|
@@ -8356,20 +8415,21 @@ const defaultEdgeLabelPlacementResolver = (context) => {
|
|
|
8356
8415
|
const maxSafeT = best.pxLen > 0 ? (best.pxLen - best.endInsetPx - context.labelPixelWidth) / best.pxLen : 0;
|
|
8357
8416
|
const clampedT = Math.max(defaultLeftT, Math.min(maxSafeT, context.previousAnchorClippedT));
|
|
8358
8417
|
if (clampedT > defaultLeftT) {
|
|
8359
|
-
return { edgeIdx: best.edgeIdx, side, anchorT: clampedT };
|
|
8418
|
+
return { edgeIdx: best.edgeIdx, candidateId: best.candidateId, side, anchorT: clampedT };
|
|
8360
8419
|
}
|
|
8361
8420
|
}
|
|
8362
8421
|
}
|
|
8363
|
-
return { edgeIdx: best.edgeIdx, side };
|
|
8422
|
+
return { edgeIdx: best.edgeIdx, candidateId: best.candidateId, side };
|
|
8364
8423
|
};
|
|
8365
8424
|
// ============================================================================
|
|
8366
8425
|
// Main function
|
|
8367
8426
|
// ============================================================================
|
|
8368
|
-
const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, labelPixelWidth, tileSize = 256, maxReadableAngleDeg, previousEdgeIdentity, previousAnchorGeo, previousLayoutSide, edgeInsets, labelAnchor = "left", labelPlacementResolver, isForced = false, }) => {
|
|
8427
|
+
const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, labelPixelWidth, tileSize = 256, maxReadableAngleDeg, previousEdgeIdentity, previousAnchorGeo, previousLayoutSide, edgeInsets, labelAnchor = "left", labelPlacementResolver, isForced = false, geodesic = false, }) => {
|
|
8369
8428
|
const [minLon, minLat, maxLon, maxLat] = viewportBounds;
|
|
8370
8429
|
const edges = extractEdges(features);
|
|
8371
8430
|
const centroid = computeGeometryCentroid(features);
|
|
8372
8431
|
const candidates = [];
|
|
8432
|
+
let nextCandidateId = 0;
|
|
8373
8433
|
// Pre-compute pixel viewport bounds so we can clip in pixel space.
|
|
8374
8434
|
// Mercator y is non-linear in latitude, so clipping in geo space then
|
|
8375
8435
|
// projecting gives clip points that are NOT on the rendered (pixel-space)
|
|
@@ -8384,75 +8444,159 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8384
8444
|
const edge = edges[edgeIdx];
|
|
8385
8445
|
if (edge === undefined)
|
|
8386
8446
|
continue;
|
|
8387
|
-
const
|
|
8388
|
-
//
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8447
|
+
const sourceEdge = edge;
|
|
8448
|
+
// Collect every clipped segment for this edge. For geodesic edges there
|
|
8449
|
+
// will be multiple short arc segments; for Mercator there is always one.
|
|
8450
|
+
// After collection we derive a single "chord" candidate that spans from the
|
|
8451
|
+
// reading-start of the first visible segment to the reading-end of the last
|
|
8452
|
+
// visible segment. Using the chord (straight-line) length — not the summed
|
|
8453
|
+
// arc length — ensures fit/visibility checks match what the label actually
|
|
8454
|
+
// occupies in screen space.
|
|
8455
|
+
const segmentClips = [];
|
|
8456
|
+
for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
|
|
8457
|
+
const { start, end } = segment;
|
|
8458
|
+
const [startPxX, startPxY] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
|
|
8459
|
+
const [endPxX, endPxY] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
|
|
8460
|
+
const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
|
|
8461
|
+
if (!clippedPx)
|
|
8462
|
+
continue;
|
|
8463
|
+
const [px0, py0, px1, py1] = clippedPx;
|
|
8464
|
+
const rawDx = px1 - px0;
|
|
8465
|
+
const rawDy = py1 - py0;
|
|
8466
|
+
const segPxLen = Math.sqrt(rawDx * rawDx + rawDy * rawDy);
|
|
8467
|
+
if (segPxLen === 0)
|
|
8468
|
+
continue;
|
|
8469
|
+
const readsFromClippedStart = rawDx >= 0;
|
|
8470
|
+
const directionPx = readsFromClippedStart
|
|
8471
|
+
? [rawDx / segPxLen, rawDy / segPxLen]
|
|
8472
|
+
: [-rawDx / segPxLen, -rawDy / segPxLen];
|
|
8473
|
+
// Inverse-project pixel clip endpoints to geo for outward-side and
|
|
8474
|
+
// polygon-interior checks (qualitative, so approximately correct geo is fine).
|
|
8475
|
+
const [ex0, ey0] = webMercatorPxToLngLat(px0, py0, zoom, tileSize);
|
|
8476
|
+
const [ex1, ey1] = webMercatorPxToLngLat(px1, py1, zoom, tileSize);
|
|
8477
|
+
const midLat = (ey0 + ey1) / 2;
|
|
8478
|
+
const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
|
|
8479
|
+
if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
|
|
8480
|
+
continue;
|
|
8481
|
+
const inset = edgeInsets?.[edgeIdx];
|
|
8482
|
+
const readingStartInset = readsFromClippedStart && segment.isOriginalStart
|
|
8483
|
+
? inset?.startPx
|
|
8484
|
+
: !readsFromClippedStart && segment.isOriginalEnd
|
|
8485
|
+
? inset?.endPx
|
|
8486
|
+
: 0;
|
|
8487
|
+
const readingEndInset = readsFromClippedStart && segment.isOriginalEnd
|
|
8488
|
+
? inset?.endPx
|
|
8489
|
+
: !readsFromClippedStart && segment.isOriginalStart
|
|
8490
|
+
? inset?.startPx
|
|
8491
|
+
: 0;
|
|
8492
|
+
const startInsetPx = Math.max(DEFAULT_EDGE_LABEL_INSET_PX, readingStartInset ?? 0);
|
|
8493
|
+
const endInsetPx = Math.max(DEFAULT_EDGE_LABEL_INSET_PX, readingEndInset ?? 0);
|
|
8494
|
+
const readingStartPx = readsFromClippedStart
|
|
8495
|
+
? [px0 - pxViewMin, py0 - pyViewMin]
|
|
8496
|
+
: [px1 - pxViewMin, py1 - pyViewMin];
|
|
8497
|
+
const readingEndPx = readsFromClippedStart
|
|
8498
|
+
? [px1 - pxViewMin, py1 - pyViewMin]
|
|
8499
|
+
: [px0 - pxViewMin, py0 - pyViewMin];
|
|
8500
|
+
segmentClips.push({
|
|
8501
|
+
segment,
|
|
8502
|
+
segmentPxLen: segPxLen,
|
|
8503
|
+
readsFromClippedStart,
|
|
8504
|
+
directionPx,
|
|
8505
|
+
readingStartPx,
|
|
8506
|
+
readingEndPx,
|
|
8507
|
+
outwardSide: edgeOutwardSide,
|
|
8508
|
+
startInsetPx,
|
|
8509
|
+
endInsetPx,
|
|
8510
|
+
});
|
|
8511
|
+
}
|
|
8512
|
+
if (segmentClips.length === 0)
|
|
8393
8513
|
continue;
|
|
8394
|
-
|
|
8395
|
-
//
|
|
8396
|
-
//
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8514
|
+
// ── Build the chord spanning the full clipped arc ─────────────────────────
|
|
8515
|
+
//
|
|
8516
|
+
// For geodesic edges the label must fit in screen-space. The chord (straight
|
|
8517
|
+
// line from the first visible segment's reading start to the last visible
|
|
8518
|
+
// segment's reading end) is the actual screen-space extent of the arc.
|
|
8519
|
+
// Using chord length instead of the summed arc length prevents the label
|
|
8520
|
+
// from extending past a polygon corner even when the arc is longer than
|
|
8521
|
+
// the chord.
|
|
8522
|
+
//
|
|
8523
|
+
// For a right-to-left edge (readsFromClippedStart = false),
|
|
8524
|
+
// getPlacementSegments returns segments in geographic A→B order but the
|
|
8525
|
+
// reading direction is B→A, so the chord start is the LAST segment's
|
|
8526
|
+
// readingStartPx and the chord end is the FIRST segment's readingEndPx.
|
|
8527
|
+
// segmentClips is non-empty (checked above); TypeScript doesn't narrow that
|
|
8528
|
+
// from .length > 0, so we do a non-null assertion via the bang-free fallback.
|
|
8529
|
+
const firstClip = segmentClips[0];
|
|
8530
|
+
const lastClip = segmentClips[segmentClips.length - 1];
|
|
8531
|
+
if (firstClip === undefined || lastClip === undefined)
|
|
8404
8532
|
continue;
|
|
8405
|
-
const
|
|
8406
|
-
const
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
const
|
|
8410
|
-
|
|
8533
|
+
const readsFromStart = firstClip.readsFromClippedStart;
|
|
8534
|
+
const chordReadingStartPx = readsFromStart ? firstClip.readingStartPx : lastClip.readingStartPx;
|
|
8535
|
+
const chordReadingEndPx = readsFromStart ? lastClip.readingEndPx : firstClip.readingEndPx;
|
|
8536
|
+
const chordDx = chordReadingEndPx[0] - chordReadingStartPx[0];
|
|
8537
|
+
const chordDy = chordReadingEndPx[1] - chordReadingStartPx[1];
|
|
8538
|
+
const chordPxLen = Math.sqrt(chordDx * chordDx + chordDy * chordDy);
|
|
8539
|
+
if (chordPxLen === 0)
|
|
8411
8540
|
continue;
|
|
8412
|
-
|
|
8413
|
-
if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
|
|
8541
|
+
if (chordPxLen < minPixelWidth)
|
|
8414
8542
|
continue;
|
|
8415
|
-
const
|
|
8416
|
-
const
|
|
8417
|
-
|
|
8418
|
-
const
|
|
8419
|
-
const
|
|
8420
|
-
|
|
8421
|
-
|
|
8422
|
-
const
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
//
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
|
|
8435
|
-
const
|
|
8436
|
-
|
|
8437
|
-
|
|
8438
|
-
|
|
8439
|
-
|
|
8440
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
8444
|
-
|
|
8445
|
-
|
|
8446
|
-
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
|
|
8450
|
-
directionPx,
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8543
|
+
const chordDirectionPx = [chordDx / chordPxLen, chordDy / chordPxLen];
|
|
8544
|
+
const chordAngleDeg = Math.atan2(chordDy, chordDx) * RAD_TO_DEG$1;
|
|
8545
|
+
// Edge insets come from the chord-start and chord-end segment.
|
|
8546
|
+
const chordStartClip = readsFromStart ? firstClip : lastClip;
|
|
8547
|
+
const chordEndClip = readsFromStart ? lastClip : firstClip;
|
|
8548
|
+
const chordStartInsetPx = chordStartClip.startInsetPx;
|
|
8549
|
+
const chordEndInsetPx = chordEndClip.endInsetPx;
|
|
8550
|
+
const chordAvailableWidthPx = Math.max(0, chordPxLen - chordStartInsetPx - chordEndInsetPx);
|
|
8551
|
+
const chordLabelFits = chordAvailableWidthPx >= labelPixelWidth;
|
|
8552
|
+
// ── One candidate per visible segment, but with chord-level properties ──
|
|
8553
|
+
//
|
|
8554
|
+
// The chord properties (pxLen, available width, fit, reading direction,
|
|
8555
|
+
// readingStartPx/EndPx) govern the final anchor position and angle — using
|
|
8556
|
+
// the chord prevents protrusion past polygon corners caused by arc > chord.
|
|
8557
|
+
//
|
|
8558
|
+
// The bbox for each candidate is computed at that segment's own label anchor
|
|
8559
|
+
// position (using the segment's own available width), oriented along the
|
|
8560
|
+
// segment's own direction. This gives per-segment viewport-fit diversity so
|
|
8561
|
+
// narrow viewports where only part of the arc is horizontal enough still
|
|
8562
|
+
// produce at least one pool candidate.
|
|
8563
|
+
for (const clip of segmentClips) {
|
|
8564
|
+
// Per-segment bbox anchor at the segment's own label center / left / right.
|
|
8565
|
+
const segAvailableWidthPx = Math.max(0, clip.segmentPxLen - clip.startInsetPx - clip.endInsetPx);
|
|
8566
|
+
const segAnchorOffsetForBbox = labelAnchor === "left"
|
|
8567
|
+
? clip.startInsetPx
|
|
8568
|
+
: labelAnchor === "center"
|
|
8569
|
+
? clip.startInsetPx + segAvailableWidthPx / 2
|
|
8570
|
+
: clip.segmentPxLen - clip.endInsetPx;
|
|
8571
|
+
const segAnchorTForBbox = clip.segmentPxLen > 0
|
|
8572
|
+
? Math.max(0, Math.min(clip.segmentPxLen, segAnchorOffsetForBbox)) / clip.segmentPxLen
|
|
8573
|
+
: 0;
|
|
8574
|
+
const bboxAnchorX = clip.readingStartPx[0] + segAnchorTForBbox * (clip.readingEndPx[0] - clip.readingStartPx[0]);
|
|
8575
|
+
const bboxAnchorY = clip.readingStartPx[1] + segAnchorTForBbox * (clip.readingEndPx[1] - clip.readingStartPx[1]);
|
|
8576
|
+
const outwardBbox = computeLabelBoundingBox(bboxAnchorX, bboxAnchorY, clip.directionPx[0], clip.directionPx[1], labelAnchor, labelPixelWidth, clip.outwardSide);
|
|
8577
|
+
const flippedSide = clip.outwardSide === "above" ? "below" : "above";
|
|
8578
|
+
const inwardBbox = computeLabelBoundingBox(bboxAnchorX, bboxAnchorY, clip.directionPx[0], clip.directionPx[1], labelAnchor, labelPixelWidth, flippedSide);
|
|
8579
|
+
candidates.push({
|
|
8580
|
+
candidateId: nextCandidateId,
|
|
8581
|
+
edgeIdx,
|
|
8582
|
+
edge: sourceEdge,
|
|
8583
|
+
// Chord-level: anchor, fit, resolver selection criteria
|
|
8584
|
+
pxLen: chordPxLen,
|
|
8585
|
+
angleDeg: chordAngleDeg,
|
|
8586
|
+
availableWidthPx: chordAvailableWidthPx,
|
|
8587
|
+
labelFits: chordLabelFits,
|
|
8588
|
+
startInsetPx: chordStartInsetPx,
|
|
8589
|
+
endInsetPx: chordEndInsetPx,
|
|
8590
|
+
directionPx: chordDirectionPx,
|
|
8591
|
+
readingStartPx: chordReadingStartPx,
|
|
8592
|
+
readingEndPx: chordReadingEndPx,
|
|
8593
|
+
// Per-segment: bbox viewport filtering
|
|
8594
|
+
outwardBbox,
|
|
8595
|
+
inwardBbox,
|
|
8596
|
+
outwardSide: clip.outwardSide,
|
|
8597
|
+
});
|
|
8598
|
+
nextCandidateId++;
|
|
8599
|
+
}
|
|
8456
8600
|
}
|
|
8457
8601
|
if (candidates.length === 0) {
|
|
8458
8602
|
return null;
|
|
@@ -8461,9 +8605,11 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8461
8605
|
// the reading direction of the matching previous-edge candidate so the resolver
|
|
8462
8606
|
// can enforce "don't slide back left" without needing raw Mercator math.
|
|
8463
8607
|
let previousAnchorClippedT;
|
|
8608
|
+
let previousAnchorClippedCandidateId;
|
|
8464
8609
|
if (previousAnchorGeo !== undefined && previousEdgeIdentity !== undefined) {
|
|
8465
|
-
const matchingCandidate
|
|
8466
|
-
|
|
8610
|
+
for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
|
|
8611
|
+
if (matchingCandidate.pxLen <= 0)
|
|
8612
|
+
continue;
|
|
8467
8613
|
const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToWebMercatorPx(previousAnchorGeo[0], previousAnchorGeo[1], zoom, tileSize);
|
|
8468
8614
|
const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
|
|
8469
8615
|
const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
|
|
@@ -8476,8 +8622,10 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8476
8622
|
// right of the current clip start (t >= 0). A negative t means the viewport has
|
|
8477
8623
|
// scrolled so far right that the anchor is now behind the left clip boundary —
|
|
8478
8624
|
// in that case the default left position is already as far right as we can go.
|
|
8479
|
-
if (t >= 0) {
|
|
8480
|
-
previousAnchorClippedT =
|
|
8625
|
+
if (t >= 0 && t <= 1) {
|
|
8626
|
+
previousAnchorClippedT = t;
|
|
8627
|
+
previousAnchorClippedCandidateId = matchingCandidate.candidateId;
|
|
8628
|
+
break;
|
|
8481
8629
|
}
|
|
8482
8630
|
}
|
|
8483
8631
|
}
|
|
@@ -8490,13 +8638,16 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8490
8638
|
previousEdgeIdentity,
|
|
8491
8639
|
isForced,
|
|
8492
8640
|
previousAnchorClippedT,
|
|
8641
|
+
previousAnchorClippedCandidateId,
|
|
8493
8642
|
previousLayoutSide,
|
|
8494
8643
|
};
|
|
8495
8644
|
const resolver = labelPlacementResolver ?? defaultEdgeLabelPlacementResolver;
|
|
8496
8645
|
const decision = resolver(context);
|
|
8497
8646
|
if (decision === null)
|
|
8498
8647
|
return null;
|
|
8499
|
-
const best =
|
|
8648
|
+
const best = decision.candidateId !== undefined
|
|
8649
|
+
? candidates.find(c => c.candidateId === decision.candidateId)
|
|
8650
|
+
: candidates.find(c => c.edgeIdx === decision.edgeIdx);
|
|
8500
8651
|
if (best === undefined)
|
|
8501
8652
|
return null;
|
|
8502
8653
|
const fitMode = best.labelFits ? "fits" : "clipped";
|
|
@@ -8519,12 +8670,68 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8519
8670
|
// readingStartPx/readingEndPx are viewport-relative; add back viewport origin for absolute px.
|
|
8520
8671
|
const anchorRelX = best.readingStartPx[0] + anchorT * (best.readingEndPx[0] - best.readingStartPx[0]);
|
|
8521
8672
|
const anchorRelY = best.readingStartPx[1] + anchorT * (best.readingEndPx[1] - best.readingStartPx[1]);
|
|
8522
|
-
|
|
8673
|
+
let position;
|
|
8674
|
+
let resolvedDirectionPx;
|
|
8675
|
+
if (geodesic) {
|
|
8676
|
+
const [A, B] = best.edge;
|
|
8677
|
+
const delta = angularDistance(A[0], A[1], B[0], B[1]);
|
|
8678
|
+
if (delta > 0) {
|
|
8679
|
+
// Project the Mercator anchor pixel back to geo, then map it onto the
|
|
8680
|
+
// great-circle arc by computing the arc fraction via angular distance
|
|
8681
|
+
// from A. This places the label physically on the visible curved arc
|
|
8682
|
+
// rather than on the straight Mercator chord.
|
|
8683
|
+
const [approxLng, approxLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
|
|
8684
|
+
const tArc = Math.max(0, Math.min(1, angularDistance(A[0], A[1], approxLng, approxLat) / delta));
|
|
8685
|
+
const geoAnchor = intermediatePoint(A, B, tArc, delta);
|
|
8686
|
+
position = [geoAnchor[0], geoAnchor[1]];
|
|
8687
|
+
// Tangent: finite-difference at the visual center of the label (not the
|
|
8688
|
+
// anchor point). For a "left"-anchored label the anchor sits at the left
|
|
8689
|
+
// edge of the pill; evaluating the tangent there means the label hugs the
|
|
8690
|
+
// arc on the left but drifts on the right on curved edges. Computing the
|
|
8691
|
+
// tangent at the label's midpoint (anchor ± halfWidth along the reading
|
|
8692
|
+
// direction) keeps both sides equally aligned to the curve.
|
|
8693
|
+
const halfLabelPx = labelPixelWidth / 2;
|
|
8694
|
+
const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
|
|
8695
|
+
const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
|
|
8696
|
+
const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
|
|
8697
|
+
const [approxCenterLng, approxCenterLat] = webMercatorPxToLngLat(centerRelX + pxViewMin, centerRelY + pyViewMin, zoom, tileSize);
|
|
8698
|
+
const tArcCenter = Math.max(0, Math.min(1, angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
|
|
8699
|
+
const TANGENT_EPS = 0.001;
|
|
8700
|
+
const p0 = intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
|
|
8701
|
+
const p1 = intermediatePoint(A, B, Math.min(1, tArcCenter + TANGENT_EPS), delta);
|
|
8702
|
+
const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
|
|
8703
|
+
const [ax, ay] = lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
|
|
8704
|
+
const ddx = ax - bx;
|
|
8705
|
+
const ddy = ay - by;
|
|
8706
|
+
const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
|
|
8707
|
+
if (tangentLen > 0) {
|
|
8708
|
+
const rawDir = [ddx / tangentLen, ddy / tangentLen];
|
|
8709
|
+
// Orient tangent to match the reading direction stored in best.directionPx.
|
|
8710
|
+
const dot = best.directionPx[0] * rawDir[0] + best.directionPx[1] * rawDir[1];
|
|
8711
|
+
const negDir = [-rawDir[0], -rawDir[1]];
|
|
8712
|
+
resolvedDirectionPx = dot >= 0 ? rawDir : negDir;
|
|
8713
|
+
}
|
|
8714
|
+
else {
|
|
8715
|
+
resolvedDirectionPx = best.directionPx;
|
|
8716
|
+
}
|
|
8717
|
+
}
|
|
8718
|
+
else {
|
|
8719
|
+
// Co-located vertices — fall back to Mercator straight-line.
|
|
8720
|
+
const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
|
|
8721
|
+
position = [anchorLng, anchorLat];
|
|
8722
|
+
resolvedDirectionPx = best.directionPx;
|
|
8723
|
+
}
|
|
8724
|
+
}
|
|
8725
|
+
else {
|
|
8726
|
+
const [anchorLng, anchorLat] = webMercatorPxToLngLat(anchorRelX + pxViewMin, anchorRelY + pyViewMin, zoom, tileSize);
|
|
8727
|
+
position = [anchorLng, anchorLat];
|
|
8728
|
+
resolvedDirectionPx = best.directionPx;
|
|
8729
|
+
}
|
|
8523
8730
|
return {
|
|
8524
|
-
position
|
|
8731
|
+
position,
|
|
8525
8732
|
layout: {
|
|
8526
8733
|
type: "edge",
|
|
8527
|
-
directionPx:
|
|
8734
|
+
directionPx: resolvedDirectionPx,
|
|
8528
8735
|
anchor: resolvedAnchor,
|
|
8529
8736
|
outwardSide: layoutOutwardSide,
|
|
8530
8737
|
},
|
|
@@ -8580,7 +8787,7 @@ const placementToAnchor = (placement) => {
|
|
|
8580
8787
|
* caller-managed annotation).
|
|
8581
8788
|
*/
|
|
8582
8789
|
const computeEdgeAutoPlacement = (feature, label, viewportBounds, zoom, tileSize, options) => {
|
|
8583
|
-
const { labelPixelWidth, mode = "auto", previousAnchorGeo, previousLayoutSide } = options;
|
|
8790
|
+
const { labelPixelWidth, mode = "auto", previousAnchorGeo, previousLayoutSide, geodesic = false } = options;
|
|
8584
8791
|
const geomType = feature.geometry?.type;
|
|
8585
8792
|
const isPointGeometry = geomType === "Point" || geomType === "MultiPoint";
|
|
8586
8793
|
if (isPointGeometry) {
|
|
@@ -8622,6 +8829,7 @@ const computeEdgeAutoPlacement = (feature, label, viewportBounds, zoom, tileSize
|
|
|
8622
8829
|
edgeInsets: options.edgeInsets,
|
|
8623
8830
|
labelAnchor: options.labelAnchor,
|
|
8624
8831
|
labelPlacementResolver: options.labelPlacementResolver,
|
|
8832
|
+
geodesic,
|
|
8625
8833
|
});
|
|
8626
8834
|
if (edgePlacement !== null) {
|
|
8627
8835
|
return {
|
|
@@ -8649,6 +8857,7 @@ const computeEdgeAutoPlacement = (feature, label, viewportBounds, zoom, tileSize
|
|
|
8649
8857
|
labelAnchor: options.labelAnchor,
|
|
8650
8858
|
labelPlacementResolver: options.labelPlacementResolver,
|
|
8651
8859
|
isForced: true,
|
|
8860
|
+
geodesic,
|
|
8652
8861
|
});
|
|
8653
8862
|
if (forcedPlacement !== null) {
|
|
8654
8863
|
const fitMode = forcedPlacement.fitMode === "clipped" ? "overflow" : forcedPlacement.fitMode;
|
|
@@ -8687,12 +8896,36 @@ const isLineGeometry = (geometry) => {
|
|
|
8687
8896
|
* For Polygons, `outwardSide` uses the cross product with the geometry centroid
|
|
8688
8897
|
* to determine which side faces away from the interior. For LineStrings (no
|
|
8689
8898
|
* interior), `outwardSide` defaults to `"above"`.
|
|
8899
|
+
*
|
|
8900
|
+
* When `geodesic` is `true`, `angleDeg` is derived from the arc tangent at
|
|
8901
|
+
* the edge midpoint (t = 0.5) via finite difference, so decorations are
|
|
8902
|
+
* rotated correctly relative to the visible curved edge.
|
|
8690
8903
|
*/
|
|
8691
|
-
const computeEdgeProperties = (start, end, geometry, zoom, tileSize) => {
|
|
8904
|
+
const computeEdgeProperties = (start, end, geometry, zoom, tileSize, geodesic = false) => {
|
|
8692
8905
|
const midLat = (start[1] + end[1]) / 2;
|
|
8693
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8906
|
+
let angleDeg;
|
|
8907
|
+
if (geodesic) {
|
|
8908
|
+
const delta = angularDistance(start[0], start[1], end[0], end[1]);
|
|
8909
|
+
if (delta > 0) {
|
|
8910
|
+
// Finite-difference tangent at the arc midpoint (t = 0.5).
|
|
8911
|
+
const TANGENT_EPS = 0.001;
|
|
8912
|
+
const p0 = intermediatePoint(start, end, 0.5 - TANGENT_EPS, delta);
|
|
8913
|
+
const p1 = intermediatePoint(start, end, 0.5 + TANGENT_EPS, delta);
|
|
8914
|
+
const [bx, by] = lngLatToWebMercatorPx(p0[0], p0[1], zoom, tileSize);
|
|
8915
|
+
const [ax, ay] = lngLatToWebMercatorPx(p1[0], p1[1], zoom, tileSize);
|
|
8916
|
+
angleDeg = normalizeReadableAngle(Math.atan2(ay - by, ax - bx) * RAD_TO_DEG);
|
|
8917
|
+
}
|
|
8918
|
+
else {
|
|
8919
|
+
const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
|
|
8920
|
+
const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
|
|
8921
|
+
angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
|
|
8922
|
+
}
|
|
8923
|
+
}
|
|
8924
|
+
else {
|
|
8925
|
+
const [startPx, startPy] = lngLatToWebMercatorPx(start[0], start[1], zoom, tileSize);
|
|
8926
|
+
const [endPx, endPy] = lngLatToWebMercatorPx(end[0], end[1], zoom, tileSize);
|
|
8927
|
+
angleDeg = normalizeReadableAngle(Math.atan2(endPy - startPy, endPx - startPx) * RAD_TO_DEG);
|
|
8928
|
+
}
|
|
8696
8929
|
const pixelLength = edgePixelLength(start[0], start[1], end[0], end[1], zoom, midLat, tileSize);
|
|
8697
8930
|
let outwardSide = "above";
|
|
8698
8931
|
if (!isLineGeometry(geometry)) {
|
|
@@ -8957,39 +9190,60 @@ const featureBbox = (feature) => {
|
|
|
8957
9190
|
return [minLng, minLat, maxLng, maxLat];
|
|
8958
9191
|
};
|
|
8959
9192
|
const bboxesIntersect = (a, b) => a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3];
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
9193
|
+
/**
|
|
9194
|
+
* Compute per-feature overlap counts for all features in one O(n²) pass.
|
|
9195
|
+
*
|
|
9196
|
+
* Previously `countOverlappingShapes` was called once per feature, re-scanning
|
|
9197
|
+
* every other feature and re-running polygon intersection for the same pairs —
|
|
9198
|
+
* O(n³) intersection work per handle per pan frame. Each unordered pair is
|
|
9199
|
+
* evaluated at most once here.
|
|
9200
|
+
*/
|
|
9201
|
+
const buildOverlappingShapesCountMap = (features, featureBboxes, viewportBounds) => {
|
|
9202
|
+
const counts = new Map();
|
|
9203
|
+
for (const feature of features) {
|
|
9204
|
+
counts.set(feature, 0);
|
|
9205
|
+
}
|
|
9206
|
+
for (let i = 0; i < features.length; i++) {
|
|
9207
|
+
const featureA = features[i];
|
|
9208
|
+
if (featureA === undefined)
|
|
8969
9209
|
continue;
|
|
8970
|
-
|
|
9210
|
+
const bboxA = featureBboxes.get(featureA);
|
|
9211
|
+
if (bboxA === undefined || bboxA === null)
|
|
8971
9212
|
continue;
|
|
8972
|
-
if (!bboxesIntersect(
|
|
9213
|
+
if (!bboxesIntersect(bboxA, viewportBounds))
|
|
8973
9214
|
continue;
|
|
8974
|
-
const
|
|
8975
|
-
const
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
if (
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
9215
|
+
const geomA = featureA.geometry;
|
|
9216
|
+
const isPolygonalA = geomA !== null && (geomA.type === "Polygon" || geomA.type === "MultiPolygon");
|
|
9217
|
+
for (let j = i + 1; j < features.length; j++) {
|
|
9218
|
+
const featureB = features[j];
|
|
9219
|
+
if (featureB === undefined)
|
|
9220
|
+
continue;
|
|
9221
|
+
const bboxB = featureBboxes.get(featureB);
|
|
9222
|
+
if (bboxB === undefined || bboxB === null)
|
|
9223
|
+
continue;
|
|
9224
|
+
if (!bboxesIntersect(bboxB, viewportBounds))
|
|
9225
|
+
continue;
|
|
9226
|
+
if (!bboxesIntersect(bboxA, bboxB))
|
|
9227
|
+
continue;
|
|
9228
|
+
const geomB = featureB.geometry;
|
|
9229
|
+
const isPolygonalB = geomB !== null && (geomB.type === "Polygon" || geomB.type === "MultiPolygon");
|
|
9230
|
+
if (isPolygonalA && isPolygonalB) {
|
|
9231
|
+
if (getGeoJsonPolygonIntersection(geomA, geomB) !== null) {
|
|
9232
|
+
if (isFullyContainedInGeoJsonGeometry(geomA, geomB) !== true) {
|
|
9233
|
+
counts.set(featureA, (counts.get(featureA) ?? 0) + 1);
|
|
9234
|
+
}
|
|
9235
|
+
if (isFullyContainedInGeoJsonGeometry(geomB, geomA) !== true) {
|
|
9236
|
+
counts.set(featureB, (counts.get(featureB) ?? 0) + 1);
|
|
9237
|
+
}
|
|
8983
9238
|
}
|
|
8984
|
-
count++;
|
|
8985
9239
|
}
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
9240
|
+
else {
|
|
9241
|
+
counts.set(featureA, (counts.get(featureA) ?? 0) + 1);
|
|
9242
|
+
counts.set(featureB, (counts.get(featureB) ?? 0) + 1);
|
|
9243
|
+
}
|
|
8990
9244
|
}
|
|
8991
9245
|
}
|
|
8992
|
-
return
|
|
9246
|
+
return counts;
|
|
8993
9247
|
};
|
|
8994
9248
|
/**
|
|
8995
9249
|
* Value-based equality for the nested style overrides maps. Used to skip
|
|
@@ -9128,6 +9382,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
|
|
|
9128
9382
|
}
|
|
9129
9383
|
return shapesInViewportCache;
|
|
9130
9384
|
};
|
|
9385
|
+
const overlappingCounts = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
|
|
9131
9386
|
handle.features.features.forEach((feature, index) => {
|
|
9132
9387
|
const geometry = feature.geometry;
|
|
9133
9388
|
if (geometry === null || geometry.type === "GeometryCollection")
|
|
@@ -9140,7 +9395,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
|
|
|
9140
9395
|
zoom,
|
|
9141
9396
|
tileSize,
|
|
9142
9397
|
shapesInViewport: shapesInViewport(),
|
|
9143
|
-
overlappingShapesCount:
|
|
9398
|
+
overlappingShapesCount: overlappingCounts.get(feature) ?? 0,
|
|
9144
9399
|
interaction: currentInteraction,
|
|
9145
9400
|
});
|
|
9146
9401
|
const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
|
|
@@ -9190,7 +9445,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
|
|
|
9190
9445
|
...(decoration.sourceProperties ?? {}),
|
|
9191
9446
|
};
|
|
9192
9447
|
if (resolved.edgeEndpoints !== null) {
|
|
9193
|
-
const edgeProps = computeEdgeProperties(resolved.edgeEndpoints.start, resolved.edgeEndpoints.end, geometry, zoom, tileSize);
|
|
9448
|
+
const edgeProps = computeEdgeProperties(resolved.edgeEndpoints.start, resolved.edgeEndpoints.end, geometry, zoom, tileSize, featureStyle.geodesic ?? true);
|
|
9194
9449
|
properties = { ...properties, ...edgeProps };
|
|
9195
9450
|
}
|
|
9196
9451
|
newLayers.push({
|
|
@@ -9283,6 +9538,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
|
|
|
9283
9538
|
labelAnchor: anchor.labelAnchor,
|
|
9284
9539
|
labelPlacementResolver: anchor.labelPlacementResolver,
|
|
9285
9540
|
mode,
|
|
9541
|
+
geodesic: featureStyle.geodesic ?? true,
|
|
9286
9542
|
});
|
|
9287
9543
|
switch (outcome.type) {
|
|
9288
9544
|
case "placed": {
|
|
@@ -9746,7 +10002,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
9746
10002
|
if (restingClip !== undefined)
|
|
9747
10003
|
restingPeerFills.set(featureId, restingClip);
|
|
9748
10004
|
}
|
|
9749
|
-
const peerFills = computePromotionGroupFills(groupMembers, winnerId, restingPeerFills);
|
|
10005
|
+
const peerFills = computePromotionGroupFills(groupMembers, winnerId, restingPeerFills, handle?.style.geodesic, handle?.featureStyles);
|
|
9750
10006
|
modifiedFills.delete(winnerId);
|
|
9751
10007
|
for (const [featureId, clip] of peerFills) {
|
|
9752
10008
|
modifiedFills.set(featureId, clip);
|
|
@@ -9799,12 +10055,28 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
9799
10055
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
9800
10056
|
.map(([handleId, ids]) => `${handleId}:${[...ids].sort().join(",")}`)
|
|
9801
10057
|
.join(";");
|
|
10058
|
+
// Geodesic key: per-handle layer flag + sorted per-feature geodesic overrides.
|
|
10059
|
+
// Toggling geodesic (layer or per-feature) must recompute instead of serving
|
|
10060
|
+
// a stale clip (mirrors suppressedKey shape).
|
|
10061
|
+
const geodesicKey = currentHandles
|
|
10062
|
+
.map(handle => {
|
|
10063
|
+
const layerFlag = handle.style.geodesic;
|
|
10064
|
+
const featureFlags = handle.featureStyles
|
|
10065
|
+
? [...handle.featureStyles.entries()]
|
|
10066
|
+
.filter(([, style]) => style.geodesic !== undefined)
|
|
10067
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
10068
|
+
.map(([id, style]) => `${id}:${String(style.geodesic)}`)
|
|
10069
|
+
.join(",")
|
|
10070
|
+
: "";
|
|
10071
|
+
return `${handle.id}:${String(layerFlag)}:${featureFlags}`;
|
|
10072
|
+
})
|
|
10073
|
+
.join(";");
|
|
9802
10074
|
// Bounds are intentionally excluded from the inputKey. Clip geometry depends on
|
|
9803
10075
|
// zoom (which affects stack order via resolveStackOrder) and feature geometry —
|
|
9804
10076
|
// not on the visible viewport region. Using global bounds means all overlapping
|
|
9805
10077
|
// features always have pre-computed clips, so polygons entering the viewport
|
|
9806
10078
|
// during zoom-out never flash their unclipped fill.
|
|
9807
|
-
const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}`;
|
|
10079
|
+
const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
|
|
9808
10080
|
if (inputKey === lastComputeInputKeyRef.current) {
|
|
9809
10081
|
return;
|
|
9810
10082
|
}
|
|
@@ -9835,6 +10107,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
9835
10107
|
zoom: currentViewport.zoom,
|
|
9836
10108
|
selectedFeatureId: selectedFeatureIdRef.current,
|
|
9837
10109
|
suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
|
|
10110
|
+
layerGeodesic: handle.style.geodesic,
|
|
10111
|
+
featureStyles: handle.featureStyles,
|
|
9838
10112
|
});
|
|
9839
10113
|
featureToGroupKeyByHandleRef.current.set(handle.id, new Map(featureToGroupKey));
|
|
9840
10114
|
if (fillGeometries.size > 0) {
|
|
@@ -9951,6 +10225,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
9951
10225
|
tileSize: currentViewport.tileSize,
|
|
9952
10226
|
strokeWidthFor,
|
|
9953
10227
|
visibleFillFor,
|
|
10228
|
+
layerGeodesic: handle.style.geodesic,
|
|
10229
|
+
featureStyles: handle.featureStyles,
|
|
9954
10230
|
});
|
|
9955
10231
|
handle.onShapesUnderCursor?.(hits, { position });
|
|
9956
10232
|
settleHitsByHandle.set(handle.id, hits);
|
|
@@ -10108,6 +10384,8 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10108
10384
|
tileSize,
|
|
10109
10385
|
strokeWidthFor,
|
|
10110
10386
|
visibleFillFor,
|
|
10387
|
+
layerGeodesic: handle.style.geodesic,
|
|
10388
|
+
featureStyles: handle.featureStyles,
|
|
10111
10389
|
});
|
|
10112
10390
|
}, []);
|
|
10113
10391
|
const setDecorationHoverActive = useCallback((active) => {
|