@trackunit/react-map 0.2.205 → 0.2.207-alpha-775ca1816d6.0

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
@@ -5994,6 +5994,65 @@ const useHeatmap = (options) => {
5994
5994
  ]);
5995
5995
  };
5996
5996
 
5997
+ const WEB_MERCATOR_TILE_SIZE_PX = 256;
5998
+ /**
5999
+ * Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
6000
+ * near `latitudeDeg`, under Web Mercator.
6001
+ *
6002
+ * Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
6003
+ * scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
6004
+ * convert a pixel label footprint into a geo bounding box so collision runs in
6005
+ * the same lng/lat space the marker positions already live in.
6006
+ */
6007
+ const degreesPerPixel$1 = (zoom, latitudeDeg) => {
6008
+ const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
6009
+ const lng = 360 / worldWidthPx;
6010
+ return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
6011
+ };
6012
+ /**
6013
+ * Label footprint as a geo-space width/height, for collision testing.
6014
+ *
6015
+ * A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
6016
+ * at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
6017
+ * `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
6018
+ * that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
6019
+ * a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
6020
+ * is otherwise tuned via `paddingPx`.
6021
+ */
6022
+ const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6023
+ const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
6024
+ const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
6025
+ // Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
6026
+ const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
6027
+ // Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
6028
+ // present (the wider case).
6029
+ const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
6030
+ const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
6031
+ const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6032
+ return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6033
+ };
6034
+ /**
6035
+ * Physical pill dimensions in px, **as the renderer paints them** (unpadded) — the full envelope at
6036
+ * the render cap, matching `MapMarkerStickSurface` (`computeFullPillEnvelopeWidth` + the fixed md pill
6037
+ * height). The stick solver uses these for tip / pill-centre geometry, kept distinct from the padded
6038
+ * collision footprint (`labelFootprintDeg`, which adds `2 * paddingPx`) used only for keep-out — so the
6039
+ * reserved pill centre lands where the pill actually paints. Assumes the icon disc is present (the wider
6040
+ * case) and no direction indicator, mirroring `labelFootprintDeg`'s assumptions. Zoom-independent.
6041
+ */
6042
+ const PHYSICAL_PILL_PX = {
6043
+ widthPx: computeFullPillEnvelopeWidth(MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_TUNING.pill.maxLabelWidthPx, true, false),
6044
+ heightPx: markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE),
6045
+ };
6046
+ /**
6047
+ * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6048
+ * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6049
+ * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6050
+ */
6051
+ const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6052
+ const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6053
+ return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6054
+ };
6055
+
5997
6056
  /**
5998
6057
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
5999
6058
  * overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
@@ -6013,6 +6072,8 @@ const UP_ANGLE_RAD = -Math.PI / 2;
6013
6072
  /** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
6014
6073
  const ANCHOR_DOT_RADIUS_PX = 5;
6015
6074
  const collidesWithPlaced = (point, footprint, placed) => placed.some(other => overlaps(point, footprint, other.point, other.footprint));
6075
+ /** Like {@link collidesWithPlaced} but only against cluster obstacles — the relaxed fallback's hard set. */
6076
+ const collidesWithClusters = (point, footprint, placed) => placed.some(other => other.isCluster === true && overlaps(point, footprint, other.point, other.footprint));
6016
6077
  /** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
6017
6078
  const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
6018
6079
  const dx = b[0] - a[0];
@@ -6079,13 +6140,16 @@ const clearanceSq = (point, placed) => {
6079
6140
  * (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
6080
6141
  * that tip.
6081
6142
  */
