Add canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9)
Build image / build-and-push (push) Successful in 16s
Gadfly review (reusable) / review (pull_request) Successful in 9m31s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m31s

The editor's technically-riskiest slice, built against mock data so #11
only adds interactions on top.

- lib/geometry.ts (+ vitest): world<->screen and object-local<->world
  transforms, clampScale, zoomViewportAt (the wheel/pinch "keep the point
  under the cursor stationary" math), zoomToFitRect. 11 geometry tests
  (round-trips, rotation, cursor-anchored zoom, fit) + 6 units tests.
- editor/store.ts: Zustand store for ephemeral editor state — viewport,
  selection, focusedObjectId (selection interactions grow in #11).
- editor/useViewport.ts: @use-gesture wiring — wheel + pinch zoom toward
  the pointer, drag-pan, scale clamped to [0.05, 20] px/cm, and an animated
  fitToRect (eased rAF tween). touch-action:none so the browser doesn't
  fight gestures.
- editor/ObjectShape.tsx: rect/circle centered + rotated, kind default
  colors, name label; non-scaling strokes so outlines stay 1px at any zoom.
- editor/GardenCanvas.tsx: full-size svg, single world <g>, fading 1m grid,
  garden boundary, z-ordered objects; ResizeObserver-driven auto-fit, a
  zoom readout, and a Fit control. Deps: zustand, @use-gesture/react,
  vitest (+ test scripts).
- GardenEditorPage renders the canvas with a mock scene (#11 swaps in
  /full).

Verified in a real browser: wheel zoom keeps the world point under the
cursor stationary (sub-cm drift on a 12m garden), a real drag pans, and
Fit animates to frame the garden. tsc + vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 19:59:40 -04:00
co-authored by Claude Opus 4.8
parent 12e660e45b
commit 30b36b7033
11 changed files with 1066 additions and 13 deletions
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
clampScale,
localToWorld,
screenToWorld,
worldToLocal,
worldToScreen,
zoomToFitRect,
zoomViewportAt,
type Viewport,
} from './geometry'
const vp: Viewport = { tx: 100, ty: 50, scale: 2 }
describe('world/screen transforms', () => {
it('round-trips a point', () => {
const p = { x: 37.5, y: -12.25 }
const back = screenToWorld(worldToScreen(p, vp), vp)
expect(back.x).toBeCloseTo(p.x, 9)
expect(back.y).toBeCloseTo(p.y, 9)
})
it('applies scale then translate', () => {
expect(worldToScreen({ x: 10, y: 10 }, vp)).toEqual({ x: 120, y: 70 })
})
})
describe('object-local/world transforms', () => {
const center = { x: 500, y: 300 }
it('round-trips through any rotation', () => {
for (const deg of [0, 30, 90, 180, 270, 45, -60, 123.4]) {
const local = { x: 40, y: -25 }
const back = worldToLocal(localToWorld(local, center, deg), center, deg)
expect(back.x).toBeCloseTo(local.x, 6)
expect(back.y).toBeCloseTo(local.y, 6)
}
})
it('rotates 90° clockwise on screen (y down)', () => {
// A point 10cm to the object's right, rotated 90°, lands 10cm below center.
const w = localToWorld({ x: 10, y: 0 }, center, 90)
expect(w.x).toBeCloseTo(center.x, 6)
expect(w.y).toBeCloseTo(center.y + 10, 6)
})
it('local origin maps to the object center', () => {
expect(localToWorld({ x: 0, y: 0 }, center, 37)).toEqual(center)
})
})
describe('clampScale', () => {
it('bounds the value', () => {
expect(clampScale(0.001, 0.05, 20)).toBe(0.05)
expect(clampScale(100, 0.05, 20)).toBe(20)
expect(clampScale(3, 0.05, 20)).toBe(3)
})
})
describe('zoomViewportAt', () => {
it('keeps the point under the cursor stationary', () => {
const cursor = { x: 640, y: 360 }
const before = screenToWorld(cursor, vp)
const next = zoomViewportAt(vp, cursor, vp.scale * 1.25, 0.05, 20)
const after = screenToWorld(cursor, next)
expect(after.x).toBeCloseTo(before.x, 6)
expect(after.y).toBeCloseTo(before.y, 6)
expect(next.scale).toBeCloseTo(2.5, 9)
})
it('clamps the scale', () => {
expect(zoomViewportAt(vp, { x: 0, y: 0 }, 1000, 0.05, 20).scale).toBe(20)
})
})
describe('zoomToFitRect', () => {
it('centers the rect and fits it within padding', () => {
const viewport = { w: 800, h: 600 }
const rect = { x: 0, y: 0, w: 1000, h: 1000 }
const fit = zoomToFitRect(rect, viewport, 20, 0.05, 20)
// Limiting dimension is height: (600-40)/1000 = 0.56.
expect(fit.scale).toBeCloseTo(0.56, 9)
// The rect's center (500,500) should map to the viewport center (400,300).
const c = worldToScreen({ x: 500, y: 500 }, fit)
expect(c.x).toBeCloseTo(400, 6)
expect(c.y).toBeCloseTo(300, 6)
})
it('respects the min/max scale clamp', () => {
const fit = zoomToFitRect({ x: 0, y: 0, w: 1, h: 1 }, { w: 800, h: 600 }, 0, 0.05, 20)
expect(fit.scale).toBe(20)
})
})
+117
View File
@@ -0,0 +1,117 @@
// Pure geometry for the editor. Everything is centimeters in "world" space
// (garden origin top-left, x→right, y→down) except the viewport transform, which
// maps world → screen pixels. Kept dependency-free and unit-tested; the canvas
// and gesture code build on top.
export interface Point {
x: number
y: number
}
export interface Size {
w: number
h: number
}
/** Axis-aligned rectangle by top-left corner + size, in world cm. */
export interface Rect {
x: number
y: number
w: number
h: number
}
/** The viewport: world→screen is `screen = world * scale + t`. scale is px/cm. */
export interface Viewport {
tx: number
ty: number
scale: number
}
export function worldToScreen(p: Point, vp: Viewport): Point {
return { x: p.x * vp.scale + vp.tx, y: p.y * vp.scale + vp.ty }
}
export function screenToWorld(p: Point, vp: Viewport): Point {
return { x: (p.x - vp.tx) / vp.scale, y: (p.y - vp.ty) / vp.scale }
}
/**
* A point in an object's local frame (origin at the object's center, axes
* aligned to the unrotated object) → garden world coordinates. rotationDeg is
* clockwise (screen y grows downward, so a positive angle rotates clockwise on
* screen). Plops live in this local frame, so moving/rotating the parent object
* moves them for free.
*/
export function localToWorld(local: Point, center: Point, rotationDeg: number): Point {
const r = (rotationDeg * Math.PI) / 180
const cos = Math.cos(r)
const sin = Math.sin(r)
return {
x: center.x + local.x * cos - local.y * sin,
y: center.y + local.x * sin + local.y * cos,
}
}
/** Inverse of localToWorld: garden world point → the object's local frame. */
export function worldToLocal(world: Point, center: Point, rotationDeg: number): Point {
const r = (rotationDeg * Math.PI) / 180
const cos = Math.cos(r)
const sin = Math.sin(r)
const dx = world.x - center.x
const dy = world.y - center.y
return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos }
}
/** Clamp a scale (px/cm) into [min, max]. */
export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale))
}
/**
* Re-scale the viewport while keeping the world point currently under
* `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The
* new scale is clamped to [minScale, maxScale].
*/
export function zoomViewportAt(
vp: Viewport,
screenPoint: Point,
nextScale: number,
minScale: number,
maxScale: number,
): Viewport {
const scale = clampScale(nextScale, minScale, maxScale)
const world = screenToWorld(screenPoint, vp)
return {
scale,
tx: screenPoint.x - world.x * scale,
ty: screenPoint.y - world.y * scale,
}
}
/**
* The viewport that frames `rect` centered in a viewport of `viewport` pixels,
* with `padding` px of margin on every side. Scale is clamped to [minScale,
* maxScale] so a tiny/huge rect still lands in a sane zoom.
*/
export function zoomToFitRect(
rect: Rect,
viewport: Size,
padding: number,
minScale: number,
maxScale: number,
): Viewport {
const availW = Math.max(1, viewport.w - padding * 2)
const availH = Math.max(1, viewport.h - padding * 2)
const rectW = Math.max(rect.w, 1e-6)
const rectH = Math.max(rect.h, 1e-6)
const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale)
const centerX = rect.x + rect.w / 2
const centerY = rect.y + rect.h / 2
return {
scale,
tx: viewport.w / 2 - centerX * scale,
ty: viewport.h / 2 - centerY * scale,
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { cmFromFtIn, cmFromMeters, displayFromCm, formatCm, formatDimensions } from './units'
describe('cm conversions', () => {
it('feet+inches → whole cm', () => {
expect(cmFromFtIn(4)).toBe(122) // 4ft = 121.92 → 122
expect(cmFromFtIn(8)).toBe(244)
expect(cmFromFtIn(4, 6)).toBe(137) // 4'6" = 137.16 → 137
})
it('meters → whole cm', () => {
expect(cmFromMeters(10)).toBe(1000)
expect(cmFromMeters(1.22)).toBe(122)
})
})
describe('displayFromCm', () => {
it('metric keeps cm precision', () => {
expect(displayFromCm(1000, 'metric')).toBe(10)
expect(displayFromCm(122, 'metric')).toBe(1.22)
})
it('imperial rounds to 0.1 ft so integers stay clean', () => {
expect(displayFromCm(122, 'imperial')).toBe(4) // not 4.0026
expect(displayFromCm(244, 'imperial')).toBe(8) // not 8.01
})
})
describe('formatCm', () => {
it('metric shows meters', () => {
expect(formatCm(122, 'metric')).toBe('1.22 m')
expect(formatCm(1000, 'metric')).toBe('10 m')
})
it('imperial shows feet and inches, carrying 12″ up', () => {
expect(formatCm(122, 'imperial')).toBe('4 0″')
expect(formatCm(244, 'imperial')).toBe('8 0″')
expect(formatCm(137, 'imperial')).toBe('4 6″')
})
it('formats width × height', () => {
expect(formatDimensions(122, 244, 'imperial')).toBe('4 0″ × 8 0″')
})
})