Add canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9)
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:
@@ -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″')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user