6082
- const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
6083
- // Recover the pill's px dimensions from the geo footprint (inverse of `labelFootprintDeg`), so the
6084
- // pill-centre geometry matches what the renderer paints.
6085
- const pillWidthPx = footprint.widthDeg / degreesPerPixel.lng;
6086
- const pillHeightPx = footprint.heightDeg / degreesPerPixel.lat;
6087
- const halfWidthDeg = footprint.widthDeg / 2;
6088
- const halfHeightDeg = footprint.heightDeg / 2;
6143
+ const findStickOffset = ({ anchor, placed, footprint, pillWidthPx, pillHeightPx, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, clusterClearFallback, }) => {
6144
+ // Geometry uses the physical (as-painted) pill size; the view-box clamp insets by its half-extents,
6145
+ // so a pill that visually fits is not rejected. The padded `footprint` is used only for keep-out
6146
+ // (`collidesWithPlaced`), not here — that separation is the point of SAGA-855.
6147
+ // Known limitation: the reserved keep-out box (the narrower `footprint`) is centred on the physical
6148
+ // pill centre, so for a lateral stick it no longer covers the pill's near (disc) end by a few px — a
6149
+ // later label can clip that strip. The proper fix is the asymmetric "protect the disc" keep-out
6150
+ // tracked in SAGA-873; accepted as-is here (small, disc-side).
6151
+ const halfWidthDeg = (pillWidthPx * degreesPerPixel.lng) / 2;
6152
+ const halfHeightDeg = (pillHeightPx * degreesPerPixel.lat) / 2;
6089
6153
  const outsideViewBox = (point) => viewBox !== undefined &&
6090
6154
  (point[0] < viewBox[0] + halfWidthDeg ||
6091
6155
  point[0] > viewBox[2] - halfWidthDeg ||
@@ -6158,7 +6222,36 @@ const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox,
6158
6222
  };
6159
6223
  }
6160
6224
  }
6161
- return fallback;
6225
+ if (fallback !== undefined)
6226
+ return fallback;
6227
+ if (clusterClearFallback !== true)
6228
+ return undefined;
6229
+ let bestOnCanvas;
6230
+ let bestOffCanvas;
6231
+ for (let ring = 0; ring < radiusSteps; ring++) {
6232
+ const distance = baseDistancePx + ring * radiusStepPx;
6233
+ for (let a = 0; a < anglesPerRing; a++) {
6234
+ const angleRad = UP_ANGLE_RAD + a * angleStep;
6235
+ const { pillCentre } = geometryFor(angleRad, distance);
6236
+ if (collidesWithClusters(pillCentre, footprint, placed))
6237
+ continue;
6238
+ const clearance = clearanceSq(pillCentre, placed);
6239
+ if (outsideViewBox(pillCentre)) {
6240
+ if (beats(clearance, bestOffCanvas))
6241
+ bestOffCanvas = { angleRad, distance, pillCentre, clearance };
6242
+ }
6243
+ else if (beats(clearance, bestOnCanvas)) {
6244
+ bestOnCanvas = { angleRad, distance, pillCentre, clearance };
6245
+ }
6246
+ }
6247
+ }
6248
+ const chosen = bestOnCanvas ?? bestOffCanvas;
6249
+ return chosen === undefined
6250
+ ? undefined
6251
+ : {
6252
+ positioning: { type: "polar", angleRad: chosen.angleRad, distance: chosen.distance },
6253
+ pillCentre: chosen.pillCentre,
6254
+ };
6162
6255
  };
6163
6256
 
6164
6257
  const EMPTY_IDS = new Set();
@@ -6300,7 +6393,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6300
6393
  // distant to overlap any label. Unwrapped around the same reference longitude as items.
6301
6394
  if (clusterPositions !== undefined && clusterFootprint !== undefined) {
6302
6395
  for (const position of clusterPositions) {
6303
- placed.push({ point: [unwrapLng(position[0], referenceLng), position[1]], footprint: clusterFootprint });
6396
+ placed.push({
6397
+ point: [unwrapLng(position[0], referenceLng), position[1]],
6398
+ footprint: clusterFootprint,
6399
+ isCluster: true,
6400
+ });
6304
6401
  }
6305
6402
  }
6306
6403
  // ---- Tier A membership ----
@@ -6342,10 +6439,15 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6342
6439
  placed.push({ point: candidate.point, footprint });
6343
6440
  continue;
6344
6441
  }
