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,94 @@
|
||||
import type { PointerEvent } from 'react'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
// Default fills by kind (overridable per object via object.color). Muted, earthy
|
||||
// tones so plops (added in #15) read clearly on top.
|
||||
const kindColors: Record<string, string> = {
|
||||
bed: '#8a6d4b',
|
||||
grow_bag: '#9c7a52',
|
||||
container: '#6b7f8a',
|
||||
in_ground: '#7a6a4a',
|
||||
tree: '#4f7a4f',
|
||||
path: '#b8b0a0',
|
||||
structure: '#8a8a8a',
|
||||
}
|
||||
|
||||
function fillFor(o: EditorObject): string {
|
||||
return o.color ?? kindColors[o.kind] ?? '#8a8a8a'
|
||||
}
|
||||
|
||||
/**
|
||||
* One garden object in world (cm) space: a centered rect or circle, rotated
|
||||
* about its center, with the object's name. The parent world <g> applies the
|
||||
* viewport scale, so geometry is authored in cm; strokes use
|
||||
* vector-effect=non-scaling-stroke to stay a constant pixel width at any zoom.
|
||||
* Full move/resize/rotate interactions come in #11.
|
||||
*/
|
||||
export function ObjectShape({
|
||||
object,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
object: EditorObject
|
||||
selected: boolean
|
||||
onSelect: (id: number) => void
|
||||
}) {
|
||||
const fill = fillFor(object)
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
// Label size scales with the object but is clamped to a sensible cm range.
|
||||
const fontCm = Math.max(8, Math.min(40, Math.min(object.widthCm, object.heightCm) * 0.28))
|
||||
|
||||
function handleDown(e: PointerEvent) {
|
||||
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
|
||||
onSelect(object.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
|
||||
onPointerDown={handleDown}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={halfW}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={selected ? '#2f7a3e' : '#00000033'}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={-halfW}
|
||||
y={-halfH}
|
||||
width={object.widthCm}
|
||||
height={object.heightCm}
|
||||
rx={Math.min(halfW, halfH) * 0.06}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={selected ? '#2f7a3e' : '#00000033'}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{object.name && (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize={fontCm}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="#ffffff"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{object.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user