@trackunit/react-map 0.2.203 → 0.2.206

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
@@ -5887,6 +5887,57 @@ const useFitToContent = (api, handles, options) => {
5887
5887
  return react.useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
5888
5888
  };
5889
5889
 
5890
+ /**
5891
+ * Single dev-warning channel for `useHeatmap` (invalid data).
5892
+ */
5893
+ const warnHeatmap = (message, ...details) => {
5894
+ // eslint-disable-next-line no-console -- Intentional: the one heatmap warning channel; invalid input is dropped and must stay visible
5895
+ console.warn(`[useHeatmap] ${message}`, ...details);
5896
+ };
5897
+
5898
+ /**
5899
+ * Converts consumer items into weighted GeoJSON `Point` features.
5900
+ *
5901
+ * Null positions and zero weights are dropped silently. Invalid positions and
5902
+ * negative or non-finite weights are dropped with one grouped warning per pass.
5903
+ */
5904
+ const normalizeHeatmapData = ({ data, getPosition, getWeight, }) => {
5905
+ const features = [];
5906
+ const positions = [];
5907
+ let invalidPositionCount = 0;
5908
+ let invalidWeightCount = 0;
5909
+ for (const item of data) {
5910
+ const position = getPosition(item);
5911
+ if (position === null)
5912
+ continue;
5913
+ const parsedPosition = geoJsonUtils.geoJsonPositionSchema.safeParse(position);
5914
+ if (!parsedPosition.success) {
5915
+ invalidPositionCount++;
5916
+ continue;
5917
+ }
5918
+ const weight = getWeight?.(item) ?? 1;
5919
+ if (weight === 0)
5920
+ continue;
5921
+ if (!Number.isFinite(weight) || weight < 0) {
5922
+ invalidWeightCount++;
5923
+ continue;
5924
+ }
5925
+ positions.push(parsedPosition.data);
5926
+ features.push({
5927
+ type: "Feature",
5928
+ geometry: { type: "Point", coordinates: parsedPosition.data },
5929
+ properties: { weight },
5930
+ });
5931
+ }
5932
+ if (invalidPositionCount > 0 || invalidWeightCount > 0) {
5933
+ warnHeatmap(`Filtered ${invalidPositionCount} item(s) with invalid position and ${invalidWeightCount} item(s) with invalid weight. Expected [longitude, latitude] with longitude between -180 and 180, latitude between -90 and 90, and a finite weight >= 0.`);
5934
+ }
5935
+ return {
5936
+ features: { type: "FeatureCollection", features },
5937
+ positions,
5938
+ };
5939
+ };
5940
+
5890
5941
  const EMPTY_CONTROLS$2 = {};
5891
5942
  /**
5892
5943
  * Weighted point heatmap layer handle (provider-agnostic).
@@ -5898,31 +5949,15 @@ const EMPTY_CONTROLS$2 = {};
5898
5949
  const useHeatmap = (options) => {
5899
5950
  const { id, name, data, getPosition, getWeight, style, controls, loading, fitParticipation = "all" } = options;
5900
5951
  const layerReady = useLayerReady(loading);
5901
- const normalized = react.useMemo(() => {
5902
- const features = [];
5903
- const positions = [];
5904
- for (const item of data) {
5905
- const position = getPosition(item);
5906
- if (position === null)
5907
- continue;
5908
- const parsedPosition = geoJsonUtils.geoJsonPositionSchema.safeParse(position);
5909
- if (!parsedPosition.success)
5910
- continue;
5911
- const weight = getWeight?.(item) ?? 1;
5912
- if (!Number.isFinite(weight) || weight <= 0)
5913
- continue;
5914
- positions.push(parsedPosition.data);
5915
- features.push({
5916
- type: "Feature",
5917
- geometry: { type: "Point", coordinates: parsedPosition.data },
5918
- properties: { weight },
5919
- });
5920
- }
5921
- return {
5922
- features: { type: "FeatureCollection", features },
5923
- positions,
5924
- };
5925
- }, [data, getPosition, getWeight]);
5952
+ // Keyed on `data` (adjust-state-during-render + isEqual) so inline accessors
5953
+ // don't re-normalize the whole dataset on every render. Accessor changes
5954
+ // apply on the next `data` change.
5955
+ const [prevData, setPrevData] = react.useState(data);
5956
+ const [normalized, setNormalized] = react.useState(() => normalizeHeatmapData({ data, getPosition, getWeight }));
5957
+ if (data !== prevData && !esToolkit.isEqual(data, prevData)) {
5958
+ setPrevData(data);
5959
+ setNormalized(normalizeHeatmapData({ data, getPosition, getWeight }));
5960
+ }
5926
5961
  const resolvedStyle = react.useMemo(() => ({
5927
5962
  radiusPixels: style?.radiusPixels ?? 30,
5928
5963
  intensity: style?.intensity ?? 1,
@@ -5959,6 +5994,65 @@ const useHeatmap = (options) => {
5959
5994
  ]);
5960
5995
  };
5961
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
+
5962
6056
  /**
5963
6057
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
5964
6058
  * overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
@@ -5978,6 +6072,8 @@ const UP_ANGLE_RAD = -Math.PI / 2;
5978
6072
  /** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
5979
6073
  const ANCHOR_DOT_RADIUS_PX = 5;
5980
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));
5981
6077
  /** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
5982
6078
  const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
5983
6079
  const dx = b[0] - a[0];
@@ -6044,13 +6140,16 @@ const clearanceSq = (point, placed) => {
6044
6140
  * (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
6045
6141
  * that tip.
6046
6142
  */
