@trackunit/react-map 0.2.142 → 0.2.144

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
@@ -5584,38 +5584,134 @@ const isWithinBounds = (position, bounds) => {
5584
5584
  const lngInside = minLng > maxLng ? lng >= minLng || lng <= maxLng : lng >= minLng && lng <= maxLng;
5585
5585
  return lngInside && lat >= minLat && lat <= maxLat;
5586
5586
  };
5587
- const overlaps = (a, b, footprint) => {
5588
- // Shortest angular longitude delta so labels near the antimeridian (e.g. 179.9° and
5589
- // -179.9°) are correctly seen as ~0.2° apart, not ~359.8°, matching `isWithinBounds`.
5590
- const rawLngDelta = Math.abs(a[0] - b[0]);
5591
- const lngDelta = Math.min(rawLngDelta, 360 - rawLngDelta);
5592
- return lngDelta < footprint.widthDeg && Math.abs(a[1] - b[1]) < footprint.heightDeg;
5587
+ /**
5588
+ * Longitude of the view box centre, antimeridian-aware: a crossing box (minLng > maxLng, e.g.
5589
+ * 170→-170) centres near ±180, not near 0. Used as the reference for unwrapping.
5590
+ */
5591
+ const boundsCentreLng = (bounds) => {
5592
+ const [minLng, , maxLng] = bounds;
5593
+ return minLng > maxLng ? (minLng + maxLng + 360) / 2 : (minLng + maxLng) / 2;
5594
+ };
5595
+ /**
5596
+ * Unwraps a longitude onto a continuous axis within ±180° of `reference`, so a viewport that
5597
+ * straddles the antimeridian becomes one continuous span (e.g. -176 → 184 next to 176). All
5598
+ * centroid/distance/overlap math then uses plain deltas with no ±180° discontinuity.
5599
+ */
5600
+ 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;
5593
5606
  };
5594
5607
  /**
5595
- * Chooses which markers render a label so that no two labels overlap.
5608
+ * Chooses which markers render a label so that no two labels overlap, in priority order.
5596
5609
  *
5597
- * Greedy placement: walks `items` in order, skips any outside the view box,
5598
- * and adds a label only when its nominal footprint does not collide with an
5599
- * already-placed one, stopping at `ceiling`. The budget is emergent — however
5600
- * many non-overlapping labels fit, bounded by the ceiling.
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).
5601
5616
  *
5602
- * Deterministic: identical input yields an identical set. Phase 1 places in the
5603
- * caller-provided order; priority ordering, dispersion, and pin-on-a-stick
5604
- * displacement layer on top in later phases.
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.
5605
5620
  */