6442
+ // If the anchor sits on a cluster, allow the solver's relaxed fallback to displace off it (clearing
6443
+ // clusters, overlapping labels if need be) rather than leave a guaranteed label covering a cluster.
6444
+ const anchorOnCluster = placed.some(other => other.isCluster === true && overlaps(other.point, other.footprint, candidate.point, footprint));
6345
6445
  const stick = findStickOffset({
6346
6446
  anchor: candidate.point,
6347
6447
  placed,
6348
6448
  footprint,
6449
+ pillWidthPx: PHYSICAL_PILL_PX.widthPx,
6450
+ pillHeightPx: PHYSICAL_PILL_PX.heightPx,
6349
6451
  degreesPerPixel,
6350
6452
  viewBox: planarViewBox,
6351
6453
  baseDistancePx: STICK_BASE_DISTANCE_PX,
@@ -6353,6 +6455,7 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6353
6455
  radiusSteps: STICK_RADIUS_STEPS,
6354
6456
  anglesPerRing: STICK_ANGLES_PER_RING,
6355
6457
  previousOffset: previousStickOffsets?.get(candidate.id),
6458
+ clusterClearFallback: anchorOnCluster,
6356
6459
  });
6357
6460
  if (stick !== undefined) {
6358
6461
  placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
@@ -6433,53 +6536,6 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6433
6536
  return placements;
6434
6537
  };
6435
6538
 
6436
- const WEB_MERCATOR_TILE_SIZE_PX = 256;
6437
- /**
6438
- * Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
6439
- * near `latitudeDeg`, under Web Mercator.
6440
- *
6441
- * Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
6442
- * scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
6443
- * convert a pixel label footprint into a geo bounding box so collision runs in
6444
- * the same lng/lat space the marker positions already live in.
6445
- */
6446
- const degreesPerPixel$1 = (zoom, latitudeDeg) => {
6447
- const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
6448
- const lng = 360 / worldWidthPx;
6449
- return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
6450
- };
6451
- /**
6452
- * Label footprint as a geo-space width/height, for collision testing.
6453
- *
6454
- * A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
6455
- * at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
6456
- * `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
6457
- * that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
6458
- * a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
6459
- * is otherwise tuned via `paddingPx`.
6460
- */
6461
- const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6462
- const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
6463
- const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
6464
- // Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
6465
- const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
6466
- // Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
6467
- // present (the wider case).
6468
- const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
6469
- const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
6470
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6471
- return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6472
- };
6473
- /**
6474
- * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6475
- * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6476
- * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6477
- */
6478
- const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6479
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6480
- return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6481
- };
6482
-
6483
6539
  const EMPTY_PLACEMENTS = new Map();
