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.
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..3c23c02f 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,18 @@ 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.
+ //
+ // 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
// 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 +162,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 +173,7 @@ export function DetectionAnnotationCanvas({
= ({ onCl
diff --git a/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx b/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx
index 0bcb8889..60f6066e 100644
--- a/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx
+++ b/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx
@@ -858,7 +858,6 @@ export function LocalizeObjectEditor({
imageInfo={stage.imageInfo}
zoomLevel={stage.zoomLevel}
panOffset={stage.panOffset}
- transformOrigin={stage.transformOrigin}
isDragging={stage.isDragging}
onMouseDown={stage.handleMouseDown}
onMouseMove={stage.handleMouseMove}
diff --git a/frontend/src/hooks/annotation/useBoxDrawingStage.ts b/frontend/src/hooks/annotation/useBoxDrawingStage.ts
index e54ed6e6..15b38396 100644
--- a/frontend/src/hooks/annotation/useBoxDrawingStage.ts
+++ b/frontend/src/hooks/annotation/useBoxDrawingStage.ts
@@ -24,10 +24,15 @@ import {
normalizedToImageCoordinates,
moveBox,
resizeBox,
+ clampPan,
+ cropToPan,
+ wheelZoomFactor,
+ zoomAtPoint,
type CurrentDrawing,
type ImageBounds,
type Point,
type ResizeHandle,
+ type StageView,
} from '@/utils/annotation';
export type Xyxyn = [number, number, number, number];
@@ -87,8 +92,8 @@ export interface BoxDrawingStage {
imageInfo: ImageGeometry | null;
handleImageLoad: () => 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 +127,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 +157,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 +169,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,16 +205,36 @@ 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;
+ // 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);
+ // 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);
- }, [containerRef]);
+ // 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, imageKey]);
// --- Coordinates --------------------------------------------------------
@@ -265,7 +276,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 +293,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 +306,7 @@ export function useBoxDrawingStage({
{ x: screenX, y: screenY },
info.containerOffset,
info.imageBounds,
- info.transform
+ info.view
);
};
@@ -355,9 +368,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 +391,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 +408,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 +453,10 @@ export function useBoxDrawingStage({
setCurrentDrawing(null);
}
- if (isDragging) setIsDragging(false);
+ if (isDragging) {
+ setIsDragging(false);
+ setPanStart(null);
+ }
};
const getCursorStyle = () => {
@@ -439,9 +469,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 b492b769..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 {
@@ -63,6 +57,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..99d5aad8
--- /dev/null
+++ b/frontend/src/utils/annotation/stageViewUtils.ts
@@ -0,0 +1,129 @@
+/**
+ * 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);
+ 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) };
+}
+
+/**
+ * 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/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..9ad5e52d 100644
--- a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx
+++ b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx
@@ -525,6 +525,118 @@ 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();
+
+ // 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,
+ });
+
+ // 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');
+ });
+
+ 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('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 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', () => {
const onCommit = vi.fn();
renderLoadedEditor({ onCommit });
@@ -974,7 +1086,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 +1097,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 +1108,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 +1116,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 +1125,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 +1149,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 +1159,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', () => {
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%)'
+ );
+ });
+});