@trackunit/react-map 0.2.123 → 0.2.126
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 +97 -0
- package/index.esm.js +97 -1
- package/package.json +10 -10
- package/src/index.d.ts +1 -0
- package/src/layers/buildLabelPlacement.d.ts +29 -0
- package/src/layers/internal/labelFootprint.d.ts +37 -0
- package/src/layers/useLabelPlacement.d.ts +31 -0
package/index.cjs.js
CHANGED
|
@@ -5579,6 +5579,102 @@ const useFitToContent = (api, handles, options) => {
|
|
|
5579
5579
|
return react.useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
|
|
5580
5580
|
};
|
|
5581
5581
|
|
|
5582
|
+
const isWithinBounds = (position, bounds) => {
|
|
5583
|
+
const [lng, lat] = position;
|
|
5584
|
+
const [minLng, minLat, maxLng, maxLat] = bounds;
|
|
5585
|
+
const lngInside = minLng > maxLng ? lng >= minLng || lng <= maxLng : lng >= minLng && lng <= maxLng;
|
|
5586
|
+
return lngInside && lat >= minLat && lat <= maxLat;
|
|
5587
|
+
};
|
|
5588
|
+
const overlaps = (a, b, footprint) => {
|
|
5589
|
+
// Shortest angular longitude delta so labels near the antimeridian (e.g. 179.9° and
|
|
5590
|
+
// -179.9°) are correctly seen as ~0.2° apart, not ~359.8°, matching `isWithinBounds`.
|
|
5591
|
+
const rawLngDelta = Math.abs(a[0] - b[0]);
|
|
5592
|
+
const lngDelta = Math.min(rawLngDelta, 360 - rawLngDelta);
|
|
5593
|
+
return lngDelta < footprint.widthDeg && Math.abs(a[1] - b[1]) < footprint.heightDeg;
|
|
5594
|
+
};
|
|
5595
|
+
/**
|
|
5596
|
+
* Chooses which markers render a label so that no two labels overlap.
|
|
5597
|
+
*
|
|
5598
|
+
* Greedy placement: walks `items` in order, skips any outside the view box,
|
|
5599
|
+
* and adds a label only when its nominal footprint does not collide with an
|
|
5600
|
+
* already-placed one, stopping at `ceiling`. The budget is emergent — however
|
|
5601
|
+
* many non-overlapping labels fit, bounded by the ceiling.
|
|
5602
|
+
*
|
|
5603
|
+
* Deterministic: identical input yields an identical set. Phase 1 places in the
|
|
5604
|
+
* caller-provided order; priority ordering, dispersion, and pin-on-a-stick
|
|
5605
|
+
* displacement layer on top in later phases.
|
|
5606
|
+
*/
|
|
5607
|
+
const buildLabelPlacement = ({ items, getId, getPosition, bounds, footprint, ceiling, }) => {
|
|
5608
|
+
const shown = new Set();
|
|
5609
|
+
const placed = [];
|
|
5610
|
+
for (const item of items) {
|
|
5611
|
+
if (shown.size >= ceiling)
|
|
5612
|
+
break;
|
|
5613
|
+
const position = getPosition(item);
|
|
5614
|
+
if (bounds !== null && !isWithinBounds(position, bounds))
|
|
5615
|
+
continue;
|
|
5616
|
+
if (placed.some(other => overlaps(other, position, footprint)))
|
|
5617
|
+
continue;
|
|
5618
|
+
shown.add(getId(item));
|
|
5619
|
+
placed.push(position);
|
|
5620
|
+
}
|
|
5621
|
+
return shown;
|
|
5622
|
+
};
|
|
5623
|
+
|
|
5624
|
+
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
5625
|
+
/**
|
|
5626
|
+
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
5627
|
+
* near `latitudeDeg`, under Web Mercator.
|
|
5628
|
+
*
|
|
5629
|
+
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
5630
|
+
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
5631
|
+
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
5632
|
+
* the same lng/lat space the marker positions already live in.
|
|
5633
|
+
*/
|
|
5634
|
+
const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
5635
|
+
const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
|
|
5636
|
+
const lng = 360 / worldWidthPx;
|
|
5637
|
+
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
5638
|
+
};
|
|
5639
|
+
/**
|
|
5640
|
+
* Upper-bound label footprint as a geo-space width/height, for collision testing.
|
|
5641
|
+
*
|
|
5642
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker`
|
|
5643
|
+
* lays every pill out at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the
|
|
5644
|
+
* label text is capped at `maxLabelWidthPx` (ellipsis beyond). We size the collision
|
|
5645
|
+
* box to that upper bound — widest pill that can render — so the box is never smaller
|
|
5646
|
+
* than what paints, which is what keeps the "no two labels overlap" guarantee honest.
|
|
5647
|
+
* Density is tuned via `paddingPx`, not by shrinking the box.
|
|
5648
|
+
*/
|
|
5649
|
+
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx }) => {
|
|
5650
|
+
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
5651
|
+
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
5652
|
+
// Left pad (== pillPaddingY) + icon disc + gap + max label text + right pad. Assume the icon
|
|
5653
|
+
// disc is present (the wider case) so the box stays an upper bound.
|
|
5654
|
+
const widthPx = pillPaddingY + circle + gapPx + maxLabelWidthPx + paddingRightPx + 2 * paddingPx;
|
|
5655
|
+
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
5656
|
+
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
5657
|
+
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
5658
|
+
};
|
|
5659
|
+
|
|
5660
|
+
const EMPTY_LABEL_IDS = new Set();
|
|
5661
|
+
/**
|
|
5662
|
+
* Viewport-driven label placement: returns the ids of the markers that should
|
|
5663
|
+
* render a label such that no two labels overlap, bounded by `ceiling`.
|
|
5664
|
+
*
|
|
5665
|
+
* Derives an upper-bound pill footprint (`labelFootprintDeg`) and delegates
|
|
5666
|
+
* selection to the pure `buildLabelPlacement`. Exposed as a hook so the placement
|
|
5667
|
+
* participates in React's stability model and later phases can hold incumbency
|
|
5668
|
+
* state here without changing the public surface.
|
|
5669
|
+
*/
|
|
5670
|
+
const useLabelPlacement = ({ enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling, }) => react.useMemo(() => {
|
|
5671
|
+
if (!enabled)
|
|
5672
|
+
return EMPTY_LABEL_IDS;
|
|
5673
|
+
const latitudeDeg = bounds === null ? 0 : (bounds[1] + bounds[3]) / 2;
|
|
5674
|
+
const footprint = labelFootprintDeg({ zoom, latitudeDeg, paddingPx });
|
|
5675
|
+
return buildLabelPlacement({ items, getId, getPosition, bounds, footprint, ceiling });
|
|
5676
|
+
}, [enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling]);
|
|
5677
|
+
|
|
5582
5678
|
// ============================================================================
|
|
5583
5679
|
// Helpers
|
|
5584
5680
|
// ============================================================================
|
|
@@ -12208,6 +12304,7 @@ exports.useExpandedIds = useExpandedIds;
|
|
|
12208
12304
|
exports.useFitFeatureBounds = useFitFeatureBounds;
|
|
12209
12305
|
exports.useFitToContent = useFitToContent;
|
|
12210
12306
|
exports.useImageOverlay = useImageOverlay;
|
|
12307
|
+
exports.useLabelPlacement = useLabelPlacement;
|
|
12211
12308
|
exports.useLayers = useLayers;
|
|
12212
12309
|
exports.useMap = useMap;
|
|
12213
12310
|
exports.useMapAnnotation = useMapAnnotation;
|
package/index.esm.js
CHANGED
|
@@ -5578,6 +5578,102 @@ const useFitToContent = (api, handles, options) => {
|
|
|
5578
5578
|
return useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
|
|
5579
5579
|
};
|
|
5580
5580
|
|
|
5581
|
+
const isWithinBounds = (position, bounds) => {
|
|
5582
|
+
const [lng, lat] = position;
|
|
5583
|
+
const [minLng, minLat, maxLng, maxLat] = bounds;
|
|
5584
|
+
const lngInside = minLng > maxLng ? lng >= minLng || lng <= maxLng : lng >= minLng && lng <= maxLng;
|
|
5585
|
+
return lngInside && lat >= minLat && lat <= maxLat;
|
|
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;
|
|
5593
|
+
};
|
|
5594
|
+
/**
|
|
5595
|
+
* Chooses which markers render a label so that no two labels overlap.
|
|
5596
|
+
*
|
|
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.
|
|
5601
|
+
*
|
|
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.
|
|
5605
|
+
*/
|
|
5606
|
+
const buildLabelPlacement = ({ items, getId, getPosition, bounds, footprint, ceiling, }) => {
|
|
5607
|
+
const shown = new Set();
|
|
5608
|
+
const placed = [];
|
|
5609
|
+
for (const item of items) {
|
|
5610
|
+
if (shown.size >= ceiling)
|
|
5611
|
+
break;
|
|
5612
|
+
const position = getPosition(item);
|
|
5613
|
+
if (bounds !== null && !isWithinBounds(position, bounds))
|
|
5614
|
+
continue;
|
|
5615
|
+
if (placed.some(other => overlaps(other, position, footprint)))
|
|
5616
|
+
continue;
|
|
5617
|
+
shown.add(getId(item));
|
|
5618
|
+
placed.push(position);
|
|
5619
|
+
}
|
|
5620
|
+
return shown;
|
|
5621
|
+
};
|
|
5622
|
+
|
|
5623
|
+
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
5624
|
+
/**
|
|
5625
|
+
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
5626
|
+
* near `latitudeDeg`, under Web Mercator.
|
|
5627
|
+
*
|
|
5628
|
+
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
5629
|
+
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
5630
|
+
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
5631
|
+
* the same lng/lat space the marker positions already live in.
|
|
5632
|
+
*/
|
|
5633
|
+
const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
5634
|
+
const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
|
|
5635
|
+
const lng = 360 / worldWidthPx;
|
|
5636
|
+
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
5637
|
+
};
|
|
5638
|
+
/**
|
|
5639
|
+
* Upper-bound label footprint as a geo-space width/height, for collision testing.
|
|
5640
|
+
*
|
|
5641
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker`
|
|
5642
|
+
* lays every pill out at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the
|
|
5643
|
+
* label text is capped at `maxLabelWidthPx` (ellipsis beyond). We size the collision
|
|
5644
|
+
* box to that upper bound — widest pill that can render — so the box is never smaller
|
|
5645
|
+
* than what paints, which is what keeps the "no two labels overlap" guarantee honest.
|
|
5646
|
+
* Density is tuned via `paddingPx`, not by shrinking the box.
|
|
5647
|
+
*/
|
|
5648
|
+
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx }) => {
|
|
5649
|
+
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
5650
|
+
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
5651
|
+
// Left pad (== pillPaddingY) + icon disc + gap + max label text + right pad. Assume the icon
|
|
5652
|
+
// disc is present (the wider case) so the box stays an upper bound.
|
|
5653
|
+
const widthPx = pillPaddingY + circle + gapPx + maxLabelWidthPx + paddingRightPx + 2 * paddingPx;
|
|
5654
|
+
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
5655
|
+
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
5656
|
+
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
5657
|
+
};
|
|
5658
|
+
|
|
5659
|
+
const EMPTY_LABEL_IDS = new Set();
|
|
5660
|
+
/**
|
|
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`.
|
|
5663
|
+
*
|
|
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.
|
|
5668
|
+
*/
|
|
5669
|
+
const useLabelPlacement = ({ enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling, }) => useMemo(() => {
|
|
5670
|
+
if (!enabled)
|
|
5671
|
+
return EMPTY_LABEL_IDS;
|
|
5672
|
+
const latitudeDeg = bounds === null ? 0 : (bounds[1] + bounds[3]) / 2;
|
|
5673
|
+
const footprint = labelFootprintDeg({ zoom, latitudeDeg, paddingPx });
|
|
5674
|
+
return buildLabelPlacement({ items, getId, getPosition, bounds, footprint, ceiling });
|
|
5675
|
+
}, [enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling]);
|
|
5676
|
+
|
|
5581
5677
|
// ============================================================================
|
|
5582
5678
|
// Helpers
|
|
5583
5679
|
// ============================================================================
|
|
@@ -12120,4 +12216,4 @@ const mockMapApi = (overrides) => {
|
|
|
12120
12216
|
*/
|
|
12121
12217
|
setupLibraryTranslations();
|
|
12122
12218
|
|
|
12123
|
-
export { ClusterMarker, ClusterStick, Controls, DEFAULT_MARKER_SIZE_BREAKPOINTS, DefaultControls, Layers, MARKER_DARK_PILL, MARKER_DISC_BORDER_WIDTH_PX, MARKER_LIGHT_PILL, MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_SIZE_MAP, MARKER_TUNING, MapLoadingState, MapMarker, MapMarkerIcon, PanelIconButton, ShapeAnnotationLabel, buildExpandedIds, cvaMapMarker, cvaMarkerIndicator, mockMapApi, useAdaptiveMarkerHelpers, useAutoPanResolver, useCameraIdle, useCameraState, useClusterCountFormat, useControlStack, useControls, useDefaultControls, useDirectionIndicator, useEntitiesNearCursor, useExpandedIds, useFitFeatureBounds, useFitToContent, useImageOverlay, useLayers, useMap, useMapAnnotation, useMapAnnotations, useMapAppearanceControls, useMapKeyboardNavigation, useMarkerColors, useMarkerStateResolvers, useMarkers, usePanel, usePanelPreload, usePreviewMap, useRoute, useShapeLabelHelpers, useShapes, useViewportContext };
|
|
12219
|
+
export { ClusterMarker, ClusterStick, Controls, DEFAULT_MARKER_SIZE_BREAKPOINTS, DefaultControls, Layers, MARKER_DARK_PILL, MARKER_DISC_BORDER_WIDTH_PX, MARKER_LIGHT_PILL, MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_SIZE_MAP, MARKER_TUNING, MapLoadingState, MapMarker, MapMarkerIcon, PanelIconButton, ShapeAnnotationLabel, buildExpandedIds, cvaMapMarker, cvaMarkerIndicator, mockMapApi, useAdaptiveMarkerHelpers, useAutoPanResolver, useCameraIdle, useCameraState, useClusterCountFormat, useControlStack, useControls, useDefaultControls, useDirectionIndicator, useEntitiesNearCursor, useExpandedIds, useFitFeatureBounds, useFitToContent, useImageOverlay, useLabelPlacement, useLayers, useMap, useMapAnnotation, useMapAnnotations, useMapAppearanceControls, useMapKeyboardNavigation, useMarkerColors, useMarkerStateResolvers, useMarkers, usePanel, usePanelPreload, usePreviewMap, useRoute, useShapeLabelHelpers, useShapes, useViewportContext };
|
package/package.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-map",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.126",
|
|
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.
|
|
11
|
-
"@trackunit/css-class-variance-utilities": "1.14.
|
|
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.0",
|
|
11
|
+
"@trackunit/css-class-variance-utilities": "1.14.29",
|
|
12
|
+
"@trackunit/react-form-components": "2.6.48",
|
|
13
|
+
"@trackunit/react-core-hooks": "1.21.41",
|
|
14
|
+
"@trackunit/geo-json-utils": "1.15.31",
|
|
15
|
+
"@trackunit/i18n-library-translation": "2.4.41",
|
|
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.112",
|
|
18
|
+
"@trackunit/react-map-color-utils": "0.0.92",
|
|
19
|
+
"@trackunit/ui-design-tokens": "1.15.19",
|
|
20
20
|
"@floating-ui/react": "^0.26.25",
|
|
21
21
|
"es-toolkit": "^1.39.10",
|
|
22
22
|
"tailwind-merge": "^2.0.0",
|
package/src/index.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export { useImageOverlay, type UseImageOverlayOptions, type UseImageOverlayRetur
|
|
|
53
53
|
export { buildExpandedIds, useExpandedIds, type MapFocus, type MapFocusTier, type MapFocusTierDisplay, } from "./layers/mapFocus";
|
|
54
54
|
export { useRoute, type UseRouteOptions, type UseRouteReturn } from "./layers/routes/useRoute";
|
|
55
55
|
export { useFitToContent, type FitToContentOptions, type FitToContentResult } from "./layers/useFitToContent";
|
|
56
|
+
export { useLabelPlacement, type UseLabelPlacementParams } from "./layers/useLabelPlacement";
|
|
56
57
|
export { useLayers, type UseLayersReturn } from "./layers/useLayers";
|
|
57
58
|
export { DEFAULT_MARKER_SIZE_BREAKPOINTS, MARKER_DISC_BORDER_WIDTH_PX, type MarkerSizeBreakpoint, type PickMarkerSizeOptions, type PickRenderMediumOptions, type PickSymbolDiameterOptions, } from "./layers/useMarkers/adaptiveHelpers";
|
|
58
59
|
export { useMarkers, type UseMarkersOptions, type UseMarkersReturn } from "./layers/useMarkers/useMarkers";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
|
|
2
|
+
import type { LabelFootprintDeg } from "./internal/labelFootprint";
|
|
3
|
+
type Position = readonly [number, number, ...Array<number>];
|
|
4
|
+
export type BuildLabelPlacementParams<TAsset> = Readonly<{
|
|
5
|
+
/** Candidate markers, in the order they should be considered for a label. */
|
|
6
|
+
items: ReadonlyArray<TAsset>;
|
|
7
|
+
getId: (item: TAsset) => string;
|
|
8
|
+
getPosition: (item: TAsset) => Position;
|
|
9
|
+
/** Current camera view box; candidates outside it are skipped. `null` disables clipping. */
|
|
10
|
+
bounds: Readonly<GeoJsonBbox> | null;
|
|
11
|
+
/** Nominal geo footprint every label is assumed to occupy, for collision testing. */
|
|
12
|
+
footprint: LabelFootprintDeg;
|
|
13
|
+
/** Maximum number of labels to place (DOM-node ceiling). */
|
|
14
|
+
ceiling: number;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* Chooses which markers render a label so that no two labels overlap.
|
|
18
|
+
*
|
|
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
|
+
*
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, bounds, footprint, ceiling, }: BuildLabelPlacementParams<TAsset>) => ReadonlySet<string>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type DegreesPerPixel = Readonly<{
|
|
2
|
+
lng: number;
|
|
3
|
+
lat: number;
|
|
4
|
+
}>;
|
|
5
|
+
/**
|
|
6
|
+
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
7
|
+
* near `latitudeDeg`, under Web Mercator.
|
|
8
|
+
*
|
|
9
|
+
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
10
|
+
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
11
|
+
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
12
|
+
* the same lng/lat space the marker positions already live in.
|
|
13
|
+
*/
|
|
14
|
+
export declare const degreesPerPixel: (zoom: number, latitudeDeg: number) => DegreesPerPixel;
|
|
15
|
+
export type LabelFootprintDeg = Readonly<{
|
|
16
|
+
widthDeg: number;
|
|
17
|
+
heightDeg: number;
|
|
18
|
+
}>;
|
|
19
|
+
export type LabelFootprintParams = Readonly<{
|
|
20
|
+
/** Current camera zoom. */
|
|
21
|
+
zoom: number;
|
|
22
|
+
/** Reference latitude (typically the viewport centre) for the lat-per-pixel scale. */
|
|
23
|
+
latitudeDeg: number;
|
|
24
|
+
/** Extra spacing added around every label — the density dial. */
|
|
25
|
+
paddingPx: number;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Upper-bound label footprint as a geo-space width/height, for collision testing.
|
|
29
|
+
*
|
|
30
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker`
|
|
31
|
+
* lays every pill out at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the
|
|
32
|
+
* label text is capped at `maxLabelWidthPx` (ellipsis beyond). We size the collision
|
|
33
|
+
* box to that upper bound — widest pill that can render — so the box is never smaller
|
|
34
|
+
* than what paints, which is what keeps the "no two labels overlap" guarantee honest.
|
|
35
|
+
* Density is tuned via `paddingPx`, not by shrinking the box.
|
|
36
|
+
*/
|
|
37
|
+
export declare const labelFootprintDeg: ({ zoom, latitudeDeg, paddingPx }: LabelFootprintParams) => LabelFootprintDeg;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
|
|
2
|
+
type Position = readonly [number, number, ...Array<number>];
|
|
3
|
+
export type UseLabelPlacementParams<TAsset> = Readonly<{
|
|
4
|
+
/** When false the hook returns a stable empty set without computing placement. */
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
/** Candidate markers, in the order they should be considered for a label. */
|
|
7
|
+
items: ReadonlyArray<TAsset>;
|
|
8
|
+
/** Stable id accessor — pass a referentially stable function (module const or `useCallback`). */
|
|
9
|
+
getId: (item: TAsset) => string;
|
|
10
|
+
/** Stable position accessor — pass a referentially stable function. */
|
|
11
|
+
getPosition: (item: TAsset) => Position;
|
|
12
|
+
/** Current camera view box; candidates outside it are skipped. `null` disables clipping. */
|
|
13
|
+
bounds: Readonly<GeoJsonBbox> | null;
|
|
14
|
+
/** Current camera zoom; drives the px→geo footprint scale. */
|
|
15
|
+
zoom: number;
|
|
16
|
+
/** Spacing added around every label footprint — the density dial. */
|
|
17
|
+
paddingPx: number;
|
|
18
|
+
/** Maximum number of labels to place (DOM-node ceiling). */
|
|
19
|
+
ceiling: number;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
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`.
|
|
24
|
+
*
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, bounds, zoom, paddingPx, ceiling, }: UseLabelPlacementParams<TAsset>) => ReadonlySet<string>;
|
|
31
|
+
export {};
|