@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.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
- const overlaps = (a, b, footprint) => Math.abs(a[0] - b[0]) < footprint.widthDeg && Math.abs(a[1] - b[1]) < footprint.heightDeg;
5601
- const sqDistance = (a, b) => {
5602
- const dx = a[0] - b[0];
5603
- const dy = a[1] - b[1];
5604
- return dx * dx + dy * dy;
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 so that no two labels overlap, in priority order.
5827
+ * Chooses which markers render a label, and how, in priority order.
5608
5828
  *
5609
- * Walks `focus.tiers` high→low; only items matching a tier are eligible. Within each tier,
5610
- * incumbents (in `previousIds`) are considered before newcomers, and each group is visited in
5611
- * dynamic **farthest-point** order — the candidate whose nearest-neighbour distance to every
5612
- * already-placed label is largest is tried first — so labels spread across the view box rather
5613
- * than lumping. Each pick is collision-tested against the placed footprints and skipped if it
5614
- * would overlap. Placement stops at `ceiling`; the budget is otherwise emergent (what fits).
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
- * All geometry runs in longitudes unwrapped around the view-box centre, so a viewport crossing
5617
- * the antimeridian is handled correctly. Deterministic: all ties (centroid seed and
5618
- * farthest-point) break by `getId` ascending, so the result is independent of server return order.
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 shown = new Set();
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
- // Below this squared distance a point is inside every label footprint, so it always overlaps.
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
- // Try to place a group of same-tier candidates, most-dispersed-first, collision-gated.
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
- // Sort by id first so every tie below resolves by id ascending, deterministically.
5651
- const pool = group
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 c of pool) {
5662
- cx += c.point[0];
5663
- cy += c.point[1];
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((c, index) => {
5668
- const d = sqDistance(c.point, centroid);
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((c, index) => {
5963
+ pool.forEach((candidate, index) => {
5679
5964
  let minDist = Infinity;
5680
5965
  for (const other of placed) {
5681
- const d = sqDistance(other, c.point);
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
- // Even the most-dispersed remaining candidate sits inside a placed footprint → every
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
- shown.add(chosen.id);
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 (shown.size >= ceiling)
5989
+ if (placements.size >= ceiling)
5706
5990
  break;
5707
- const candidates = items.filter(item => !shown.has(getId(item)) && tier.match(item) && (bounds === null || isWithinBounds(getPosition(item), bounds)));
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(item => previousIds.has(getId(item))));
5713
- placeGroup(candidates.filter(item => !previousIds.has(getId(item))));
6001
+ placeGroup(candidates.filter(candidate => previousIds.has(candidate.id)));
6002
+ placeGroup(candidates.filter(candidate => !previousIds.has(candidate.id)));
5714
6003
  }
5715
- return shown;
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
- * Upper-bound label footprint as a geo-space width/height, for collision testing.
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
- * lays every pill out at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the
5738
- * label text is capped at `maxLabelWidthPx` (ellipsis beyond). We size the collision
5739
- * box to that upper bound — widest pill that can render — so the box is never smaller
5740
- * than what paints, which is what keeps the "no two labels overlap" guarantee honest.
5741
- * Density is tuned via `paddingPx`, not by shrinking the box.
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
- // Left pad (== pillPaddingY) + icon disc + gap + max label text + right pad. Assume the icon
5747
- // disc is present (the wider case) so the box stays an upper bound.
5748
- const widthPx = pillPaddingY + circle + gapPx + maxLabelWidthPx + paddingRightPx + 2 * paddingPx;
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 EMPTY_LABEL_IDS = new Set();
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 the ids of the markers that should
5757
- * render a label such that no two labels overlap, in priority order, bounded by `ceiling`.
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
- * as `previousIds` so labels that were shown stay shown across pan/zoom refetches (incumbents
5761
- * that leave the view box drop via candidate clipping). Memory resets when `focus.id` changes,
5762
- * not on viewport change. Returns the previous `Set` reference when content is unchanged so
5763
- * downstream `useMemo`/callbacks stay stable. Same render-phase-setState pattern as
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
- ids: EMPTY_LABEL_IDS,
6092
+ placements: EMPTY_PLACEMENTS,
5770
6093
  }));
5771
- if (!enabled) {
5772
- if (snapshot.ids !== EMPTY_LABEL_IDS || snapshot.focusId !== focus.id) {
5773
- setSnapshot({ focusId: focus.id, ids: EMPTY_LABEL_IDS });
5774
- }
5775
- return EMPTY_LABEL_IDS;
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
- const footprint = labelFootprintDeg({ zoom, latitudeDeg, paddingPx });
5780
- const result = buildLabelPlacement({
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
- footprint,
6132
+ zoom,
6133
+ latitudeDeg,
6134
+ paddingPx,
6135
+ labelWidthPx,
5787
6136
  ceiling,
5788
- previousIds: prevIds,
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.size === prevIds.size && [...result].every(id => prevIds.has(id)) ? prevIds : result;
5792
- if (stable !== snapshot.ids || focus.id !== snapshot.focusId) {
5793
- setSnapshot({ focusId: focus.id, ids: stable });
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
  };