Add plops editor: focus mode, place/move/resize, semantic zoom (#15)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 14m55s
Adversarial Review (Gadfly) / review (pull_request) Successful in 14m56s

Pansy's core vision: click into a bed, plop plants in its corners, zoom out and
still see what's planted where. Built on the field editor (#11), PlantPicker
(#13), and plantings API (#14).

Data layer
- lib/plantings.ts: ServerPlanting zod shape, EditorPlanting, effectiveCount, and
  a client mirror of the derived-count formula (computeDerivedCount) for live
  resize display; unit-tested.
- lib/objects.ts: /full now types plantings + plants; useCreatePlanting /
  useUpdatePlanting / useRemovePlanting patch the same FullGarden cache with the
  #11 optimistic + 409-rollback contract. A soft-remove drops the plop from the
  active list.

Editor
- store: selectedPlantingId / livePlanting / armedPlant (mutually-exclusive
  object vs plop selection).
- Focus mode: "Plant here" (object inspector) → focusedObjectId; the canvas
  animates fitToRect to the object and dims the rest; a breadcrumb / Escape /
  empty-tap exits. focusedObjectId mirrors to ?focus (deep-linkable, survives
  reload).
- Placing: "+ Add plant" opens the PlantPicker; the chosen plant stays armed for
  repeat placement; tapping inside the focused object drops a plop (default
  radius max(1.5·spacing, 15cm)) in the object's local frame.
- Manipulating: PlopOverlay (drag to move, edge handle to resize, one PATCH per
  gesture) + PlopInspector (change plant, radius, count override vs live derived,
  label, planted date, soft-remove).
- PlopMarker semantic zoom (px/cm bands): far = flat color patch + a dominant-
  plant tint on the object; mid = circle + emoji; near = + name + count. Plops
  render inside each object's rotated group, so moving/rotating a bed carries
  them with no planting writes.

tsc --noEmit clean; 24/24 vitest; production build 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:
2026-07-18 22:56:54 -04:00
co-authored by Claude Opus 4.8
parent f4e5dab98c
commit 617dbfd703
11 changed files with 1037 additions and 65 deletions
+123 -47
View File
@@ -1,7 +1,11 @@
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { screenToWorld, type Rect, type Size } from '@/lib/geometry'
import { useCreateObject } from '@/lib/objects'
import { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry'
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 { useEditorStore } from './store'
@@ -11,40 +15,58 @@ import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18
const DIMMED_OPACITY = 0.35
/** 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 }
}
/**
* The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the
* armed palette kind, click to select, and — for the selected object —
* move/resize/rotate via SelectionOverlay. The object being dragged is rendered
* from liveObject for instant feedback; the PATCH fires on release.
* 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,
focusId,
plantings,
plants,
}: {
garden: EditorGarden
objects: EditorObject[]
focusId?: number
plantings: EditorPlanting[]
plants: Plant[]
}) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fittedForRef = useRef<number | null>(null)
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 create = useCreateObject(garden.id)
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],
)
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
useEffect(() => {
const el = containerRef.current
if (!el) return
@@ -53,61 +75,83 @@ export function GardenCanvas({
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(() => {
const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0
if (usable && fittedForRef.current !== garden.id) {
fittedForRef.current = garden.id
// ?focus=<id> frames that object (groundwork for #15's focus mode);
// otherwise frame the whole garden.
const focus = focusId != null ? objects.find((o) => o.id === focusId) : undefined
const target: Rect = focus
? { x: focus.xCm - focus.widthCm / 2, y: focus.yCm - focus.heightCm / 2, w: focus.widthCm, h: focus.heightCm }
: gardenRect
fitToRect(target, size)
}
}, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect, focusId, objects])
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])
const cellPx = GRID_CM * viewport.scale
const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY))
// Sort once by z-order; only re-sorts when the server objects change.
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
// Merge the in-flight live geometry over the sorted list. A gesture never
// changes z, so this stays a cheap O(n) map on every pointermove — no re-sort.
const rendered = useMemo(
() => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
[sorted, liveObject],
)
const selected = rendered.find((o) => o.id === selectedId) ?? null
// 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 kind there, or
// deselect. (Objects/handles stopPropagation.)
// 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
if (!armed) {
select(null)
if (armed) {
useEditorStore.getState().setArmedKind(null)
const def = kindDef(armed)
const rect = svgRef.current?.getBoundingClientRect()
if (!def || !rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
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
}
const def = kindDef(armed)
useEditorStore.getState().setArmedKind(null)
if (!def) 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 (!focusedObject || !armedPlant) return
const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
create.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) },
)
const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
const x = Math.max(-focusedObject.widthCm / 2, Math.min(focusedObject.widthCm / 2, local.x))
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y))
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
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg
@@ -131,10 +175,42 @@ export function GardenCanvas({
<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) => (
<ObjectShape key={o.id} object={o} selected={o.id === selectedId} onSelect={select} />
<g key={o.id} opacity={focusedObjectId != null && focusedObjectId !== o.id ? DIMMED_OPACITY : 1}>
<ObjectShape object={o} selected={o.id === selectedId} onSelect={select} />
</g>
))}
{selected && <SelectionOverlay object={selected} gardenId={garden.id} svgRef={svgRef} />}
<PlopLayer
objects={rendered}
plantings={renderedPlops}
plantsById={plantsById}
scale={viewport.scale}
focusedObjectId={focusedObjectId}
selectedPlantingId={selectedPlantingId}
onSelectPlop={selectPlanting}
/>
{selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />}
{selectedPlop && selectedPlopObject && (
<PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} />
)}
{/* Placement capture: a transparent sheet over the focused object while a
plant is armed, so taps drop plops instead of selecting the object. */}
{focusedObject && armedPlant && (
<g transform={`translate(${focusedObject.xCm} ${focusedObject.yCm}) rotate(${focusedObject.rotationDeg})`}>
<rect
x={-halfFW}
y={-halfFH}
width={halfFW * 2}
height={halfFH * 2}
fill="transparent"
pointerEvents="all"
style={{ cursor: 'crosshair' }}
onPointerDown={onPlace}
/>
</g>
)}
</g>
</svg>
+8
View File
@@ -26,10 +26,12 @@ export function Inspector({
object,
gardenId,
unit,
onFocus,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
onFocus?: () => void
}) {
const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId)
@@ -94,6 +96,12 @@ export function Inspector({
</button>
</div>
{object.plantable && onFocus && (
<Button onClick={onFocus} className="w-full">
🌱 Plant here
</Button>
)}
<TextField
label="Name"
name="name"
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField'
import { useRemovePlanting, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
import { PlantIcon } from '@/components/plants/PlantIcon'
const MIN_RADIUS_CM = 1
/**
* Property panel for the selected plop. Radius/label/date/count commit a PATCH on
* blur (carrying the version); the count field shows the live derived value as a
* placeholder and takes an override when typed. "Remove" soft-removes (keeps the
* row with removed_at); "Change plant" opens the picker via onChangePlant.
*/
export function PlopInspector({
plop,
plant,
gardenId,
unit,
onChangePlant,
onClose,
}: {
plop: EditorPlanting
plant?: Plant
gardenId: number
unit: UnitPref
onChangePlant: () => void
onClose: () => void
}) {
const update = useUpdatePlanting(gardenId)
const remove = useRemovePlanting(gardenId)
const rootRef = useRef<HTMLDivElement>(null)
const [radius, setRadius] = useState(String(spacingFromCm(plop.radiusCm, unit)))
const [count, setCount] = useState(plop.count != null ? String(plop.count) : '')
const [label, setLabel] = useState(plop.label ?? '')
const [planted, setPlanted] = useState(plop.plantedAt ?? '')
// Re-sync when the plop changes underneath us (a drag/resize or a server row),
// unless the user is editing a field here.
useEffect(() => {
if (rootRef.current?.contains(document.activeElement)) return
setRadius(String(spacingFromCm(plop.radiusCm, unit)))
setCount(plop.count != null ? String(plop.count) : '')
setLabel(plop.label ?? '')
setPlanted(plop.plantedAt ?? '')
}, [plop.version, plop.radiusCm, plop.count, plop.label, plop.plantedAt, unit])
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) =>
update.mutate({ id: plop.id, version: plop.version, ...fields })
const u = spacingUnitLabel(unit)
const derived = plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount
function commitRadius() {
const v = parseFloat(radius)
if (!Number.isFinite(v)) return
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
if (cm !== plop.radiusCm) patch({ radiusCm: cm })
}
function commitCount() {
if (count.trim() === '') {
if (plop.count != null) patch({ count: null }) // restore derived
return
}
const n = Number(count)
if (!Number.isInteger(n) || n < 1) return
if (n !== plop.count) patch({ count: n })
}
return (
<div ref={rootRef} className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-fg">Plant</h2>
<button
type="button"
onClick={onClose}
className="rounded px-1.5 text-sm text-muted hover:text-fg"
aria-label="Close inspector"
>
</button>
</div>
<div className="flex items-center gap-2 rounded-lg border border-border p-2">
{plant ? (
<PlantIcon color={plant.color} icon={plant.icon} className="h-9 w-9 rounded-md text-xl" />
) : (
<span className="grid h-9 w-9 place-items-center rounded-md bg-border/50 text-muted">?</span>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">{plant?.name ?? 'Unknown plant'}</span>
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onChangePlant}>
Change
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<TextField
label={`Radius (${u})`}
name="radius"
type="number"
inputMode="decimal"
step="any"
min="0"
value={radius}
onChange={(e) => setRadius(e.target.value)}
onBlur={commitRadius}
/>
<TextField
label="Count"
name="count"
type="number"
inputMode="numeric"
step="1"
min="1"
placeholder={`${derived} (auto)`}
value={count}
onChange={(e) => setCount(e.target.value)}
onBlur={commitCount}
hint={plop.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
/>
</div>
<TextField
label="Label (optional)"
name="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={() => label !== (plop.label ?? '') && patch({ label: label.trim() || null })}
/>
<TextField
label="Planted"
name="planted"
type="date"
value={planted}
onChange={(e) => setPlanted(e.target.value)}
onBlur={() => planted !== (plop.plantedAt ?? '') && planted !== '' && patch({ plantedAt: planted })}
/>
<Button
variant="ghost"
className="text-red-600 dark:text-red-400"
disabled={remove.isPending}
onClick={() => {
onClose()
remove.mutate({ id: plop.id, version: plop.version })
}}
>
Remove plant
</Button>
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { memo } from 'react'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { PlopMarker, SEMANTIC_FAR } from './PlopMarker'
import type { EditorObject } from './types'
/** The most common plant color among an object's plops (for the far-zoom tint). */
function dominantColor(plops: EditorPlanting[], plantsById: Map<number, Plant>): string | null {
const freq = new Map<string, number>()
for (const p of plops) {
const c = plantsById.get(p.plantId)?.color
if (c) freq.set(c, (freq.get(c) ?? 0) + 1)
}
let best: string | null = null
let bestN = 0
for (const [c, n] of freq) {
if (n > bestN) {
best = c
bestN = n
}
}
return best
}
/**
* Renders every active plop, grouped under its parent object's translate+rotate
* transform so plops track the bed when it's moved/rotated. Far zoom adds a
* dominant-plant tint over each planted object so beds read at a glance. Focus
* mode dims plops on non-focused objects.
*/
export const PlopLayer = memo(function PlopLayer({
objects,
plantings,
plantsById,
scale,
focusedObjectId,
selectedPlantingId,
onSelectPlop,
}: {
objects: EditorObject[]
plantings: EditorPlanting[]
plantsById: Map<number, Plant>
scale: number
focusedObjectId: number | null
selectedPlantingId: number | null
onSelectPlop: (id: number) => void
}) {
const byObject = new Map<number, EditorPlanting[]>()
for (const p of plantings) {
const arr = byObject.get(p.objectId)
if (arr) arr.push(p)
else byObject.set(p.objectId, [p])
}
const far = scale < SEMANTIC_FAR
return (
<>
{objects.map((o) => {
const plops = byObject.get(o.id)
if (!plops || plops.length === 0) return null
const dimmed = focusedObjectId != null && focusedObjectId !== o.id
const halfW = o.widthCm / 2
const halfH = o.heightCm / 2
const tint = far ? dominantColor(plops, plantsById) : null
return (
<g
key={o.id}
transform={`translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`}
opacity={dimmed ? 0.4 : 1}
>
{tint &&
(o.shape === 'circle' ? (
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill={tint} fillOpacity={0.5} pointerEvents="none" />
) : (
<rect x={-halfW} y={-halfH} width={halfW * 2} height={halfH * 2} fill={tint} fillOpacity={0.5} pointerEvents="none" />
))}
{plops.map((p) => (
<PlopMarker
key={p.id}
plop={p}
plant={plantsById.get(p.plantId)}
scale={scale}
selected={p.id === selectedPlantingId}
dimmed={false}
onSelect={onSelectPlop}
/>
))}
</g>
)
})}
</>
)
})
+93
View File
@@ -0,0 +1,93 @@
import { memo, type PointerEvent } from 'react'
import type { Plant } from '@/lib/plants'
import { effectiveCount, type EditorPlanting } from '@/lib/plantings'
// Semantic-zoom thresholds in px/cm (DESIGN § Editor / rendering), tuned by feel:
// < FAR — flat color patch, no text ("what's planted where" at a glance)
// FAR..NEAR — colored circle + plant emoji
// ≥ NEAR — circle + emoji + plant name + count
export const SEMANTIC_FAR = 0.75
export const SEMANTIC_NEAR = 3
const SELECT_COLOR = '#2f7a3e'
const DEFAULT_COLOR = '#6aa84f'
/**
* One plop, rendered in its parent object's local frame (the caller wraps it in
* the object's translate+rotate group, so the plop tracks the bed). A circle in
* the plant's color; emoji and name/count fade in with zoom. memo'd so a pan/zoom
* that only changes the world transform doesn't re-render every plop.
*/
export const PlopMarker = memo(function PlopMarker({
plop,
plant,
scale,
selected,
dimmed,
onSelect,
}: {
plop: EditorPlanting
plant?: Plant
scale: number
selected: boolean
dimmed: boolean
onSelect: (id: number) => void
}) {
const color = plant?.color ?? DEFAULT_COLOR
const r = Math.max(0, plop.radiusCm)
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
const showText = scale >= SEMANTIC_NEAR && !!plant
function down(e: PointerEvent) {
e.stopPropagation()
onSelect(plop.id)
}
return (
<g
transform={`translate(${plop.xCm} ${plop.yCm})`}
opacity={dimmed ? 0.3 : 1}
onPointerDown={down}
style={{ cursor: 'pointer' }}
>
<circle
cx={0}
cy={0}
r={r}
fill={color}
fillOpacity={0.82}
stroke={selected ? SELECT_COLOR : '#00000033'}
strokeWidth={selected ? 2.5 : 1}
vectorEffect="non-scaling-stroke"
/>
{showIcon && (
<text
x={0}
y={0}
fontSize={r * 1.2}
textAnchor="middle"
dominantBaseline="central"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.icon}
</text>
)}
{showText && (
<text
x={0}
y={r + r * 0.3}
fontSize={Math.max(r * 0.5, 6)}
textAnchor="middle"
dominantBaseline="hanging"
fill="#1f2937"
stroke="#ffffff"
strokeWidth={0.5}
paintOrder="stroke"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.name} · {effectiveCount(plop)}
</text>
)}
</g>
)
})
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdatePlanting } from '@/lib/objects'
import type { EditorPlanting } from '@/lib/plantings'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
const HANDLE_PX = 12
const MIN_RADIUS_CM = 1
const MAX_RADIUS_CM = 10_000
const SELECT_COLOR = '#2f7a3e'
/**
* Move/resize handles for the selected plop, rendered inside its parent object's
* translate+rotate group so the plop stays in the object's local frame. Drag the
* body to move (clamped to the object's bounds); drag the edge handle to resize
* the radius. Each gesture updates livePlanting for instant feedback and fires
* one PATCH on release — the same contract as SelectionOverlay (#11).
*/
export function PlopOverlay({
plop,
object,
gardenId,
svgRef,
}: {
plop: EditorPlanting
object: EditorObject
gardenId: number
svgRef: RefObject<SVGSVGElement | null>
}) {
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
const scale = useEditorStore((s) => s.viewport.scale)
const update = useUpdatePlanting(gardenId)
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(
() => () => {
cleanupRef.current?.()
cleanupRef.current = null
},
[],
)
const handleCm = HANDLE_PX / scale
const halfW = object.widthCm / 2
const halfH = object.heightCm / 2
const center: Point = { x: object.xCm, y: object.yCm }
const rot = object.rotationDeg
// Pointer (client) → the object's local frame, snapshotting the svg rect once
// per gesture (the object doesn't move during a plop drag).
const makePointerLocal = () => {
const rect = svgRef.current?.getBoundingClientRect()
return (e: { clientX: number; clientY: number }): Point => {
const vp = useEditorStore.getState().viewport
const canvas = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
return worldToLocal(screenToWorld(canvas, vp), center, rot)
}
}
const clampLocal = (p: Point): Point => ({
x: Math.max(-halfW, Math.min(halfW, p.x)),
y: Math.max(-halfH, Math.min(halfH, p.y)),
})
const begin = (
e: ReactPointerEvent,
onMove: (e: PointerEvent) => EditorPlanting,
fields: (final: EditorPlanting) => Parameters<typeof update.mutate>[0],
) => {
e.stopPropagation()
e.preventDefault()
setObjectDragging(true)
const move = (ev: PointerEvent) => setLivePlanting(onMove(ev))
const detach = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', finish)
window.removeEventListener('pointercancel', finish)
}
const finish = () => {
detach()
cleanupRef.current = null
const final = useEditorStore.getState().livePlanting
setObjectDragging(false)
setLivePlanting(null)
if (final) update.mutate(fields(final))
}
cleanupRef.current = () => {
detach()
setObjectDragging(false)
setLivePlanting(null)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', finish)
window.addEventListener('pointercancel', finish)
}
const base = { ...plop }
const startMove = (e: ReactPointerEvent) => {
const ptr = makePointerLocal()
const start = ptr(e.nativeEvent)
begin(
e,
(ev) => {
const p = ptr(ev)
const next = clampLocal({ x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) })
return { ...base, xCm: next.x, yCm: next.y }
},
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
)
}
const startResize = (e: ReactPointerEvent) => {
const ptr = makePointerLocal()
begin(
e,
(ev) => {
const p = ptr(ev)
const r = Math.max(MIN_RADIUS_CM, Math.min(MAX_RADIUS_CM, Math.hypot(p.x - base.xCm, p.y - base.yCm)))
return { ...base, radiusCm: r }
},
(f) => ({ id: base.id, version: base.version, radiusCm: f.radiusCm }),
)
}
const r = plop.radiusCm
return (
<g transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}>
{/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */}
<circle
cx={plop.xCm}
cy={plop.yCm}
r={Math.max(r, handleCm)}
fill="transparent"
pointerEvents="all"
style={{ cursor: 'move' }}
onPointerDown={startMove}
/>
{/* Selection ring. */}
<circle
cx={plop.xCm}
cy={plop.yCm}
r={r}
fill="none"
stroke={SELECT_COLOR}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
pointerEvents="none"
/>
{/* Radius handle at the local +x edge. */}
<circle
cx={plop.xCm + r}
cy={plop.yCm}
r={handleCm * 0.6}
fill="#ffffff"
stroke={SELECT_COLOR}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
style={{ cursor: 'ew-resize' }}
onPointerDown={startResize}
/>
</g>
)
}
+26 -1
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'
import type { Viewport } from '@/lib/geometry'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import type { EditorObject } from './types'
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
@@ -13,17 +15,31 @@ interface EditorState {
viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
// The selected object OR plop (mutually exclusive; selecting one clears the
// other). selectedId is a garden object; selectedPlantingId is a plop.
selectedId: number | null
select: (id: number | null) => void
selectedPlantingId: number | null
selectPlanting: (id: number | null) => void
focusedObjectId: number | null
setFocusedObject: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
setArmedPlant: (p: Plant | null) => void
// During a move/resize/rotate, the object's live geometry is held here so the
// canvas renders it instantly; the PATCH fires only on gesture end.
liveObject: EditorObject | null
setLiveObject: (o: EditorObject | null) => void
// The same, for a plop being moved/resized.
livePlanting: EditorPlanting | null
setLivePlanting: (p: EditorPlanting | null) => void
// The palette kind armed for tap-to-place (mobile-friendly; also set while
// dragging a kind from the palette). null when nothing is armed.
armedKind: string | null
@@ -40,14 +56,23 @@ export const useEditorStore = create<EditorState>((set) => ({
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
selectedId: null,
select: (id) => set({ selectedId: id }),
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
selectedPlantingId: null,
selectPlanting: (id) => set({ selectedPlantingId: id, selectedId: null }),
focusedObjectId: null,
setFocusedObject: (id) => set({ focusedObjectId: id }),
armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }),
liveObject: null,
setLiveObject: (o) => set({ liveObject: o }),
livePlanting: null,
setLivePlanting: (p) => set({ livePlanting: p }),
armedKind: null,
setArmedKind: (kind) => set({ armedKind: kind }),
+134 -2
View File
@@ -7,6 +7,8 @@ import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient }
import { z } from 'zod'
import { ApiError, api } from './api'
import { gardenSchema } from './gardens'
import { plantSchema } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings'
import { toast } from '@/components/ui/toast'
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
@@ -35,8 +37,8 @@ export type ServerObject = z.infer<typeof serverObjectSchema>
export const fullGardenSchema = z.object({
garden: gardenSchema,
objects: z.array(serverObjectSchema),
plantings: z.array(z.unknown()), // typed in #14
plants: z.array(z.unknown()), // typed in #12
plantings: z.array(serverPlantingSchema),
plants: z.array(plantSchema),
})
export type FullGarden = z.infer<typeof fullGardenSchema>
@@ -181,12 +183,142 @@ export function useDeleteObject(gardenId: number) {
})
}
// --- planting (plop) mutations ---------------------------------------------
// Plops are part of the FullGarden cache, so these patch the same query as the
// object mutations, with the same optimistic + 409-rollback contract.
export interface PlantingCreate {
objectId: number
plantId: number
xCm: number
yCm: number
radiusCm: number
count?: number | null
label?: string | null
}
export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)),
onSuccess: (created) => {
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
},
onError: (err) => toast.error(objectErrorMessage(err, 'Could not place that plant.')),
})
}
/** Fields the plop editor patches. version is required for the optimistic guard. */
export interface PlantingPatch {
id: number
version: number
plantId?: number
xCm?: number
yCm?: number
radiusCm?: number
count?: number | null
label?: string | null
plantedAt?: string | null
removedAt?: string | null
}
export function useUpdatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...body }: PlantingPatch): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === patch.id ? applyPlantingPatch(p, patch) : p)),
}))
return { prev }
},
onSuccess: (updated) => {
patchFullCache(qc, gardenId, (full) => ({
...full,
// A soft-remove (removedAt set) drops the plop from the active list;
// otherwise write the authoritative row (carries the fresh derivedCount).
plantings: updated.removedAt
? full.plantings.filter((p) => p.id !== updated.id)
: full.plantings.map((p) => (p.id === updated.id ? updated : p)),
}))
},
onError: (err, _patch, ctx) => {
const current = conflictPlanting(err)
if (current) {
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
}))
toast.error('That plant was updated elsewhere — your change was rolled back.')
} else if (ctx?.prev) {
qc.setQueryData(fullKey(gardenId), ctx.prev)
toast.error(objectErrorMessage(err, 'Could not save that change.'))
}
},
})
}
/** Soft-remove ("Remove"): PATCH removed_at to today; the row is kept but leaves
* the active list. Distinct from a hard delete. */
export function useRemovePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
const today = new Date().toISOString().slice(0, 10)
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
},
onMutate: async ({ id }) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.filter((p) => p.id !== id) }))
return { prev }
},
onError: (err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev)
toast.error(objectErrorMessage(err, 'Could not remove that plant.'))
},
})
}
// --- helpers ---------------------------------------------------------------
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
}
/** Apply a plop patch onto a cached row for the optimistic pass. version is
* bumped to mirror the server's post-commit increment (as applyServerPatch does
* for objects), so a fast follow-up edit doesn't self-conflict. derivedCount is
* left as-is here; the editor recomputes it live from the plant's spacing and
* onSuccess writes the authoritative value. */
function applyPlantingPatch(p: ServerPlanting, patch: PlantingPatch): ServerPlanting {
return {
...p,
version: p.version + 1,
...(patch.plantId !== undefined ? { plantId: patch.plantId } : {}),
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
...(patch.radiusCm !== undefined ? { radiusCm: patch.radiusCm } : {}),
...(patch.count !== undefined ? { count: patch.count } : {}),
...(patch.label !== undefined ? { label: patch.label } : {}),
...(patch.plantedAt !== undefined ? { plantedAt: patch.plantedAt } : {}),
}
}
/** The current server plop from a 409 conflict body, or null. */
function conflictPlanting(err: unknown): ServerPlanting | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const parsed = serverPlantingSchema.safeParse((err.body as { current?: unknown }).current)
if (parsed.success) return parsed.data
}
return null
}
/** Apply a patch onto a cached server object for the optimistic pass. The
* version is bumped to mirror the server's post-commit increment, so a second
* edit started before the first PATCH resolves reads the next version instead
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { computeDerivedCount, effectiveCount } from './plantings'
describe('computeDerivedCount', () => {
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
expect(computeDerivedCount(10, 10)).toBe(3) // π·100/100 = 3.14 → 3
expect(computeDerivedCount(50, 10)).toBe(79) // π·2500/100 = 78.5 → 79
expect(computeDerivedCount(30, 15)).toBe(13) // π·900/225 = 12.57 → 13
})
it('floors at 1 for tiny / non-positive inputs', () => {
expect(computeDerivedCount(0.1, 10)).toBe(1)
expect(computeDerivedCount(0, 10)).toBe(1)
expect(computeDerivedCount(10, 0)).toBe(1)
})
it('caps like the server', () => {
expect(computeDerivedCount(10_000, 0.1)).toBe(1_000_000)
})
})
describe('effectiveCount', () => {
it('prefers the override, else the derived value', () => {
expect(effectiveCount({ count: 12, derivedCount: 79 })).toBe(12)
expect(effectiveCount({ count: null, derivedCount: 79 })).toBe(79)
})
})
+70
View File
@@ -0,0 +1,70 @@
// Plantings ("plops") data layer: the zod shape for a /full planting plus the
// EditorPlanting the canvas renders. Optimistic create/update/remove mutations
// live in objects.ts alongside the FullGarden cache they patch (a plop is part
// of the one-shot editor payload).
import { z } from 'zod'
export const serverPlantingSchema = z.object({
id: z.number(),
objectId: z.number(),
plantId: z.number(),
xCm: z.number(),
yCm: z.number(),
radiusCm: z.number(),
count: z.number().nullable().optional(), // null/absent = derived
label: z.string().nullable().optional(),
plantedAt: z.string().nullable().optional(),
removedAt: z.string().nullable().optional(),
derivedCount: z.number(), // computed server-side from area / spacing²
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type ServerPlanting = z.infer<typeof serverPlantingSchema>
/** The plop shape the canvas renders. x/y/radius are in the parent object's
* local frame (origin at object center), so a plop tracks its object. */
export interface EditorPlanting {
id: number
objectId: number
plantId: number
xCm: number
yCm: number
radiusCm: number
count: number | null
derivedCount: number
label: string | null
plantedAt: string | null
version: number
}
export function toEditorPlanting(p: ServerPlanting): EditorPlanting {
return {
id: p.id,
objectId: p.objectId,
plantId: p.plantId,
xCm: p.xCm,
yCm: p.yCm,
radiusCm: p.radiusCm,
count: p.count ?? null,
derivedCount: p.derivedCount,
label: p.label ?? null,
plantedAt: p.plantedAt ?? null,
version: p.version,
}
}
/** The count a plop shows: its explicit override, else the derived value. */
export function effectiveCount(p: { count: number | null; derivedCount: number }): number {
return p.count ?? p.derivedCount
}
/** Client-side mirror of the server's derived-count formula, for live display
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
* spacing²)), capped like the server. */
export function computeDerivedCount(radiusCm: number, spacingCm: number): number {
if (radiusCm <= 0 || spacingCm <= 0) return 1
const n = Math.round((Math.PI * radiusCm * radiusCm) / (spacingCm * spacingCm))
return Math.max(1, Math.min(1_000_000, n))
}
+139 -15
View File
@@ -1,12 +1,17 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas'
import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { toEditorObject, useGardenFull } from '@/lib/objects'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
const routeApi = getRouteApi('/gardens/$gardenId')
@@ -14,29 +19,83 @@ export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
const gid = Number(gardenId)
const { focus } = routeApi.useSearch()
const navigate = routeApi.useNavigate()
const full = useGardenFull(gid)
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 setArmedPlant = useEditorStore((s) => s.setArmedPlant)
const setArmedKind = useEditorStore((s) => s.setArmedKind)
const setLiveObject = useEditorStore((s) => s.setLiveObject)
const livePlanting = useEditorStore((s) => s.livePlanting)
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const updatePlanting = useUpdatePlanting(gid)
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
// Map the server rows to EditorObjects once per cache change. Rebuilding this
// every render (e.g. on each viewport/selection update) would hand ObjectShape
// fresh identities and defeat its memo, re-rendering every object.
const serverObjects = full.data?.objects
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
const serverPlantings = full.data?.plantings
const plantings = useMemo(() => serverPlantings?.map(toEditorPlanting) ?? [], [serverPlantings])
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
// Clear transient editor state on entering/switching gardens and on leaving.
// Adopt the URL's focus and clear transient editor state on entering/switching
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
// so URL syncs below don't retrigger a full reset.
useEffect(() => {
const reset = () => {
const clear = () => {
select(null)
selectPlanting(null)
setArmedKind(null)
setArmedPlant(null)
setLiveObject(null)
setLivePlanting(null)
}
reset()
return reset
}, [gid, select, setArmedKind, setLiveObject])
clear()
setFocusedObject(focus ?? null)
return () => {
clear()
setFocusedObject(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gid])
// Mirror focusedObjectId → ?focus so focus mode is deep-linkable and survives
// reload. Declared after the reset effect so the initial adopt wins.
useEffect(() => {
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
}, [focusedObjectId, navigate])
const exitFocus = () => {
setFocusedObject(null)
setArmedPlant(null)
select(null)
selectPlanting(null)
}
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return
const s = useEditorStore.getState()
if (s.armedPlant) s.setArmedPlant(null)
else if (s.selectedPlantingId != null) s.selectPlanting(null)
else if (s.focusedObjectId != null) exitFocus()
else if (s.selectedId != null) s.select(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError)
@@ -54,7 +113,23 @@ export function GardenEditorPage() {
heightCm: g.heightCm,
unitPref: g.unitPref,
}
const selected = objects.find((o) => o.id === selectedId) ?? null
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
const rawSelectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
const selectedPlop =
rawSelectedPlop && livePlanting?.id === rawSelectedPlop.id ? livePlanting : rawSelectedPlop
function onPickPlant(plantId: number) {
const plant = plantsById.get(plantId)
if (!plant) return
if (picker === 'change' && selectedPlop) {
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else {
setArmedPlant(plant) // 'place' mode: arm for repeat placement
}
setPicker(null)
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
@@ -62,18 +137,67 @@ export function GardenEditorPage() {
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
<Palette />
{focusedObjectId == null && <Palette />}
</div>
<div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} focusId={focus} />
{focusedObject && (
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
</span>
{armedPlant ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Placing {armedPlant.icon} Done
</Button>
) : (
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
+ Add plant
</Button>
)}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plants={plants} />
</div>
{selected && (
{(selectedObject || selectedPlop) && (
<div className="fixed inset-x-0 bottom-0 z-30 max-h-[65vh] overflow-y-auto rounded-t-xl border-t border-border bg-surface p-4 shadow-lg md:static md:max-h-none md:w-72 md:shrink-0 md:rounded-xl md:border md:p-4 md:shadow-sm">
<Inspector key={selected.id} object={selected} gardenId={gid} unit={garden.unitPref} />
{selectedObject && (
<Inspector
key={`obj-${selectedObject.id}`}
object={selectedObject}
gardenId={gid}
unit={garden.unitPref}
onFocus={() => {
setFocusedObject(selectedObject.id)
select(null)
}}
/>
)}
{selectedPlop && (
<PlopInspector
key={`plop-${selectedPlop.id}`}
plop={selectedPlop}
plant={plantsById.get(selectedPlop.plantId)}
gardenId={gid}
unit={garden.unitPref}
onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)}
/>
)}
</div>
)}
{picker && (
<PlantPicker
unit={garden.unitPref}
onClose={() => setPicker(null)}
onSelect={(p) => onPickPlant(p.id)}
/>
)}
</div>
)
}