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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user