6047
- const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
6048
- // Recover the pill's px dimensions from the geo footprint (inverse of `labelFootprintDeg`), so the
6049
- // pill-centre geometry matches what the renderer paints.
6050
- const pillWidthPx = footprint.widthDeg / degreesPerPixel.lng;
6051
- const pillHeightPx = footprint.heightDeg / degreesPerPixel.lat;
6052
- const halfWidthDeg = footprint.widthDeg / 2;
6053
- 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;
6054
6153
  const outsideViewBox = (point) => viewBox !== undefined &&
6055
6154
  (point[0] < viewBox[0] + halfWidthDeg ||
6056
6155
  point[0] > viewBox[2] - halfWidthDeg ||
@@ -6123,7 +6222,36 @@ const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox,
6123
6222
  };
6124
6223
  }
6125
6224
  }
6126
- 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
+ };
6127
6255
  };
6128
6256
 
6129
6257
  const EMPTY_IDS = new Set();
@@ -6265,7 +6393,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6265
6393
  // distant to overlap any label. Unwrapped around the same reference longitude as items.
6266
6394
  if (clusterPositions !== undefined && clusterFootprint !== undefined) {
6267
6395
  for (const position of clusterPositions) {
6268
- 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
+ });
6269
6401
  }
6270
6402
  }
6271
6403
  // ---- Tier A membership ----
@@ -6307,10 +6439,15 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6307
6439
  placed.push({ point: candidate.point, footprint });
6308
6440
  continue;
6309
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));
6310
6445
  const stick = findStickOffset({
6311
6446
  anchor: candidate.point,
6312
6447
  placed,
6313
6448
  footprint,
6449
+ pillWidthPx: PHYSICAL_PILL_PX.widthPx,
6450
+ pillHeightPx: PHYSICAL_PILL_PX.heightPx,
6314
6451
  degreesPerPixel,
6315
6452
  viewBox: planarViewBox,
6316
6453
  baseDistancePx: STICK_BASE_DISTANCE_PX,
@@ -6318,6 +6455,7 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6318
6455
  radiusSteps: STICK_RADIUS_STEPS,
6319
6456
  anglesPerRing: STICK_ANGLES_PER_RING,
6320
6457
  previousOffset: previousStickOffsets?.get(candidate.id),
6458
+ clusterClearFallback: anchorOnCluster,
6321
6459
  });
6322
6460
  if (stick !== undefined) {
6323
6461
  placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
@@ -6398,53 +6536,6 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6398
6536
  return placements;
6399
6537
  };
6400
6538
 
