import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import { screenToWorld, snapLocalToBedGrid, snapPoint, visibleGridStepCm, worldToLocal, type Rect, type Size, } from '@/lib/geometry' import { formatCm, type UnitPref } from '@/lib/units' import { useCreateObject, useCreatePlanting } from '@/lib/objects' import type { Plant } from '@/lib/plants' import type { EditorPlanting } from '@/lib/plantings' import { ObjectShape } from './ObjectShape' import { PlopLayer } from './PlopLayer' import { PlopOverlay } from './PlopOverlay' import { SelectionOverlay } from './SelectionOverlay' import { kindDef } from './kinds' import { DIMMED_OPACITY, objectTransform } from './shared' import { useEditorStore } from './store' import { useViewport } from './useViewport' import type { EditorGarden, EditorObject } from './types' const GRID_MIN_CELL_PX = 6 const GRID_OPACITY = 0.18 const OVERLAY_BADGE_CLASS = 'rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur' const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed /** * What to tell the user about the grid they're looking at, or null when the * drawn lines are simply the garden's own grid and need no explanation. Two * states are worth naming rather than leaving them to infer: lines drawn every * Nth grid cell (the grid was too fine to draw at this zoom), and — degenerate — * a grid so fine no multiple of it is drawable, which matters most when snapping * is on and would otherwise be invisibly active (#47). */ function gridNoteFor( drawnCm: number | null, gridCm: number, snapToGrid: boolean, unit: UnitPref, ): string | null { if (drawnCm == null) return snapToGrid ? 'grid too fine to draw — zoom in' : null if (drawnCm === gridCm) return null return `grid every ${formatCm(drawnCm, unit)}` } /** The world-space bounds rect of an object (center + size → top-left rect). */ function objectRect(o: EditorObject): Rect { return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm } } /** Grid-line offsets (from a bed's top-left corner, in the object-local frame) * for a bed of the given half-extents and grid step. Starts at the corner so the * lines match snapLocalToBedGrid, and is capped so a tiny grid on a big bed can't * emit thousands of lines. Returns cm offsets along one axis. */ function bedGridLines(half: number, step: number): number[] { if (!(step > 0) || (2 * half) / step > BED_GRID_MAX_LINES) return [] const lines: number[] = [] for (let v = -half; v <= half + 1e-6; v += step) lines.push(v) return lines } /** * The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/ * rotate an object, and — the #15 core — focus into a plantable object to place, * move, resize and remove plops with semantic-zoom rendering. The object/plop * being dragged renders from liveObject/livePlanting for instant feedback; the * PATCH fires on release. */ export function GardenCanvas({ garden, objects, plantings, plantsById, canEdit, }: { garden: EditorGarden objects: EditorObject[] plantings: EditorPlanting[] plantsById: Map canEdit: boolean }) { const svgRef = useRef(null) const containerRef = useRef(null) const [size, setSize] = useState({ w: 0, h: 0 }) const fitKeyRef = useRef(null) const gridId = 'garden-grid-' + useId().replace(/:/g, '') const viewport = useEditorStore((s) => s.viewport) const selectedId = useEditorStore((s) => s.selectedId) const select = useEditorStore((s) => s.select) const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId) const selectPlanting = useEditorStore((s) => s.selectPlanting) const focusedObjectId = useEditorStore((s) => s.focusedObjectId) const setFocusedObject = useEditorStore((s) => s.setFocusedObject) const armedPlant = useEditorStore((s) => s.armedPlant) const armedLotId = useEditorStore((s) => s.armedLotId) const liveObject = useEditorStore((s) => s.liveObject) const livePlanting = useEditorStore((s) => s.livePlanting) const { fitToRect } = useViewport(svgRef) const createObject = useCreateObject(garden.id) const createPlanting = useCreatePlanting(garden.id) const gardenRect: Rect = useMemo( () => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }), [garden.widthCm, garden.heightCm], ) 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() }, []) // Single fit mechanism: frame the focused object, else the whole garden, and // animate whenever that target (or the garden) changes. The key guard stops a // refit on unrelated re-renders (a plop add, a bed drag). useEffect(() => { if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return const key = `${garden.id}:${focusedObjectId}` if (fitKeyRef.current === key) return const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is fitKeyRef.current = key fitToRect(target ? objectRect(target) : gardenRect, size) }, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect]) // Draw the true grid when its cells are legible, else the coarsest-but-smallest // multiple of it that is (see visibleGridStepCm). Snapping still uses gridCm — // the drawn lines are a subset of the snap positions, never a different grid. const gridCm = garden.gridSizeCm const drawnGridCm = visibleGridStepCm(gridCm, viewport.scale, GRID_MIN_CELL_PX) const gridNote = gridNoteFor(drawnGridCm, gridCm, garden.snapToGrid, garden.unitPref) const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects]) const rendered = useMemo( () => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted), [sorted, liveObject], ) // Merge the in-flight live plop over the active list, same as liveObject. const renderedPlops = useMemo( () => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings), [plantings, livePlanting], ) const selectedObject = rendered.find((o) => o.id === selectedId) ?? null const selectedPlop = renderedPlops.find((p) => p.id === selectedPlantingId) ?? null const selectedPlopObject = selectedPlop ? rendered.find((o) => o.id === selectedPlop.objectId) ?? null : null const focusedObject = focusedObjectId != null ? rendered.find((o) => o.id === focusedObjectId) ?? null : null // A pointerdown reaching the svg is empty space: place the armed object kind, // or exit focus mode, or just deselect. function onCanvasPointerDown(e: ReactPointerEvent) { const armed = useEditorStore.getState().armedKind // Defense in depth: a viewer can't place objects even if a stale armed kind // slipped through (the palette isn't rendered for them). if (armed && canEdit) { useEditorStore.getState().setArmedKind(null) const def = kindDef(armed) const rect = svgRef.current?.getBoundingClientRect() if (!def || !rect) return let world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) // Snap the new object's center to the garden grid when the garden opts in. if (garden.snapToGrid) world = snapPoint(world, gridCm) createObject.mutate( { kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ }, { onSuccess: (o) => select(o.id) }, ) return } // Empty-space tap while focused exits focus (and any placement); otherwise // just clears the selection. if (focusedObjectId != null) { setFocusedObject(null) useEditorStore.getState().setArmedPlant(null) } select(null) selectPlanting(null) } // Drop a plop where the user taps inside the focused object (placement stays // armed for repeat-placement until Escape / Done). function onPlace(e: ReactPointerEvent) { e.stopPropagation() if (!canEdit || !focusedObject || !armedPlant || !focusedObject.plantable) return const rect = svgRef.current?.getBoundingClientRect() if (!rect) return const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg) // snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes // it a pure clamp, so both paths go through one helper. const { x, y } = snapLocalToBedGrid( local, focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0, focusedObject.widthCm / 2, focusedObject.heightCm / 2, ) const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15) // Stay armed for repeat-placement; don't select (the placement sheet covers // the object, so a selection would be hidden until placement ends anyway). createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm, seedLotId: armedLotId ?? undefined, }) } const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0 const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0 // Draw the bed's own grid inside a focused plantable bed that has snapping on, // so the user sees exactly where plants will land. Lines are in the bed's local // frame (origin at center), anchored to the corner to match snapLocalToBedGrid. // Memoized so a high-frequency plop drag (which re-renders the canvas on every // pointermove) doesn't rebuild the line arrays each frame. null when the focused // object isn't a snapping plantable bed. const bedGrid = useMemo(() => { if (!focusedObject || !focusedObject.plantable || !focusedObject.snapToGrid) return null return { v: bedGridLines(focusedObject.widthCm / 2, focusedObject.gridSizeCm), h: bedGridLines(focusedObject.heightCm / 2, focusedObject.gridSizeCm), } }, [focusedObject]) return (
{drawnGridCm != null && ( <> )} {rendered.map((o) => ( ))} {focusedObject && bedGrid && ( {bedGrid.v.map((x) => ( ))} {bedGrid.h.map((y) => ( ))} )} {/* Edit handles are mounted only for editors/owners; viewers can still select to inspect (read-only), but never move/resize. */} {canEdit && selectedObject && ( )} {canEdit && selectedPlop && selectedPlopObject && ( )} {/* Placement capture: a transparent sheet over the focused object while a plant is armed, so taps drop plops instead of selecting the object. */} {canEdit && focusedObject && armedPlant && focusedObject.plantable && ( )}
{viewport.scale.toFixed(2)} px/cm {gridNote && {gridNote}}
) }