@trackunit/react-map 0.2.142 → 0.2.147
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 +154 -32
- package/index.esm.js +154 -32
- package/package.json +10 -10
- package/src/layers/buildLabelPlacement.d.ts +17 -11
- package/src/layers/useLabelPlacement.d.ts +14 -9
- package/migrations/entry.js.map +0 -1
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
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
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
|
-
*
|
|
5598
|
-
*
|
|
5599
|
-
*
|
|
5600
|
-
*
|
|
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
|
-
*
|
|
5603
|
-
*
|
|
5604
|
-
*
|
|
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
|
|
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
|
|
5613
|
-
if (
|
|
5614
|
-
|
|
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
|
-
|
|
5618
|
-
|
|
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
|
-
*
|
|
5665
|
-
*
|
|
5666
|
-
*
|
|
5667
|
-
*
|
|
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, }) =>
|
|
5670
|
-
|
|
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
|
-
|
|
5675
|
-
|
|
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
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
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
|
-
*
|
|
5597
|
-
*
|
|
5598
|
-
*
|
|
5599
|
-
*
|
|
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
|
-
*
|
|
5602
|
-
*
|
|
5603
|
-
*
|
|
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
|
|
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
|
|
5612
|
-
if (
|
|
5613
|
-
|
|
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
|
-
|
|
5617
|
-
|
|
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
|
-
*
|
|
5664
|
-
*
|
|
5665
|
-
*
|
|
5666
|
-
*
|
|
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, }) =>
|
|
5669
|
-
|
|
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
|
-
|
|
5674
|
-
|
|
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,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-map",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.147",
|
|
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.
|
|
11
|
-
"@trackunit/css-class-variance-utilities": "2.0.
|
|
12
|
-
"@trackunit/react-form-components": "2.6.
|
|
13
|
-
"@trackunit/react-core-hooks": "1.21.
|
|
14
|
-
"@trackunit/geo-json-utils": "1.15.
|
|
15
|
-
"@trackunit/i18n-library-translation": "2.4.
|
|
10
|
+
"@trackunit/react-components": "2.13.19",
|
|
11
|
+
"@trackunit/css-class-variance-utilities": "2.0.12",
|
|
12
|
+
"@trackunit/react-form-components": "2.6.67",
|
|
13
|
+
"@trackunit/react-core-hooks": "1.21.59",
|
|
14
|
+
"@trackunit/geo-json-utils": "1.15.45",
|
|
15
|
+
"@trackunit/i18n-library-translation": "2.4.59",
|
|
16
16
|
"react-minimal-pie-chart": "^8.4.0",
|
|
17
|
-
"@trackunit/react-map-adapter-shared": "0.0.
|
|
18
|
-
"@trackunit/react-map-color-utils": "0.0.
|
|
19
|
-
"@trackunit/ui-design-tokens": "1.15.
|
|
17
|
+
"@trackunit/react-map-adapter-shared": "0.0.127",
|
|
18
|
+
"@trackunit/react-map-color-utils": "0.0.105",
|
|
19
|
+
"@trackunit/ui-design-tokens": "1.15.31",
|
|
20
20
|
"@floating-ui/react": "^0.26.25",
|
|
21
21
|
"es-toolkit": "^1.39.10",
|
|
22
22
|
"tailwind-merge": "^2.0.0",
|
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
5
|
+
/** When false the hook returns a stable empty set and clears incumbency memory. */
|
|
5
6
|
enabled: boolean;
|
|
6
|
-
/** Candidate markers
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 {};
|
package/migrations/entry.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
|