6401
- const WEB_MERCATOR_TILE_SIZE_PX = 256;
6402
- /**
6403
- * Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
6404
- * near `latitudeDeg`, under Web Mercator.
6405
- *
6406
- * Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
6407
- * scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
6408
- * convert a pixel label footprint into a geo bounding box so collision runs in
6409
- * the same lng/lat space the marker positions already live in.
6410
- */
6411
- const degreesPerPixel$1 = (zoom, latitudeDeg) => {
6412
- const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
6413
- const lng = 360 / worldWidthPx;
6414
- return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
6415
- };
6416
- /**
6417
- * Label footprint as a geo-space width/height, for collision testing.
6418
- *
6419
- * A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
6420
- * at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
6421
- * `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
6422
- * that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
6423
- * a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
6424
- * is otherwise tuned via `paddingPx`.
6425
- */
6426
- const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6427
- const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
6428
- const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
6429
- // Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
6430
- const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
6431
- // Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
6432
- // present (the wider case).
6433
- const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
6434
- const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
6435
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6436
- return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6437
- };
6438
- /**
6439
- * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6440
- * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6441
- * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6442
- */
6443
- const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6444
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6445
- return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6446
- };
6447
-
6448
6539
  const EMPTY_PLACEMENTS = new Map();
6449
6540
  /**
6450
6541
  * Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
package/index.esm.js CHANGED
@@ -5886,6 +5886,57 @@ const useFitToContent = (api, handles, options) => {
5886
5886
  return useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
5887
5887
  };
5888
5888
 
5889
+ /**
5890
+ * Single dev-warning channel for `useHeatmap` (invalid data).
5891
+ */
5892
+ const warnHeatmap = (message, ...details) => {
5893
+ // eslint-disable-next-line no-console -- Intentional: the one heatmap warning channel; invalid input is dropped and must stay visible
5894
+ console.warn(`[useHeatmap] ${message}`, ...details);
5895
+ };
5896
+
5897
+ /**
5898
+ * Converts consumer items into weighted GeoJSON `Point` features.
5899
+ *
5900
+ * Null positions and zero weights are dropped silently. Invalid positions and
5901
+ * negative or non-finite weights are dropped with one grouped warning per pass.
5902
+ */
5903
+ const normalizeHeatmapData = ({ data, getPosition, getWeight, }) => {
5904
+ const features = [];
5905
+ const positions = [];
5906
+ let invalidPositionCount = 0;
5907
+ let invalidWeightCount = 0;
5908
+ for (const item of data) {
5909
+ const position = getPosition(item);
5910
+ if (position === null)
5911
+ continue;
5912
+ const parsedPosition = geoJsonPositionSchema.safeParse(position);
5913
+ if (!parsedPosition.success) {
5914
+ invalidPositionCount++;
5915
+ continue;
5916
+ }
5917
+ const weight = getWeight?.(item) ?? 1;
5918
+ if (weight === 0)
5919
+ continue;
5920
+ if (!Number.isFinite(weight) || weight < 0) {
5921
+ invalidWeightCount++;
5922
+ continue;
5923
+ }
5924
+ positions.push(parsedPosition.data);
5925
+ features.push({
5926
+ type: "Feature",
5927
+ geometry: { type: "Point", coordinates: parsedPosition.data },
5928
+ properties: { weight },
5929
+ });
5930
+ }
5931
+ if (invalidPositionCount > 0 || invalidWeightCount > 0) {
5932
+ warnHeatmap(`Filtered ${invalidPositionCount} item(s) with invalid position and ${invalidWeightCount} item(s) with invalid weight. Expected [longitude, latitude] with longitude between -180 and 180, latitude between -90 and 90, and a finite weight >= 0.`);
5933
+ }
5934
+ return {
5935
+ features: { type: "FeatureCollection", features },
5936
+ positions,
5937
+ };
5938
+ };
5939
+
5889
5940
  const EMPTY_CONTROLS$2 = {};
