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