@trackunit/react-map 0.2.163 → 0.2.167

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