5890
5941
  /**
5891
5942
  * Weighted point heatmap layer handle (provider-agnostic).
@@ -5897,31 +5948,15 @@ const EMPTY_CONTROLS$2 = {};
5897
5948
  const useHeatmap = (options) => {
5898
5949
  const { id, name, data, getPosition, getWeight, style, controls, loading, fitParticipation = "all" } = options;
5899
5950
  const layerReady = useLayerReady(loading);
5900
- const normalized = useMemo(() => {
5901
- const features = [];
5902
- const positions = [];
5903
- for (const item of data) {
5904
- const position = getPosition(item);
5905
- if (position === null)
5906
- continue;
5907
- const parsedPosition = geoJsonPositionSchema.safeParse(position);
5908
- if (!parsedPosition.success)
5909
- continue;
5910
- const weight = getWeight?.(item) ?? 1;
5911
- if (!Number.isFinite(weight) || weight <= 0)
5912
- continue;
5913
- positions.push(parsedPosition.data);
5914
- features.push({
5915
- type: "Feature",
5916
- geometry: { type: "Point", coordinates: parsedPosition.data },
5917
- properties: { weight },
5918
- });
5919
- }
5920
- return {
5921
- features: { type: "FeatureCollection", features },
5922
- positions,
5923
- };
5924
- }, [data, getPosition, getWeight]);
5951
+ // Keyed on `data` (adjust-state-during-render + isEqual) so inline accessors
5952
+ // don't re-normalize the whole dataset on every render. Accessor changes
5953
+ // apply on the next `data` change.
5954
+ const [prevData, setPrevData] = useState(data);
5955
+ const [normalized, setNormalized] = useState(() => normalizeHeatmapData({ data, getPosition, getWeight }));
5956
+ if (data !== prevData && !isEqual(data, prevData)) {
5957
+ setPrevData(data);
5958
+ setNormalized(normalizeHeatmapData({ data, getPosition, getWeight }));
5959
+ }
5925
5960
  const resolvedStyle = useMemo(() => ({
5926
5961
  radiusPixels: style?.radiusPixels ?? 30,
5927
5962
  intensity: style?.intensity ?? 1,
@@ -5958,6 +5993,65 @@ const useHeatmap = (options) => {
5958
5993
  ]);
5959
5994
  };
5960
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
+
5961
6055
  /**
5962
6056
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
5963
6057
  * overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
@@ -5977,6 +6071,8 @@ const UP_ANGLE_RAD = -Math.PI / 2;
5977
6071
  /** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
5978
6072
  const ANCHOR_DOT_RADIUS_PX = 5;
5979
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));
5980
6076
  /** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
5981
6077
  const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
5982
6078
  const dx = b[0] - a[0];
@@ -6043,13 +6139,16 @@ const clearanceSq = (point, placed) => {
6043
6139
  * (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
6044
6140
  * that tip.
6045
6141
  */
6046
- const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
6047
- // Recover the pill's px dimensions from the geo footprint (inverse of `labelFootprintDeg`), so the
6048
- // pill-centre geometry matches what the renderer paints.
6049
- const pillWidthPx = footprint.widthDeg / degreesPerPixel.lng;
6050
- const pillHeightPx = footprint.heightDeg / degreesPerPixel.lat;
6051
- const halfWidthDeg = footprint.widthDeg / 2;
6052
- 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;
6053
6152
  const outsideViewBox = (point) => viewBox !== undefined &&
6054
6153
  (point[0] < viewBox[0] + halfWidthDeg ||
6055
6154
  point[0] > viewBox[2] - halfWidthDeg ||
@@ -6122,7 +6221,36 @@ const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox,
6122
6221
  };
6123
6222
  }
6124
6223
  }
6125
- 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
+ };
6126
6254
  };
6127
6255
 
6128
6256
  const EMPTY_IDS = new Set();
@@ -6264,7 +6392,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6264
6392
  // distant to overlap any label. Unwrapped around the same reference longitude as items.
6265
6393
  if (clusterPositions !== undefined && clusterFootprint !== undefined) {
6266
6394
  for (const position of clusterPositions) {
6267
- 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
+ });
6268
6400
  }
6269
6401
  }
6270
6402
  // ---- Tier A membership ----
@@ -6306,10 +6438,15 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6306
6438
  placed.push({ point: candidate.point, footprint });
6307
6439
  continue;
6308
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));
6309
6444
  const stick = findStickOffset({
6310
6445
  anchor: candidate.point,
6311
6446
  placed,
6312
6447
  footprint,
6448
+ pillWidthPx: PHYSICAL_PILL_PX.widthPx,
6449
+ pillHeightPx: PHYSICAL_PILL_PX.heightPx,
6313
6450
  degreesPerPixel,
6314
6451
  viewBox: planarViewBox,
6315
6452
  baseDistancePx: STICK_BASE_DISTANCE_PX,
@@ -6317,6 +6454,7 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6317
6454
  radiusSteps: STICK_RADIUS_STEPS,
6318
6455
  anglesPerRing: STICK_ANGLES_PER_RING,
6319
6456
  previousOffset: previousStickOffsets?.get(candidate.id),
6457
+ clusterClearFallback: anchorOnCluster,
6320
6458
  });