5606
- const buildLabelPlacement = ({ items, getId, getPosition, bounds, footprint, ceiling, }) => {
5621
+ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, ceiling, previousIds, }) => {
5607
5622
  const shown = new Set();
5608
5623
  const placed = [];
5609
- for (const item of items) {
5624
+ // 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).
5627
+ let referenceLng;
5628
+ if (bounds !== null) {
5629
+ referenceLng = boundsCentreLng(bounds);
5630
+ }
5631
+ else {
5632
+ let refItem;
5633
+ let refId;
5634
+ for (const item of items) {
5635
+ const id = getId(item);
5636
+ if (refId === undefined || id < refId) {
5637
+ refId = id;
5638
+ refItem = item;
5639
+ }
5640
+ }
5641
+ referenceLng = refItem === undefined ? 0 : getPosition(refItem)[0];
5642
+ }
5643
+ const planar = (item) => {
5644
+ const p = getPosition(item);
5645
+ return [unwrapLng(p[0], referenceLng), p[1]];
5646
+ };
5647
+ // Below this squared distance a point is inside every label footprint, so it always overlaps.
5648
+ const minFootprintSq = Math.min(footprint.widthDeg, footprint.heightDeg) ** 2;
5649
+ // Try to place a group of same-tier candidates, most-dispersed-first, collision-gated.
5650
+ 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) {
5656
+ let bestIndex = 0;
5657
+ 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
+ let cx = 0;
5661
+ let cy = 0;
5662
+ for (const c of pool) {
5663
+ cx += c.point[0];
5664
+ cy += c.point[1];
5665
+ }
5666
+ const centroid = [cx / pool.length, cy / pool.length];
5667
+ let bestSeedDist = Infinity;
5668
+ pool.forEach((c, index) => {
5669
+ const d = sqDistance(c.point, centroid);
5670
+ if (d < bestSeedDist) {
5671
+ bestSeedDist = d;
5672
+ bestIndex = index;
5673
+ }
5674
+ });
5675
+ }
5676
+ else {
5677
+ // Pick the candidate whose nearest already-placed label is farthest away.
5678
+ let bestMinDist = -1;
5679
+ pool.forEach((c, index) => {
5680
+ let minDist = Infinity;
5681
+ for (const other of placed) {
5682
+ const d = sqDistance(other, c.point);
5683
+ if (d < minDist)
5684
+ minDist = d;
5685
+ }
5686
+ if (minDist > bestMinDist) {
5687
+ bestMinDist = minDist;
5688
+ bestIndex = index;
5689
+ }
5690
+ });
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.
5693
+ if (bestMinDist < minFootprintSq)
5694
+ break;
5695
+ }
5696
+ const [chosen] = pool.splice(bestIndex, 1);
5697
+ if (chosen === undefined)
5698
+ break;
5699
+ if (placed.some(other => overlaps(other, chosen.point, footprint)))
5700
+ continue;
5701
+ shown.add(chosen.id);
5702
+ placed.push(chosen.point);
5703
+ }
5704
+ };
5705
+ for (const tier of focus.tiers) {
5610
5706
  if (shown.size >= ceiling)
5611
5707
  break;
5612
- const position = getPosition(item);
5613
- if (bounds !== null && !isWithinBounds(position, bounds))
5614
- continue;
5615
- if (placed.some(other => overlaps(other, position, footprint)))
5708
+ const candidates = items.filter(item => !shown.has(getId(item)) && tier.match(item) && (bounds === null || isWithinBounds(getPosition(item), bounds)));
5709
+ if (previousIds === undefined) {
5710
+ placeGroup(candidates);
5616
5711
  continue;
5617
- shown.add(getId(item));
5618
- placed.push(position);
5712
+ }
5713
+ placeGroup(candidates.filter(item => previousIds.has(getId(item))));
5714
+ placeGroup(candidates.filter(item => !previousIds.has(getId(item))));
5619
5715
  }
5620
5716
  return shown;
5621
5717
  };
@@ -5658,21 +5754,47 @@ const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx }) => {
5658
5754
 
5659
5755
  const EMPTY_LABEL_IDS = new Set();
5660
5756
  /**
5661
- * Viewport-driven label placement: returns the ids of the markers that should
5662
- * render a label such that no two labels overlap, bounded by `ceiling`.
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`.
5663
5759
  *
5664
- * Derives an upper-bound pill footprint (`labelFootprintDeg`) and delegates
5665
- * selection to the pure `buildLabelPlacement`. Exposed as a hook so the placement
5666
- * participates in React's stability model and later phases can hold incumbency
5667
- * state here without changing the public surface.
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`.
5668
5766
  */
5669
- const useLabelPlacement = ({ enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling, }) => react.useMemo(() => {
5670
- if (!enabled)
5767
+ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, ceiling, }) => {
5768
+ const [snapshot, setSnapshot] = react.useState(() => ({
5769
+ focusId: focus.id,
5770
+ ids: EMPTY_LABEL_IDS,
5771
+ }));
5772
+ if (!enabled) {
5773
+ if (snapshot.ids !== EMPTY_LABEL_IDS || snapshot.focusId !== focus.id) {
5774
+ setSnapshot({ focusId: focus.id, ids: EMPTY_LABEL_IDS });
5775
+ }
5671
5776
  return EMPTY_LABEL_IDS;
5777
+ }
5778
+ const prevIds = snapshot.focusId === focus.id ? snapshot.ids : EMPTY_LABEL_IDS;
5672
5779
  const latitudeDeg = bounds === null ? 0 : (bounds[1] + bounds[3]) / 2;
5673
5780
  const footprint = labelFootprintDeg({ zoom, latitudeDeg, paddingPx });
5674
- return buildLabelPlacement({ items, getId, getPosition, bounds, footprint, ceiling });
5675
- }, [enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling]);
5781
+ const result = buildLabelPlacement({
5782
+ items,
5783
+ getId,
5784
+ getPosition,
5785
+ focus,
5786
+ bounds,
5787
+ footprint,
5788
+ ceiling,
5789
+ previousIds: prevIds,
5790
+ });
5791
+ // 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 });
5795
+ }
5796
+ return stable;
5797
+ };
5676
5798
 
