import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, type PointerEvent as ReactPointerEvent, } from 'react' import { clampScale, type Point } from '@/lib/geometry' import { monogramInk } from '@/lib/monogram' import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants' import type { EditorPlanting } from '@/lib/plantings' import { formatSize } from '@/lib/units' import { kindDef, objectStyle, rectRadius } from './kinds' import { DIM_OBJECT, DIM_PLOP, MAX_SCALE, MIN_OBJECT_CM, MIN_SCALE, SNAP_CM, clampPlopLocal, defaultPlopRadius, objectAt, objectTransform, rotatedHalfHeight, snapPlopLocal, toLocal, toWorld, } from './shared' import { useEditorStore, type Viewport } from './store' import type { EditorGarden, EditorObject } from './types' const WHEEL_SENSITIVITY = 0.0016 const ANIM_MS = 520 const REFIT_THRESHOLD_PX = 60 const FT = 30.48 export interface CanvasHandle { zoomFit: () => void zoomIn: () => void zoomOut: () => void /** Frame a bed and enter focus mode (the page handles mode + URL). */ focusObject: (o: EditorObject) => void } type Drag = | { t: 'pan'; sx: number; sy: number; tx: number; ty: number; moved: boolean; th: number } | { t: 'obj'; base: EditorObject; sx: number; sy: number; moved: boolean; th: number } | { t: 'plop'; base: EditorPlanting; obj: EditorObject; sx: number; sy: number; moved: boolean; th: number } | { t: 'resize'; base: EditorObject; cx: number; cy: number; opp: Point; moved: boolean } interface Pinch { d0: number cx0: number cy0: number s0: number tx0: number ty0: number } /** * The garden canvas: one SVG, world = garden cm under a single translate/scale * group. Everything the prototype does — wheel zoom to the cursor, two-finger * pinch about the centroid, one-finger pan, drag-to-move with a 3″ snap, tap to * select, semantic zoom on the plant markers, a ghost while a kind is armed, * drag-and-drop from the toolkit — lives here, with native SVG hit-testing * doing the picking. Drags commit ONE change on release (a PATCH with the row's * version); placement creates. Corner handles on the selected object resize it. */ export const Canvas = forwardRef< CanvasHandle, { garden: EditorGarden objects: EditorObject[] plantings: EditorPlanting[] plantsById: Map letters: Map canEdit: boolean isMobile: boolean } >(function Canvas({ garden, objects, plantings, plantsById, letters, canEdit, isMobile }, ref) { const svgRef = useRef(null) const hostRef = useRef(null) const [size, setSize] = useState({ w: 0, h: 0 }) const fitSize = useRef({ w: 0, h: 0 }) const fittedGarden = useRef(null) const wasMobile = useRef(isMobile) const animTimer = useRef(null) const pts = useRef(new Map()) const drag = useRef(null) const pinch = useRef(null) const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null) const vp = useEditorStore((s) => s.vp) const anim = useEditorStore((s) => s.anim) const sel = useEditorStore((s) => s.sel) const focusId = useEditorStore((s) => s.focusId) const armedKind = useEditorStore((s) => s.armedKind) const armedPlant = useEditorStore((s) => s.armedPlant) const ghost = useEditorStore((s) => s.ghost) const liveObject = useEditorStore((s) => s.liveObject) const livePlanting = useEditorStore((s) => s.livePlanting) const createObject = useCreateObject(garden.id) const createPlanting = useCreatePlanting(garden.id) const updateObject = useUpdateObject(garden.id) const updatePlanting = useUpdatePlanting(garden.id) const GW = garden.widthCm const GH = garden.heightCm const s = vp.s const snapStep = garden.snapToGrid && garden.gridSizeCm > 0 ? garden.gridSizeCm : SNAP_CM // Objects in draw order, with any in-flight drag geometry merged in. const rendered = useMemo(() => { const sorted = [...objects].sort((a, b) => a.zIndex - b.zIndex || a.id - b.id) return liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted }, [objects, liveObject]) const renderedPlops = useMemo( () => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings), [plantings, livePlanting], ) const byId = useMemo(() => new Map(rendered.map((o) => [o.id, o])), [rendered]) // Latest lists for the pointer handlers, which must not close over a render. const latest = useRef({ rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep }) latest.current = { rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep } // ── camera ────────────────────────────────────────────────────────────── const camera = useCallback((next: Viewport, animate: boolean) => { useEditorStore.getState().setVp(next, animate) if (animTimer.current != null) window.clearTimeout(animTimer.current) if (animate) { animTimer.current = window.setTimeout(() => { animTimer.current = null useEditorStore.getState().setAnim(false) }, ANIM_MS) } }, []) const zoomFit = useCallback(() => { const el = svgRef.current if (!el) return const w = el.clientWidth const h = el.clientHeight const pad = latest.current.isMobile ? 20 : 40 const { widthCm, heightCm } = latest.current.garden const ns = clampScale(Math.min((w - pad * 2) / widthCm, (h - pad * 2) / heightCm), MIN_SCALE, MAX_SCALE) if (!Number.isFinite(ns) || ns <= 0) return fitSize.current = { w, h } camera({ s: ns, tx: (w - widthCm * ns) / 2, ty: (h - heightCm * ns) / 2 }, true) }, [camera]) const zoomBy = useCallback( (f: number) => { const el = svgRef.current if (!el) return const { tx, ty, s: cur } = useEditorStore.getState().vp const ns = clampScale(cur * f, MIN_SCALE, MAX_SCALE) const px = el.clientWidth / 2 const py = el.clientHeight / 2 camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, true) }, [camera], ) const focusObject = useCallback( (o: EditorObject) => { const el = svgRef.current const st = useEditorStore.getState() const m = latest.current.isMobile st.setFocus(o.id) st.setSel(m ? null : { type: 'object', id: o.id }) st.setArmedKind(null) st.setGhost(null) st.setTab('plot') if (!el) return const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm const ns = Math.min( MAX_SCALE * 0.75, Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))), ) camera({ s: ns, tx: el.clientWidth / 2 - o.xCm * ns, ty: el.clientHeight / 2 - o.yCm * ns + (m ? 0 : 10) }, true) }, [camera], ) useImperativeHandle(ref, () => ({ zoomFit, zoomIn: () => zoomBy(1.45), zoomOut: () => zoomBy(1 / 1.45), focusObject }), [ zoomFit, zoomBy, focusObject, ]) // Measure; refit on a real size change (>60px) or when the chrome flips. useEffect(() => { const el = hostRef.current if (!el) return const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height })) ro.observe(el) return () => ro.disconnect() }, []) useEffect(() => { if (size.w === 0 || size.h === 0 || GW === 0 || GH === 0) return const first = fittedGarden.current !== garden.id const flipped = wasMobile.current !== isMobile const big = Math.abs(size.w - fitSize.current.w) > REFIT_THRESHOLD_PX || Math.abs(size.h - fitSize.current.h) > REFIT_THRESHOLD_PX wasMobile.current = isMobile if (!first && !flipped && !big) return fittedGarden.current = garden.id // A focused bed (a ?focus= deep link, or a chrome flip mid-planting) frames // its bed rather than the whole garden. const f = useEditorStore.getState().focusId const target = f != null ? latest.current.byId.get(f) : undefined if (target) { fitSize.current = { w: size.w, h: size.h } focusObject(target) } else zoomFit() }, [size, garden.id, GW, GH, isMobile, zoomFit, focusObject]) // Wheel zooms to the cursor. Non-passive so the page doesn't scroll. useEffect(() => { const el = svgRef.current if (!el) return const onWheel = (e: WheelEvent) => { e.preventDefault() if (!Number.isFinite(e.deltaY)) return const r = el.getBoundingClientRect() const { tx, ty, s: cur } = useEditorStore.getState().vp const ns = clampScale(cur * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE) const px = e.clientX - r.left const py = e.clientY - r.top camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, false) } el.addEventListener('wheel', onWheel, { passive: false }) return () => el.removeEventListener('wheel', onWheel) }, [camera]) useEffect( () => () => { if (animTimer.current != null) window.clearTimeout(animTimer.current) }, [], ) // ── pointer math ──────────────────────────────────────────────────────── const world = (e: { clientX: number; clientY: number }): Point => { const r = svgRef.current!.getBoundingClientRect() const { tx, ty, s: cur } = useEditorStore.getState().vp return { x: (e.clientX - r.left - tx) / cur, y: (e.clientY - r.top - ty) / cur } } const snap = (v: number) => Math.round(v / latest.current.snapStep) * latest.current.snapStep const thresh = (e: { pointerType?: string }) => (e.pointerType === 'touch' ? 7 : 3) const track = (e: ReactPointerEvent) => { const r = svgRef.current!.getBoundingClientRect() pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top }) try { svgRef.current!.setPointerCapture(e.pointerId) } catch { /* not all pointers capture */ } } const pinchStart = () => { const [a, b] = [...pts.current.values()] drag.current = null const { s: cur, tx, ty } = useEditorStore.getState().vp pinch.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: cur, tx0: tx, ty0: ty } } // ── placement ─────────────────────────────────────────────────────────── const placeObject = (kind: string, w: Point) => { const def = kindDef(kind) const st = useEditorStore.getState() st.setArmedKind(null) st.setGhost(null) if (!def || !latest.current.canEdit) return const n = latest.current.rendered.filter((o) => o.kind === def.kind).length + 1 createObject.mutate( { kind: def.kind, shape: def.shape, name: `${def.label} ${n}`, xCm: snap(w.x), yCm: snap(w.y), widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ, plantable: def.plantable, }, { onSuccess: (o) => useEditorStore.getState().setSel({ type: 'object', id: o.id }) }, ) } const placePlop = (o: EditorObject, local: Point, plant: Plant, lotId: number | null) => { if (!latest.current.canEdit || !o.plantable) return const r = defaultPlopRadius(plant) const c = clampPlopLocal(o, snapPlopLocal(o, local), r) createPlanting.mutate( { objectId: o.id, plantId: plant.id, xCm: c.x, yCm: c.y, radiusCm: r, seedLotId: lotId ?? undefined }, { onSuccess: (p) => { // Desktop selects what it just placed; the phone keeps the strip's plant // armed and the peek closed so the next tap plants again. if (!latest.current.isMobile) useEditorStore.getState().setSel({ type: 'plop', id: p.id }) }, }, ) } // ── pointer handlers ──────────────────────────────────────────────────── const onCanvasDown = (e: ReactPointerEvent) => { track(e) if (pts.current.size === 2) return pinchStart() const st = useEditorStore.getState() const w = world(e) if (st.armedKind) return placeObject(st.armedKind, w) if (st.armedPlant) { const o = st.focusId != null ? latest.current.byId.get(st.focusId) : objectAt(latest.current.rendered, w) if (o?.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId) return } if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) } } const objDown = (o: EditorObject) => (e: ReactPointerEvent) => { e.stopPropagation() track(e) if (pts.current.size === 2) return pinchStart() const st = useEditorStore.getState() const w = world(e) if (st.armedKind) return placeObject(st.armedKind, w) if (st.armedPlant) { if (o.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId) return } // Dimmed siblings stay inert inside a focused bed. if (st.focusId != null && o.id !== st.focusId) return if (!latest.current.canEdit) { st.setSel({ type: 'object', id: o.id }) st.setTab('plot') return } drag.current = { t: 'obj', base: o, sx: w.x, sy: w.y, moved: false, th: thresh(e) } } const plopDown = (p: EditorPlanting) => (e: ReactPointerEvent) => { const st = useEditorStore.getState() if (st.armedKind || st.armedPlant) return // bubbles to the bed / canvas, which places e.stopPropagation() const o = latest.current.byId.get(p.objectId) if (!o) return // Outside focus a plop is just part of its bed: grabbing it grabs the bed. if (st.focusId !== p.objectId) return objDown(o)(e) track(e) if (pts.current.size === 2) return pinchStart() if (!latest.current.canEdit) { st.setSel({ type: 'plop', id: p.id }) st.setTab('plot') return } const l = toLocal(o, world(e)) drag.current = { t: 'plop', base: p, obj: o, sx: l.x, sy: l.y, moved: false, th: thresh(e) } } const handleDown = (o: EditorObject, cx: number, cy: number) => (e: ReactPointerEvent) => { e.stopPropagation() track(e) if (!latest.current.canEdit) return // The corner opposite the dragged one stays put, in the object's own frame. drag.current = { t: 'resize', base: o, cx, cy, opp: { x: -cx * (o.widthCm / 2), y: -cy * (o.heightCm / 2) }, moved: false } } const onCanvasMove = (e: ReactPointerEvent) => { const r = svgRef.current!.getBoundingClientRect() if (pts.current.has(e.pointerId)) pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top }) const st = useEditorStore.getState() if (pinch.current && pts.current.size >= 2) { const [a, b] = [...pts.current.values()] const p = pinch.current const d1 = Math.hypot(a.x - b.x, a.y - b.y) const cx = (a.x + b.x) / 2 const cy = (a.y + b.y) / 2 const ns = clampScale(p.s0 * (d1 / Math.max(1, p.d0)), MIN_SCALE, MAX_SCALE) const wx = (p.cx0 - p.tx0) / p.s0 const wy = (p.cy0 - p.ty0) / p.s0 camera({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns }, false) return } const d = drag.current if (!d) { if (st.armedKind && e.pointerType !== 'touch') { const w = world(e) st.setGhost({ x: snap(w.x), y: snap(w.y) }) } return } if (d.t === 'pan') { if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > d.th) d.moved = true camera({ ...st.vp, tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy }, false) } else if (d.t === 'obj') { const w = world(e) if (Math.hypot(w.x - d.sx, w.y - d.sy) > d.th / st.vp.s) d.moved = true if (!d.moved) return st.setLiveObject({ ...d.base, xCm: snap(d.base.xCm + w.x - d.sx), yCm: snap(d.base.yCm + w.y - d.sy) }) } else if (d.t === 'plop') { const l = toLocal(d.obj, world(e)) if (Math.hypot(l.x - d.sx, l.y - d.sy) > d.th / st.vp.s) d.moved = true if (!d.moved) return const next = clampPlopLocal( d.obj, snapPlopLocal(d.obj, { x: d.base.xCm + l.x - d.sx, y: d.base.yCm + l.y - d.sy }), d.base.radiusCm, ) st.setLivePlanting({ ...d.base, xCm: next.x, yCm: next.y }) } else if (d.t === 'resize') { const p = toLocal(d.base, world(e)) d.moved = true let newW = Math.max(MIN_OBJECT_CM, Math.abs(p.x - d.opp.x)) let newH = Math.max(MIN_OBJECT_CM, Math.abs(p.y - d.opp.y)) if (latest.current.garden.snapToGrid) { const g = latest.current.snapStep newW = Math.max(g, Math.round(newW / g) * g) newH = Math.max(g, Math.round(newH / g) * g) } const dragged = { x: d.opp.x + d.cx * newW, y: d.opp.y + d.cy * newH } const c = toWorld(d.base, { x: (d.opp.x + dragged.x) / 2, y: (d.opp.y + dragged.y) / 2 }) st.setLiveObject({ ...d.base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH }) } } const onCanvasUp = (e: ReactPointerEvent) => { pts.current.delete(e.pointerId) if (pinch.current) { if (pts.current.size < 2) pinch.current = null return } const d = drag.current drag.current = null if (!d) return const st = useEditorStore.getState() if (d.t === 'pan') { if (!d.moved) st.setSel(null) return } if (d.t === 'obj') { if (!d.moved) { st.setSel({ type: 'object', id: d.base.id }) st.setTab('plot') return } const final = st.liveObject st.setLiveObject(null) if (final && (final.xCm !== d.base.xCm || final.yCm !== d.base.yCm)) updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm }) } else if (d.t === 'plop') { if (!d.moved) { st.setSel({ type: 'plop', id: d.base.id }) st.setTab('plot') return } const final = st.livePlanting st.setLivePlanting(null) if (final) updatePlanting.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm }) } else if (d.t === 'resize') { const final = st.liveObject st.setLiveObject(null) if (final && d.moved) updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm, widthCm: final.widthCm, heightCm: final.heightCm, }) } } // HTML5 drag-and-drop from the toolkit (desktop). const onDragOver = (e: DragEvent) => e.preventDefault() const onDrop = (e: DragEvent) => { e.preventDefault() const data = e.dataTransfer.getData('text/plain') if (!data) return const w = world(e) const [t, id] = data.split(':') if (t === 'kind') placeObject(id, w) if (t === 'plant' && dropPlant.current) { const o = objectAt(latest.current.rendered, w) if (o?.plantable) placePlop(o, toLocal(o, w), dropPlant.current.plant, dropPlant.current.lotId) } } // The plant being dragged from the toolkit rides along in the store (the // dataTransfer only carries its id); the armed plant is what's being dragged. dropPlant.current = armedPlant ? { plant: armedPlant, lotId: useEditorStore.getState().armedLotId } : null // ── derived drawing data ─────────────────────────────────────────────── const grid = useMemo(() => { const imperial = garden.unitPref === 'imperial' let step = imperial ? FT : 25 const majorEvery = imperial ? 5 : 4 while (Math.max(GW, GH) / step > 400) step *= majorEvery // a 100 m field still draws a sane number of lines const minor: string[] = [] const major: string[] = [] for (let i = 1; i * step < GW; i++) (i % majorEvery ? minor : major).push(`M${(i * step).toFixed(2)} 0V${GH}`) for (let i = 1; i * step < GH; i++) (i % majorEvery ? minor : major).push(`M0 ${(i * step).toFixed(2)}H${GW}`) return { minor: minor.join(''), major: major.join('') } }, [GW, GH, garden.unitPref]) const selectedObject = sel?.type === 'object' ? (byId.get(sel.id) ?? null) : null const showLabels = s > 0.28 const handlePx = isMobile ? 14 : 9 const cursor = armedKind || armedPlant ? 'crosshair' : 'default' return (
{garden.name} plan {rendered.map((o) => { const st = objectStyle(o) const isSel = sel?.type === 'object' && sel.id === o.id const dim = focusId != null && o.id !== focusId const common = { fill: st.fill, stroke: isSel ? 'var(--color-accent)' : st.stroke, strokeWidth: (isSel ? 3.5 : 2.5) / s, strokeDasharray: st.dash, } return ( focusObject(o) : undefined} > {o.shape === 'circle' ? ( ) : ( )} ) })} {renderedPlops.map((p) => { const o = byId.get(p.objectId) if (!o) return null const plant = plantsById.get(p.plantId) const w = toWorld(o, { x: p.xCm, y: p.yCm }) const isSel = sel?.type === 'plop' && sel.id === p.id const inFocus = focusId === p.objectId return ( {/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */} ) })} {/* Labels — semantic zoom. pointer-events none so they never catch a tap. */} {showLabels && rendered.map((o) => { if (!o.name || Math.max(o.widthCm, o.heightCm) * s <= 54 || (focusId != null && focusId !== o.id)) return null const bh = rotatedHalfHeight(o) return o.plantable ? ( {o.name} ) : ( {o.name} ) })} {renderedPlops.map((p) => { const o = byId.get(p.objectId) if (!o || (focusId != null && p.objectId !== focusId)) return null const plant = plantsById.get(p.plantId) const r = p.radiusCm if (r * s < 9) return null const w = toWorld(o, { x: p.xCm, y: p.yCm }) return ( {letters.get(p.plantId) ?? '?'} {r * s >= 34 && plant && ( {plant.name} )} ) })} {selectedObject && ( {formatSize(selectedObject.widthCm, selectedObject.heightCm, garden.unitPref)} )} {/* Selection: a dashed accent outline offset from the object, and — for editors — corner handles to resize it. */} {selectedObject && ( {selectedObject.shape === 'circle' ? ( ) : ( )} {canEdit && !liveObject && ( [ [-1, -1], [1, -1], [1, 1], [-1, 1], ] as const ).map(([cx, cy]) => ( 0 ? 'nwse-resize' : 'nesw-resize' }} onPointerDown={handleDown(selectedObject, cx, cy)} /> ))} )} {ghost && armedKind && (() => { const def = kindDef(armedKind) if (!def) return null const st = objectStyle({ kind: def.kind }) return ( {def.shape === 'circle' ? ( ) : ( )} ) })()}
) })