6321
6459
  if (stick !== undefined) {
6322
6460
  placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
@@ -6397,53 +6535,6 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
6397
6535
  return placements;
6398
6536
  };
6399
6537
 
6400
- const WEB_MERCATOR_TILE_SIZE_PX = 256;
6401
- /**
6402
- * Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
6403
- * near `latitudeDeg`, under Web Mercator.
6404
- *
6405
- * Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
6406
- * scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
6407
- * convert a pixel label footprint into a geo bounding box so collision runs in
6408
- * the same lng/lat space the marker positions already live in.
6409
- */
6410
- const degreesPerPixel$1 = (zoom, latitudeDeg) => {
6411
- const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
6412
- const lng = 360 / worldWidthPx;
6413
- return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
6414
- };
6415
- /**
6416
- * Label footprint as a geo-space width/height, for collision testing.
6417
- *
6418
- * A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
6419
- * at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
6420
- * `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
6421
- * that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
6422
- * a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
6423
- * is otherwise tuned via `paddingPx`.
6424
- */
6425
- const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
6426
- const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
6427
- const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
6428
- // Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
6429
- const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
6430
- // Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
6431
- // present (the wider case).
6432
- const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
6433
- const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
6434
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6435
- return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
6436
- };
6437
- /**
6438
- * Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
6439
- * diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
6440
- * obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
6441
- */
6442
- const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
6443
- const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
6444
- return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
6445
- };
6446
-
6447
6538
  const EMPTY_PLACEMENTS = new Map();
6448
6539
  /**
6449
6540
  * Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.203",
3
+ "version": "0.2.206",
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.0",
11
- "@trackunit/css-class-variance-utilities": "2.3.3",
12
- "@trackunit/react-form-components": "2.10.6",
13
- "@trackunit/react-core-hooks": "1.25.3",
14
- "@trackunit/geo-json-utils": "1.18.3",
15
- "@trackunit/i18n-library-translation": "2.8.3",
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",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
- "@trackunit/react-map-adapter-shared": "0.0.154",
18
- "@trackunit/react-map-color-utils": "0.0.129",
19
- "@trackunit/shared-utils": "1.19.3",
20
- "@trackunit/ui-design-tokens": "1.18.3",
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",
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 {};
@@ -1,4 +1,4 @@
1
- import { type GeoJsonPosition } from "@trackunit/geo-json-utils";
1
+ import type { GeoJsonPosition } from "@trackunit/geo-json-utils";
2
2
  import type { CategoryKey, ControlConfig } from "../../controls/types";
3
3
  import type { FitParticipation, HeatmapLayerHandle } from "../types";
4
4
  export type HeatmapStyle = Readonly<{
@@ -0,0 +1,19 @@
1
+ import { type GeoJsonPosition } from "@trackunit/geo-json-utils";
2
+ import type { GeoJsonFeatureCollection } from "../../types";
3
+ export type NormalizedHeatmapData = Readonly<{
4
+ features: GeoJsonFeatureCollection;
5
+ positions: ReadonlyArray<GeoJsonPosition>;
6
+ }>;
7
+ type NormalizeHeatmapDataOptions<TItem> = Readonly<{
8
+ data: ReadonlyArray<TItem>;
9
+ getPosition: (item: TItem) => GeoJsonPosition | null;
10
+ getWeight: ((item: TItem) => number) | undefined;
11
+ }>;
12
+ /**
13
+ * Converts consumer items into weighted GeoJSON `Point` features.
14
+ *
15
+ * Null positions and zero weights are dropped silently. Invalid positions and
16
+ * negative or non-finite weights are dropped with one grouped warning per pass.
17
+ */
18
+ export declare const normalizeHeatmapData: <TItem>({ data, getPosition, getWeight, }: NormalizeHeatmapDataOptions<TItem>) => NormalizedHeatmapData;
19
+ export {};
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Single dev-warning channel for `useHeatmap` (invalid data).
3
+ */
4
+ export declare const warnHeatmap: (message: string, ...details: ReadonlyArray<unknown>) => void;