From 790f3e9147a2aeb4230c22c392456bfb493dc825 Mon Sep 17 00:00:00 2001 From: Samuel Prevost Date: Fri, 1 May 2026 00:34:00 +0200 Subject: [PATCH] fix(measurements): labels also dodge handle dots, not just other labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/CorrectedImageViewer.vue | 68 ++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/src/components/CorrectedImageViewer.vue b/src/components/CorrectedImageViewer.vue index 74503b2..5babe22 100644 --- a/src/components/CorrectedImageViewer.vue +++ b/src/components/CorrectedImageViewer.vue @@ -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 { - 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,