6484
6540
  /**
6485
6541
  * Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
package/index.esm.js CHANGED
@@ -5993,6 +5993,65 @@ const useHeatmap = (options) => {
5993
5993
  ]);
5994
5994
  };
5995
5995
 
5996
+ const WEB_MERCATOR_TILE_SIZE_PX = 256;
5997
+ /**
5998
+ * Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
5999
+ * near `latitudeDeg`, under Web Mercator.
6000
+ *
6001
+ * Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
6002
+ * scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
6003
+ * convert a pixel label footprint into a geo bounding box so collision runs in
6004
+ * the same lng/lat space the marker positions already live in.
6005
+ */
6006
+ const degreesPerPixel$1 = (zoom, latitudeDeg) => {
6007
+ const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
6008
+ const lng = 360 / worldWidthPx;
6009
+ return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
6010
+ };
6011
+ /**
6012
+ * Label footprint as a geo-space width/height, for collision testing.
6013
+ *
6014
+ * A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
6015
+ * at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
6016
+ * `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
6017
+ * that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
6018
+ * a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
6019
+ * is otherwise tuned via `paddingPx`.
6020
+ */
6021
+ const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6022
+ const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
6023
+ const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
6024
+ // Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
6025
+ const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
6026
+ // Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
6027
+ // present (the wider case).
6028
+ const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
6029
+ const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
6030
+ const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6031
+ return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6032
+ };
6033
+ /**
6034
+ * Physical pill dimensions in px, **as the renderer paints them** (unpadded) — the full envelope at
6035
+ * the render cap, matching `MapMarkerStickSurface` (`computeFullPillEnvelopeWidth` + the fixed md pill
6036
+ * height). The stick solver uses these for tip / pill-centre geometry, kept distinct from the padded
6037
+ * collision footprint (`labelFootprintDeg`, which adds `2 * paddingPx`) used only for keep-out — so the
6038
+ * reserved pill centre lands where the pill actually paints. Assumes the icon disc is present (the wider
6039
+ * case) and no direction indicator, mirroring `labelFootprintDeg`'s assumptions. Zoom-independent.
6040
+ */
6041
+ const PHYSICAL_PILL_PX = {
6042
+ widthPx: computeFullPillEnvelopeWidth(MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_TUNING.pill.maxLabelWidthPx, true, false),
6043
+ heightPx: markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE),
6044
+ };
6045
+ /**
6046
+ * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6047
+ * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6048
+ * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6049
+ */
6050
+ const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6051
+ const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6052
+ return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6053
+ };
6054
+
5996
6055
  /**
5997
6056
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
5998
6057
  * overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
@@ -6012,6 +6071,8 @@ const UP_ANGLE_RAD = -Math.PI / 2;
6012
6071
  /** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
6013
6072
  const ANCHOR_DOT_RADIUS_PX = 5;
6014
6073
  const collidesWithPlaced = (point, footprint, placed) => placed.some(other => overlaps(point, footprint, other.point, other.footprint));
6074
+ /** Like {@link collidesWithPlaced} but only against cluster obstacles — the relaxed fallback's hard set. */
6075
+ const collidesWithClusters = (point, footprint, placed) => placed.some(other => other.isCluster === true && overlaps(point, footprint, other.point, other.footprint));
6015
6076
  /** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
6016
6077
  const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
6017
6078
  const dx = b[0] - a[0];
@@ -6078,13 +6139,16 @@ const clearanceSq = (point, placed) => {
6078
6139
  * (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
6079
6140
  * that tip.
6080
6141
  */
6081
- const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
6082
- // Recover the pill's px dimensions from the geo footprint (inverse of `labelFootprintDeg`), so the
6083
- // pill-centre geometry matches what the renderer paints.
6084
- const pillWidthPx = footprint.widthDeg / degreesPerPixel.lng;
6085
- const pillHeightPx = footprint.heightDeg / degreesPerPixel.lat;
6086
- const halfWidthDeg = footprint.widthDeg / 2;
6087
- const halfHeightDeg = footprint.heightDeg / 2;
6142
+ const findStickOffset = ({ anchor, placed, footprint, pillWidthPx, pillHeightPx, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, clusterClearFallback, }) => {
6143
+ // Geometry uses the physical (as-painted) pill size; the view-box clamp insets by its half-extents,
6144
+ // so a pill that visually fits is not rejected. The padded `footprint` is used only for keep-out
6145
+ // (`collidesWithPlaced`), not here — that separation is the point of SAGA-855.
6146
+ // Known limitation: the reserved keep-out box (the narrower `footprint`) is centred on the physical
6147
+ // pill centre, so for a lateral stick it no longer covers the pill's near (disc) end by a few px — a
6148
+ // later label can clip that strip. The proper fix is the asymmetric "protect the disc" keep-out
6149
+ // tracked in SAGA-873; accepted as-is here (small, disc-side).
6150
+ const halfWidthDeg = (pillWidthPx * degreesPerPixel.lng) / 2;
6151
+ const halfHeightDeg = (pillHeightPx * degreesPerPixel.lat) / 2;
6088
6152
  const outsideViewBox = (point) => viewBox !== undefined &&
6089
6153
  (point[0] < viewBox[0] + halfWidthDeg ||
6090
6154
  point[0] > viewBox[2] - halfWidthDeg ||
@@ -6157,7 +6221,36 @@ const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox,
6157
6221
  };
6158
6222
  }
6159
6223
  }
