@trackunit/react-map 0.2.163 → 0.2.166
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 +438 -87
- package/index.esm.js +438 -87
- package/package.json +4 -4
- package/src/layers/buildLabelPlacement.d.ts +41 -16
- package/src/layers/internal/labelFootprint.d.ts +15 -8
- package/src/layers/internal/placementGeometry.d.ts +16 -0
- package/src/layers/internal/stickSolver.d.ts +62 -0
- package/src/layers/useLabelPlacement.d.ts +23 -10
package/index.esm.js
CHANGED
|
@@ -4735,7 +4735,7 @@ const useHoverPreloadInitiator = (initiator, onShouldPreload) => {
|
|
|
4735
4735
|
};
|
|
4736
4736
|
|
|
4737
4737
|
const DEFAULT_PROXIMITY_COUNT = 5;
|
|
4738
|
-
const EMPTY_IDS = [];
|
|
4738
|
+
const EMPTY_IDS$1 = [];
|
|
4739
4739
|
/**
|
|
4740
4740
|
* Squared Euclidean distance in lat/lon space. Sufficient for proximity
|
|
4741
4741
|
* ranking — we need relative ordering, not geographic accuracy.
|
|
@@ -4765,11 +4765,11 @@ const useProximityPreloadInitiator = (initiator, api, onShouldPreload) => {
|
|
|
4765
4765
|
});
|
|
4766
4766
|
const nearestIds = useMemo(() => {
|
|
4767
4767
|
if (referenceEntityId === null || entities === undefined || entities.length === 0) {
|
|
4768
|
-
return EMPTY_IDS;
|
|
4768
|
+
return EMPTY_IDS$1;
|
|
4769
4769
|
}
|
|
4770
4770
|
const reference = entities.find(e => e.id === referenceEntityId);
|
|
4771
4771
|
if (reference === undefined)
|
|
4772
|
-
return EMPTY_IDS;
|
|
4772
|
+
return EMPTY_IDS$1;
|
|
4773
4773
|
return entities
|
|
4774
4774
|
.filter(e => e.id !== referenceEntityId)
|
|
4775
4775
|
.map(e => ({ id: e.id, dist: squaredDistance(e.position, reference.position) }))
|
|
@@ -4779,7 +4779,7 @@ const useProximityPreloadInitiator = (initiator, api, onShouldPreload) => {
|
|
|
4779
4779
|
}, [referenceEntityId, entities, count]);
|
|
4780
4780
|
// Sync computed nearest ids to a ref so the stable effect below always
|
|
4781
4781
|
// reads the freshest result without being re-scheduled on every render.
|
|
4782
|
-
const nearestIdsRef = useRef(EMPTY_IDS);
|
|
4782
|
+
const nearestIdsRef = useRef(EMPTY_IDS$1);
|
|
4783
4783
|
useLayoutEffect(() => {
|
|
4784
4784
|
nearestIdsRef.current = nearestIds;
|
|
4785
4785
|
}, [nearestIds]);
|
|
@@ -5577,6 +5577,180 @@ const useFitToContent = (api, handles, options) => {
|
|
|
5577
5577
|
return useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
|
|
5578
5578
|
};
|
|
5579
5579
|
|
|
5580
|
+
/**
|
|
5581
|
+
* Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
|
|
5582
|
+
* overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
|
|
5583
|
+
* placement core and the stick solver so Tier A and Tier B collide by exactly the same rule.
|
|
5584
|
+
*/
|
|
5585
|
+
const overlaps = (a, aFootprint, b, bFootprint) => Math.abs(a[0] - b[0]) < (aFootprint.widthDeg + bFootprint.widthDeg) / 2 &&
|
|
5586
|
+
Math.abs(a[1] - b[1]) < (aFootprint.heightDeg + bFootprint.heightDeg) / 2;
|
|
5587
|
+
/** Squared euclidean distance between two planar points. */
|
|
5588
|
+
const sqDistance = (a, b) => {
|
|
5589
|
+
const dx = a[0] - b[0];
|
|
5590
|
+
const dy = a[1] - b[1];
|
|
5591
|
+
return dx * dx + dy * dy;
|
|
5592
|
+
};
|
|
5593
|
+
|
|
5594
|
+
/** Screen "up" in math radians (+x right, y down): −π/2 points up on screen. */
|
|
5595
|
+
const UP_ANGLE_RAD = -Math.PI / 2;
|
|
5596
|
+
/** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
|
|
5597
|
+
const ANCHOR_DOT_RADIUS_PX = 5;
|
|
5598
|
+
const collidesWithPlaced = (point, footprint, placed) => placed.some(other => overlaps(point, footprint, other.point, other.footprint));
|
|
5599
|
+
/** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
|
|
5600
|
+
const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
|
|
5601
|
+
const dx = b[0] - a[0];
|
|
5602
|
+
const dy = b[1] - a[1];
|
|
5603
|
+
let t0 = 0;
|
|
5604
|
+
let t1 = 1;
|
|
5605
|
+
const clip = (p, q) => {
|
|
5606
|
+
if (p === 0)
|
|
5607
|
+
return q >= 0; // parallel to this slab: only crosses if the origin is inside it
|
|
5608
|
+
const r = q / p;
|
|
5609
|
+
if (p < 0) {
|
|
5610
|
+
if (r > t1)
|
|
5611
|
+
return false;
|
|
5612
|
+
if (r > t0)
|
|
5613
|
+
t0 = r;
|
|
5614
|
+
}
|
|
5615
|
+
else {
|
|
5616
|
+
if (r < t0)
|
|
5617
|
+
return false;
|
|
5618
|
+
if (r < t1)
|
|
5619
|
+
t1 = r;
|
|
5620
|
+
}
|
|
5621
|
+
return true;
|
|
5622
|
+
};
|
|
5623
|
+
return clip(-dx, a[0] - minX) && clip(dx, maxX - a[0]) && clip(-dy, a[1] - minY) && clip(dy, maxY - a[1]) && t0 <= t1;
|
|
5624
|
+
};
|
|
5625
|
+
/**
|
|
5626
|
+
* Does the leader line anchor→tip pass over another placed label's box? A box containing the anchor
|
|
5627
|
+
* is skipped — the line necessarily starts inside the collision that forced the stick, which is
|
|
5628
|
+
* unavoidable. Each placed label uses its own half-extent box.
|
|
5629
|
+
*/
|
|
5630
|
+
const lineCrossesPlaced = (anchor, tip, placed) => placed.some(({ point, footprint }) => {
|
|
5631
|
+
const halfW = footprint.widthDeg / 2;
|
|
5632
|
+
const halfH = footprint.heightDeg / 2;
|
|
5633
|
+
if (Math.abs(anchor[0] - point[0]) <= halfW && Math.abs(anchor[1] - point[1]) <= halfH)
|
|
5634
|
+
return false;
|
|
5635
|
+
return segmentIntersectsBox(anchor, tip, point[0] - halfW, point[1] - halfH, point[0] + halfW, point[1] + halfH);
|
|
5636
|
+
});
|
|
5637
|
+
/** Squared distance from `point` to the nearest placed label centre (Infinity when none). */
|
|
5638
|
+
const clearanceSq = (point, placed) => {
|
|
5639
|
+
let min = Infinity;
|
|
5640
|
+
for (const other of placed) {
|
|
5641
|
+
const d = sqDistance(point, other.point);
|
|
5642
|
+
if (d < min)
|
|
5643
|
+
min = d;
|
|
5644
|
+
}
|
|
5645
|
+
return min;
|
|
5646
|
+
};
|
|
5647
|
+
/**
|
|
5648
|
+
* Finds a stick offset for a guaranteed (Tier-A) label whose anchor is taken.
|
|
5649
|
+
*
|
|
5650
|
+
* Searches a candidate ring — `anglesPerRing` evenly-spaced angles, at increasing radii from
|
|
5651
|
+
* `baseDistancePx` outward (`radiusSteps` rings). At the shortest ring that has room it aims the
|
|
5652
|
+
* stick into the **most open direction** — the footprint-free angle whose displaced label is
|
|
5653
|
+
* farthest from every other label — preferring angles whose leader line does not cross a label, and
|
|
5654
|
+
* breaking ties straight-up-first for determinism. Candidates whose pill would fall outside `viewBox`
|
|
5655
|
+
* are rejected so a guaranteed label is not pushed off-canvas. If a ring offers only line-crossing
|
|
5656
|
+
* angles it keeps looking outward for a clean one, else falls back to the most-open crossing angle of
|
|
5657
|
+
* the innermost ring that had room; if every candidate is rejected it returns `undefined` (the caller
|
|
5658
|
+
* then keeps the label at its anchor — never dropped). `previousOffset` is reused only while it stays
|
|
5659
|
+
* footprint-free, on-canvas and line-clear, so a clean stick keeps its direction between settles.
|
|
5660
|
+
*
|
|
5661
|
+
* Geometry matches the renderer: the collision/reserved point is the **rendered pill centre**
|
|
5662
|
+
* (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
|
|
5663
|
+
* that tip.
|
|
5664
|
+
*/
|
|
5665
|
+
const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
|
|
5666
|
+
// Recover the pill's px dimensions from the geo footprint (inverse of `labelFootprintDeg`), so the
|
|
5667
|
+
// pill-centre geometry matches what the renderer paints.
|
|
5668
|
+
const pillWidthPx = footprint.widthDeg / degreesPerPixel.lng;
|
|
5669
|
+
const pillHeightPx = footprint.heightDeg / degreesPerPixel.lat;
|
|
5670
|
+
const halfWidthDeg = footprint.widthDeg / 2;
|
|
5671
|
+
const halfHeightDeg = footprint.heightDeg / 2;
|
|
5672
|
+
const outsideViewBox = (point) => viewBox !== undefined &&
|
|
5673
|
+
(point[0] < viewBox[0] + halfWidthDeg ||
|
|
5674
|
+
point[0] > viewBox[2] - halfWidthDeg ||
|
|
5675
|
+
point[1] < viewBox[1] + halfHeightDeg ||
|
|
5676
|
+
point[1] > viewBox[3] - halfHeightDeg);
|
|
5677
|
+
// Rendered pill centre + leader-line tip (both anchor-relative geo) for a polar offset.
|
|
5678
|
+
const geometryFor = (angleRad, distancePx) => {
|
|
5679
|
+
const positioning = { type: "polar", angleRad, distance: distancePx };
|
|
5680
|
+
const tip = resolveTentativeTip(positioning, pillHeightPx, ANCHOR_DOT_RADIUS_PX, pillWidthPx);
|
|
5681
|
+
const css = computePillCSSPlacement(tip.x, tip.y, pillHeightPx);
|
|
5682
|
+
// Pill centre X = left edge + half width; `translateXPct` shifts the element by its own width.
|
|
5683
|
+
const pillCentreXpx = css.pillLeft + (css.translateXPct / 100 + 0.5) * pillWidthPx;
|
|
5684
|
+
return {
|
|
5685
|
+
// Screen y is down, so a negative tip.y (up on screen) raises latitude.
|
|
5686
|
+
pillCentre: [anchor[0] + pillCentreXpx * degreesPerPixel.lng, anchor[1] - tip.y * degreesPerPixel.lat],
|
|
5687
|
+
tip: [anchor[0] + tip.x * degreesPerPixel.lng, anchor[1] - tip.y * degreesPerPixel.lat],
|
|
5688
|
+
};
|
|
5689
|
+
};
|
|
5690
|
+
const evaluate = (angleRad, distancePx) => {
|
|
5691
|
+
const { pillCentre, tip } = geometryFor(angleRad, distancePx);
|
|
5692
|
+
if (outsideViewBox(pillCentre))
|
|
5693
|
+
return undefined;
|
|
5694
|
+
if (collidesWithPlaced(pillCentre, footprint, placed))
|
|
5695
|
+
return undefined;
|
|
5696
|
+
return { pillCentre, lineClear: !lineCrossesPlaced(anchor, tip, placed) };
|
|
5697
|
+
};
|
|
5698
|
+
// Reuse the remembered offset only while it stays clean, so a settled stick keeps its direction.
|
|
5699
|
+
if (previousOffset !== undefined) {
|
|
5700
|
+
const prev = evaluate(previousOffset.angleRad, previousOffset.distancePx);
|
|
5701
|
+
if (prev !== undefined && prev.lineClear) {
|
|
5702
|
+
return {
|
|
5703
|
+
positioning: { type: "polar", angleRad: previousOffset.angleRad, distance: previousOffset.distancePx },
|
|
5704
|
+
pillCentre: prev.pillCentre,
|
|
5705
|
+
};
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
const angleStep = (2 * Math.PI) / anglesPerRing;
|
|
5709
|
+
// A candidate only beats the current best when meaningfully more open; near-ties (including the
|
|
5710
|
+
// float noise of a radially symmetric ring) keep the earlier, up-first angle — deterministic.
|
|
5711
|
+
const beats = (clearance, best) => best === undefined || clearance > best.clearance * (1 + 1e-6);
|
|
5712
|
+
let fallback;
|
|
5713
|
+
for (let ring = 0; ring < radiusSteps; ring++) {
|
|
5714
|
+
const distance = baseDistancePx + ring * radiusStepPx;
|
|
5715
|
+
let bestClear;
|
|
5716
|
+
let bestCrossing;
|
|
5717
|
+
for (let a = 0; a < anglesPerRing; a++) {
|
|
5718
|
+
const angleRad = UP_ANGLE_RAD + a * angleStep;
|
|
5719
|
+
const evaluated = evaluate(angleRad, distance);
|
|
5720
|
+
if (evaluated === undefined)
|
|
5721
|
+
continue;
|
|
5722
|
+
const clearance = clearanceSq(evaluated.pillCentre, placed);
|
|
5723
|
+
if (evaluated.lineClear) {
|
|
5724
|
+
if (beats(clearance, bestClear))
|
|
5725
|
+
bestClear = { angleRad, pillCentre: evaluated.pillCentre, clearance };
|
|
5726
|
+
}
|
|
5727
|
+
else if (beats(clearance, bestCrossing)) {
|
|
5728
|
+
bestCrossing = { angleRad, pillCentre: evaluated.pillCentre, clearance };
|
|
5729
|
+
}
|
|
5730
|
+
}
|
|
5731
|
+
if (bestClear !== undefined) {
|
|
5732
|
+
return {
|
|
5733
|
+
positioning: { type: "polar", angleRad: bestClear.angleRad, distance },
|
|
5734
|
+
pillCentre: bestClear.pillCentre,
|
|
5735
|
+
};
|
|
5736
|
+
}
|
|
5737
|
+
if (fallback === undefined && bestCrossing !== undefined) {
|
|
5738
|
+
fallback = {
|
|
5739
|
+
positioning: { type: "polar", angleRad: bestCrossing.angleRad, distance },
|
|
5740
|
+
pillCentre: bestCrossing.pillCentre,
|
|
5741
|
+
};
|
|
5742
|
+
}
|
|
5743
|
+
}
|
|
5744
|
+
return fallback;
|
|
5745
|
+
};
|
|
5746
|
+
|
|
5747
|
+
const EMPTY_IDS = new Set();
|
|
5748
|
+
// Candidate-ring stick search (tunable): base distance from the shared stick tuning, then out.
|
|
5749
|
+
const STICK_BASE_DISTANCE_PX = MARKER_TUNING.stick.defaultPositioning.distance;
|
|
5750
|
+
const STICK_RADIUS_STEP_PX = 24;
|
|
5751
|
+
const STICK_RADIUS_STEPS = 3;
|
|
5752
|
+
const STICK_ANGLES_PER_RING = 8;
|
|
5753
|
+
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
5580
5754
|
const isWithinBounds = (position, bounds) => {
|
|
5581
5755
|
const [lng, lat] = position;
|
|
5582
5756
|
const [minLng, minLat, maxLng, maxLat] = bounds;
|
|
@@ -5597,32 +5771,81 @@ const boundsCentreLng = (bounds) => {
|
|
|
5597
5771
|
* centroid/distance/overlap math then uses plain deltas with no ±180° discontinuity.
|
|
5598
5772
|
*/
|
|
5599
5773
|
const unwrapLng = (lng, reference) => lng - 360 * Math.round((lng - reference) / 360);
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5774
|
+
/**
|
|
5775
|
+
* Selects up to `count` candidates from `pool` spread as far apart as possible: the first pick is
|
|
5776
|
+
* the candidate nearest the pool centroid (or, when `seeds` already exist, the one farthest from
|
|
5777
|
+
* them), each subsequent pick maximises the distance to everything chosen so far. `pool` must be
|
|
5778
|
+
* id-sorted; ties break to the earlier (lower-id) candidate, so the result is deterministic.
|
|
5779
|
+
*/
|
|
5780
|
+
const disperseSelect = (pool, count, seeds) => {
|
|
5781
|
+
const remaining = [...pool];
|
|
5782
|
+
const chosenPoints = [...seeds];
|
|
5783
|
+
const out = [];
|
|
5784
|
+
while (out.length < count && remaining.length > 0) {
|
|
5785
|
+
let bestIndex = 0;
|
|
5786
|
+
if (chosenPoints.length === 0) {
|
|
5787
|
+
let cx = 0;
|
|
5788
|
+
let cy = 0;
|
|
5789
|
+
for (const candidate of remaining) {
|
|
5790
|
+
cx += candidate.point[0];
|
|
5791
|
+
cy += candidate.point[1];
|
|
5792
|
+
}
|
|
5793
|
+
const centroid = [cx / remaining.length, cy / remaining.length];
|
|
5794
|
+
let bestSeedDist = Infinity;
|
|
5795
|
+
remaining.forEach((candidate, index) => {
|
|
5796
|
+
const d = sqDistance(candidate.point, centroid);
|
|
5797
|
+
if (d < bestSeedDist) {
|
|
5798
|
+
bestSeedDist = d;
|
|
5799
|
+
bestIndex = index;
|
|
5800
|
+
}
|
|
5801
|
+
});
|
|
5802
|
+
}
|
|
5803
|
+
else {
|
|
5804
|
+
let bestMinDist = -1;
|
|
5805
|
+
remaining.forEach((candidate, index) => {
|
|
5806
|
+
let minDist = Infinity;
|
|
5807
|
+
for (const other of chosenPoints) {
|
|
5808
|
+
const d = sqDistance(candidate.point, other);
|
|
5809
|
+
if (d < minDist)
|
|
5810
|
+
minDist = d;
|
|
5811
|
+
}
|
|
5812
|
+
if (minDist > bestMinDist) {
|
|
5813
|
+
bestMinDist = minDist;
|
|
5814
|
+
bestIndex = index;
|
|
5815
|
+
}
|
|
5816
|
+
});
|
|
5817
|
+
}
|
|
5818
|
+
const [chosen] = remaining.splice(bestIndex, 1);
|
|
5819
|
+
if (chosen === undefined)
|
|
5820
|
+
break;
|
|
5821
|
+
out.push(chosen);
|
|
5822
|
+
chosenPoints.push(chosen.point);
|
|
5823
|
+
}
|
|
5824
|
+
return out;
|
|
5605
5825
|
};
|
|
5606
5826
|
/**
|
|
5607
|
-
* Chooses which markers render a label
|
|
5827
|
+
* Chooses which markers render a label, and how, in priority order.
|
|
5608
5828
|
*
|
|
5609
|
-
*
|
|
5610
|
-
*
|
|
5611
|
-
*
|
|
5612
|
-
*
|
|
5613
|
-
*
|
|
5614
|
-
*
|
|
5829
|
+
* **Tier A (guaranteed):** every in-view `mustShowIds` marker (regardless of tier), plus the
|
|
5830
|
+
* top-`tierACount` eligible markers by priority — the latter chosen high→low tier then, within a
|
|
5831
|
+
* tier, by spatial spread so one cluster cannot consume the whole guarantee. None are dropped: each
|
|
5832
|
+
* takes its anchor if collision-free, else a candidate-ring solver finds a leader-line **stick**
|
|
5833
|
+
* offset (kept on-canvas), else it stays at the anchor overlapping. Tier A uses the same `footprint`
|
|
5834
|
+
* as Tier B, so a guaranteed label may overlap a denser neighbour a little (accepted trade-off).
|
|
5615
5835
|
*
|
|
5616
|
-
*
|
|
5617
|
-
*
|
|
5618
|
-
*
|
|
5836
|
+
* **Tier B (collision-fill):** the remaining eligible markers, walked high→low tier, placed at their
|
|
5837
|
+
* anchor only if collision-free with the (possibly narrower) `footprint`, most-dispersed-first, until
|
|
5838
|
+
* the `ceiling` is hit. Incumbents (`previousIds`) are preferred within their tier (hysteresis).
|
|
5839
|
+
*
|
|
5840
|
+
* All geometry runs in longitudes unwrapped around the view-box centre, so a viewport crossing the
|
|
5841
|
+
* antimeridian is handled correctly. Deterministic: ties break by `getId` ascending, so the result is
|
|
5842
|
+
* independent of server return order. Returns a placement map keyed by id.
|
|
5619
5843
|
*/
|
|
5620
|
-
const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, ceiling, previousIds, }) => {
|
|
5621
|
-
const
|
|
5844
|
+
const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }) => {
|
|
5845
|
+
const placements = new Map();
|
|
5622
5846
|
const placed = [];
|
|
5623
5847
|
// Reference longitude for unwrapping: the view-box centre when clipping, else the lowest-id
|
|
5624
|
-
// item's longitude — a fixed, order-independent anchor so the result never depends on input
|
|
5625
|
-
// order (a plain "first item" reference would not; see the order-independence guarantee above).
|
|
5848
|
+
// item's longitude — a fixed, order-independent anchor so the result never depends on input order.
|
|
5626
5849
|
let referenceLng;
|
|
5627
5850
|
if (bounds !== null) {
|
|
5628
5851
|
referenceLng = boundsCentreLng(bounds);
|
|
@@ -5643,29 +5866,92 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
5643
5866
|
const p = getPosition(item);
|
|
5644
5867
|
return [unwrapLng(p[0], referenceLng), p[1]];
|
|
5645
5868
|
};
|
|
5646
|
-
|
|
5869
|
+
const toCandidate = (item) => ({ id: getId(item), point: planar(item) });
|
|
5870
|
+
const inView = (item) => bounds === null || isWithinBounds(getPosition(item), bounds);
|
|
5871
|
+
// Planar (unwrapped) view box for the stick solver, so it can keep displaced pills on-canvas.
|
|
5872
|
+
const planarViewBox = bounds === null
|
|
5873
|
+
? undefined
|
|
5874
|
+
: [unwrapLng(bounds[0], referenceLng), bounds[1], unwrapLng(bounds[2], referenceLng), bounds[3]];
|
|
5875
|
+
// Below this squared distance a point is inside every Tier-B footprint, so it always overlaps.
|
|
5647
5876
|
const minFootprintSq = Math.min(footprint.widthDeg, footprint.heightDeg) ** 2;
|
|
5648
|
-
//
|
|
5877
|
+
// ---- Tier A membership ----
|
|
5878
|
+
const mustShow = mustShowIds ?? EMPTY_IDS;
|
|
5879
|
+
// Must-show is guaranteed even if it matches no tier (e.g. a selected asset with null state).
|
|
5880
|
+
const mustShowCandidates = items
|
|
5881
|
+
.filter(item => mustShow.has(getId(item)) && inView(item))
|
|
5882
|
+
.map(toCandidate)
|
|
5883
|
+
.sort(byId);
|
|
5884
|
+
const guaranteed = new Set(mustShowCandidates.map(candidate => candidate.id));
|
|
5885
|
+
// Top-N by priority: fill from higher tiers first, choosing within a tier by spatial spread.
|
|
5886
|
+
const selectedPoints = mustShowCandidates.map(candidate => candidate.point);
|
|
5887
|
+
const seenTier = new Set();
|
|
5888
|
+
const topN = [];
|
|
5889
|
+
for (const tier of focus.tiers) {
|
|
5890
|
+
if (topN.length >= tierACount)
|
|
5891
|
+
break;
|
|
5892
|
+
const pool = items
|
|
5893
|
+
.filter(item => tier.match(item) && inView(item) && !seenTier.has(getId(item)))
|
|
5894
|
+
.map(toCandidate)
|
|
5895
|
+
.sort(byId);
|
|
5896
|
+
for (const candidate of pool)
|
|
5897
|
+
seenTier.add(candidate.id);
|
|
5898
|
+
const eligible = pool.filter(candidate => !guaranteed.has(candidate.id));
|
|
5899
|
+
const picked = disperseSelect(eligible, tierACount - topN.length, selectedPoints);
|
|
5900
|
+
for (const candidate of picked) {
|
|
5901
|
+
topN.push(candidate);
|
|
5902
|
+
selectedPoints.push(candidate.point);
|
|
5903
|
+
}
|
|
5904
|
+
}
|
|
5905
|
+
for (const candidate of topN)
|
|
5906
|
+
guaranteed.add(candidate.id);
|
|
5907
|
+
// ---- Place Tier A (never dropped), must-show first so a touched asset claims its anchor ----
|
|
5908
|
+
for (const candidate of [...mustShowCandidates, ...topN]) {
|
|
5909
|
+
if (placements.size >= ceiling)
|
|
5910
|
+
break;
|
|
5911
|
+
if (!placed.some(other => overlaps(other.point, other.footprint, candidate.point, footprint))) {
|
|
5912
|
+
placements.set(candidate.id, { mode: "anchor" });
|
|
5913
|
+
placed.push({ point: candidate.point, footprint });
|
|
5914
|
+
continue;
|
|
5915
|
+
}
|
|
5916
|
+
const stick = findStickOffset({
|
|
5917
|
+
anchor: candidate.point,
|
|
5918
|
+
placed,
|
|
5919
|
+
footprint,
|
|
5920
|
+
degreesPerPixel,
|
|
5921
|
+
viewBox: planarViewBox,
|
|
5922
|
+
baseDistancePx: STICK_BASE_DISTANCE_PX,
|
|
5923
|
+
radiusStepPx: STICK_RADIUS_STEP_PX,
|
|
5924
|
+
radiusSteps: STICK_RADIUS_STEPS,
|
|
5925
|
+
anglesPerRing: STICK_ANGLES_PER_RING,
|
|
5926
|
+
previousOffset: previousStickOffsets?.get(candidate.id),
|
|
5927
|
+
});
|
|
5928
|
+
if (stick !== undefined) {
|
|
5929
|
+
placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
|
|
5930
|
+
// Reserve the rendered pill centre (not the anchor) so Tier B avoids where the label paints.
|
|
5931
|
+
placed.push({ point: stick.pillCentre, footprint });
|
|
5932
|
+
}
|
|
5933
|
+
else {
|
|
5934
|
+
// Ring full or no on-canvas spot — keep the guaranteed label at its anchor, overlapping.
|
|
5935
|
+
placements.set(candidate.id, { mode: "anchor" });
|
|
5936
|
+
placed.push({ point: candidate.point, footprint });
|
|
5937
|
+
}
|
|
5938
|
+
}
|
|
5939
|
+
// ---- Tier B (collision-fill), most-dispersed-first, using the (possibly narrower) footprint ----
|
|
5649
5940
|
const placeGroup = (group) => {
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
.map(item => ({ id: getId(item), point: planar(item) }))
|
|
5653
|
-
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
5654
|
-
while (pool.length > 0 && shown.size < ceiling) {
|
|
5941
|
+
const pool = [...group].sort(byId);
|
|
5942
|
+
while (pool.length > 0 && placements.size < ceiling) {
|
|
5655
5943
|
let bestIndex = 0;
|
|
5656
5944
|
if (placed.length === 0) {
|
|
5657
|
-
// Seed the very first label on the candidate nearest the group centroid so the
|
|
5658
|
-
// spread fans out evenly from the middle.
|
|
5659
5945
|
let cx = 0;
|
|
5660
5946
|
let cy = 0;
|
|
5661
|
-
for (const
|
|
5662
|
-
cx +=
|
|
5663
|
-
cy +=
|
|
5947
|
+
for (const candidate of pool) {
|
|
5948
|
+
cx += candidate.point[0];
|
|
5949
|
+
cy += candidate.point[1];
|
|
5664
5950
|
}
|
|
5665
5951
|
const centroid = [cx / pool.length, cy / pool.length];
|
|
5666
5952
|
let bestSeedDist = Infinity;
|
|
5667
|
-
pool.forEach((
|
|
5668
|
-
const d = sqDistance(
|
|
5953
|
+
pool.forEach((candidate, index) => {
|
|
5954
|
+
const d = sqDistance(candidate.point, centroid);
|
|
5669
5955
|
if (d < bestSeedDist) {
|
|
5670
5956
|
bestSeedDist = d;
|
|
5671
5957
|
bestIndex = index;
|
|
@@ -5673,12 +5959,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
5673
5959
|
});
|
|
5674
5960
|
}
|
|
5675
5961
|
else {
|
|
5676
|
-
// Pick the candidate whose nearest already-placed label is farthest away.
|
|
5677
5962
|
let bestMinDist = -1;
|
|
5678
|
-
pool.forEach((
|
|
5963
|
+
pool.forEach((candidate, index) => {
|
|
5679
5964
|
let minDist = Infinity;
|
|
5680
5965
|
for (const other of placed) {
|
|
5681
|
-
const d = sqDistance(other,
|
|
5966
|
+
const d = sqDistance(other.point, candidate.point);
|
|
5682
5967
|
if (d < minDist)
|
|
5683
5968
|
minDist = d;
|
|
5684
5969
|
}
|
|
@@ -5687,32 +5972,36 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
5687
5972
|
bestIndex = index;
|
|
5688
5973
|
}
|
|
5689
5974
|
});
|
|
5690
|
-
//
|
|
5691
|
-
// candidate now overlaps something placed, so none can be added. Stop scanning this group.
|
|
5975
|
+
// Every remaining candidate sits inside a placed footprint → none can be added. Stop.
|
|
5692
5976
|
if (bestMinDist < minFootprintSq)
|
|
5693
5977
|
break;
|
|
5694
5978
|
}
|
|
5695
5979
|
const [chosen] = pool.splice(bestIndex, 1);
|
|
5696
5980
|
if (chosen === undefined)
|
|
5697
5981
|
break;
|
|
5698
|
-
if (placed.some(other => overlaps(other, chosen.point, footprint)))
|
|
5982
|
+
if (placed.some(other => overlaps(other.point, other.footprint, chosen.point, footprint)))
|
|
5699
5983
|
continue;
|
|
5700
|
-
|
|
5701
|
-
placed.push(chosen.point);
|
|
5984
|
+
placements.set(chosen.id, { mode: "anchor" });
|
|
5985
|
+
placed.push({ point: chosen.point, footprint });
|
|
5702
5986
|
}
|
|
5703
5987
|
};
|
|
5704
5988
|
for (const tier of focus.tiers) {
|
|
5705
|
-
if (
|
|
5989
|
+
if (placements.size >= ceiling)
|
|
5706
5990
|
break;
|
|
5707
|
-
const candidates = items
|
|
5991
|
+
const candidates = items
|
|
5992
|
+
.filter(item => {
|
|
5993
|
+
const id = getId(item);
|
|
5994
|
+
return !placements.has(id) && !guaranteed.has(id) && tier.match(item) && inView(item);
|
|
5995
|
+
})
|
|
5996
|
+
.map(toCandidate);
|
|
5708
5997
|
if (previousIds === undefined) {
|
|
5709
5998
|
placeGroup(candidates);
|
|
5710
5999
|
continue;
|
|
5711
6000
|
}
|
|
5712
|
-
placeGroup(candidates.filter(
|
|
5713
|
-
placeGroup(candidates.filter(
|
|
6001
|
+
placeGroup(candidates.filter(candidate => previousIds.has(candidate.id)));
|
|
6002
|
+
placeGroup(candidates.filter(candidate => !previousIds.has(candidate.id)));
|
|
5714
6003
|
}
|
|
5715
|
-
return
|
|
6004
|
+
return placements;
|
|
5716
6005
|
};
|
|
5717
6006
|
|
|
5718
6007
|
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
@@ -5731,66 +6020,128 @@ const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
|
5731
6020
|
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
5732
6021
|
};
|
|
5733
6022
|
/**
|
|
5734
|
-
*
|
|
6023
|
+
* Label footprint as a geo-space width/height, for collision testing.
|
|
5735
6024
|
*
|
|
5736
|
-
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker`
|
|
5737
|
-
*
|
|
5738
|
-
*
|
|
5739
|
-
*
|
|
5740
|
-
*
|
|
5741
|
-
*
|
|
6025
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
|
|
6026
|
+
* at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
|
|
6027
|
+
* `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
|
|
6028
|
+
* that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
|
|
6029
|
+
* a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
|
|
6030
|
+
* is otherwise tuned via `paddingPx`.
|
|
5742
6031
|
*/
|
|
5743
|
-
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx }) => {
|
|
6032
|
+
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
|
|
5744
6033
|
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
5745
6034
|
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
5746
|
-
//
|
|
5747
|
-
|
|
5748
|
-
|
|
6035
|
+
// Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
|
|
6036
|
+
const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
|
|
6037
|
+
// Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
|
|
6038
|
+
// present (the wider case).
|
|
6039
|
+
const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
|
|
5749
6040
|
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
5750
6041
|
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
5751
6042
|
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
5752
6043
|
};
|
|
5753
6044
|
|
|
5754
|
-
const
|
|
6045
|
+
const EMPTY_PLACEMENTS = new Map();
|
|
6046
|
+
const samePlacement = (a, b) => {
|
|
6047
|
+
if (a.mode === "anchor")
|
|
6048
|
+
return b.mode === "anchor";
|
|
6049
|
+
if (b.mode === "anchor")
|
|
6050
|
+
return false;
|
|
6051
|
+
const pa = a.stickPositioning;
|
|
6052
|
+
const pb = b.stickPositioning;
|
|
6053
|
+
return pa.type === "polar" && pb.type === "polar" && pa.angleRad === pb.angleRad && pa.distance === pb.distance;
|
|
6054
|
+
};
|
|
6055
|
+
const placementsEqual = (a, b) => {
|
|
6056
|
+
if (a.size !== b.size)
|
|
6057
|
+
return false;
|
|
6058
|
+
for (const [id, placement] of a) {
|
|
6059
|
+
const other = b.get(id);
|
|
6060
|
+
if (other === undefined || !samePlacement(placement, other))
|
|
6061
|
+
return false;
|
|
6062
|
+
}
|
|
6063
|
+
return true;
|
|
6064
|
+
};
|
|
6065
|
+
/** Extracts the polar stick offsets from a placement map, to seed the next solve (incumbency). */
|
|
6066
|
+
const stickOffsetsFrom = (placements) => {
|
|
6067
|
+
const offsets = new Map();
|
|
6068
|
+
for (const [id, placement] of placements) {
|
|
6069
|
+
if (placement.mode === "stick" && placement.stickPositioning.type === "polar") {
|
|
6070
|
+
offsets.set(id, {
|
|
6071
|
+
angleRad: placement.stickPositioning.angleRad,
|
|
6072
|
+
distancePx: placement.stickPositioning.distance,
|
|
6073
|
+
});
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
6076
|
+
return offsets;
|
|
6077
|
+
};
|
|
5755
6078
|
/**
|
|
5756
|
-
* Viewport-driven label placement with hysteresis: returns
|
|
5757
|
-
*
|
|
6079
|
+
* Viewport-driven label placement with hysteresis: returns a map of the markers that should render
|
|
6080
|
+
* a label to how they render it (`anchor` or a displaced `stick`), no two overlapping, in priority
|
|
6081
|
+
* order, bounded by `ceiling`.
|
|
5758
6082
|
*
|
|
5759
|
-
* Stateful wrapper around the pure {@link buildLabelPlacement} — feeds the previous result back
|
|
5760
|
-
*
|
|
5761
|
-
*
|
|
5762
|
-
* not on viewport change. Returns the previous
|
|
5763
|
-
*
|
|
5764
|
-
* `useExpandedIds`.
|
|
6083
|
+
* Stateful wrapper around the pure {@link buildLabelPlacement} — feeds the previous result back as
|
|
6084
|
+
* `previousIds` (so Tier-B labels stay shown across pan/zoom refetches) and as `previousStickOffsets`
|
|
6085
|
+
* (so a displaced Tier-A label keeps its stick direction). Memory resets when `focus.id` changes,
|
|
6086
|
+
* not on viewport change. Returns the previous map reference when content is unchanged so downstream
|
|
6087
|
+
* `useMemo`/callbacks stay stable. Same render-phase-setState pattern as `useExpandedIds`.
|
|
5765
6088
|
*/
|
|
5766
|
-
const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, ceiling, }) => {
|
|
6089
|
+
const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, frozen, }) => {
|
|
5767
6090
|
const [snapshot, setSnapshot] = useState(() => ({
|
|
5768
6091
|
focusId: focus.id,
|
|
5769
|
-
|
|
6092
|
+
placements: EMPTY_PLACEMENTS,
|
|
5770
6093
|
}));
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
}
|
|
5777
|
-
const prevIds = snapshot.focusId === focus.id ? snapshot.ids : EMPTY_LABEL_IDS;
|
|
6094
|
+
// Incumbency source: the prior placement, unless disabled or the focus changed (memory reset).
|
|
6095
|
+
const prev = enabled && snapshot.focusId === focus.id ? snapshot.placements : EMPTY_PLACEMENTS;
|
|
6096
|
+
// Hold the last placement while a viewport refetch is in flight (frozen), so labels resettle once
|
|
6097
|
+
// the new data lands rather than blinking as the view box moves ahead of the data.
|
|
6098
|
+
const held = enabled && frozen === true && snapshot.focusId === focus.id;
|
|
5778
6099
|
const latitudeDeg = bounds === null ? 0 : (bounds[1] + bounds[3]) / 2;
|
|
5779
|
-
|
|
5780
|
-
|
|
6100
|
+
// Memoised so a re-render that doesn't change any input (e.g. cursor movement upstream) reuses the
|
|
6101
|
+
// last placement instead of recomputing. `prev` only changes when the result itself changed.
|
|
6102
|
+
const result = useMemo(() => {
|
|
6103
|
+
if (!enabled)
|
|
6104
|
+
return EMPTY_PLACEMENTS;
|
|
6105
|
+
if (held)
|
|
6106
|
+
return prev;
|
|
6107
|
+
// One footprint for both tiers: `labelWidthPx` narrows the width only (height stays full, so
|
|
6108
|
+
// labels never overlap vertically). A guaranteed label may overlap a denser neighbour a little.
|
|
6109
|
+
const footprint = labelFootprintDeg({ zoom, latitudeDeg, paddingPx, labelWidthPx });
|
|
6110
|
+
return buildLabelPlacement({
|
|
6111
|
+
items,
|
|
6112
|
+
getId,
|
|
6113
|
+
getPosition,
|
|
6114
|
+
focus,
|
|
6115
|
+
bounds,
|
|
6116
|
+
footprint,
|
|
6117
|
+
degreesPerPixel: degreesPerPixel$1(zoom, latitudeDeg),
|
|
6118
|
+
ceiling,
|
|
6119
|
+
tierACount,
|
|
6120
|
+
mustShowIds,
|
|
6121
|
+
previousIds: new Set(prev.keys()),
|
|
6122
|
+
previousStickOffsets: stickOffsetsFrom(prev),
|
|
6123
|
+
});
|
|
6124
|
+
}, [
|
|
6125
|
+
enabled,
|
|
6126
|
+
held,
|
|
5781
6127
|
items,
|
|
5782
6128
|
getId,
|
|
5783
6129
|
getPosition,
|
|
5784
6130
|
focus,
|
|
5785
6131
|
bounds,
|
|
5786
|
-
|
|
6132
|
+
zoom,
|
|
6133
|
+
latitudeDeg,
|
|
6134
|
+
paddingPx,
|
|
6135
|
+
labelWidthPx,
|
|
5787
6136
|
ceiling,
|
|
5788
|
-
|
|
5789
|
-
|
|
6137
|
+
tierACount,
|
|
6138
|
+
mustShowIds,
|
|
6139
|
+
prev,
|
|
6140
|
+
]);
|
|
5790
6141
|
// Return the previous reference when content is identical — keeps downstream caches stable.
|
|
5791
|
-
const stable = result
|
|
5792
|
-
if (stable !== snapshot.
|
|
5793
|
-
setSnapshot({ focusId: focus.id,
|
|
6142
|
+
const stable = placementsEqual(result, prev) ? prev : result;
|
|
6143
|
+
if (stable !== snapshot.placements || focus.id !== snapshot.focusId) {
|
|
6144
|
+
setSnapshot({ focusId: focus.id, placements: stable });
|
|
5794
6145
|
}
|
|
5795
6146
|
return stable;
|
|
5796
6147
|
};
|