5677
5799
  // ============================================================================
5678
5800
  // Helpers
package/index.esm.js CHANGED
@@ -5583,38 +5583,134 @@ const isWithinBounds = (position, bounds) => {
5583
5583
  const lngInside = minLng > maxLng ? lng >= minLng || lng <= maxLng : lng >= minLng && lng <= maxLng;
5584
5584
  return lngInside && lat >= minLat && lat <= maxLat;
5585
5585
  };
5586
- const overlaps = (a, b, footprint) => {
5587
- // Shortest angular longitude delta so labels near the antimeridian (e.g. 179.9° and
5588
- // -179.9°) are correctly seen as ~0.2° apart, not ~359.8°, matching `isWithinBounds`.
5589
- const rawLngDelta = Math.abs(a[0] - b[0]);
5590
- const lngDelta = Math.min(rawLngDelta, 360 - rawLngDelta);
5591
- return lngDelta < footprint.widthDeg && Math.abs(a[1] - b[1]) < footprint.heightDeg;
5586
+ /**
5587
+ * Longitude of the view box centre, antimeridian-aware: a crossing box (minLng > maxLng, e.g.
5588
+ * 170→-170) centres near ±180, not near 0. Used as the reference for unwrapping.
5589
+ */
5590
+ const boundsCentreLng = (bounds) => {
5591
+ const [minLng, , maxLng] = bounds;
5592
+ return minLng > maxLng ? (minLng + maxLng + 360) / 2 : (minLng + maxLng) / 2;
5593
+ };
5594
+ /**
5595
+ * Unwraps a longitude onto a continuous axis within ±180° of `reference`, so a viewport that
5596
+ * straddles the antimeridian becomes one continuous span (e.g. -176 → 184 next to 176). All
5597
+ * centroid/distance/overlap math then uses plain deltas with no ±180° discontinuity.
5598
+ */
5599
+ 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;
5592
5605
  };
5593
5606
  /**
5594
- * Chooses which markers render a label so that no two labels overlap.
5607
+ * Chooses which markers render a label so that no two labels overlap, in priority order.
5595
5608
  *
5596
- * Greedy placement: walks `items` in order, skips any outside the view box,
5597
- * and adds a label only when its nominal footprint does not collide with an
5598
- * already-placed one, stopping at `ceiling`. The budget is emergent — however
5599
- * many non-overlapping labels fit, bounded by the ceiling.
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).
5600
5615
  *
5601
- * Deterministic: identical input yields an identical set. Phase 1 places in the
5602
- * caller-provided order; priority ordering, dispersion, and pin-on-a-stick
5603
- * displacement layer on top in later phases.
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.
5604
5619
  */