6160
- return fallback;
6224
+ if (fallback !== undefined)
6225
+ return fallback;
6226
+ if (clusterClearFallback !== true)
6227
+ return undefined;
6228
+ let bestOnCanvas;
6229
+ let bestOffCanvas;
6230
+ for (let ring = 0; ring < radiusSteps; ring++) {
6231
+ const distance = baseDistancePx + ring * radiusStepPx;
6232
+ for (let a = 0; a < anglesPerRing; a++) {
6233
+ const angleRad = UP_ANGLE_RAD + a * angleStep;
6234
+ const { pillCentre } = geometryFor(angleRad, distance);
6235
+ if (collidesWithClusters(pillCentre, footprint, placed))
6236
+ continue;
6237
+ const clearance = clearanceSq(pillCentre, placed);
6238
+ if (outsideViewBox(pillCentre)) {
6239
+ if (beats(clearance, bestOffCanvas))
6240
+ bestOffCanvas = { angleRad, distance, pillCentre, clearance };
6241
+ }
6242
+ else if (beats(clearance, bestOnCanvas)) {
6243
+ bestOnCanvas = { angleRad, distance, pillCentre, clearance };
6244
+ }
6245
+ }
6246
+ }
6247
+ const chosen = bestOnCanvas ?? bestOffCanvas;
6248
+ return chosen === undefined
6249
+ ? undefined
6250
+ : {
6251
+ positioning: { type: "polar", angleRad: chosen.angleRad, distance: chosen.distance },
6252
+ pillCentre: chosen.pillCentre,
6253
+ };
6161
6254
  };
6162
6255
 
6163
6256
  const EMPTY_IDS = new Set();
@@ -6299,7 +6392,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6299
6392
  // distant to overlap any label. Unwrapped around the same reference longitude as items.
6300
6393
  if (clusterPositions !== undefined && clusterFootprint !== undefined) {
6301
6394
  for (const position of clusterPositions) {
6302
- placed.push({ point: [unwrapLng(position[0], referenceLng), position[1]], footprint: clusterFootprint });
6395
+ placed.push({
6396
+ point: [unwrapLng(position[0], referenceLng), position[1]],
6397
+ footprint: clusterFootprint,
6398
+ isCluster: true,
6399
+ });
6303
6400
  }
6304
6401
  }
6305
6402
  // ---- Tier A membership ----
@@ -6341,10 +6438,15 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6341
6438
  placed.push({ point: candidate.point, footprint });
6342
6439
  continue;
6343
6440
  }
6441
+ // If the anchor sits on a cluster, allow the solver's relaxed fallback to displace off it (clearing
6442
+ // clusters, overlapping labels if need be) rather than leave a guaranteed label covering a cluster.
6443
+ const anchorOnCluster = placed.some(other => other.isCluster === true && overlaps(other.point, other.footprint, candidate.point, footprint));
6344
6444
  const stick = findStickOffset({
6345
6445
  anchor: candidate.point,
6346
6446
  placed,
6347
6447
  footprint,
6448
+ pillWidthPx: PHYSICAL_PILL_PX.widthPx,
6449
+ pillHeightPx: PHYSICAL_PILL_PX.heightPx,
6348
6450
  degreesPerPixel,
6349
6451
  viewBox: planarViewBox,
6350
6452
  baseDistancePx: STICK_BASE_DISTANCE_PX,
@@ -6352,6 +6454,7 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6352
6454
  radiusSteps: STICK_RADIUS_STEPS,
6353
6455
  anglesPerRing: STICK_ANGLES_PER_RING,
6354
6456
  previousOffset: previousStickOffsets?.get(candidate.id),
6457
+ clusterClearFallback: anchorOnCluster,
6355
6458
  });
6356
6459
  if (stick !== undefined) {
6357
6460
  placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
@@ -6432,53 +6535,6 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6432
6535
  return placements;
6433
6536
  };
6434
6537
 
