@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 +172 -81
- package/index.esm.js +172 -81
- package/package.json +11 -11
- package/src/layers/buildLabelPlacement.d.ts +1 -1
- package/src/layers/internal/labelFootprint.d.ts +12 -0
- package/src/layers/internal/placementGeometry.d.ts +6 -2
- package/src/layers/internal/stickSolver.d.ts +19 -4
- package/src/layers/useHeatmap/useHeatmap.d.ts +1 -1
- package/src/layers/useHeatmap/utils/normalizeHeatmapData.d.ts +19 -0
- package/src/layers/useHeatmap/utils/warnHeatmap.d.ts +4 -0
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
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
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
|
-
//
|
|
6049
|
-
// pill
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
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
|
-
//
|
|
6048
|
-
// pill
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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.
|
|
11
|
-
"@trackunit/css-class-variance-utilities": "2.3.
|
|
12
|
-
"@trackunit/react-form-components": "2.10.
|
|
13
|
-
"@trackunit/react-core-hooks": "1.25.
|
|
14
|
-
"@trackunit/geo-json-utils": "1.18.
|
|
15
|
-
"@trackunit/i18n-library-translation": "2.8.
|
|
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.
|
|
18
|
-
"@trackunit/react-map-color-utils": "0.0.
|
|
19
|
-
"@trackunit/shared-utils": "1.19.
|
|
20
|
-
"@trackunit/ui-design-tokens": "1.18.
|
|
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
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
|
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<
|
|
22
|
-
/** Geo footprint of the displaced label
|
|
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 {
|
|
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 {};
|