@trackunit/react-map 0.2.187 → 0.2.190

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
@@ -6116,7 +6116,7 @@ const disperseSelect = (pool, count, seeds) => {
6116
6116
  * antimeridian is handled correctly. Deterministic: ties break by `getId` ascending, so the result is
6117
6117
  * independent of server return order. Returns a placement map keyed by id.
6118
6118
  */
6119
- const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }) => {
6119
+ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, clusterPositions, clusterFootprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }) => {
6120
6120
  const placements = new Map();
6121
6121
  const placed = [];
6122
6122
  // Reference longitude for unwrapping: the view-box centre when clipping, else the lowest-id
@@ -6149,6 +6149,18 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6149
6149
  : [unwrapLng(bounds[0], referenceLng), bounds[1], unwrapLng(bounds[2], referenceLng), bounds[3]];
6150
6150
  // Below this squared distance a point is inside every Tier-B footprint, so it always overlaps.
6151
6151
  const minFootprintSq = Math.min(footprint.widthDeg, footprint.heightDeg) ** 2;
6152
+ // ---- Seed cluster bubbles as hard obstacles (SAGA-736) ----
6153
+ // Pushed into `placed` before any label, so every collision test (Tier-A anchor, stick candidates,
6154
+ // Tier-B anchor) already avoids clusters — no per-tier special-casing needed. Seeded as given, with
6155
+ // no view-box clip: a cluster whose centre is just off-screen still shows its bubble on-screen and can
6156
+ // be overlapped by an in-view edge label (anchor labels are not view-box clipped), so clipping by
6157
+ // centre would reopen that gap. Cluster counts are small, and a genuinely far cluster is simply too
6158
+ // distant to overlap any label. Unwrapped around the same reference longitude as items.
6159
+ if (clusterPositions !== undefined && clusterFootprint !== undefined) {
6160
+ for (const position of clusterPositions) {
6161
+ placed.push({ point: [unwrapLng(position[0], referenceLng), position[1]], footprint: clusterFootprint });
6162
+ }
6163
+ }
6152
6164
  // ---- Tier A membership ----
6153
6165
  const mustShow = mustShowIds ?? EMPTY_IDS;
6154
6166
  // Must-show is guaranteed even if it matches no tier (e.g. a selected asset with null state).
@@ -6316,8 +6328,23 @@ const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6316
6328
  const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6317
6329
  return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6318
6330
  };
6331
+ /**
6332
+ * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6333
+ * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6334
+ * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6335
+ */
6336
+ const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6337
+ const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6338
+ return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6339
+ };
6319
6340
 
6320
6341
  const EMPTY_PLACEMENTS = new Map();
6342
+ /**
6343
+ * Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
6344
+ * covers up to a 3-digit count. Slightly over-reserving is the safe side for a hard "never cover a
6345
+ * cluster" keep-out. Tunable in the browser.
6346
+ */
6347
+ const CLUSTER_OBSTACLE_DIAMETER_PX = 44;
6321
6348
  const samePlacement = (a, b) => {
6322
6349
  if (a.mode === "anchor")
6323
6350
  return b.mode === "anchor";
@@ -6361,7 +6388,7 @@ const stickOffsetsFrom = (placements) => {
6361
6388
  * not on viewport change. Returns the previous map reference when content is unchanged so downstream
6362
6389
  * `useMemo`/callbacks stay stable. Same render-phase-setState pattern as `useExpandedIds`.
6363
6390
  */
6364
- const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, frozen, }) => {
6391
+ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, clusterPositions, frozen, }) => {
6365
6392
  const [snapshot, setSnapshot] = react.useState(() => ({
6366
6393
  focusId: focus.id,
6367
6394
  placements: EMPTY_PLACEMENTS,
@@ -6389,6 +6416,10 @@ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds,
6389
6416
  focus,
6390
6417
  bounds,
6391
6418
  footprint,
6419
+ clusterPositions,
6420
+ clusterFootprint: clusterPositions === undefined
6421
+ ? undefined
6422
+ : obstacleFootprintDeg({ zoom, latitudeDeg, diameterPx: CLUSTER_OBSTACLE_DIAMETER_PX }),
6392
6423
  degreesPerPixel: degreesPerPixel$1(zoom, latitudeDeg),
6393
6424
  ceiling,
6394
6425
  tierACount,
@@ -6411,6 +6442,7 @@ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds,
6411
6442
  ceiling,
6412
6443
  tierACount,
6413
6444
  mustShowIds,
6445
+ clusterPositions,
6414
6446
  prev,
6415
6447
  ]);
6416
6448
  // Return the previous reference when content is identical — keeps downstream caches stable.
package/index.esm.js CHANGED
@@ -6115,7 +6115,7 @@ const disperseSelect = (pool, count, seeds) => {
6115
6115
  * antimeridian is handled correctly. Deterministic: ties break by `getId` ascending, so the result is
6116
6116
  * independent of server return order. Returns a placement map keyed by id.
6117
6117
  */
6118
- const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }) => {
6118
+ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footprint, clusterPositions, clusterFootprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }) => {
6119
6119
  const placements = new Map();
6120
6120
  const placed = [];
6121
6121
  // Reference longitude for unwrapping: the view-box centre when clipping, else the lowest-id
@@ -6148,6 +6148,18 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6148
6148
  : [unwrapLng(bounds[0], referenceLng), bounds[1], unwrapLng(bounds[2], referenceLng), bounds[3]];
6149
6149
  // Below this squared distance a point is inside every Tier-B footprint, so it always overlaps.
6150
6150
  const minFootprintSq = Math.min(footprint.widthDeg, footprint.heightDeg) ** 2;
6151
+ // ---- Seed cluster bubbles as hard obstacles (SAGA-736) ----
6152
+ // Pushed into `placed` before any label, so every collision test (Tier-A anchor, stick candidates,
6153
+ // Tier-B anchor) already avoids clusters — no per-tier special-casing needed. Seeded as given, with
6154
+ // no view-box clip: a cluster whose centre is just off-screen still shows its bubble on-screen and can
6155
+ // be overlapped by an in-view edge label (anchor labels are not view-box clipped), so clipping by
6156
+ // centre would reopen that gap. Cluster counts are small, and a genuinely far cluster is simply too
6157
+ // distant to overlap any label. Unwrapped around the same reference longitude as items.
6158
+ if (clusterPositions !== undefined && clusterFootprint !== undefined) {
6159
+ for (const position of clusterPositions) {
6160
+ placed.push({ point: [unwrapLng(position[0], referenceLng), position[1]], footprint: clusterFootprint });
6161
+ }
6162
+ }
6151
6163
  // ---- Tier A membership ----
6152
6164
  const mustShow = mustShowIds ?? EMPTY_IDS;
6153
6165
  // Must-show is guaranteed even if it matches no tier (e.g. a selected asset with null state).
@@ -6315,8 +6327,23 @@ const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6315
6327
  const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6316
6328
  return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6317
6329
  };
