From 9f055971e6c2fe3a2ff4337f983a01683390afa6 Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 15:26:53 +0200 Subject: [PATCH 1/5] docs(localize): design pointer-anchored zoom for the object editor stage --- ...2026-08-12-localize-pointer-zoom-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/specs/2026-08-12-localize-pointer-zoom-design.md diff --git a/docs/specs/2026-08-12-localize-pointer-zoom-design.md b/docs/specs/2026-08-12-localize-pointer-zoom-design.md new file mode 100644 index 00000000..54db20df --- /dev/null +++ b/docs/specs/2026-08-12-localize-pointer-zoom-design.md @@ -0,0 +1,196 @@ +# Pointer-anchored zoom on the localize stage + +Date: 2026-08-12 + +## Problem + +On the object editor's canvas (`/localize/:sequenceId/object/:laneId/:detectionId`) +the wheel zooms about the image's center, wherever the pointer is. Magnifying a +plume in a corner therefore means zoom, pan, zoom, pan — and the pan is the part +that fights back, because the same handler also resets the framing. + +`useBoxDrawingStage`'s wheel handler is the whole of the current behavior: + +```ts +setTransformOrigin({ x: 50, y: 50 }); +setZoomLevel(z => Math.max(1, Math.min(4, z + (e.deltaY < 0 ? 0.2 : -0.2)))); +``` + +Three things are wrong with it: + +1. **It ignores the pointer.** The point under the cursor moves away as you zoom. +2. **It discards the `Z` framing.** `applyView` frames the object by moving + `transformOrigin` to the box center; the wheel hard-resets that origin to + 50/50, so one notch after pressing `Z` snaps the view back to the middle of + the scene. +3. **Its steps are additive.** `+0.2` is a 20% jump at 1x and a 5% nudge at 4x, + so deep zoom crawls. + +There is also a latent bug on the path: `constrainPan`'s bound +`base·(z−1)/(2z)` is exactly right for a *centered* transform origin and wrong +for an off-center one, so panning inside `Z` crop view can currently drag blank +space into view. + +## The transform, stated once + +The `` and every overlay layer share one transform. Writing `p` for a point +in image-layout pixels, `O` for the transform origin, `z` for the scale and `t` +for the pan: + +``` +transform: scale(z) translate(t) (translate applies inside the scale) + +s(p) = O + z·((p − O) + t) +``` + +`screenToImageCoordinates` inverts exactly this, which is why box drawing stays +correct at any zoom. + +The root difficulty is that this has **two** positional knobs, `O` and `t`, and +pointer anchoring needs a single one to solve for. + +## Design + +### 1. Pan is the only positional knob + +`transformOrigin` leaves the stage's state and the components' props. The origin +is always the image center (`50% 50%`, the CSS default, so nothing sets it), and +all framing lives in the pan. With `O = W/2` the model collapses to: + +``` +s(p) = O + z·((p − O) + t·W) +``` + +The stage keeps one state object rather than three pieces, so a zoom step cannot +read a stale pan: + +```ts +type StageView = { scale: number; pan: Point }; // pan in FRACTIONS of the image's rendered size +``` + +Pan is dimensionless — a fraction of the image's rendered size, not layout +pixels. Every layer renders `translate(tx*100%, ty*100%)`. This keeps the +framing math free of layout, exactly as the percentage origin was: the clamp, +the `Z` conversion and their tests are pure numbers, and only the pointer anchor +needs live geometry. + +CSS percentages in `translate()` resolve against each element's own pre-transform +border box. For the `` that is the picture; for the `absolute inset-0` +overlay layers it is the canvas container — which is a shrink-to-fit flex item +wrapping only the image, so the two boxes coincide. That coincidence is +load-bearing and gets a test, not a comment. + +### 2. A pure math module + +`src/utils/annotation/stageViewUtils.ts` — no React, no DOM: + +| Function | Contract | +| --- | --- | +| `wheelZoomFactor(e)` | `exp(−Δ·k)` with Δ normalized across `deltaMode` (line ×33, page ×400) and `k = ln(1.15)/100`, so a mouse notch (Δ≈100px) is a ~1.15 factor | +| `zoomAtPoint(view, cursorNorm, nextScale)` | `t' = (z·t + (z − z')·(c − 0.5)) / z'`, then clamped | +| `clampPan(pan, scale)` | `\|t\| ≤ (z−1)/(2z)` | +| `cropToPan(crop)` | `t = (1 − z)·(c − 0.5)/z` | + +`clampPan`'s bound is dimensionless and exact for a centered origin — which is +now the only origin there is. It also subsumes the old "snap pan to 0 when the +zoom returns to 1" special case, since the bound is 0 at `z = 1`. + +`cropToPan` is the algebraic equivalent of the origin-based framing: equating +`O' + z(p − O')` with `O + z(p − O) + z·t·W` gives `t = (1 − z)(O' − O)/(z·W)`. +`Z`'s framing is therefore preserved pixel for pixel, and the equivalence is +what its test asserts. + +### 3. The wheel handler + +```ts +const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const c = imageToNormalized(...screenToImageCoords(e.clientX, e.clientY)); + setView(v => zoomAtPoint(v, c, clamp(v.scale * wheelZoomFactor(e), 1, MAX_ZOOM))); +}; +``` + +`imageToNormalizedCoordinates` already clamps to 0..1, so a cursor outside the +image anchors at the nearest edge rather than flinging the view. + +- **Multiplicative steps**, so a notch feels the same at 1x and at 6x. +- **Scaled by delta magnitude**, so a trackpad's stream of small deltas zooms + smoothly instead of slamming into the ceiling; a `ctrl+wheel` pinch arrives at + this same handler and needs nothing extra. +- **Ceiling 8** (from 4), matching the grid's `MAX_SCALE`, so a small distant + plume can fill the frame. `Z`'s own ceiling stays 3 (`OBJECT_FRAMING`); the + wheel now pushes past it from there instead of resetting. + +Wheeling inside crop view leaves the `Z` toggle **pressed**. It is a mode, not a +snapshot: the wheel refines the framing, and stepping to the next frame re-frames +the object as it does today. + +### 4. Coordinate inversion + +`screenToImageCoordinates` loses its `transformOrigin` parameter and reads pan as +a fraction: + +``` +p = (X − bounds.x − W/2)/z + W/2 − t·W +``` + +`TransformConfig` narrows to match. The stage hook is its only caller. + +## Footprint + +- `src/hooks/annotation/useBoxDrawingStage.ts` — merged view state, new wheel + handler, `applyView` / `resetZoom` / `constrainPan` rewritten. The public API + keeps `zoomLevel` and `panOffset` and drops `transformOrigin`. +- `src/utils/annotation/stageViewUtils.ts` — new. +- `src/utils/annotation/coordinateUtils.ts` — signature and inversion. +- `src/components/detection-annotation/DetectionAnnotationCanvas.tsx`, + `src/components/annotation/ImageOverlays.tsx` (`DrawingOverlay`), + `src/components/localize/add-object/AddObjectOverlay.tsx` — drop the prop, + render `translate` in percent. +- `src/components/localize/editor/EditorShortcutsModal.tsx` — the wheel row names + the pointer. + +The add-object overlay inherits pointer zoom for free: same hook, same gesture. + +**Untouched:** the grid (`AlertFrameGrid`) and `DetectionImageCard` keep their own +origin-based crop CSS. Different surface, no pan, working fine. + +## Tests + +`tests/utils/annotation/stageViewUtils.test.ts` carries the load: + +- **Anchor invariance** — push a point through the forward transform before and + after a zoom step and assert its screen position is unchanged. Covers zooming + in and out, from an already-panned view, and at the pan clamp (where the anchor + is allowed to slip, because the alternative is blank edges). +- **Clamp** — 0 at `z = 1`, `(z−1)/(2z)` above it. +- **`cropToPan` equivalence** — the new transform puts the box center at the same + screen position the origin-based one did. This is the `Z` regression proof. +- **Delta normalization** — a line-mode notch and a pixel-mode notch produce + comparable factors; a small trackpad delta produces a small one. + +`tests/utils/annotation/coordinateUtils.test.ts` updates to the new signature and +gains a screen → image → screen round trip at `z > 1` with a nonzero pan. + +In the editor tests the `Z` / `R` assertions become `scale(3) translate(20%, 20%)` +shaped and stay stub-free, plus: + +- one wheel test using the existing `stubGeometry` helper, proving the anchor + end to end through the real component; +- one test pinning container box == image box, since the percent basis depends + on it. + +`DrawingOverlay.strokes` and `DetectionAnnotationCanvas` tests drop the +`transformOrigin` prop. + +## Verification + +`npm run quality` and `npm test`, then a dev server from this worktree against +the shared API for a hands-on pass over +`/localize/971/object/971/16860`: wheel over a corner plume, wheel back out, +`Z` then wheel, `R`, and a pan at high zoom to confirm no blank edges. + +## Out of scope + +Keyboard `+` / `-` zoom, double-click zoom, gesture events beyond `ctrl+wheel`, +and any change to the grid's crop mode. From b08893ad0e2f6322f516039e7c93af1dd1a4c36c Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 15:36:12 +0200 Subject: [PATCH 2/5] feat(localize): add pure view math for the drawing stage --- frontend/src/utils/annotation/index.ts | 13 ++ .../src/utils/annotation/stageViewUtils.ts | 123 +++++++++++++++ .../utils/annotation/stageViewUtils.test.ts | 147 ++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 frontend/src/utils/annotation/stageViewUtils.ts create mode 100644 frontend/tests/utils/annotation/stageViewUtils.test.ts diff --git a/frontend/src/utils/annotation/index.ts b/frontend/src/utils/annotation/index.ts index b492b769..8a19cb5f 100644 --- a/frontend/src/utils/annotation/index.ts +++ b/frontend/src/utils/annotation/index.ts @@ -63,6 +63,19 @@ export type { CellState, QuickSubmitPlan, QuickSubmitPayload } from './quickSubm export { computeCellCrop, focusOnMainObject } from './gridCropUtils'; export type { CellCrop } from './gridCropUtils'; +// Box-drawing stage: scale plus a pan expressed as a fraction of the image +export { + MAX_ZOOM, + MIN_ZOOM, + clampPan, + clampScale, + cropToPan, + stageTransform, + wheelZoomFactor, + zoomAtPoint, +} from './stageViewUtils'; +export type { StageView } from './stageViewUtils'; + // Bounding box move/resize geometry export { moveBox, resizeBox, HANDLE_CURSOR } from './boxEditUtils'; export type { ResizeHandle, Box } from './boxEditUtils'; diff --git a/frontend/src/utils/annotation/stageViewUtils.ts b/frontend/src/utils/annotation/stageViewUtils.ts new file mode 100644 index 00000000..18032ad9 --- /dev/null +++ b/frontend/src/utils/annotation/stageViewUtils.ts @@ -0,0 +1,123 @@ +/** + * View math for the box-drawing stage. + * + * The stage's view is a scale plus a pan, and the pan is a FRACTION of the + * image's rendered size rather than layout pixels. That keeps the framing + * math free of layout — the clamp and the `Z` conversion below are pure + * numbers — and only the pointer anchor needs to know how big the image + * actually is, to say where the cursor fell. + * + * The transform origin is always the image's centre, so there is exactly one + * positional knob to solve for. Writing `O` for that centre, the CSS + * `scale(z) translate(t)` applied to a point `p` in image pixels is + * + * s(p) = O + z * ((p - O) + t * W) + * + * with the translate inside the scale. See + * docs/specs/2026-08-12-localize-pointer-zoom-design.md. + */ + +export interface StageView { + scale: number; + /** Pan, as a fraction of the image's rendered size. */ + pan: { x: number; y: number }; +} + +export const MIN_ZOOM = 1; +export const MAX_ZOOM = 8; + +/** What one mouse notch multiplies the zoom by. */ +const NOTCH_FACTOR = 1.15; +/** The wheel delta a notch reports, in pixel mode. */ +const NOTCH_DELTA = 100; +const SENSITIVITY = Math.log(NOTCH_FACTOR) / NOTCH_DELTA; +/** + * `deltaMode` units in pixels. Firefox reports 3 LINEs where Chrome reports + * ~100 pixels for the same notch, so a line is worth about 33. + */ +const LINE_PX = 33; +const PAGE_PX = 400; + +const round = (value: number, dp: number): number => { + const factor = 10 ** dp; + return Math.round(value * factor) / factor; +}; + +/** + * The factor one wheel event should multiply the zoom by. Exponential in the + * delta, so a step feels the same at 1x and at 6x, and proportional to the + * delta's size, so a trackpad's stream of small deltas zooms smoothly + * instead of slamming into the ceiling. + */ +export function wheelZoomFactor(e: { deltaY: number; deltaMode?: number }): number { + const unit = e.deltaMode === 1 ? LINE_PX : e.deltaMode === 2 ? PAGE_PX : 1; + return Math.exp(-e.deltaY * unit * SENSITIVITY); +} + +export function clampScale(scale: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale)); +} + +/** + * The pan that still leaves the image covering its frame. The pan applies + * inside the scale, so the bound is (z - 1) / 2z — dimensionless, because + * the pan is a fraction. It is 0 at the full frame, which is what returns + * the view to centre when the zoom comes back to 1. + */ +export function clampPan(pan: { x: number; y: number }, scale: number): { x: number; y: number } { + const max = (scale - 1) / (2 * scale); + // Clamping a negative pan to a bound of 0 yields -0, which compares + // unequal to the 0 every other path produces. + const clamp = (value: number) => Math.max(-max, Math.min(max, value)) || 0; + return { x: clamp(pan.x), y: clamp(pan.y) }; +} + +/** + * Zoom to `nextScale` while holding `cursor` — a normalized image point — + * still on screen. Solving s(cursor) before = s(cursor) after for the new + * pan gives t' = (z*t + (z - z')*(c - 0.5)) / z'. + * + * The clamp can override the anchor near the edges; letting the anchor win + * there would be letting blank space in. + */ +export function zoomAtPoint( + view: StageView, + cursor: { x: number; y: number }, + nextScale: number +): StageView { + const z = view.scale; + const next = clampScale(nextScale); + const solve = (pan: number, c: number) => (z * pan + (z - next) * (c - 0.5)) / next; + return { + scale: next, + pan: clampPan({ x: solve(view.pan.x, cursor.x), y: solve(view.pan.y, cursor.y) }, next), + }; +} + +/** + * The pan equivalent to a `computeCellCrop` framing, which speaks in + * transform-origin percentages. Equating the origin-based transform + * `O' + z(p - O')` with the centred one gives t = (1 - z)(c - 0.5) / z, so + * the object is framed exactly where the origin version framed it. + */ +export function cropToPan(crop: { scale: number; originX: number; originY: number }): StageView { + const z = crop.scale; + const solve = (originPercent: number) => ((1 - z) * (originPercent / 100 - 0.5)) / z; + return { + scale: z, + pan: clampPan({ x: solve(crop.originX), y: solve(crop.originY) }, z), + }; +} + +/** + * The CSS every layer of the stage shares. The pan renders as a percentage + * so it needs no layout to apply; the rounding is there because a + * multiplicative zoom step leaves float noise that would otherwise reach the + * DOM as `scale(1.1499999999999997)`. + */ +export function stageTransform(view: StageView): string { + return `scale(${round(view.scale, 4)}) translate(${round(view.pan.x * 100, 3)}%, ${round( + view.pan.y * 100, + 3 + )}%)`; +} diff --git a/frontend/tests/utils/annotation/stageViewUtils.test.ts b/frontend/tests/utils/annotation/stageViewUtils.test.ts new file mode 100644 index 00000000..fcab911f --- /dev/null +++ b/frontend/tests/utils/annotation/stageViewUtils.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_ZOOM, + StageView, + clampPan, + clampScale, + cropToPan, + stageTransform, + wheelZoomFactor, + zoomAtPoint, +} from '@/utils/annotation/stageViewUtils'; + +/** + * The forward transform, restated here independently of the module under + * test: where a point at normalized image coordinate `u` lands, as a + * fraction of the image's rendered size. Every anchoring claim below is + * "this point projects to the same place before and after". + */ +const project = (u: number, view: StageView, axis: 'x' | 'y' = 'x') => + 0.5 + view.scale * (u - 0.5 + view.pan[axis]); + +/** The old transform-origin framing: origin `c` (a fraction) held fixed. */ +const projectAboutOrigin = (u: number, scale: number, c: number) => c + scale * (u - c); + +const AT_REST: StageView = { scale: 1, pan: { x: 0, y: 0 } }; + +describe('zoomAtPoint', () => { + it('holds the point under the cursor still while zooming in', () => { + const cursor = { x: 0.8, y: 0.3 }; + const zoomed = zoomAtPoint(AT_REST, cursor, 2); + + expect(zoomed.scale).toBe(2); + expect(project(cursor.x, zoomed, 'x')).toBeCloseTo(project(cursor.x, AT_REST, 'x'), 10); + expect(project(cursor.y, zoomed, 'y')).toBeCloseTo(project(cursor.y, AT_REST, 'y'), 10); + }); + + it('holds it still when zooming out of an already panned view', () => { + const panned: StageView = { scale: 4, pan: { x: -0.1, y: 0.1 } }; + const cursor = { x: 0.45, y: 0.55 }; + const zoomed = zoomAtPoint(panned, cursor, 2.5); + + expect(project(cursor.x, zoomed, 'x')).toBeCloseTo(project(cursor.x, panned, 'x'), 10); + expect(project(cursor.y, zoomed, 'y')).toBeCloseTo(project(cursor.y, panned, 'y'), 10); + }); + + it('lets the clamp win over the anchor rather than showing a blank edge', () => { + // Zooming out shrinks the pan the image can afford — (z-1)/2z falls from + // 0.375 to 0.3 here — so holding this point still would need a pan that + // uncovers the frame. The anchor is what gives. + const panned: StageView = { scale: 4, pan: { x: -0.2, y: 0.15 } }; + const zoomed = zoomAtPoint(panned, { x: 0.35, y: 0.62 }, 2.5); + + expect(zoomed.pan.x).toBeCloseTo(-0.3, 10); + expect(Math.abs(zoomed.pan.x)).toBeLessThanOrEqual((2.5 - 1) / (2 * 2.5)); + }); + + it('holds it still across a run of steps, so a wheel burst does not drift', () => { + const cursor = { x: 0.18, y: 0.9 }; + let view = AT_REST; + for (let i = 0; i < 8; i++) view = zoomAtPoint(view, cursor, view.scale * 1.15); + + expect(view.scale).toBeGreaterThan(3); + expect(project(cursor.x, view, 'x')).toBeCloseTo(project(cursor.x, AT_REST, 'x'), 10); + }); + + it('refuses to zoom past the ceiling or below the full frame', () => { + expect(zoomAtPoint(AT_REST, { x: 0.5, y: 0.5 }, 99).scale).toBe(MAX_ZOOM); + expect(zoomAtPoint(AT_REST, { x: 0.5, y: 0.5 }, 0.2).scale).toBe(1); + }); + + it('returns to no pan at all when the zoom returns to 1', () => { + const panned: StageView = { scale: 3, pan: { x: 0.3, y: -0.3 } }; + expect(zoomAtPoint(panned, { x: 0.1, y: 0.1 }, 1).pan).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('clampPan', () => { + it('allows no pan at the full frame', () => { + expect(clampPan({ x: 0.5, y: -0.5 }, 1)).toEqual({ x: 0, y: 0 }); + }); + + it('stops the pan where the image would uncover its frame', () => { + // Half the image is off-frame at 2x, and the pan sits inside the scale: + // (z - 1) / 2z = 0.25. + expect(clampPan({ x: 0.9, y: -0.9 }, 2)).toEqual({ x: 0.25, y: -0.25 }); + expect(clampPan({ x: 0.1, y: -0.1 }, 2)).toEqual({ x: 0.1, y: -0.1 }); + }); +}); + +describe('clampScale', () => { + it('holds the scale between the full frame and the ceiling', () => { + expect(clampScale(0.5)).toBe(1); + expect(clampScale(2.5)).toBe(2.5); + expect(clampScale(20)).toBe(MAX_ZOOM); + }); +}); + +describe('cropToPan', () => { + it('frames the object exactly where the transform-origin version did', () => { + // The editor's own framing of the fixture box [0.2,0.2,0.3,0.3]: + // 0.32 target fill over a 0.1 span clamps to scale 3, centred on 25%. + const view = cropToPan({ scale: 3, originX: 25, originY: 25 }); + + for (const u of [0, 0.25, 0.5, 1]) { + expect(project(u, view, 'x')).toBeCloseTo(projectAboutOrigin(u, 3, 0.25), 10); + expect(project(u, view, 'y')).toBeCloseTo(projectAboutOrigin(u, 3, 0.25), 10); + } + }); + + it('is a no-op at scale 1, where there is nothing to frame', () => { + expect(cropToPan({ scale: 1, originX: 50, originY: 50 })).toEqual({ + scale: 1, + pan: { x: 0, y: 0 }, + }); + }); +}); + +describe('wheelZoomFactor', () => { + it('turns one mouse notch into a ~15% step, in whichever direction', () => { + expect(wheelZoomFactor({ deltaY: -100 })).toBeCloseTo(1.15, 4); + expect(wheelZoomFactor({ deltaY: 100 })).toBeCloseTo(1 / 1.15, 4); + }); + + it('reads a line-mode notch as comparable to a pixel-mode one', () => { + // Firefox reports 3 lines where Chrome reports ~100px for the same notch. + expect(wheelZoomFactor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(1.148, 3); + }); + + it('scales with the delta, so a trackpad nudge is a nudge', () => { + expect(wheelZoomFactor({ deltaY: -12 })).toBeCloseTo(1.017, 3); + }); +}); + +describe('stageTransform', () => { + it('renders the pan as a percentage of the image, trimmed', () => { + expect(stageTransform({ scale: 3, pan: { x: 0.5 / 3, y: 0.5 / 3 } })).toBe( + 'scale(3) translate(16.667%, 16.667%)' + ); + expect(stageTransform({ scale: 1, pan: { x: 0, y: 0 } })).toBe('scale(1) translate(0%, 0%)'); + }); + + it('trims the float noise a multiplicative step leaves on the scale', () => { + expect(stageTransform({ scale: 1.1499999999999997, pan: { x: 0, y: 0 } })).toBe( + 'scale(1.15) translate(0%, 0%)' + ); + }); +}); From 0ebfce54a9c58867f9c697305052b5010a410297 Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 15:41:07 +0200 Subject: [PATCH 3/5] refactor(localize): make pan the stage's only positional knob --- .../components/annotation/ImageOverlays.tsx | 7 +- .../DetectionAnnotationCanvas.tsx | 20 ++-- .../localize/add-object/AddObjectOverlay.tsx | 1 - .../localize/editor/LocalizeObjectEditor.tsx | 1 - .../hooks/annotation/useBoxDrawingStage.ts | 109 +++++++++--------- .../src/utils/annotation/coordinateUtils.ts | 49 +++----- frontend/src/utils/annotation/index.ts | 8 +- .../DrawingOverlay.strokes.test.tsx | 1 - .../DetectionAnnotationCanvas.test.tsx | 1 - .../editor/LocalizeObjectEditor.test.tsx | 44 +++++-- .../utils/annotation/coordinateUtils.test.ts | 31 +++-- 11 files changed, 138 insertions(+), 134 deletions(-) diff --git a/frontend/src/components/annotation/ImageOverlays.tsx b/frontend/src/components/annotation/ImageOverlays.tsx index 076b251f..cb6d6bd3 100644 --- a/frontend/src/components/annotation/ImageOverlays.tsx +++ b/frontend/src/components/annotation/ImageOverlays.tsx @@ -11,6 +11,7 @@ import { ModelLayer, ResizeHandle, HANDLE_CURSOR, + stageTransform, } from '@/utils/annotation'; import { normalizedToPixelBox, @@ -363,8 +364,8 @@ interface DrawingOverlayProps { selectedRectangleId: string | null; imageInfo: ImageInfo; zoomLevel: number; + /** Pan, as a fraction of the image's rendered size. */ panOffset: { x: number; y: number }; - transformOrigin: { x: number; y: number }; isDragging: boolean; normalizedToImage: (normX: number, normY: number) => { x: number; y: number }; // Drag-to-move (box body) and drag-to-resize (handles) on the selected box. @@ -393,7 +394,6 @@ export function DrawingOverlay({ imageInfo, zoomLevel, panOffset, - transformOrigin, isDragging, normalizedToImage, onBoxPointerDown, @@ -438,8 +438,7 @@ export function DrawingOverlay({
diff --git a/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx b/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx index 62788c56..4aef6770 100644 --- a/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx +++ b/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx @@ -28,6 +28,7 @@ import { } from '@/components/annotation/ImageOverlays'; import { SOURCE_COLOR, SOURCE_WEIGHT } from '@/components/localize/editor/sourceIdentity'; import { hairlineStroke } from '@/utils/annotation/hairlineStroke'; +import { stageTransform } from '@/utils/annotation'; interface ImageInfo { width: number; @@ -64,10 +65,10 @@ interface DetectionAnnotationCanvasProps { containerRef: React.RefObject; imgRef: React.RefObject; imageInfo: ImageInfo | null; - // Zoom/pan state passed from parent + // Zoom/pan state passed from parent. The pan is a fraction of the image's + // rendered size; the transform origin is always the image's centre. zoomLevel: number; panOffset: Point; - transformOrigin: Point; isDragging: boolean; // Event handlers onMouseDown: (e: React.MouseEvent) => void; @@ -98,7 +99,6 @@ export function DetectionAnnotationCanvas({ imageInfo, zoomLevel, panOffset, - transformOrigin, isDragging, onMouseDown, onMouseMove, @@ -111,6 +111,10 @@ export function DetectionAnnotationCanvas({ }: DetectionAnnotationCanvasProps) { const { data: imageData } = useDetectionImage(detection.id); + // Every layer shares the stage's transform — the image, the other objects' + // boxes, the ghosts and the drawing overlay have to scale and pan as one. + const transform = stageTransform({ scale: zoomLevel, pan: panOffset }); + // `DrawingOverlay` speaks in rectangle arrays; the committed box is a // one-element array. Selecting it is what reveals its move/resize // affordances — unselected it renders in its own smoke-type color, so the @@ -150,8 +154,7 @@ export function DetectionAnnotationCanvas({ alt={`Detection ${detection.id}`} className="max-w-full max-h-[95vh] object-contain block" style={{ - transform: `scale(${zoomLevel}) translate(${panOffset.x}px, ${panOffset.y}px)`, - transformOrigin: `${transformOrigin.x}% ${transformOrigin.y}%`, + transform, transition: isDragging ? 'none' : 'transform 0.1s ease-out', }} onLoad={handleImageLoad} @@ -162,8 +165,7 @@ export function DetectionAnnotationCanvas({
void; zoomLevel: number; + /** Pan, as a fraction of the image's rendered size. */ panOffset: Point; - transformOrigin: Point; isDragging: boolean; spaceHeld: boolean; currentDrawing: CurrentDrawing | null; @@ -122,12 +125,22 @@ export function useBoxDrawingStage({ }: UseBoxDrawingStageParams): BoxDrawingStage { const [imageInfo, setImageInfo] = useState(null); - // Zoom / pan — lifted from ImageModal unchanged; that plumbing is sound. - const [zoomLevel, setZoomLevel] = useState(1); - const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); - const [transformOrigin, setTransformOrigin] = useState({ x: 50, y: 50 }); + // One positional knob: the transform origin is the image's centre and all + // framing lives in the pan, as a fraction of the image's rendered size. + // Anchoring a zoom on the pointer is a solve for that one number; with an + // origin as well it was two coupled unknowns, and the wheel resolved them + // by throwing the framing away. + const [view, setView] = useState({ scale: 1, pan: { x: 0, y: 0 } }); const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [panStart, setPanStart] = useState<{ clientX: number; clientY: number; pan: Point } | null>( + null + ); + + // The coordinate converters are plain functions and the wheel listener is + // attached once; both read the view through here, so neither can be left + // holding the one it was created with. + const viewRef = useRef(view); + viewRef.current = view; const [currentDrawing, setCurrentDrawing] = useState(null); // Space swaps the drag from drawing to panning, as it does in every other @@ -142,15 +155,11 @@ export function useBoxDrawingStage({ // --- Zoom --------------------------------------------------------------- const resetZoom = useCallback(() => { - setZoomLevel(1); - setPanOffset({ x: 0, y: 0 }); - setTransformOrigin({ x: 50, y: 50 }); + setView({ scale: 1, pan: { x: 0, y: 0 } }); }, []); - const applyView = useCallback((view: { scale: number; originX: number; originY: number }) => { - setZoomLevel(view.scale); - setPanOffset({ x: 0, y: 0 }); - setTransformOrigin({ x: view.originX, y: view.originY }); + const applyView = useCallback((crop: { scale: number; originX: number; originY: number }) => { + setView(cropToPan(crop)); }, []); const resetTransient = useCallback(() => { @@ -158,26 +167,6 @@ export function useBoxDrawingStage({ setBoxEdit(null); }, []); - const constrainPan = useCallback( - (offset: Point): Point => { - if (!imgRef.current || zoomLevel <= 1) return offset; - // Layout size, not the transformed rect: the pan applies INSIDE the - // scale, so the max offset keeping the image covering its box is - // baseSize*(z-1)/(2z). - const maxPanX = (imgRef.current.offsetWidth * (zoomLevel - 1)) / (2 * zoomLevel); - const maxPanY = (imgRef.current.offsetHeight * (zoomLevel - 1)) / (2 * zoomLevel); - return { - x: Math.max(-maxPanX, Math.min(maxPanX, offset.x)), - y: Math.max(-maxPanY, Math.min(maxPanY, offset.y)), - }; - }, - [zoomLevel, imgRef] - ); - - useEffect(() => { - setPanOffset(prev => constrainPan(prev)); - }, [constrainPan]); - useEffect(() => { const setHeld = (held: boolean) => { spaceHeldRef.current = held; @@ -214,12 +203,10 @@ export function useBoxDrawingStage({ if (!container) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); - setTransformOrigin({ x: 50, y: 50 }); - setZoomLevel(z => { - const next = Math.max(1, Math.min(4, z + (e.deltaY < 0 ? 0.2 : -0.2))); - if (next === 1) setPanOffset({ x: 0, y: 0 }); - return next; - }); + setView(v => ({ + scale: Math.max(1, Math.min(4, v.scale + (e.deltaY < 0 ? 0.2 : -0.2))), + pan: { x: 0, y: 0 }, + })); }; container.addEventListener('wheel', onWheel, { passive: false }); return () => container.removeEventListener('wheel', onWheel); @@ -265,7 +252,7 @@ export function useBoxDrawingStage({ const getImageInfo = (): { containerOffset: Point; imageBounds: ImageBounds; - transform: { zoomLevel: number; panOffset: Point; transformOrigin: Point }; + view: StageView; } | null => { if (!imgRef.current || !containerRef.current) return null; const container = containerRef.current; @@ -282,7 +269,9 @@ export function useBoxDrawingStage({ imageNaturalWidth: imgRef.current.naturalWidth, imageNaturalHeight: imgRef.current.naturalHeight, }), - transform: { zoomLevel, panOffset, transformOrigin }, + // Through the ref, so a converter created on an earlier render still + // reports where the cursor is in the view showing NOW. + view: viewRef.current, }; }; @@ -293,7 +282,7 @@ export function useBoxDrawingStage({ { x: screenX, y: screenY }, info.containerOffset, info.imageBounds, - info.transform + info.view ); }; @@ -355,9 +344,9 @@ export function useBoxDrawingStage({ if (wantsPan) { // Middle-press otherwise starts the browser's autoscroll. if (e.button === 1) e.preventDefault(); - if (zoomLevel > 1) { + if (view.scale > 1) { setIsDragging(true); - setDragStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }); + setPanStart({ clientX: e.clientX, clientY: e.clientY, pan: view.pan }); } return; } @@ -378,8 +367,8 @@ export function useBoxDrawingStage({ if (boxEdit && imgRef.current) { // Screen-px delta over the image's on-screen size: pan- and // origin-invariant, so the box tracks the cursor 1:1 at any zoom. - const displayW = imgRef.current.offsetWidth * zoomLevel; - const displayH = imgRef.current.offsetHeight * zoomLevel; + const displayW = imgRef.current.offsetWidth * view.scale; + const displayH = imgRef.current.offsetHeight * view.scale; const dx = (e.clientX - boxEdit.startClient.x) / displayW; const dy = (e.clientY - boxEdit.startClient.y) / displayH; const next = @@ -395,8 +384,22 @@ export function useBoxDrawingStage({ setCurrentDrawing(prev => prev ? { ...prev, currentX: coords.x, currentY: coords.y } : null ); - } else if (isDragging && zoomLevel > 1) { - setPanOffset(constrainPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y })); + } else if (isDragging && panStart && imgRef.current) { + // Screen px over the image's ON-SCREEN size. The pan lives inside the + // scale, so dividing by width * scale is what makes the image track the + // cursor 1:1; dividing by width alone would move it `scale` times + // faster than the hand. + const { offsetWidth, offsetHeight } = imgRef.current; + setView(v => ({ + ...v, + pan: clampPan( + { + x: panStart.pan.x + (e.clientX - panStart.clientX) / (offsetWidth * v.scale || 1), + y: panStart.pan.y + (e.clientY - panStart.clientY) / (offsetHeight * v.scale || 1), + }, + v.scale + ), + })); } }; @@ -426,7 +429,10 @@ export function useBoxDrawingStage({ setCurrentDrawing(null); } - if (isDragging) setIsDragging(false); + if (isDragging) { + setIsDragging(false); + setPanStart(null); + } }; const getCursorStyle = () => { @@ -439,9 +445,8 @@ export function useBoxDrawingStage({ return { imageInfo, handleImageLoad, - zoomLevel, - panOffset, - transformOrigin, + zoomLevel: view.scale, + panOffset: view.pan, isDragging, spaceHeld, currentDrawing, diff --git a/frontend/src/utils/annotation/coordinateUtils.ts b/frontend/src/utils/annotation/coordinateUtils.ts index b87af8e2..5dfe00c4 100644 --- a/frontend/src/utils/annotation/coordinateUtils.ts +++ b/frontend/src/utils/annotation/coordinateUtils.ts @@ -6,6 +6,8 @@ * - Normalized coordinates (0-1 range for storage) */ +import type { StageView } from './stageViewUtils'; + /** * Configuration for image display within a container using object-contain behavior. */ @@ -34,15 +36,6 @@ export interface Point { y: number; } -/** - * Transform configuration for zoom and pan operations. - */ -export interface TransformConfig { - zoomLevel: number; - panOffset: Point; - transformOrigin: Point; // Percentages (0-100) -} - /** * Calculates the bounds of an image displayed with object-contain behavior. * This is a pure function that determines how an image fits within its container. @@ -93,7 +86,7 @@ export const calculateImageBounds = (config: ImageContainConfig): ImageBounds => * @param screenPoint - Point in screen/viewport coordinates * @param containerOffset - Container's position relative to viewport * @param imageBounds - Calculated image bounds within container - * @param transform - Current zoom and pan transform state + * @param view - Current stage view: scale, plus pan as a fraction of the image * @returns Point in image coordinate space * * @example @@ -102,7 +95,7 @@ export const calculateImageBounds = (config: ImageContainConfig): ImageBounds => * { x: 400, y: 300 }, * { x: 100, y: 50 }, * { width: 800, height: 600, x: 0, y: 0 }, - * { zoomLevel: 2.0, panOffset: { x: 10, y: 20 }, transformOrigin: { x: 50, y: 50 } } + * { scale: 2.0, pan: { x: 0.1, y: 0.05 } } * ); * ``` */ @@ -110,35 +103,25 @@ export const screenToImageCoordinates = ( screenPoint: Point, containerOffset: Point, imageBounds: ImageBounds, - transform: TransformConfig + view: StageView ): Point => { - const { zoomLevel, panOffset, transformOrigin } = transform; + const { scale, pan } = view; // Get mouse position relative to container const relativeX = screenPoint.x - containerOffset.x; const relativeY = screenPoint.y - containerOffset.y; - // Calculate transform origin in original image pixel coordinates - const originX = (transformOrigin.x / 100) * imageBounds.width; - const originY = (transformOrigin.y / 100) * imageBounds.height; + // The transform origin is the image's centre, always — the stage keeps all + // its framing in the pan, which is a fraction of the image's rendered size + // and applies INSIDE the scale. Inverting s(p) = O + z*((p - O) + t*W): + // p = (X - O)/z + W/2 - t*W + const originContainerX = imageBounds.x + imageBounds.width / 2; + const originContainerY = imageBounds.y + imageBounds.height / 2; - // Transform origin in container coordinates - const originContainerX = imageBounds.x + originX; - const originContainerY = imageBounds.y + originY; - - // Reverse the CSS transform: scale(zoomLevel) translate(panOffset.x, panOffset.y) - // Step 1: Reverse translation. The translate is applied INSIDE the scale, so - // its on-screen contribution is panOffset * zoomLevel. - const afterTranslateX = relativeX - panOffset.x * zoomLevel; - const afterTranslateY = relativeY - panOffset.y * zoomLevel; - - // Step 2: Reverse scaling around transform origin - const imageX = - (afterTranslateX - originContainerX) / zoomLevel + originContainerX - imageBounds.x; - const imageY = - (afterTranslateY - originContainerY) / zoomLevel + originContainerY - imageBounds.y; - - return { x: imageX, y: imageY }; + return { + x: (relativeX - originContainerX) / scale + imageBounds.width / 2 - pan.x * imageBounds.width, + y: (relativeY - originContainerY) / scale + imageBounds.height / 2 - pan.y * imageBounds.height, + }; }; /** diff --git a/frontend/src/utils/annotation/index.ts b/frontend/src/utils/annotation/index.ts index 8a19cb5f..e520b06d 100644 --- a/frontend/src/utils/annotation/index.ts +++ b/frontend/src/utils/annotation/index.ts @@ -15,13 +15,7 @@ export { normalizedToPixelBox, } from './coordinateUtils'; -export type { - ImageContainConfig, - ImageBounds, - Point, - TransformConfig, - ImageInfo, -} from './coordinateUtils'; +export type { ImageContainConfig, ImageBounds, Point, ImageInfo } from './coordinateUtils'; // Drawing utilities export { diff --git a/frontend/tests/components/annotation/DrawingOverlay.strokes.test.tsx b/frontend/tests/components/annotation/DrawingOverlay.strokes.test.tsx index 77b3a4a7..2107c2f7 100644 --- a/frontend/tests/components/annotation/DrawingOverlay.strokes.test.tsx +++ b/frontend/tests/components/annotation/DrawingOverlay.strokes.test.tsx @@ -22,7 +22,6 @@ const imageInfo: ImageInfo = { width: 100, height: 100, offsetX: 0, offsetY: 0 } const baseProps = { imageInfo, panOffset: { x: 0, y: 0 }, - transformOrigin: { x: 50, y: 50 }, isDragging: false, normalizedToImage: (x: number, y: number) => ({ x: x * 100, y: y * 100 }), }; diff --git a/frontend/tests/components/detection-annotation/DetectionAnnotationCanvas.test.tsx b/frontend/tests/components/detection-annotation/DetectionAnnotationCanvas.test.tsx index 71520650..1bb559ee 100644 --- a/frontend/tests/components/detection-annotation/DetectionAnnotationCanvas.test.tsx +++ b/frontend/tests/components/detection-annotation/DetectionAnnotationCanvas.test.tsx @@ -53,7 +53,6 @@ const defaultProps = { imageInfo, zoomLevel: 1, panOffset: { x: 0, y: 0 }, - transformOrigin: { x: 50, y: 50 }, isDragging: false, onMouseDown: noop, onMouseMove: noop, diff --git a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx index c4ae529a..d895eb01 100644 --- a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx +++ b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx @@ -525,6 +525,36 @@ describe('LocalizeObjectEditor canvas', () => { vi.unstubAllGlobals(); }); + it('pans with the cursor, not faster than it', () => { + // The pan applies inside the scale, so a 100px drag at 3x over an 800px + // image is 100 / (800 * 3) of the image — anything else and the picture + // slides out from under the hand. + renderLoadedEditor({ existingAnnotation: committedAnnotation(firstDetection.id, 'auto') }); + const image = stubGeometry(); + fireEvent.keyDown(window, { key: 'z' }); + + fireEvent.keyDown(window, { code: 'Space' }); + fireEvent.mouseDown(image, { button: 0, clientX: 400, clientY: 225 }); + fireEvent.mouseMove(image, { clientX: 500, clientY: 225 }); + fireEvent.mouseUp(image); + fireEvent.keyUp(window, { code: 'Space' }); + + // The 16.667% object framing plus 100 / 2400 of the image. + expect(image).toHaveStyle({ transform: 'scale(3) translate(20.833%, 16.667%)' }); + }); + + it('keeps the stage container hugging the image, which the percentage pan rests on', () => { + // `translate(%)` on the overlay layers resolves against the container and + // on the against the picture. They agree only while the container is + // a shrink-to-fit flex item centring one image — the same centring + // `calculateImageBounds` assumes. jsdom lays nothing out, so this pins the + // mechanism; the measured version is a browser check. + renderLoadedEditor(); + const stage = screen.getByAltText(/^Detection /).parentElement?.parentElement; + expect(stage?.className).toContain('items-center'); + expect(stage?.className).toContain('justify-center'); + }); + it('draws on a plain drag, with nothing to arm first', () => { const onCommit = vi.fn(); renderLoadedEditor({ onCommit }); @@ -974,7 +1004,7 @@ describe('LocalizeObjectEditor chrome', () => { // The fixture box spans 0.1 of the frame, so the framing wants 3.2x and // the ceiling holds it at 3. expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(3) translate(0px, 0px)', + transform: 'scale(3) translate(16.667%, 16.667%)', }); }); @@ -985,7 +1015,7 @@ describe('LocalizeObjectEditor chrome', () => { fireEvent.click(toggle); expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(1) translate(0px, 0px)', + transform: 'scale(1) translate(0%, 0%)', }); expect(toggle).toHaveAttribute('aria-pressed', 'false'); }); @@ -996,7 +1026,7 @@ describe('LocalizeObjectEditor chrome', () => { fireEvent.click(toggle); fireEvent.click(toggle); expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(3) translate(0px, 0px)', + transform: 'scale(3) translate(16.667%, 16.667%)', }); }); @@ -1004,7 +1034,7 @@ describe('LocalizeObjectEditor chrome', () => { renderLoadedEditor({ existingAnnotation: committedAnnotation(firstDetection.id, 'auto') }); fireEvent.keyDown(window, { key: 'r' }); expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(1) translate(0px, 0px)', + transform: 'scale(1) translate(0%, 0%)', }); }); @@ -1013,7 +1043,7 @@ describe('LocalizeObjectEditor chrome', () => { fireEvent.keyDown(window, { key: 'r' }); fireEvent.keyDown(window, { key: 'z' }); expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(3) translate(0px, 0px)', + transform: 'scale(3) translate(16.667%, 16.667%)', }); }); @@ -1037,7 +1067,7 @@ describe('LocalizeObjectEditor chrome', () => { // 0.32 target fill over the pick's 0.2 span = 1.6. The union of all // three candidates spans 0.7, which clamps to 1 — no zoom at all. expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(1.6) translate(0px, 0px)', + transform: 'scale(1.6) translate(9.375%, 9.375%)', }); }); @@ -1047,7 +1077,7 @@ describe('LocalizeObjectEditor chrome', () => { laneDetections: [detectionWithNoBoxes, lastDetection], }); expect(screen.getByAltText(/^Detection /)).toHaveStyle({ - transform: 'scale(1) translate(0px, 0px)', + transform: 'scale(1) translate(0%, 0%)', }); }); diff --git a/frontend/tests/utils/annotation/coordinateUtils.test.ts b/frontend/tests/utils/annotation/coordinateUtils.test.ts index 35efe48f..ca5f7a33 100644 --- a/frontend/tests/utils/annotation/coordinateUtils.test.ts +++ b/frontend/tests/utils/annotation/coordinateUtils.test.ts @@ -99,9 +99,8 @@ describe('coordinateUtils', () => { const containerOffset: Point = { x: 0, y: 0 }; const defaultTransform = { - zoomLevel: 1.0, - panOffset: { x: 0, y: 0 }, - transformOrigin: { x: 50, y: 50 } + scale: 1.0, + pan: { x: 0, y: 0 } }; it('should convert screen coordinates to image coordinates with no transform', () => { @@ -121,21 +120,19 @@ describe('coordinateUtils', () => { expect(imagePoint.y).toBeCloseTo(0, 5); }); - it('should handle zoom transformation', () => { + it('round-trips a screen point through a zoomed, panned view', () => { + // The inverse of s(p) = O + z*((p - O) + t*W): whatever the stage draws + // with has to land back where the cursor was. The pan is a FRACTION of + // the image's rendered size, and the origin is always its centre. + const view = { scale: 2.5, pan: { x: -0.12, y: 0.08 } }; const screenPoint: Point = { x: 250, y: 175 }; - const zoomedTransform = { - zoomLevel: 2.0, - panOffset: { x: 0, y: 0 }, - transformOrigin: { x: 50, y: 50 } - }; - const imagePoint = screenToImageCoordinates(screenPoint, containerOffset, imageBounds, zoomedTransform); - - // With 2x zoom and transform origin at center, the math is complex - // Let's just verify it produces reasonable coordinates - expect(typeof imagePoint.x).toBe('number'); - expect(typeof imagePoint.y).toBe('number'); - expect(imagePoint.x).toBeGreaterThan(0); - expect(imagePoint.y).toBeGreaterThan(0); + + const imagePoint = screenToImageCoordinates(screenPoint, containerOffset, imageBounds, view); + + const forward = (p: number, size: number, origin: number, pan: number) => + origin + size / 2 + view.scale * (p - size / 2 + pan * size); + expect(forward(imagePoint.x, 400, 50, view.pan.x)).toBeCloseTo(screenPoint.x, 6); + expect(forward(imagePoint.y, 300, 25, view.pan.y)).toBeCloseTo(screenPoint.y, 6); }); it('should handle container offset', () => { From 90f6f4d582908a243ddb4d69a28755c2ea7133de Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 15:42:28 +0200 Subject: [PATCH 4/5] feat(localize): zoom the editor canvas at the pointer --- .../localize/editor/EditorShortcutsModal.tsx | 2 +- .../hooks/annotation/useBoxDrawingStage.ts | 16 ++++++--- .../editor/LocalizeObjectEditor.test.tsx | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/localize/editor/EditorShortcutsModal.tsx b/frontend/src/components/localize/editor/EditorShortcutsModal.tsx index eaef2c6b..2e0f6c71 100644 --- a/frontend/src/components/localize/editor/EditorShortcutsModal.tsx +++ b/frontend/src/components/localize/editor/EditorShortcutsModal.tsx @@ -82,7 +82,7 @@ export const EditorShortcutsModal: React.FC = ({ onCl
- +
diff --git a/frontend/src/hooks/annotation/useBoxDrawingStage.ts b/frontend/src/hooks/annotation/useBoxDrawingStage.ts index 7e5ffa52..3c35f359 100644 --- a/frontend/src/hooks/annotation/useBoxDrawingStage.ts +++ b/frontend/src/hooks/annotation/useBoxDrawingStage.ts @@ -26,6 +26,8 @@ import { resizeBox, clampPan, cropToPan, + wheelZoomFactor, + zoomAtPoint, type CurrentDrawing, type ImageBounds, type Point, @@ -203,13 +205,19 @@ export function useBoxDrawingStage({ if (!container) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); - setView(v => ({ - scale: Math.max(1, Math.min(4, v.scale + (e.deltaY < 0 ? 0.2 : -0.2))), - pan: { x: 0, y: 0 }, - })); + // Where the cursor sits on the image is what decides where the zoom + // happens. Read through the converters, so it accounts for the view + // already applied and a burst of notches compounds without drifting — + // and so the object framing is refined rather than thrown away. + const point = screenToImageCoords(e.clientX, e.clientY); + const cursor = imageToNormalized(point.x, point.y); + setView(v => zoomAtPoint(v, cursor, v.scale * wheelZoomFactor(e))); }; container.addEventListener('wheel', onWheel, { passive: false }); return () => container.removeEventListener('wheel', onWheel); + // The converters read the live view through `viewRef`, so the listener + // attached on the first render stays correct and needs no re-attaching. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [containerRef]); // --- Coordinates -------------------------------------------------------- diff --git a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx index d895eb01..c8dd8f0d 100644 --- a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx +++ b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx @@ -525,6 +525,39 @@ describe('LocalizeObjectEditor canvas', () => { vi.unstubAllGlobals(); }); + it('zooms at the pointer, holding the point under it still', () => { + renderLoadedEditor(); + const image = stubGeometry(); + + // 640px across an 800px image is 0.8 of the way over; one notch up is a + // 1.15 factor, and holding 0.8 still takes (1 - 1.15)(0.3)/1.15 of pan. + fireEvent.wheel(image.parentElement as HTMLElement, { + deltaY: -100, + clientX: 640, + clientY: 225, + }); + + expect(image).toHaveStyle({ transform: 'scale(1.15) translate(-3.913%, 0%)' }); + }); + + it('keeps the object framing when the wheel refines it', () => { + renderLoadedEditor({ existingAnnotation: committedAnnotation(firstDetection.id, 'auto') }); + const image = stubGeometry(); + fireEvent.keyDown(window, { key: 'z' }); + + // Wheeling used to reset the transform origin, which threw the framing + // away on the first notch. Now it refines it — and the Z toggle stays + // pressed, because the framing is a mode, not a snapshot. + fireEvent.wheel(image.parentElement as HTMLElement, { + deltaY: -100, + clientX: 400, + clientY: 225, + }); + + expect(image.style.transform).toContain('scale(3.45)'); + expect(screen.getByTestId('editor-zoom-toggle')).toHaveAttribute('aria-pressed', 'true'); + }); + it('pans with the cursor, not faster than it', () => { // The pan applies inside the scale, so a 100px drag at 3x over an 800px // image is 100 / (800 * 3) of the image — anything else and the picture From 4d39796dd9709f968803bd4643b69f8235d2050d Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 16:02:29 +0200 Subject: [PATCH 5/5] fix(localize): attach the wheel on a cold open, and ignore unmeasurable frames --- .../DetectionAnnotationCanvas.tsx | 8 +++ .../hooks/annotation/useBoxDrawingStage.ts | 24 +++++-- .../src/utils/annotation/stageViewUtils.ts | 12 +++- .../editor/LocalizeObjectEditor.test.tsx | 69 ++++++++++++++++--- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx b/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx index 4aef6770..3c23c02f 100644 --- a/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx +++ b/frontend/src/components/detection-annotation/DetectionAnnotationCanvas.tsx @@ -113,6 +113,14 @@ export function DetectionAnnotationCanvas({ // Every layer shares the stage's transform — the image, the other objects' // boxes, the ghosts and the drawing overlay have to scale and pan as one. + // + // The pan renders as a percentage, which CSS resolves against each element's + // OWN box: the picture for the , this container for the `inset-0` + // overlay layers. They agree because the container is a shrink-to-fit flex + // item wrapping one centred image — measured in Chromium from 1920x1080 down + // to 1280x420, where `max-h-[95vh]` binds, as agreeing within 0.02px. Give + // the container a width of its own and the overlays would drift from the + // image by pan% of the difference, but only once panned. const transform = stageTransform({ scale: zoomLevel, pan: panOffset }); // `DrawingOverlay` speaks in rectangle arrays; the committed box is a diff --git a/frontend/src/hooks/annotation/useBoxDrawingStage.ts b/frontend/src/hooks/annotation/useBoxDrawingStage.ts index 3c35f359..15b38396 100644 --- a/frontend/src/hooks/annotation/useBoxDrawingStage.ts +++ b/frontend/src/hooks/annotation/useBoxDrawingStage.ts @@ -211,14 +211,30 @@ export function useBoxDrawingStage({ // and so the object framing is refined rather than thrown away. const point = screenToImageCoords(e.clientX, e.clientY); const cursor = imageToNormalized(point.x, point.y); - setView(v => zoomAtPoint(v, cursor, v.scale * wheelZoomFactor(e))); + // An reports no natural size until it decodes, and the bounds come + // from its aspect ratio — 0/0 — so mid-frame-change every coordinate is + // NaN. Zooming on that would throw the framing away rather than anchor + // anything, so let the notch go rather than corrupt the view. + if (!Number.isFinite(cursor.x) || !Number.isFinite(cursor.y)) return; + setView(v => { + const next = zoomAtPoint(v, cursor, v.scale * wheelZoomFactor(e)); + // A horizontal trackpad scroll (deltaY 0) and every further notch at + // the clamp both land on the view already showing; a fresh object + // would re-render the whole editor to paint the same pixels. + return next.scale === v.scale && next.pan.x === v.pan.x && next.pan.y === v.pan.y + ? v + : next; + }); }; container.addEventListener('wheel', onWheel, { passive: false }); return () => container.removeEventListener('wheel', onWheel); - // The converters read the live view through `viewRef`, so the listener - // attached on the first render stays correct and needs no re-attaching. + // The converters read the live view through `viewRef`, so a listener + // attached once stays correct and the view is deliberately not a dep. + // `imageKey` is, for the same reason the resize observer needs it: on a + // cold open the canvas has no container yet, and without this the wheel + // would be bound to nothing for the life of the editor. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [containerRef]); + }, [containerRef, imageKey]); // --- Coordinates -------------------------------------------------------- diff --git a/frontend/src/utils/annotation/stageViewUtils.ts b/frontend/src/utils/annotation/stageViewUtils.ts index 18032ad9..99d5aad8 100644 --- a/frontend/src/utils/annotation/stageViewUtils.ts +++ b/frontend/src/utils/annotation/stageViewUtils.ts @@ -66,9 +66,15 @@ export function clampScale(scale: number): number { */ export function clampPan(pan: { x: number; y: number }, scale: number): { x: number; y: number } { const max = (scale - 1) / (2 * scale); - // Clamping a negative pan to a bound of 0 yields -0, which compares - // unequal to the 0 every other path produces. - const clamp = (value: number) => Math.max(-max, Math.min(max, value)) || 0; + const clamp = (value: number) => { + const clamped = Math.max(-max, Math.min(max, value)); + // Clamping a negative pan to a bound of 0 yields -0, which compares + // unequal to the 0 every other path produces. Only -0 is normalized: + // `|| 0` would also swallow a NaN, and a NaN here means the caller + // measured a geometry that was not there yet — worth surfacing, not + // rounding into a view that looks deliberate. + return clamped === 0 ? 0 : clamped; + }; return { x: clamp(pan.x), y: clamp(pan.y) }; } diff --git a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx index c8dd8f0d..9ad5e52d 100644 --- a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx +++ b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx @@ -525,6 +525,50 @@ describe('LocalizeObjectEditor canvas', () => { vi.unstubAllGlobals(); }); + // The same cold-open trap the resize observer has above, and the reason + // `imageKey` is threaded into the stage at all: the canvas renders no + // container until the image URL resolves, so a wheel listener attached only + // on mount is attached to nothing. A deep link into the editor — or a + // refresh on it — is exactly that cold open, and the zoom would be dead + // there while every warm-open test passed. + it('still zooms at the pointer when the image URL arrives late', () => { + imageState.data = undefined; + const { rerender } = renderEditor(); + expect(document.querySelector('img')).toBeNull(); + + imageState.data = { url: IMAGE_URL }; + rerender(editorWith()); + fireEvent.load(screen.getByAltText(/^Detection /)); + + const image = stubGeometry(); + fireEvent.wheel(image.parentElement as HTMLElement, { + deltaY: -100, + clientX: 640, + clientY: 225, + }); + + expect(image).toHaveStyle({ transform: 'scale(1.15) translate(-3.913%, 0%)' }); + }); + + it('ignores a wheel fired before the image can be measured', () => { + // An reports no natural size until it decodes, and the bounds are + // computed from its aspect ratio — 0/0, so every coordinate comes back + // NaN. Anchoring on that would quietly reset the pan and throw the + // framing away on a wheel during a frame change. jsdom lays nothing out, + // so leaving the geometry unstubbed IS that state. + renderLoadedEditor({ existingAnnotation: committedAnnotation(firstDetection.id, 'auto') }); + const image = screen.getByAltText(/^Detection /); + expect(image).toHaveStyle({ transform: 'scale(3) translate(16.667%, 16.667%)' }); + + fireEvent.wheel(image.parentElement as HTMLElement, { + deltaY: -100, + clientX: 640, + clientY: 225, + }); + + expect(image).toHaveStyle({ transform: 'scale(3) translate(16.667%, 16.667%)' }); + }); + it('zooms at the pointer, holding the point under it still', () => { renderLoadedEditor(); const image = stubGeometry(); @@ -554,7 +598,11 @@ describe('LocalizeObjectEditor canvas', () => { clientY: 225, }); - expect(image.style.transform).toContain('scale(3.45)'); + // The screen's centre is image point 0.333 under this framing, and + // anchoring it across 3 -> 3.45 leaves the pan exactly where it was. The + // pan is the whole assertion: asserting the scale alone would pass just + // as well with the framing thrown away, which is the bug. + expect(image).toHaveStyle({ transform: 'scale(3.45) translate(16.667%, 16.667%)' }); expect(screen.getByTestId('editor-zoom-toggle')).toHaveAttribute('aria-pressed', 'true'); }); @@ -576,16 +624,17 @@ describe('LocalizeObjectEditor canvas', () => { expect(image).toHaveStyle({ transform: 'scale(3) translate(20.833%, 16.667%)' }); }); - it('keeps the stage container hugging the image, which the percentage pan rests on', () => { - // `translate(%)` on the overlay layers resolves against the container and - // on the against the picture. They agree only while the container is - // a shrink-to-fit flex item centring one image — the same centring - // `calculateImageBounds` assumes. jsdom lays nothing out, so this pins the - // mechanism; the measured version is a browser check. + it('centres the image in the stage panel, which the bounds maths assumes', () => { + // `calculateImageBounds` works out where an object-contain image sits by + // assuming it is CENTRED in its container; drop the centring and every + // screen-to-image conversion is off by half the leftover width. The same + // centring is what keeps the container's box on the image's, which the + // percentage pan resolves against — measured in Chromium down to a 1280x420 + // viewport (where max-h-[95vh] binds) as agreeing within 0.02px. renderLoadedEditor(); - const stage = screen.getByAltText(/^Detection /).parentElement?.parentElement; - expect(stage?.className).toContain('items-center'); - expect(stage?.className).toContain('justify-center'); + const panel = screen.getByAltText(/^Detection /).parentElement?.parentElement; + expect(panel?.className).toContain('items-center'); + expect(panel?.className).toContain('justify-center'); }); it('draws on a plain drag, with nothing to arm first', () => {