fix(measurements): labels also dodge handle dots, not just other labels
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

The collision resolver only avoided overlapping label rects. Labels
could still land on handle dots — easiest to see on a horizontal line
where the endpoint dots sit at the same Y as the midpoint anchor,
making the pill cover the dot the user wants to grab.

Extended `resolveLabelPositions` to push labels past any handle dot
too. Each handle becomes a 20×20 phantom rect (centred on the dot,
sized to match the primary handle's outer reach + ring) that the
resolver dodges with a 4 px gap. The handle-gap is smaller than the
inter-label gap (8 px) since dots are smaller than pills — keeps the
result visually tight.

Per-anchor exception: the handle sitting at a label's own anchor
(ellipse / circle / angle anchors are at the primary handle disk) is
skipped, otherwise the resolver would chase the label arbitrarily far
from its own measurement. The per-type `offsetY` already places the
pill clear of that one specific handle.
This commit is contained in:
Samuel Prevost 2026-05-01 00:34:00 +02:00
parent 6dc5454d46
commit 790f3e9147

View File

@ -992,16 +992,50 @@ function rectsOverlap(
// Greedy collision avoidance: process labels top-to-bottom by anchor Y,
// place each at its desired position, and if it overlaps any already-placed
// label, push it down past the offender. Predictable, fast for typical
// measurement counts, and keeps every label still horizontally aligned with
// its anchor (we only shift in Y). Labels that move significantly get a
// leader line back to their anchor in `drawLabelAt`.
// label OR any handle dot, push it down past the offender. Predictable,
// fast for typical measurement counts, and keeps every label still
// horizontally aligned with its anchor (we only shift in Y). Labels that
// move significantly get a leader line back to their anchor in
// `drawLabelAt`.
function resolveLabelPositions(
ctx: CanvasRenderingContext2D,
list: Measurement[],
rt: RenderCtx,
): Map<string, LabelPos> {
const gap = 8 * rt.strokeMul
const labelGap = 8 * rt.strokeMul
const handleGap = 4 * rt.strokeMul
// Approximate outer reach of a primary handle (8 px disk + 2 px white
// ring). Used as the half-extent of a phantom rect labels must dodge.
const handleHalf = 10 * rt.strokeMul
// Phantom rects around every handle on every measurement, in canvas
// coords. Storing the original handle centre so we can skip the handle
// sitting on a label's own anchor (ellipse / circle / angle anchors
// sit on the primary handle disk; without this exception the resolver
// would push the label arbitrarily far from its own measurement).
interface HandleRect {
cx: number
cy: number
x: number
y: number
w: number
h: number
}
const handleRects: HandleRect[] = []
for (const m of list) {
for (const h of getHandlePositions(m)) {
const s = imgToCtx(h.pt, rt)
handleRects.push({
cx: s.x,
cy: s.y,
x: s.x - handleHalf,
y: s.y - handleHalf,
w: handleHalf * 2,
h: handleHalf * 2,
})
}
}
const items = list.map((m) => {
const anchor = imgToCtx(labelAnchor(m), rt)
const rect = labelRect(ctx, m, rt)
@ -1015,8 +1049,28 @@ function resolveLabelPositions(
while (safety-- > 0) {
let collided = false
for (const p of placed) {
if (rectsOverlap(it.rect, p.rect, gap)) {
const shift = p.rect.y + p.rect.h + gap - it.rect.y
if (rectsOverlap(it.rect, p.rect, labelGap)) {
const shift = p.rect.y + p.rect.h + labelGap - it.rect.y
it.rect = {
...it.rect,
y: it.rect.y + shift,
textY: it.rect.textY + shift,
}
collided = true
break
}
}
if (collided) continue
for (const hr of handleRects) {
// Skip the handle that sits on this label's own anchor
// the per-type `offsetY` in `labelRect` already places the
// pill clear of it, and dodging it again would chase the
// label off-target.
const dx = hr.cx - it.anchor.x
const dy = hr.cy - it.anchor.y
if (dx * dx + dy * dy < 16) continue
if (rectsOverlap(it.rect, hr, handleGap)) {
const shift = hr.y + hr.h + handleGap - it.rect.y
it.rect = {
...it.rect,
y: it.rect.y + shift,