6330
+ /**
6331
+ * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6332
+ * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6333
+ * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6334
+ */
6335
+ const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6336
+ const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6337
+ return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6338
+ };
6318
6339
 
6319
6340
  const EMPTY_PLACEMENTS = new Map();
6341
+ /**
6342
+ * Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
6343
+ * covers up to a 3-digit count. Slightly over-reserving is the safe side for a hard "never cover a
6344
+ * cluster" keep-out. Tunable in the browser.
6345
+ */
6346
+ const CLUSTER_OBSTACLE_DIAMETER_PX = 44;
6320
6347
  const samePlacement = (a, b) => {
6321
6348
  if (a.mode === "anchor")
6322
6349
  return b.mode === "anchor";
@@ -6360,7 +6387,7 @@ const stickOffsetsFrom = (placements) => {
6360
6387
  * not on viewport change. Returns the previous map reference when content is unchanged so downstream
6361
6388
  * `useMemo`/callbacks stay stable. Same render-phase-setState pattern as `useExpandedIds`.
6362
6389
  */
6363
- const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, frozen, }) => {
6390
+ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, clusterPositions, frozen, }) => {
6364
6391
  const [snapshot, setSnapshot] = useState(() => ({
6365
6392
  focusId: focus.id,
6366
6393
  placements: EMPTY_PLACEMENTS,
@@ -6388,6 +6415,10 @@ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds,
6388
6415
  focus,
6389
6416
  bounds,
6390
6417
  footprint,
6418
+ clusterPositions,
6419
+ clusterFootprint: clusterPositions === undefined
6420
+ ? undefined
6421
+ : obstacleFootprintDeg({ zoom, latitudeDeg, diameterPx: CLUSTER_OBSTACLE_DIAMETER_PX }),
6391
6422
  degreesPerPixel: degreesPerPixel$1(zoom, latitudeDeg),
6392
6423
  ceiling,
6393
6424
  tierACount,
@@ -6410,6 +6441,7 @@ const useLabelPlacement = ({ enabled, items, getId, getPosition, focus, bounds,
6410
6441
  ceiling,
6411
6442
  tierACount,
6412
6443
  mustShowIds,
6444
+ clusterPositions,
6413
6445
  prev,
6414
6446
  ]);
6415
6447
  // Return the previous reference when content is identical — keeps downstream caches stable.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.187",
3
+ "version": "0.2.190",
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.5.1",
11
- "@trackunit/css-class-variance-utilities": "2.2.1",
12
- "@trackunit/react-form-components": "2.9.1",
13
- "@trackunit/react-core-hooks": "1.24.1",
14
- "@trackunit/geo-json-utils": "1.17.1",
15
- "@trackunit/i18n-library-translation": "2.7.1",
10
+ "@trackunit/react-components": "3.5.2",
11
+ "@trackunit/css-class-variance-utilities": "2.2.2",
12
+ "@trackunit/react-form-components": "2.9.2",
13
+ "@trackunit/react-core-hooks": "1.24.2",
14
+ "@trackunit/geo-json-utils": "1.17.2",
15
+ "@trackunit/i18n-library-translation": "2.7.2",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
- "@trackunit/react-map-adapter-shared": "0.0.148",
18
- "@trackunit/react-map-color-utils": "0.0.123",
19
- "@trackunit/shared-utils": "1.18.1",
20
- "@trackunit/ui-design-tokens": "1.17.1",
17
+ "@trackunit/react-map-adapter-shared": "0.0.149",
18
+ "@trackunit/react-map-color-utils": "0.0.124",
19
+ "@trackunit/shared-utils": "1.18.2",
20
+ "@trackunit/ui-design-tokens": "1.17.2",
21
21
  "@floating-ui/react": "^0.26.25",
22
- "es-toolkit": "^1.39.10",
22
+ "es-toolkit": "^1.52.0",
23
23
  "tailwind-merge": "^2.0.0",
24
24
  "zod": "^3.25.76"
25
25
  },
@@ -21,6 +21,17 @@ export type BuildLabelPlacementParams<TAsset> = Readonly<{
21
21
  bounds: Readonly<GeoJsonBbox> | null;
22
22
  /** Geo footprint every label occupies for collision (may be narrowed for density). */
23
23
  footprint: LabelFootprintDeg;
24
+ /**
25
+ * Cluster bubble positions (raw lng/lat), seeded as **hard** obstacles so no label of any tier is
26
+ * placed over a cluster. Unwrapped like items and seeded as given — the caller passes the in-view /
27
+ * near-view set; not re-clipped here, so an edge cluster whose bubble is partly on-screen is still
28
+ * avoided (clipping by centre would let an in-view edge label cover it). Marker pins are deliberately
29
+ * NOT obstacles — routing labels around a dense pin field is self-defeating and not the industry norm
30
+ * (deck.gl collision groups / Maplex soft weight ignore point symbols); see SAGA-736.
31
+ */
32
+ clusterPositions?: ReadonlyArray<Position>;
33
+ /** Geo footprint each cluster obstacle occupies (bubble size). Required when `clusterPositions` is set. */
34
+ clusterFootprint?: LabelFootprintDeg;
24
35
  /** Degrees per screen pixel at the current zoom/latitude — maps the px stick ring into geo. */
25
36
  degreesPerPixel: DegreesPerPixel;
26
37
  /**
@@ -56,5 +67,5 @@ export type BuildLabelPlacementParams<TAsset> = Readonly<{
56
67
  * antimeridian is handled correctly. Deterministic: ties break by `getId` ascending, so the result is
57
68
  * independent of server return order. Returns a placement map keyed by id.
58
69
  */
59
- export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, focus, bounds, footprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }: BuildLabelPlacementParams<TAsset>) => ReadonlyMap<string, LabelPlacement>;
70
+ export declare const buildLabelPlacement: <TAsset>({ items, getId, getPosition, focus, bounds, footprint, clusterPositions, clusterFootprint, degreesPerPixel, ceiling, tierACount, mustShowIds, previousIds, previousStickOffsets, }: BuildLabelPlacementParams<TAsset>) => ReadonlyMap<string, LabelPlacement>;
60
71
  export {};
@@ -42,3 +42,13 @@ export type LabelFootprintParams = Readonly<{
42
42
  * is otherwise tuned via `paddingPx`.
43
43
  */
44
44
  export declare const labelFootprintDeg: ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }: LabelFootprintParams) => LabelFootprintDeg;
45
+ /**
46
+ * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
47
+ * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
48
+ * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
49
+ */
50
+ export declare const obstacleFootprintDeg: ({ zoom, latitudeDeg, diameterPx, }: Readonly<{
51
+ zoom: number;
52
+ latitudeDeg: number;
53
+ diameterPx: number;
54
+ }>) => LabelFootprintDeg;
@@ -27,6 +27,8 @@ export type UseLabelPlacementParams<TAsset> = Readonly<{
27
27
  tierACount: number;
28
28
  /** Always-guaranteed ids (selected/hovered/proximity) — Tier A regardless of priority rank. */
29
29
  mustShowIds?: ReadonlySet<string>;
30
+ /** Cluster bubble positions (raw lng/lat) — seeded as hard obstacles so labels never cover a cluster. */
31
+ clusterPositions?: ReadonlyArray<Position>;
30
32
  /**
31
33
  * When true, hold the last placement unchanged instead of recomputing — for use while a viewport
32
34
  * refetch is in flight, so labels resettle once (when data lands) rather than blinking as the
@@ -45,5 +47,5 @@ export type UseLabelPlacementParams<TAsset> = Readonly<{
45
47
  * not on viewport change. Returns the previous map reference when content is unchanged so downstream
46
48
  * `useMemo`/callbacks stay stable. Same render-phase-setState pattern as `useExpandedIds`.
47
49
  */
48
- export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, frozen, }: UseLabelPlacementParams<TAsset>) => ReadonlyMap<string, LabelPlacement>;
50
+ export declare const useLabelPlacement: <TAsset>({ enabled, items, getId, getPosition, focus, bounds, zoom, paddingPx, labelWidthPx, ceiling, tierACount, mustShowIds, clusterPositions, frozen, }: UseLabelPlacementParams<TAsset>) => ReadonlyMap<string, LabelPlacement>;
49
51
  export {};