5605
- const buildLabelPlacement = ({ items, getId, getPosition, bounds, footprint, ceiling, }) => {
5620
+ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, ceiling, previousIds, }) => {
5606
5621
  const shown = new Set();
5607
5622
  const placed = [];
5608
- for (const item of items) {
5623
+ // 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).
5626
+ let referenceLng;
5627
+ if (bounds !== null) {
5628
+ referenceLng = boundsCentreLng(bounds);
5629
+ }
5630
+ else {
5631
+ let refItem;
5632
+ let refId;
5633
+ for (const item of items) {
5634
+ const id = getId(item);
5635
+ if (refId === undefined || id < refId) {
5636
+ refId = id;
5637
+ refItem = item;
5638
+ }
5639
+ }
5640
+ referenceLng = refItem === undefined ? 0 : getPosition(refItem)[0];
5641
+ }
5642
+ const planar = (item) => {
5643
+ const p = getPosition(item);
5644
+ return [unwrapLng(p[0], referenceLng), p[1]];
5645
+ };
5646
+ // Below this squared distance a point is inside every label footprint, so it always overlaps.
5647
+ const minFootprintSq = Math.min(footprint.widthDeg, footprint.heightDeg) ** 2;
5648
+ // Try to place a group of same-tier candidates, most-dispersed-first, collision-gated.
5649
+ 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) {
5655
+ let bestIndex = 0;
5656
+ 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
+ let cx = 0;
5660
+ let cy = 0;
5661
+ for (const c of pool) {
5662
+ cx += c.point[0];
5663
+ cy += c.point[1];
5664
+ }
5665
+ const centroid = [cx / pool.length, cy / pool.length];
5666
+ let bestSeedDist = Infinity;
5667
+ pool.forEach((c, index) => {
5668
+ const d = sqDistance(c.point, centroid);
5669
+ if (d < bestSeedDist) {
5670
+ bestSeedDist = d;
5671
+ bestIndex = index;
5672
+ }
5673
+ });
5674
+ }
5675
+ else {
5676
+ // Pick the candidate whose nearest already-placed label is farthest away.
5677
+ let bestMinDist = -1;
5678
+ pool.forEach((c, index) => {
5679
+ let minDist = Infinity;
5680
+ for (const other of placed) {
5681
+ const d = sqDistance(other, c.point);
5682
+ if (d < minDist)
5683
+ minDist = d;
5684
+ }
5685
+ if (minDist > bestMinDist) {
5686
+ bestMinDist = minDist;
5687
+ bestIndex = index;
5688
+ }
5689
+ });
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.
5692
+ if (bestMinDist < minFootprintSq)
5693
+ break;
5694
+ }
5695
+ const [chosen] = pool.splice(bestIndex, 1);
5696
+ if (chosen === undefined)
5697
+ break;
5698
+ if (placed.some(other => overlaps(other, chosen.point, footprint)))
5699
+ continue;
5700
+ shown.add(chosen.id);
5701
+ placed.push(chosen.point);
5702
+ }
5703
+ };
5704
+ for (const tier of focus.tiers) {
5609
5705
  if (shown.size >= ceiling)
5610
5706
  break;
5611
- const position = getPosition(item);
5612
- if (bounds !== null && !isWithinBounds(position, bounds))
5613
- continue;
5614
- if (placed.some(other => overlaps(other, position, footprint)))
5707
+ const candidates = items.filter(item => !shown.has(getId(item)) && tier.match(item) && (bounds === null || isWithinBounds(getPosition(item), bounds)));
5708
+ if (previousIds === undefined) {
5709
+ placeGroup(candidates);
5615
5710
  continue;
5616
- shown.add(getId(item));
5617
- placed.push(position);
5711
+ }
5712
+ placeGroup(candidates.filter(item => previousIds.has(getId(item))));
5713
+ placeGroup(candidates.filter(item => !previousIds.has(getId(item))));
5618
5714
  }
5619
5715
  return shown;
5620
5716
  };