6435
- const WEB_MERCATOR_TILE_SIZE_PX = 256;
6436
- /**
6437
- * Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
6438
- * near `latitudeDeg`, under Web Mercator.
6439
- *
6440
- * Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
6441
- * scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
6442
- * convert a pixel label footprint into a geo bounding box so collision runs in
6443
- * the same lng/lat space the marker positions already live in.
6444
- */
6445
- const degreesPerPixel$1 = (zoom, latitudeDeg) => {
6446
- const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
6447
- const lng = 360 / worldWidthPx;
6448
- return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
6449
- };
6450
- /**
6451
- * Label footprint as a geo-space width/height, for collision testing.
6452
- *
6453
- * A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
6454
- * at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
6455
- * `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
6456
- * that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
6457
- * a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
6458
- * is otherwise tuned via `paddingPx`.
6459
- */
6460
- const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6461
- const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
6462
- const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
6463
- // Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
6464
- const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
6465
- // Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
6466
- // present (the wider case).
6467
- const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
6468
- const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
6469
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6470
- return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6471
- };
6472
- /**
6473
- * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6474
- * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6475
- * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6476
- */
6477
- const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6478
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6479
- return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6480
- };
6481
-
6482
6538
  const EMPTY_PLACEMENTS = new Map();
6483
6539
  /**
6484
6540
  * Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
@@ -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,23 +1,23 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.205",
3
+ "version": "0.2.207-alpha-775ca1816d6.0",
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.8.1",
11
- "@trackunit/css-class-variance-utilities": "2.3.4",
12
- "@trackunit/react-form-components": "2.10.7",
13
- "@trackunit/react-core-hooks": "1.25.4",
14
- "@trackunit/geo-json-utils": "1.18.4",
15
- "@trackunit/i18n-library-translation": "2.8.4",
10
+ "@trackunit/react-components": "3.8.2-alpha-775ca1816d6.0",
11
+ "@trackunit/css-class-variance-utilities": "2.3.5-alpha-775ca1816d6.0",
12
+ "@trackunit/react-form-components": "2.10.8-alpha-775ca1816d6.0",
13
+ "@trackunit/react-core-hooks": "1.25.5-alpha-775ca1816d6.0",
14
+ "@trackunit/geo-json-utils": "1.18.5-alpha-775ca1816d6.0",
15
+ "@trackunit/i18n-library-translation": "2.8.5-alpha-775ca1816d6.0",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
- "@trackunit/react-map-adapter-shared": "0.0.155",
18
- "@trackunit/react-map-color-utils": "0.0.130",
19
- "@trackunit/shared-utils": "1.19.4",
20
- "@trackunit/ui-design-tokens": "1.18.4",
17
+ "@trackunit/react-map-adapter-shared": "0.0.156-alpha-775ca1816d6.0",
18
+ "@trackunit/react-map-color-utils": "0.0.131-alpha-775ca1816d6.0",
19
+ "@trackunit/shared-utils": "1.19.5-alpha-775ca1816d6.0",
20
+ "@trackunit/ui-design-tokens": "1.18.5-alpha-775ca1816d6.0",
21
21
  "@floating-ui/react": "^0.26.25",
22
22
  "es-toolkit": "^1.52.0",
23
23
  "tailwind-merge": "^2.0.0",
@@ -1,6 +1,6 @@
1
1
  import type { GeoJsonBbox } from "@trackunit/geo-json-utils";
2
2
  import type { StickPositioning } from "../markers/model/markerDomTypes";
3
- import type { DegreesPerPixel, LabelFootprintDeg } from "./internal/labelFootprint";
3
+ import { type DegreesPerPixel, type LabelFootprintDeg } from "./internal/labelFootprint";
4
4
  import { type StickOffset } from "./internal/stickSolver";
5
5
  import type { MapFocus } from "./mapFocus";
6
6
  type Position = readonly [number, number, ...Array<number>];
@@ -42,6 +42,18 @@ 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
+ * Physical pill dimensions in px, **as the renderer paints them** (unpadded) — the full envelope at
47
+ * the render cap, matching `MapMarkerStickSurface` (`computeFullPillEnvelopeWidth` + the fixed md pill
48
+ * height). The stick solver uses these for tip / pill-centre geometry, kept distinct from the padded
49
+ * collision footprint (`labelFootprintDeg`, which adds `2 * paddingPx`) used only for keep-out — so the
50
+ * reserved pill centre lands where the pill actually paints. Assumes the icon disc is present (the wider
51
+ * case) and no direction indicator, mirroring `labelFootprintDeg`'s assumptions. Zoom-independent.
52
+ */
53
+ export declare const PHYSICAL_PILL_PX: Readonly<{
54
+ widthPx: number;
55
+ heightPx: number;
56
+ }>;
45
57
  /**
46
58
  * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
47
59
  * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
@@ -1,10 +1,14 @@
1
1
  import type { LabelFootprintDeg } from "./labelFootprint";
2
2
  /** An unwrapped lng/lat point used by label placement (longitudes made continuous per viewport). */
