Files
pansy/web/src/editor/GardenCanvas.tsx
T
steve 8a12069118
Build image / build-and-push (push) Successful in 6s
Units correctness: exact imperial entry, garden grid at dimension scale (#47) (#60)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 04:50:49 +00:00

327 lines
14 KiB
TypeScript

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<number, Plant>
canEdit: boolean
}) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fitKeyRef = useRef<string | null>(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 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 })
}
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 (
<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' }}
onPointerDown={onCanvasPointerDown}
>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{drawnGridCm != null && (
<>
<defs>
<pattern id={gridId} width={drawnGridCm} height={drawnGridCm} patternUnits="userSpaceOnUse">
<path d={`M ${drawnGridCm} 0 L 0 0 0 ${drawnGridCm}`} fill="none" stroke="#808080" strokeOpacity={GRID_OPACITY} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
</>
)}
<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" />
{rendered.map((o) => (
<g key={o.id} opacity={focusedObjectId != null && focusedObjectId !== o.id ? DIMMED_OPACITY : 1}>
<ObjectShape object={o} selected={o.id === selectedId} onSelect={select} />
</g>
))}
{focusedObject && bedGrid && (
<g transform={objectTransform(focusedObject)} pointerEvents="none">
{bedGrid.v.map((x) => (
<line key={`v${x}`} x1={x} y1={-halfFH} x2={x} y2={halfFH} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
{bedGrid.h.map((y) => (
<line key={`h${y}`} x1={-halfFW} y1={y} x2={halfFW} y2={y} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
</g>
)}
<PlopLayer
objects={rendered}
plantings={renderedPlops}
plantsById={plantsById}
scale={viewport.scale}
focusedObjectId={focusedObjectId}
selectedPlantingId={selectedPlantingId}
onSelectPlop={selectPlanting}
/>
{/* Edit handles are mounted only for editors/owners; viewers can still
select to inspect (read-only), but never move/resize. */}
{canEdit && selectedObject && (
<SelectionOverlay
object={selectedObject}
gardenId={garden.id}
svgRef={svgRef}
snap={garden.snapToGrid}
gridCm={gridCm}
/>
)}
{canEdit && selectedPlop && selectedPlopObject && (
<PlopOverlay
plop={selectedPlop}
object={selectedPlopObject}
gardenId={garden.id}
svgRef={svgRef}
snap={selectedPlopObject.snapToGrid}
gridCm={selectedPlopObject.gridSizeCm}
/>
)}
{/* 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 && (
<g transform={objectTransform(focusedObject)}>
<rect
x={-halfFW}
y={-halfFH}
width={halfFW * 2}
height={halfFH * 2}
fill="transparent"
pointerEvents="all"
style={{ cursor: 'crosshair' }}
onPointerDown={onPlace}
/>
</g>
)}
</g>
</svg>
<div className="pointer-events-none absolute left-3 top-3 flex flex-col items-start gap-1">
<span className={OVERLAY_BADGE_CLASS}>{viewport.scale.toFixed(2)} px/cm</span>
{gridNote && <span className={OVERLAY_BADGE_CLASS}>{gridNote}</span>}
</div>
<button
type="button"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="absolute bottom-3 right-3 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>
)
}