@@ -5657,21 +5753,47 @@ const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx }) => {
5657
5753
 
5658
5754
  const EMPTY_LABEL_IDS = new Set();
5659
5755
  /**
5660
- * Viewport-driven label placement: returns the ids of the markers that should
5661
- * render a label such that no two labels overlap, bounded by `ceiling`.
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`.
5662
5758
  *
5663
- * Derives an upper-bound pill footprint (`labelFootprintDeg`) and delegates
5664
- * selection to the pure `buildLabelPlacement`. Exposed as a hook so the placement
5665
- * participates in React's stability model and later phases can hold incumbency
5666
- * state here without changing the public surface.
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`.
5667
5765
  */
5668
- const useLabelPlacement = ({ enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling, }) => useMemo(() => {
5669
- if (!enabled)
5766
+ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, ceiling, }) => {
5767
+ const [snapshot, setSnapshot] = useState(() => ({
5768
+ focusId: focus.id,
5769
+ ids: EMPTY_LABEL_IDS,
5770
+ }));
5771
+ if (!enabled) {
5772
+ if (snapshot.ids !== EMPTY_LABEL_IDS || snapshot.focusId !== focus.id) {
5773
+ setSnapshot({ focusId: focus.id, ids: EMPTY_LABEL_IDS });
5774
+ }
5670
5775
  return EMPTY_LABEL_IDS;
5776
+ }
5777
+ const prevIds = snapshot.focusId === focus.id ? snapshot.ids : EMPTY_LABEL_IDS;
5671
5778
  const latitudeDeg = bounds === null ? 0 : (bounds[1] + bounds[3]) / 2;
5672
5779
  const footprint = labelFootprintDeg({ zoom, latitudeDeg, paddingPx });
5673
- return buildLabelPlacement({ items, getId, getPosition, bounds, footprint, ceiling });
5674
- }, [enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling]);
5780
+ const result = buildLabelPlacement({
5781
+ items,
5782
+ getId,
5783
+ getPosition,
5784
+ focus,
5785
+ bounds,
5786
+ footprint,
5787
+ ceiling,
5788
+ previousIds: prevIds,
5789
+ });
5790
+ // 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 });
5794
+ }
5795
+ return stable;
5796
+ };
5675
5797
 
5676
5798
  // ============================================================================
5677
5799
  // Helpers
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.142",
3
+ "version": "0.2.144",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-components": "2.13.15",
10
+ "@trackunit/react-components": "2.13.16",
11
11
  "@trackunit/css-class-variance-utilities": "2.0.10",
12
- "@trackunit/react-form-components": "2.6.63",
13
- "@trackunit/react-core-hooks": "1.21.55",
12
+ "@trackunit/react-form-components": "2.6.64",
13
+ "@trackunit/react-core-hooks": "1.21.56",
14
14
  "@trackunit/geo-json-utils": "1.15.42",
15
- "@trackunit/i18n-library-translation": "2.4.55",
15
+ "@trackunit/i18n-library-translation": "2.4.56",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
17
  "@trackunit/react-map-adapter-shared": "0.0.124",
18
18
  "@trackunit/react-map-color-utils": "0.0.103",
@@ -1,29 +1,35 @@
1
1
  import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
2
2
  import type { LabelFootprintDeg } from "./internal/labelFootprint";
3
+ import type { MapFocus } from "./mapFocus";
3
4
  type Position = readonly [number, number, ...Array<number>];
4
5
  export type BuildLabelPlacementParams<TAsset> = Readonly<{
5
- /** Candidate markers, in the order they should be considered for a label. */
6
6
  items: ReadonlyArray<TAsset>;
7
7
  getId: (item: TAsset) => string;
8
8
  getPosition: (item: TAsset) => Position;
9
+ /** Priority tiers, evaluated high→low; only tier-matching items are label-eligible. */
10
+ focus: MapFocus<TAsset>;
9
11
  /** Current camera view box; candidates outside it are skipped. `null` disables clipping. */
10
12
  bounds: Readonly<GeoJsonBbox> | null;
11
- /** Nominal geo footprint every label is assumed to occupy, for collision testing. */
13
+ /** Geo footprint every label is assumed to occupy, for collision testing. */
12
14
  footprint: LabelFootprintDeg;
13
15
  /** Maximum number of labels to place (DOM-node ceiling). */
14
16
  ceiling: number;
17
+ /** Previously-shown ids — kept preferentially within their tier (hysteresis). */
18
+ previousIds?: ReadonlySet<string>;
15
19
  }>;