3
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<{
4
+ /**
5
+ * A placed obstacle: its centre plus the footprint it occupies. `isCluster` marks a cluster bubble
6
+ * (a hard keep-out that the stick solver's relaxed fallback still honours, unlike label obstacles).
7
+ */
8
+ export type PlacedObstacle = Readonly<{
6
9
  point: PlanarPoint;
7
10
  footprint: LabelFootprintDeg;
11
+ isCluster?: boolean;
8
12
  }>;
9
13
  /**
10
14
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
@@ -1,6 +1,6 @@
1
1
  import type { StickPositioning } from "../../markers/model/markerDomTypes";
2
2
  import type { DegreesPerPixel, LabelFootprintDeg } from "./labelFootprint";
3
- import { type PlacedLabel, type PlanarPoint } from "./placementGeometry";
3
+ import { type PlacedObstacle, type PlanarPoint } from "./placementGeometry";
4
4
  type PolarStick = Extract<StickPositioning, {
5
5
  type: "polar";
6
6
  }>;
@@ -18,9 +18,17 @@ export type StickSolverParams = Readonly<{
18
18
  /** Asset anchor as an unwrapped lng/lat point (same planar space as `placed`). */
19
19
  anchor: PlanarPoint;
20
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). */
21
+ placed: ReadonlyArray<PlacedObstacle>;
22
+ /** Geo footprint of the displaced label — the padded box, used **only** for keep-out collision. */
23
23
  footprint: LabelFootprintDeg;
24
+ /**
25
+ * Physical pill width in px, as the renderer paints it (unpadded full envelope). Drives the tip /
26
+ * pill-centre geometry and the view-box clamp, so the reserved centre matches where the pill paints —
27
+ * distinct from the padded `footprint` used for collision.
28
+ */
29
+ pillWidthPx: number;
30
+ /** Physical pill height in px, as the renderer paints it (unpadded). See `pillWidthPx`. */
31
+ pillHeightPx: number;
24
32
  /** Degrees spanned per screen pixel at the current zoom/latitude — converts the px ring to geo. */
25
33
  degreesPerPixel: DegreesPerPixel;
26
34
  /**
@@ -39,6 +47,13 @@ export type StickSolverParams = Readonly<{
39
47
  anglesPerRing: number;
40
48
  /** Previously-chosen offset for this asset — tried first so a stable label does not swing. */
41
49
  previousOffset?: StickOffset;
50
+ /**
51
+ * When true and no clean offset exists, run a relaxed last-resort scan that clears all **clusters**
52
+ * but may overlap other labels — so a guaranteed label whose anchor sits on a cluster is displaced
53
+ * off it rather than kept on the cluster. Set by the caller only when the anchor overlaps a cluster;
54
+ * for label-only crowding it stays false so the label keeps its anchor (fewer leader lines).
55
+ */
56
+ clusterClearFallback?: boolean;
42
57
  }>;
43
58
  /**
44
59
  * Finds a stick offset for a guaranteed (Tier-A) label whose anchor is taken.
@@ -58,5 +73,5 @@ export type StickSolverParams = Readonly<{
58
73
  * (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
59
74
  * that tip.
60
75
  */
61
- export declare const findStickOffset: ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }: StickSolverParams) => StickSolution | undefined;
76
+ export declare const findStickOffset: ({ anchor, placed, footprint, pillWidthPx, pillHeightPx, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, clusterClearFallback, }: StickSolverParams) => StickSolution | undefined;
62
77
  export {};