@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 +438 -87
- package/index.esm.js +438 -87
- package/package.json +10 -10
- package/src/layers/buildLabelPlacement.d.ts +41 -16
- package/src/layers/internal/labelFootprint.d.ts +15 -8
- package/src/layers/internal/placementGeometry.d.ts +16 -0
- package/src/layers/internal/stickSolver.d.ts +62 -0
- package/src/layers/useLabelPlacement.d.ts +23 -10
- package/migrations/entry.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-map",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.167",
|
|
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": "3.1.
|
|
11
|
-
"@trackunit/css-class-variance-utilities": "2.0.
|
|
12
|
-
"@trackunit/react-form-components": "2.7.
|
|
13
|
-
"@trackunit/react-core-hooks": "1.22.
|
|
14
|
-
"@trackunit/geo-json-utils": "1.15.
|
|
15
|
-
"@trackunit/i18n-library-translation": "2.5.
|
|
10
|
+
"@trackunit/react-components": "3.1.5",
|
|
11
|
+
"@trackunit/css-class-variance-utilities": "2.0.18",
|
|
12
|
+
"@trackunit/react-form-components": "2.7.7",
|
|
13
|
+
"@trackunit/react-core-hooks": "1.22.3",
|
|
14
|
+
"@trackunit/geo-json-utils": "1.15.51",
|
|
15
|
+
"@trackunit/i18n-library-translation": "2.5.3",
|
|
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.136",
|
|
18
|
+
"@trackunit/react-map-color-utils": "0.0.111",
|
|
19
|
+
"@trackunit/ui-design-tokens": "1.15.39",
|
|
20
20
|
"@floating-ui/react": "^0.26.25",
|
|
21
21
|
"es-toolkit": "^1.39.10",
|
|
22
22
|
"tailwind-merge": "^2.0.0",
|
|
@@ -1,35 +1,60 @@
|
|
|
1
1
|
import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
|
|
2
|
-
import type {
|
|
2
|
+
import type { StickPositioning } from "../markers/model/markerDomTypes";
|
|
3
|
+
import type { DegreesPerPixel, LabelFootprintDeg } from "./internal/labelFootprint";
|
|
4
|
+
import { type StickOffset } from "./internal/stickSolver";
|
|
3
5
|
import type { MapFocus } from "./mapFocus";
|
|
4
6
|
type Position = readonly [number, number, ...Array<number>];
|
|
7
|
+
/** Where a chosen label renders: at its geo anchor, or displaced on a leader-line stick. */
|
|
8
|
+
export type LabelPlacement = Readonly<{
|
|
9
|
+
mode: "anchor";
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
mode: "stick";
|
|
12
|
+
stickPositioning: StickPositioning;
|
|
13
|
+
}>;
|
|
5
14
|
export type BuildLabelPlacementParams<TAsset> = Readonly<{
|
|
6
15
|
items: ReadonlyArray<TAsset>;
|
|
7
16
|
getId: (item: TAsset) => string;
|
|
8
17
|
getPosition: (item: TAsset) => Position;
|
|
9
|
-
/** Priority tiers, evaluated high→low; only tier-matching items are label-eligible. */
|
|
18
|
+
/** Priority tiers, evaluated high→low; only tier-matching items are label-eligible (Tier B). */
|
|
10
19
|
focus: MapFocus<TAsset>;
|
|
11
20
|
/** Current camera view box; candidates outside it are skipped. `null` disables clipping. */
|
|
12
21
|
bounds: Readonly<GeoJsonBbox> | null;
|
|
13
|
-
/** Geo footprint every label
|
|
22
|
+
/** Geo footprint every label occupies for collision (may be narrowed for density). */
|
|
14
23
|
footprint: LabelFootprintDeg;
|
|
15
|
-
/**
|
|
24
|
+
/** Degrees per screen pixel at the current zoom/latitude — maps the px stick ring into geo. */
|
|
25
|
+
degreesPerPixel: DegreesPerPixel;
|
|
26
|
+
/**
|
|
27
|
+
* Maximum number of labels to place (DOM-node ceiling), across both tiers. Tier A counts toward
|
|
28
|
+
* it, so the "never dropped" guarantee only holds while `tierACount + |in-view mustShowIds| ≤
|
|
29
|
+
* ceiling`; callers must keep the guaranteed set within the ceiling.
|
|
30
|
+
*/
|
|
16
31
|
ceiling: number;
|
|
17
|
-
/**
|
|
32
|
+
/** Size of the guaranteed set: the top-N by priority within the view box are never dropped. */
|
|
33
|
+
tierACount: number;
|
|
34
|
+
/** Always-guaranteed ids (e.g. the selected asset) — Tier A even if they match no tier. */
|
|
35
|
+
mustShowIds?: ReadonlySet<string>;
|
|
36
|
+
/** Previously-shown ids — kept preferentially within their Tier-B tier (hysteresis). */
|
|
18
37
|
previousIds?: ReadonlySet<string>;
|
|
38
|
+
/** Previously-chosen stick offsets by id — reused so a displaced label does not swing. */
|
|
39
|
+
previousStickOffsets?: ReadonlyMap<string, StickOffset>;
|
|
19
40
|
}>;
|
|
20
41
|
/**
|
|
21
|
-
* Chooses which markers render a label
|
|
42
|
+
* Chooses which markers render a label, and how, in priority order.
|
|
43
|
+
*
|
|
44
|
+
* **Tier A (guaranteed):** every in-view `mustShowIds` marker (regardless of tier), plus the
|
|
45
|
+
* top-`tierACount` eligible markers by priority — the latter chosen high→low tier then, within a
|
|
46
|
+
* tier, by spatial spread so one cluster cannot consume the whole guarantee. None are dropped: each
|
|
47
|
+
* takes its anchor if collision-free, else a candidate-ring solver finds a leader-line **stick**
|
|
48
|
+
* offset (kept on-canvas), else it stays at the anchor overlapping. Tier A uses the same `footprint`
|
|
49
|
+
* as Tier B, so a guaranteed label may overlap a denser neighbour a little (accepted trade-off).
|
|
22
50
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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).
|
|
51
|
+
* **Tier B (collision-fill):** the remaining eligible markers, walked high→low tier, placed at their
|
|
52
|
+
* anchor only if collision-free with the (possibly narrower) `footprint`, most-dispersed-first, until
|
|
53
|
+
* the `ceiling` is hit. Incumbents (`previousIds`) are preferred within their tier (hysteresis).
|
|
29
54
|
*
|
|
30
|
-
* All geometry runs in longitudes unwrapped around the view-box centre, so a viewport crossing
|
|
31
|
-
*
|
|
32
|
-
*
|
|
55
|
+
* All geometry runs in longitudes unwrapped around the view-box centre, so a viewport crossing the
|
|
56
|
+
* antimeridian is handled correctly. Deterministic: ties break by `getId` ascending, so the result is
|
|
57
|
+
* independent of server return order. Returns a placement map keyed by id.
|
|
33
58
|
*/
|
|
34
|
-
export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, focus, bounds, footprint, ceiling, previousIds, }: BuildLabelPlacementParams<TAsset>) =>
|
|
59
|
+
export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, focus, bounds, footprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }: BuildLabelPlacementParams<TAsset>) => ReadonlyMap<string, LabelPlacement>;
|
|
35
60
|
export {};
|
|
@@ -23,15 +23,22 @@ export type LabelFootprintParams = Readonly<{
|
|
|
23
23
|
latitudeDeg: number;
|
|
24
24
|
/** Extra spacing added around every label — the density dial. */
|
|
25
25
|
paddingPx: number;
|
|
26
|
+
/**
|
|
27
|
+
* Assumed label-text width in px for the collision box. Defaults to the render cap
|
|
28
|
+
* `MARKER_TUNING.pill.maxLabelWidthPx`, which makes the box a true upper bound so no two labels
|
|
29
|
+
* ever overlap. A smaller value packs labels denser but lets a name wider than the assumption
|
|
30
|
+
* overlap its neighbour — a deliberate density/overlap trade-off, tuned in the browser.
|
|
31
|
+
*/
|
|
32
|
+
labelWidthPx?: number;
|
|
26
33
|
}>;
|
|
27
34
|
/**
|
|
28
|
-
*
|
|
35
|
+
* Label footprint as a geo-space width/height, for collision testing.
|
|
29
36
|
*
|
|
30
|
-
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker`
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
37
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
|
|
38
|
+
* at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
|
|
39
|
+
* `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
|
|
40
|
+
* that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
|
|
41
|
+
* a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
|
|
42
|
+
* is otherwise tuned via `paddingPx`.
|
|
36
43
|
*/
|
|
37
|
-
export declare const labelFootprintDeg: ({ zoom, latitudeDeg, paddingPx }: LabelFootprintParams) => LabelFootprintDeg;
|
|
44
|
+
export declare const labelFootprintDeg: ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }: LabelFootprintParams) => LabelFootprintDeg;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LabelFootprintDeg } from "./labelFootprint";
|
|
2
|
+
/** An unwrapped lng/lat point used by label placement (longitudes made continuous per viewport). */
|
|
3
|
+
export type PlanarPoint = readonly [number, number];
|
|
4
|
+
/** A placed label: its centre plus the footprint it occupies (Tier A and Tier B use different sizes). */
|
|
5
|
+
export type PlacedLabel = Readonly<{
|
|
6
|
+
point: PlanarPoint;
|
|
7
|
+
footprint: LabelFootprintDeg;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
10
|
+
* Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
|
|
11
|
+
* overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
|
|
12
|
+
* placement core and the stick solver so Tier A and Tier B collide by exactly the same rule.
|
|
13
|
+
*/
|
|
14
|
+
export declare const overlaps: (a: PlanarPoint, aFootprint: LabelFootprintDeg, b: PlanarPoint, bFootprint: LabelFootprintDeg) => boolean;
|
|
15
|
+
/** Squared euclidean distance between two planar points. */
|
|
16
|
+
export declare const sqDistance: (a: PlanarPoint, b: PlanarPoint) => number;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { StickPositioning } from "../../markers/model/markerDomTypes";
|
|
2
|
+
import type { DegreesPerPixel, LabelFootprintDeg } from "./labelFootprint";
|
|
3
|
+
import { type PlacedLabel, type PlanarPoint } from "./placementGeometry";
|
|
4
|
+
type PolarStick = Extract<StickPositioning, {
|
|
5
|
+
type: "polar";
|
|
6
|
+
}>;
|
|
7
|
+
/** Polar stick offset in screen pixels — the incumbency hint and the solver's own output shape. */
|
|
8
|
+
export type StickOffset = Readonly<{
|
|
9
|
+
angleRad: number;
|
|
10
|
+
distancePx: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** A chosen stick: the polar positioning to render, plus the rendered pill centre to reserve. */
|
|
13
|
+
export type StickSolution = Readonly<{
|
|
14
|
+
positioning: PolarStick;
|
|
15
|
+
pillCentre: PlanarPoint;
|
|
16
|
+
}>;
|
|
17
|
+
export type StickSolverParams = Readonly<{
|
|
18
|
+
/** Asset anchor as an unwrapped lng/lat point (same planar space as `placed`). */
|
|
19
|
+
anchor: PlanarPoint;
|
|
20
|
+
/** Already-placed labels (unwrapped lng/lat + footprint) to avoid. */
|
|
21
|
+
placed: ReadonlyArray<PlacedLabel>;
|
|
22
|
+
/** Geo footprint of the displaced label (same box used for anchor collisions). */
|
|
23
|
+
footprint: LabelFootprintDeg;
|
|
24
|
+
/** Degrees spanned per screen pixel at the current zoom/latitude — converts the px ring to geo. */
|
|
25
|
+
degreesPerPixel: DegreesPerPixel;
|
|
26
|
+
/**
|
|
27
|
+
* Planar view box `[minLng, minLat, maxLng, maxLat]` (unwrapped). When given, a candidate whose
|
|
28
|
+
* displaced pill would fall outside it (inset by half the pill) is rejected, so a guaranteed label
|
|
29
|
+
* is not pushed off-canvas and clipped. `undefined` disables the check.
|
|
30
|
+
*/
|
|
31
|
+
viewBox?: readonly [number, number, number, number];
|
|
32
|
+
/** Innermost ring distance in px (typically `MARKER_TUNING.stick.defaultPositioning.distance`). */
|
|
33
|
+
baseDistancePx: number;
|
|
34
|
+
/** Radius increment per ring in px. */
|
|
35
|
+
radiusStepPx: number;
|
|
36
|
+
/** Number of rings tried outward from the base distance. */
|
|
37
|
+
radiusSteps: number;
|
|
38
|
+
/** Candidate angles per ring (evenly spaced, starting straight up). */
|
|
39
|
+
anglesPerRing: number;
|
|
40
|
+
/** Previously-chosen offset for this asset — tried first so a stable label does not swing. */
|
|
41
|
+
previousOffset?: StickOffset;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Finds a stick offset for a guaranteed (Tier-A) label whose anchor is taken.
|
|
45
|
+
*
|
|
46
|
+
* Searches a candidate ring — `anglesPerRing` evenly-spaced angles, at increasing radii from
|
|
47
|
+
* `baseDistancePx` outward (`radiusSteps` rings). At the shortest ring that has room it aims the
|
|
48
|
+
* stick into the **most open direction** — the footprint-free angle whose displaced label is
|
|
49
|
+
* farthest from every other label — preferring angles whose leader line does not cross a label, and
|
|
50
|
+
* breaking ties straight-up-first for determinism. Candidates whose pill would fall outside `viewBox`
|
|
51
|
+
* are rejected so a guaranteed label is not pushed off-canvas. If a ring offers only line-crossing
|
|
52
|
+
* angles it keeps looking outward for a clean one, else falls back to the most-open crossing angle of
|
|
53
|
+
* the innermost ring that had room; if every candidate is rejected it returns `undefined` (the caller
|
|
54
|
+
* then keeps the label at its anchor — never dropped). `previousOffset` is reused only while it stays
|
|
55
|
+
* footprint-free, on-canvas and line-clear, so a clean stick keeps its direction between settles.
|
|
56
|
+
*
|
|
57
|
+
* Geometry matches the renderer: the collision/reserved point is the **rendered pill centre**
|
|
58
|
+
* (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
|
|
59
|
+
* that tip.
|
|
60
|
+
*/
|
|
61
|
+
export declare const findStickOffset: ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }: StickSolverParams) => StickSolution | undefined;
|
|
62
|
+
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
|
|
2
|
+
import { type LabelPlacement } from "./buildLabelPlacement";
|
|
2
3
|
import type { MapFocus } from "./mapFocus";
|
|
3
4
|
type Position = readonly [number, number, ...Array<number>];
|
|
4
5
|
export type UseLabelPlacementParams<TAsset> = Readonly<{
|
|
5
|
-
/** When false the hook returns a stable empty
|
|
6
|
+
/** When false the hook returns a stable empty map and clears incumbency memory. */
|
|
6
7
|
enabled: boolean;
|
|
7
8
|
/** Candidate markers; only those matching a `focus` tier are label-eligible. */
|
|
8
9
|
items: ReadonlyArray<TAsset>;
|
|
@@ -18,19 +19,31 @@ export type UseLabelPlacementParams<TAsset> = Readonly<{
|
|
|
18
19
|
zoom: number;
|
|
19
20
|
/** Spacing added around every label footprint — the density dial. */
|
|
20
21
|
paddingPx: number;
|
|
22
|
+
/** Assumed label-text width for the footprint; defaults to the render cap. Smaller = denser. */
|
|
23
|
+
labelWidthPx?: number;
|
|
21
24
|
/** Maximum number of labels to place (DOM-node ceiling). */
|
|
22
25
|
ceiling: number;
|
|
26
|
+
/** Guaranteed set size: the top-N by priority within the view box are never dropped (Tier A). */
|
|
27
|
+
tierACount: number;
|
|
28
|
+
/** Always-guaranteed ids (selected/hovered/proximity) — Tier A regardless of priority rank. */
|
|
29
|
+
mustShowIds?: ReadonlySet<string>;
|
|
30
|
+
/**
|
|
31
|
+
* When true, hold the last placement unchanged instead of recomputing — for use while a viewport
|
|
32
|
+
* refetch is in flight, so labels resettle once (when data lands) rather than blinking as the
|
|
33
|
+
* view box moves ahead of the data. Does not clear incumbency memory (unlike `enabled: false`).
|
|
34
|
+
*/
|
|
35
|
+
frozen?: boolean;
|
|
23
36
|
}>;
|
|
24
37
|
/**
|
|
25
|
-
* Viewport-driven label placement with hysteresis: returns
|
|
26
|
-
*
|
|
38
|
+
* Viewport-driven label placement with hysteresis: returns a map of the markers that should render
|
|
39
|
+
* a label to how they render it (`anchor` or a displaced `stick`), no two overlapping, in priority
|
|
40
|
+
* order, bounded by `ceiling`.
|
|
27
41
|
*
|
|
28
|
-
* Stateful wrapper around the pure {@link buildLabelPlacement} — feeds the previous result back
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* not on viewport change. Returns the previous
|
|
32
|
-
*
|
|
33
|
-
* `useExpandedIds`.
|
|
42
|
+
* Stateful wrapper around the pure {@link buildLabelPlacement} — feeds the previous result back as
|
|
43
|
+
* `previousIds` (so Tier-B labels stay shown across pan/zoom refetches) and as `previousStickOffsets`
|
|
44
|
+
* (so a displaced Tier-A label keeps its stick direction). Memory resets when `focus.id` changes,
|
|
45
|
+
* not on viewport change. Returns the previous map reference when content is unchanged so downstream
|
|
46
|
+
* `useMemo`/callbacks stay stable. Same render-phase-setState pattern as `useExpandedIds`.
|
|
34
47
|
*/
|
|
35
|
-
export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, ceiling, }: UseLabelPlacementParams<TAsset>) =>
|
|
48
|
+
export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, frozen, }: UseLabelPlacementParams<TAsset>) => ReadonlyMap<string, LabelPlacement>;
|
|
36
49
|
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"]}
|