16
20
  /**
17
- * Chooses which markers render a label so that no two labels overlap.
21
+ * Chooses which markers render a label so that no two labels overlap, in priority order.
18
22
  *
19
- * Greedy placement: walks `items` in order, skips any outside the view box,
20
- * and adds a label only when its nominal footprint does not collide with an
21
- * already-placed one, stopping at `ceiling`. The budget is emergent — however
22
- * many non-overlapping labels fit, bounded by the ceiling.
23
+ * Walks `focus.tiers` high→low; only items matching a tier are eligible. Within each tier,
24
+ * incumbents (in `previousIds`) are considered before newcomers, and each group is visited in
25
+ * dynamic **farthest-point** order — the candidate whose nearest-neighbour distance to every
26
+ * already-placed label is largest is tried first — so labels spread across the view box rather
27
+ * than lumping. Each pick is collision-tested against the placed footprints and skipped if it
28
+ * would overlap. Placement stops at `ceiling`; the budget is otherwise emergent (what fits).
23
29
  *
24
- * Deterministic: identical input yields an identical set. Phase 1 places in the
25
- * caller-provided order; priority ordering, dispersion, and pin-on-a-stick
26
- * displacement layer on top in later phases.
30
+ * All geometry runs in longitudes unwrapped around the view-box centre, so a viewport crossing
31
+ * the antimeridian is handled correctly. Deterministic: all ties (centroid seed and
32
+ * farthest-point) break by `getId` ascending, so the result is independent of server return order.
27
33
  */
28
- export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, bounds, footprint, ceiling, }: BuildLabelPlacementParams<TAsset>) => ReadonlySet<string>;
34
+ export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, focus, bounds, footprint, ceiling, previousIds, }: BuildLabelPlacementParams<TAsset>) => ReadonlySet<string>;
29
35
  export {};
@@ -1,14 +1,17 @@
1
1
  import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
2
+ import type { MapFocus } from "./mapFocus";
2
3
  type Position = readonly [number, number, ...Array<number>];
3
4
  export type UseLabelPlacementParams<TAsset> = Readonly<{
4
- /** When false the hook returns a stable empty set without computing placement. */
5
+ /** When false the hook returns a stable empty set and clears incumbency memory. */
5
6
  enabled: boolean;
6
- /** Candidate markers, in the order they should be considered for a label. */
7
+ /** Candidate markers; only those matching a `focus` tier are label-eligible. */
7
8
  items: ReadonlyArray<TAsset>;
8
9
  /** Stable id accessor — pass a referentially stable function (module const or `useCallback`). */
9
10
  getId: (item: TAsset) => string;
10
11
  /** Stable position accessor — pass a referentially stable function. */
11
12
  getPosition: (item: TAsset) => Position;
13
+ /** Priority tiers (label order); `focus.id` also keys the incumbency memory. */
14
+ focus: MapFocus<TAsset>;
12
15
  /** Current camera view box; candidates outside it are skipped. `null` disables clipping and converts the footprint at the equator. */
13
16
  bounds: Readonly<GeoJsonBbox> | null;
14
17
  /** Current camera zoom; drives the px→geo footprint scale. */
@@ -19,13 +22,15 @@ export type UseLabelPlacementParams<TAsset> = Readonly<{
19
22
  ceiling: number;
20
23
  }>;
21
24
  /**
22
- * Viewport-driven label placement: returns the ids of the markers that should
23
- * render a label such that no two labels overlap, bounded by `ceiling`.
25
+ * Viewport-driven label placement with hysteresis: returns the ids of the markers that should
26
+ * render a label such that no two labels overlap, in priority order, bounded by `ceiling`.
24
27
  *
25
- * Derives an upper-bound pill footprint (`labelFootprintDeg`) and delegates
26
- * selection to the pure `buildLabelPlacement`. Exposed as a hook so the placement
27
- * participates in React's stability model and later phases can hold incumbency
28
- * state here without changing the public surface.
28
+ * Stateful wrapper around the pure {@link buildLabelPlacement} — feeds the previous result back
29
+ * as `previousIds` so labels that were shown stay shown across pan/zoom refetches (incumbents
30
+ * that leave the view box drop via candidate clipping). Memory resets when `focus.id` changes,
31
+ * not on viewport change. Returns the previous `Set` reference when content is unchanged so
32
+ * downstream `useMemo`/callbacks stay stable. Same render-phase-setState pattern as
33
+ * `useExpandedIds`.
29
34
  */
30
- export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling, }: UseLabelPlacementParams<TAsset>) => ReadonlySet<string>;
35
+ export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, ceiling, }: UseLabelPlacementParams<TAsset>) => ReadonlySet<string>;
31
36
  export {};