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
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Size } from '@/lib/geometry'
import { ObjectShape } from './ObjectShape'
import { useEditorStore } from './store'
import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6 // hide the grid when cells get this small (zoomed out)
/**
* The editor canvas: a full-size SVG with a single world <g> that pan/zoom/pinch
* transforms. Renders a fading 1 m grid, the garden boundary, and its objects.
* Interactions beyond viewport + basic select (move/resize/rotate, palette) land
* in #11; this is the foundation, driven by mock data until then.
*/
export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fittedRef = useRef(false)
const viewport = useEditorStore((s) => s.viewport)
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
const { fitToRect } = useViewport(svgRef)
const gardenRect = useMemo(
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
[garden.widthCm, garden.heightCm],
)
// Track the container's pixel size for fit math.
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(([entry]) => {
setSize({ w: entry.contentRect.width, h: entry.contentRect.height })
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// Frame the garden the first time we know the canvas size.
useEffect(() => {
if (!fittedRef.current && size.w > 0 && size.h > 0) {
fittedRef.current = true
fitToRect(gardenRect, size)
}
}, [size, gardenRect, fitToRect])
const showGrid = GRID_CM * viewport.scale >= GRID_MIN_CELL_PX
const ordered = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg
ref={svgRef}
className="h-full w-full select-none"
style={{ touchAction: 'none' }}
// Any pointerdown reaching the svg is empty space (objects stopPropagation),
// so it deselects.
onPointerDown={() => select(null)}
>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{showGrid && (
<>
<defs>
<pattern id="garden-grid" width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
<path
d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`}
fill="none"
stroke="#808080"
strokeOpacity={0.18}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="url(#garden-grid)" />
</>
)}
{/* Garden boundary. */}
<rect
x={0}
y={0}
width={garden.widthCm}
height={garden.heightCm}
fill="none"
stroke="#3f8f4f"
strokeOpacity={0.7}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
{ordered.map((o) => (
<ObjectShape key={o.id} object={o} selected={o.id === selectedId} onSelect={select} />
))}
</g>
</svg>
{/* Overlay: zoom readout + fit control. */}
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">
{viewport.scale.toFixed(2)} px/cm
</div>
<div className="absolute bottom-3 right-3">
<button
type="button"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="rounded-md border border-border bg-surface/90 px-3 py-1.5 text-sm font-medium text-fg shadow-sm outline-none backdrop-blur transition-colors hover:bg-border/50 focus-visible:ring-2 focus-visible:ring-accent/40"
>
Fit
</button>
</div>
</div>
)
}