@trackunit/react-map 0.2.205 → 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 +112 -56
- package/index.esm.js +112 -56
- package/migrations/entry.js.map +1 -0
- package/package.json +1 -1
- 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/index.cjs.js
CHANGED
|
@@ -5994,6 +5994,65 @@ const useHeatmap = (options) => {
|
|
|
5994
5994
|
]);
|
|
5995
5995
|
};
|
|
5996
5996
|
|
|
5997
|
+
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
5998
|
+
/**
|
|
5999
|
+
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
6000
|
+
* near `latitudeDeg`, under Web Mercator.
|
|
6001
|
+
*
|
|
6002
|
+
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
6003
|
+
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
6004
|
+
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
6005
|
+
* the same lng/lat space the marker positions already live in.
|
|
6006
|
+
*/
|
|
6007
|
+
const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
6008
|
+
const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
|
|
6009
|
+
const lng = 360 / worldWidthPx;
|
|
6010
|
+
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
6011
|
+
};
|
|
6012
|
+
/**
|
|
6013
|
+
* Label footprint as a geo-space width/height, for collision testing.
|
|
6014
|
+
*
|
|
6015
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
|
|
6016
|
+
* at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
|
|
6017
|
+
* `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
|
|
6018
|
+
* that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
|
|
6019
|
+
* a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
|
|
6020
|
+
* is otherwise tuned via `paddingPx`.
|
|
6021
|
+
*/
|
|
6022
|
+
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
|
|
6023
|
+
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
6024
|
+
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
6025
|
+
// Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
|
|
6026
|
+
const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
|
|
6027
|
+
// Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
|
|
6028
|
+
// present (the wider case).
|
|
6029
|
+
const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
|
|
6030
|
+
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
6031
|
+
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6032
|
+
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
6033
|
+
};
|
|
6034
|
+
/**
|
|
6035
|
+
* Physical pill dimensions in px, **as the renderer paints them** (unpadded) — the full envelope at
|
|
6036
|
+
* the render cap, matching `MapMarkerStickSurface` (`computeFullPillEnvelopeWidth` + the fixed md pill
|
|
6037
|
+
* height). The stick solver uses these for tip / pill-centre geometry, kept distinct from the padded
|
|
6038
|
+
* collision footprint (`labelFootprintDeg`, which adds `2 * paddingPx`) used only for keep-out — so the
|
|
6039
|
+
* reserved pill centre lands where the pill actually paints. Assumes the icon disc is present (the wider
|
|
6040
|
+
* case) and no direction indicator, mirroring `labelFootprintDeg`'s assumptions. Zoom-independent.
|
|
6041
|
+
*/
|
|
6042
|
+
const PHYSICAL_PILL_PX = {
|
|
6043
|
+
widthPx: computeFullPillEnvelopeWidth(MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_TUNING.pill.maxLabelWidthPx, true, false),
|
|
6044
|
+
heightPx: markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE),
|
|
6045
|
+
};
|
|
6046
|
+
/**
|
|
6047
|
+
* Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
|
|
6048
|
+
* diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
|
|
6049
|
+
* obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
|
|
6050
|
+
*/
|
|
6051
|
+
const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
|
|
6052
|
+
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6053
|
+
return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
|
|
6054
|
+
};
|
|
6055
|
+
|
|
5997
6056
|
/**
|
|
5998
6057
|
* Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
|
|
5999
6058
|
* overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
|
|
@@ -6013,6 +6072,8 @@ const UP_ANGLE_RAD = -Math.PI / 2;
|
|
|
6013
6072
|
/** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
|
|
6014
6073
|
const ANCHOR_DOT_RADIUS_PX = 5;
|
|
6015
6074
|
const collidesWithPlaced = (point, footprint, placed) => placed.some(other => overlaps(point, footprint, other.point, other.footprint));
|
|
6075
|
+
/** Like {@link collidesWithPlaced} but only against cluster obstacles — the relaxed fallback's hard set. */
|
|
6076
|
+
const collidesWithClusters = (point, footprint, placed) => placed.some(other => other.isCluster === true && overlaps(point, footprint, other.point, other.footprint));
|
|
6016
6077
|
/** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
|
|
6017
6078
|
const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
|
|
6018
6079
|
const dx = b[0] - a[0];
|
|
@@ -6079,13 +6140,16 @@ const clearanceSq = (point, placed) => {
|
|
|
6079
6140
|
* (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
|
|
6080
6141
|
* that tip.
|
|
6081
6142
|
*/
|
|
6082
|
-
const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
|
|
6083
|
-
//
|
|
6084
|
-
// pill
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6143
|
+
const findStickOffset = ({ anchor, placed, footprint, pillWidthPx, pillHeightPx, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, clusterClearFallback, }) => {
|
|
6144
|
+
// Geometry uses the physical (as-painted) pill size; the view-box clamp insets by its half-extents,
|
|
6145
|
+
// so a pill that visually fits is not rejected. The padded `footprint` is used only for keep-out
|
|
6146
|
+
// (`collidesWithPlaced`), not here — that separation is the point of SAGA-855.
|
|
6147
|
+
// Known limitation: the reserved keep-out box (the narrower `footprint`) is centred on the physical
|
|
6148
|
+
// pill centre, so for a lateral stick it no longer covers the pill's near (disc) end by a few px — a
|
|
6149
|
+
// later label can clip that strip. The proper fix is the asymmetric "protect the disc" keep-out
|
|
6150
|
+
// tracked in SAGA-873; accepted as-is here (small, disc-side).
|
|
6151
|
+
const halfWidthDeg = (pillWidthPx * degreesPerPixel.lng) / 2;
|
|
6152
|
+
const halfHeightDeg = (pillHeightPx * degreesPerPixel.lat) / 2;
|
|
6089
6153
|
const outsideViewBox = (point) => viewBox !== undefined &&
|
|
6090
6154
|
(point[0] < viewBox[0] + halfWidthDeg ||
|
|
6091
6155
|
point[0] > viewBox[2] - halfWidthDeg ||
|
|
@@ -6158,7 +6222,36 @@ const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox,
|
|
|
6158
6222
|
};
|
|
6159
6223
|
}
|
|
6160
6224
|
}
|
|
6161
|
-
|
|
6225
|
+
if (fallback !== undefined)
|
|
6226
|
+
return fallback;
|
|
6227
|
+
if (clusterClearFallback !== true)
|
|
6228
|
+
return undefined;
|
|
6229
|
+
let bestOnCanvas;
|
|
6230
|
+
let bestOffCanvas;
|
|
6231
|
+
for (let ring = 0; ring < radiusSteps; ring++) {
|
|
6232
|
+
const distance = baseDistancePx + ring * radiusStepPx;
|
|
6233
|
+
for (let a = 0; a < anglesPerRing; a++) {
|
|
6234
|
+
const angleRad = UP_ANGLE_RAD + a * angleStep;
|
|
6235
|
+
const { pillCentre } = geometryFor(angleRad, distance);
|
|
6236
|
+
if (collidesWithClusters(pillCentre, footprint, placed))
|
|
6237
|
+
continue;
|
|
6238
|
+
const clearance = clearanceSq(pillCentre, placed);
|
|
6239
|
+
if (outsideViewBox(pillCentre)) {
|
|
6240
|
+
if (beats(clearance, bestOffCanvas))
|
|
6241
|
+
bestOffCanvas = { angleRad, distance, pillCentre, clearance };
|
|
6242
|
+
}
|
|
6243
|
+
else if (beats(clearance, bestOnCanvas)) {
|
|
6244
|
+
bestOnCanvas = { angleRad, distance, pillCentre, clearance };
|
|
6245
|
+
}
|
|
6246
|
+
}
|
|
6247
|
+
}
|
|
6248
|
+
const chosen = bestOnCanvas ?? bestOffCanvas;
|
|
6249
|
+
return chosen === undefined
|
|
6250
|
+
? undefined
|
|
6251
|
+
: {
|
|
6252
|
+
positioning: { type: "polar", angleRad: chosen.angleRad, distance: chosen.distance },
|
|
6253
|
+
pillCentre: chosen.pillCentre,
|
|
6254
|
+
};
|
|
6162
6255
|
};
|
|
6163
6256
|
|
|
6164
6257
|
const EMPTY_IDS = new Set();
|
|
@@ -6300,7 +6393,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6300
6393
|
// distant to overlap any label. Unwrapped around the same reference longitude as items.
|
|
6301
6394
|
if (clusterPositions !== undefined && clusterFootprint !== undefined) {
|
|
6302
6395
|
for (const position of clusterPositions) {
|
|
6303
|
-
placed.push({
|
|
6396
|
+
placed.push({
|
|
6397
|
+
point: [unwrapLng(position[0], referenceLng), position[1]],
|
|
6398
|
+
footprint: clusterFootprint,
|
|
6399
|
+
isCluster: true,
|
|
6400
|
+
});
|
|
6304
6401
|
}
|
|
6305
6402
|
}
|
|
6306
6403
|
// ---- Tier A membership ----
|
|
@@ -6342,10 +6439,15 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6342
6439
|
placed.push({ point: candidate.point, footprint });
|
|
6343
6440
|
continue;
|
|
6344
6441
|
}
|
|
6442
|
+
// If the anchor sits on a cluster, allow the solver's relaxed fallback to displace off it (clearing
|
|
6443
|
+
// clusters, overlapping labels if need be) rather than leave a guaranteed label covering a cluster.
|
|
6444
|
+
const anchorOnCluster = placed.some(other => other.isCluster === true && overlaps(other.point, other.footprint, candidate.point, footprint));
|
|
6345
6445
|
const stick = findStickOffset({
|
|
6346
6446
|
anchor: candidate.point,
|
|
6347
6447
|
placed,
|
|
6348
6448
|
footprint,
|
|
6449
|
+
pillWidthPx: PHYSICAL_PILL_PX.widthPx,
|
|
6450
|
+
pillHeightPx: PHYSICAL_PILL_PX.heightPx,
|
|
6349
6451
|
degreesPerPixel,
|
|
6350
6452
|
viewBox: planarViewBox,
|
|
6351
6453
|
baseDistancePx: STICK_BASE_DISTANCE_PX,
|
|
@@ -6353,6 +6455,7 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6353
6455
|
radiusSteps: STICK_RADIUS_STEPS,
|
|
6354
6456
|
anglesPerRing: STICK_ANGLES_PER_RING,
|
|
6355
6457
|
previousOffset: previousStickOffsets?.get(candidate.id),
|
|
6458
|
+
clusterClearFallback: anchorOnCluster,
|
|
6356
6459
|
});
|
|
6357
6460
|
if (stick !== undefined) {
|
|
6358
6461
|
placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
|
|
@@ -6433,53 +6536,6 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6433
6536
|
return placements;
|
|
6434
6537
|
};
|
|
6435
6538
|
|
|
6436
|
-
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
6437
|
-
/**
|
|
6438
|
-
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
6439
|
-
* near `latitudeDeg`, under Web Mercator.
|
|
6440
|
-
*
|
|
6441
|
-
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
6442
|
-
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
6443
|
-
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
6444
|
-
* the same lng/lat space the marker positions already live in.
|
|
6445
|
-
*/
|
|
6446
|
-
const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
6447
|
-
const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
|
|
6448
|
-
const lng = 360 / worldWidthPx;
|
|
6449
|
-
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
6450
|
-
};
|
|
6451
|
-
/**
|
|
6452
|
-
* Label footprint as a geo-space width/height, for collision testing.
|
|
6453
|
-
*
|
|
6454
|
-
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
|
|
6455
|
-
* at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
|
|
6456
|
-
* `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
|
|
6457
|
-
* that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
|
|
6458
|
-
* a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
|
|
6459
|
-
* is otherwise tuned via `paddingPx`.
|
|
6460
|
-
*/
|
|
6461
|
-
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
|
|
6462
|
-
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
6463
|
-
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
6464
|
-
// Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
|
|
6465
|
-
const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
|
|
6466
|
-
// Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
|
|
6467
|
-
// present (the wider case).
|
|
6468
|
-
const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
|
|
6469
|
-
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
6470
|
-
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6471
|
-
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
6472
|
-
};
|
|
6473
|
-
/**
|
|
6474
|
-
* Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
|
|
6475
|
-
* diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
|
|
6476
|
-
* obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
|
|
6477
|
-
*/
|
|
6478
|
-
const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
|
|
6479
|
-
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6480
|
-
return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
|
|
6481
|
-
};
|
|
6482
|
-
|
|
6483
6539
|
const EMPTY_PLACEMENTS = new Map();
|
|
6484
6540
|
/**
|
|
6485
6541
|
* Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
|
package/index.esm.js
CHANGED
|
@@ -5993,6 +5993,65 @@ const useHeatmap = (options) => {
|
|
|
5993
5993
|
]);
|
|
5994
5994
|
};
|
|
5995
5995
|
|
|
5996
|
+
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
5997
|
+
/**
|
|
5998
|
+
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
5999
|
+
* near `latitudeDeg`, under Web Mercator.
|
|
6000
|
+
*
|
|
6001
|
+
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
6002
|
+
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
6003
|
+
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
6004
|
+
* the same lng/lat space the marker positions already live in.
|
|
6005
|
+
*/
|
|
6006
|
+
const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
6007
|
+
const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
|
|
6008
|
+
const lng = 360 / worldWidthPx;
|
|
6009
|
+
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
6010
|
+
};
|
|
6011
|
+
/**
|
|
6012
|
+
* Label footprint as a geo-space width/height, for collision testing.
|
|
6013
|
+
*
|
|
6014
|
+
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
|
|
6015
|
+
* at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
|
|
6016
|
+
* `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
|
|
6017
|
+
* that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
|
|
6018
|
+
* a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
|
|
6019
|
+
* is otherwise tuned via `paddingPx`.
|
|
6020
|
+
*/
|
|
6021
|
+
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
|
|
6022
|
+
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
6023
|
+
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
6024
|
+
// Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
|
|
6025
|
+
const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
|
|
6026
|
+
// Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
|
|
6027
|
+
// present (the wider case).
|
|
6028
|
+
const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
|
|
6029
|
+
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
6030
|
+
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6031
|
+
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
6032
|
+
};
|
|
6033
|
+
/**
|
|
6034
|
+
* Physical pill dimensions in px, **as the renderer paints them** (unpadded) — the full envelope at
|
|
6035
|
+
* the render cap, matching `MapMarkerStickSurface` (`computeFullPillEnvelopeWidth` + the fixed md pill
|
|
6036
|
+
* height). The stick solver uses these for tip / pill-centre geometry, kept distinct from the padded
|
|
6037
|
+
* collision footprint (`labelFootprintDeg`, which adds `2 * paddingPx`) used only for keep-out — so the
|
|
6038
|
+
* reserved pill centre lands where the pill actually paints. Assumes the icon disc is present (the wider
|
|
6039
|
+
* case) and no direction indicator, mirroring `labelFootprintDeg`'s assumptions. Zoom-independent.
|
|
6040
|
+
*/
|
|
6041
|
+
const PHYSICAL_PILL_PX = {
|
|
6042
|
+
widthPx: computeFullPillEnvelopeWidth(MARKER_PILL_CONTENT_LAYOUT_SIZE, MARKER_TUNING.pill.maxLabelWidthPx, true, false),
|
|
6043
|
+
heightPx: markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE),
|
|
6044
|
+
};
|
|
6045
|
+
/**
|
|
6046
|
+
* Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
|
|
6047
|
+
* diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
|
|
6048
|
+
* obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
|
|
6049
|
+
*/
|
|
6050
|
+
const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
|
|
6051
|
+
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6052
|
+
return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
|
|
6053
|
+
};
|
|
6054
|
+
|
|
5996
6055
|
/**
|
|
5997
6056
|
* Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
|
|
5998
6057
|
* overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
|
|
@@ -6012,6 +6071,8 @@ const UP_ANGLE_RAD = -Math.PI / 2;
|
|
|
6012
6071
|
/** Anchor pin radius in px (the `xs` dot, 8px + 2px border): only feeds the tip's soft floor. */
|
|
6013
6072
|
const ANCHOR_DOT_RADIUS_PX = 5;
|
|
6014
6073
|
const collidesWithPlaced = (point, footprint, placed) => placed.some(other => overlaps(point, footprint, other.point, other.footprint));
|
|
6074
|
+
/** Like {@link collidesWithPlaced} but only against cluster obstacles — the relaxed fallback's hard set. */
|
|
6075
|
+
const collidesWithClusters = (point, footprint, placed) => placed.some(other => other.isCluster === true && overlaps(point, footprint, other.point, other.footprint));
|
|
6015
6076
|
/** Liang–Barsky: does the segment a→b enter the axis-aligned box [minX,minY]–[maxX,maxY]? */
|
|
6016
6077
|
const segmentIntersectsBox = (a, b, minX, minY, maxX, maxY) => {
|
|
6017
6078
|
const dx = b[0] - a[0];
|
|
@@ -6078,13 +6139,16 @@ const clearanceSq = (point, placed) => {
|
|
|
6078
6139
|
* (`resolveTentativeTip` + `computePillCSSPlacement`), not the raw polar tip; the line is tested to
|
|
6079
6140
|
* that tip.
|
|
6080
6141
|
*/
|
|
6081
|
-
const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, }) => {
|
|
6082
|
-
//
|
|
6083
|
-
// pill
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6142
|
+
const findStickOffset = ({ anchor, placed, footprint, pillWidthPx, pillHeightPx, degreesPerPixel, viewBox, baseDistancePx, radiusStepPx, radiusSteps, anglesPerRing, previousOffset, clusterClearFallback, }) => {
|
|
6143
|
+
// Geometry uses the physical (as-painted) pill size; the view-box clamp insets by its half-extents,
|
|
6144
|
+
// so a pill that visually fits is not rejected. The padded `footprint` is used only for keep-out
|
|
6145
|
+
// (`collidesWithPlaced`), not here — that separation is the point of SAGA-855.
|
|
6146
|
+
// Known limitation: the reserved keep-out box (the narrower `footprint`) is centred on the physical
|
|
6147
|
+
// pill centre, so for a lateral stick it no longer covers the pill's near (disc) end by a few px — a
|
|
6148
|
+
// later label can clip that strip. The proper fix is the asymmetric "protect the disc" keep-out
|
|
6149
|
+
// tracked in SAGA-873; accepted as-is here (small, disc-side).
|
|
6150
|
+
const halfWidthDeg = (pillWidthPx * degreesPerPixel.lng) / 2;
|
|
6151
|
+
const halfHeightDeg = (pillHeightPx * degreesPerPixel.lat) / 2;
|
|
6088
6152
|
const outsideViewBox = (point) => viewBox !== undefined &&
|
|
6089
6153
|
(point[0] < viewBox[0] + halfWidthDeg ||
|
|
6090
6154
|
point[0] > viewBox[2] - halfWidthDeg ||
|
|
@@ -6157,7 +6221,36 @@ const findStickOffset = ({ anchor, placed, footprint, degreesPerPixel, viewBox,
|
|
|
6157
6221
|
};
|
|
6158
6222
|
}
|
|
6159
6223
|
}
|
|
6160
|
-
|
|
6224
|
+
if (fallback !== undefined)
|
|
6225
|
+
return fallback;
|
|
6226
|
+
if (clusterClearFallback !== true)
|
|
6227
|
+
return undefined;
|
|
6228
|
+
let bestOnCanvas;
|
|
6229
|
+
let bestOffCanvas;
|
|
6230
|
+
for (let ring = 0; ring < radiusSteps; ring++) {
|
|
6231
|
+
const distance = baseDistancePx + ring * radiusStepPx;
|
|
6232
|
+
for (let a = 0; a < anglesPerRing; a++) {
|
|
6233
|
+
const angleRad = UP_ANGLE_RAD + a * angleStep;
|
|
6234
|
+
const { pillCentre } = geometryFor(angleRad, distance);
|
|
6235
|
+
if (collidesWithClusters(pillCentre, footprint, placed))
|
|
6236
|
+
continue;
|
|
6237
|
+
const clearance = clearanceSq(pillCentre, placed);
|
|
6238
|
+
if (outsideViewBox(pillCentre)) {
|
|
6239
|
+
if (beats(clearance, bestOffCanvas))
|
|
6240
|
+
bestOffCanvas = { angleRad, distance, pillCentre, clearance };
|
|
6241
|
+
}
|
|
6242
|
+
else if (beats(clearance, bestOnCanvas)) {
|
|
6243
|
+
bestOnCanvas = { angleRad, distance, pillCentre, clearance };
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
}
|
|
6247
|
+
const chosen = bestOnCanvas ?? bestOffCanvas;
|
|
6248
|
+
return chosen === undefined
|
|
6249
|
+
? undefined
|
|
6250
|
+
: {
|
|
6251
|
+
positioning: { type: "polar", angleRad: chosen.angleRad, distance: chosen.distance },
|
|
6252
|
+
pillCentre: chosen.pillCentre,
|
|
6253
|
+
};
|
|
6161
6254
|
};
|
|
6162
6255
|
|
|
6163
6256
|
const EMPTY_IDS = new Set();
|
|
@@ -6299,7 +6392,11 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6299
6392
|
// distant to overlap any label. Unwrapped around the same reference longitude as items.
|
|
6300
6393
|
if (clusterPositions !== undefined && clusterFootprint !== undefined) {
|
|
6301
6394
|
for (const position of clusterPositions) {
|
|
6302
|
-
placed.push({
|
|
6395
|
+
placed.push({
|
|
6396
|
+
point: [unwrapLng(position[0], referenceLng), position[1]],
|
|
6397
|
+
footprint: clusterFootprint,
|
|
6398
|
+
isCluster: true,
|
|
6399
|
+
});
|
|
6303
6400
|
}
|
|
6304
6401
|
}
|
|
6305
6402
|
// ---- Tier A membership ----
|
|
@@ -6341,10 +6438,15 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6341
6438
|
placed.push({ point: candidate.point, footprint });
|
|
6342
6439
|
continue;
|
|
6343
6440
|
}
|
|
6441
|
+
// If the anchor sits on a cluster, allow the solver's relaxed fallback to displace off it (clearing
|
|
6442
|
+
// clusters, overlapping labels if need be) rather than leave a guaranteed label covering a cluster.
|
|
6443
|
+
const anchorOnCluster = placed.some(other => other.isCluster === true && overlaps(other.point, other.footprint, candidate.point, footprint));
|
|
6344
6444
|
const stick = findStickOffset({
|
|
6345
6445
|
anchor: candidate.point,
|
|
6346
6446
|
placed,
|
|
6347
6447
|
footprint,
|
|
6448
|
+
pillWidthPx: PHYSICAL_PILL_PX.widthPx,
|
|
6449
|
+
pillHeightPx: PHYSICAL_PILL_PX.heightPx,
|
|
6348
6450
|
degreesPerPixel,
|
|
6349
6451
|
viewBox: planarViewBox,
|
|
6350
6452
|
baseDistancePx: STICK_BASE_DISTANCE_PX,
|
|
@@ -6352,6 +6454,7 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6352
6454
|
radiusSteps: STICK_RADIUS_STEPS,
|
|
6353
6455
|
anglesPerRing: STICK_ANGLES_PER_RING,
|
|
6354
6456
|
previousOffset: previousStickOffsets?.get(candidate.id),
|
|
6457
|
+
clusterClearFallback: anchorOnCluster,
|
|
6355
6458
|
});
|
|
6356
6459
|
if (stick !== undefined) {
|
|
6357
6460
|
placements.set(candidate.id, { mode: "stick", stickPositioning: stick.positioning });
|
|
@@ -6432,53 +6535,6 @@ const buildLabelPlacement = ({ items, getId, getPosition, focus, bounds, footpri
|
|
|
6432
6535
|
return placements;
|
|
6433
6536
|
};
|
|
6434
6537
|
|
|
6435
|
-
const WEB_MERCATOR_TILE_SIZE_PX = 256;
|
|
6436
|
-
/**
|
|
6437
|
-
* Degrees of longitude/latitude spanned by a single screen pixel at `zoom`,
|
|
6438
|
-
* near `latitudeDeg`, under Web Mercator.
|
|
6439
|
-
*
|
|
6440
|
-
* Longitude degrees-per-pixel is constant everywhere; latitude degrees-per-pixel
|
|
6441
|
-
* scales by `cos(latitude)` because Mercator stretches higher latitudes. Used to
|
|
6442
|
-
* convert a pixel label footprint into a geo bounding box so collision runs in
|
|
6443
|
-
* the same lng/lat space the marker positions already live in.
|
|
6444
|
-
*/
|
|
6445
|
-
const degreesPerPixel$1 = (zoom, latitudeDeg) => {
|
|
6446
|
-
const worldWidthPx = WEB_MERCATOR_TILE_SIZE_PX * 2 ** zoom;
|
|
6447
|
-
const lng = 360 / worldWidthPx;
|
|
6448
|
-
return { lng, lat: lng * Math.cos((latitudeDeg * Math.PI) / 180) };
|
|
6449
|
-
};
|
|
6450
|
-
/**
|
|
6451
|
-
* Label footprint as a geo-space width/height, for collision testing.
|
|
6452
|
-
*
|
|
6453
|
-
* A rendered pill's size does NOT follow the zoom circle-size tier: `MapMarker` lays every pill out
|
|
6454
|
-
* at the fixed {@link MARKER_PILL_CONTENT_LAYOUT_SIZE}, and the label text is capped at
|
|
6455
|
-
* `maxLabelWidthPx` (ellipsis beyond). By default the box is sized to that cap — the widest pill
|
|
6456
|
-
* that can render — so it is never smaller than what paints (no two labels overlap). Callers can pass
|
|
6457
|
-
* a smaller `labelWidthPx` to pack denser at the cost of occasional overlap for long names; density
|
|
6458
|
-
* is otherwise tuned via `paddingPx`.
|
|
6459
|
-
*/
|
|
6460
|
-
const labelFootprintDeg = ({ zoom, latitudeDeg, paddingPx, labelWidthPx, }) => {
|
|
6461
|
-
const { circle, pillPaddingY } = MARKER_SIZE_MAP[MARKER_PILL_CONTENT_LAYOUT_SIZE];
|
|
6462
|
-
const { maxLabelWidthPx, gapPx, paddingRightPx } = MARKER_TUNING.pill;
|
|
6463
|
-
// Assumed text width: the render cap by default (upper bound → no overlap), or a caller override.
|
|
6464
|
-
const textWidthPx = labelWidthPx ?? maxLabelWidthPx;
|
|
6465
|
-
// Left pad (== pillPaddingY) + icon disc + gap + label text + right pad. Assume the icon disc is
|
|
6466
|
-
// present (the wider case).
|
|
6467
|
-
const widthPx = pillPaddingY + circle + gapPx + textWidthPx + paddingRightPx + 2 * paddingPx;
|
|
6468
|
-
const heightPx = markerPillOuterHeightPx(MARKER_PILL_CONTENT_LAYOUT_SIZE) + 2 * paddingPx;
|
|
6469
|
-
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6470
|
-
return { widthDeg: widthPx * perPixel.lng, heightDeg: heightPx * perPixel.lat };
|
|
6471
|
-
};
|
|
6472
|
-
/**
|
|
6473
|
-
* Geo footprint for a circular on-screen obstacle (a cluster bubble or a marker pin) from its pixel
|
|
6474
|
-
* diameter, so labels can collision-avoid it. A square box of side `diameterPx` centred on the
|
|
6475
|
-
* obstacle — slightly conservative vs the inscribed circle, which is the safe side for a keep-out.
|
|
6476
|
-
*/
|
|
6477
|
-
const obstacleFootprintDeg = ({ zoom, latitudeDeg, diameterPx, }) => {
|
|
6478
|
-
const perPixel = degreesPerPixel$1(zoom, latitudeDeg);
|
|
6479
|
-
return { widthDeg: diameterPx * perPixel.lng, heightDeg: diameterPx * perPixel.lat };
|
|
6480
|
-
};
|
|
6481
|
-
|
|
6482
6538
|
const EMPTY_PLACEMENTS = new Map();
|
|
6483
6539
|
/**
|
|
6484
6540
|
* Assumed cluster-bubble diameter (px) for the obstacle footprint — a conservative constant that
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
|
package/package.json
CHANGED
|
@@ -1,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 {};
|