From 617dbfd703dc15ac46d2bc510bd6d55d7d0547ca Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 22:56:54 -0400 Subject: [PATCH] Add plops editor: focus mode, place/move/resize, semantic zoom (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- web/src/editor/GardenCanvas.tsx | 170 +++++++++++++++++++++-------- web/src/editor/Inspector.tsx | 8 ++ web/src/editor/PlopInspector.tsx | 158 +++++++++++++++++++++++++++ web/src/editor/PlopLayer.tsx | 93 ++++++++++++++++ web/src/editor/PlopMarker.tsx | 93 ++++++++++++++++ web/src/editor/PlopOverlay.tsx | 166 ++++++++++++++++++++++++++++ web/src/editor/store.ts | 27 ++++- web/src/lib/objects.ts | 136 ++++++++++++++++++++++- web/src/lib/plantings.test.ts | 27 +++++ web/src/lib/plantings.ts | 70 ++++++++++++ web/src/pages/GardenEditorPage.tsx | 154 +++++++++++++++++++++++--- 11 files changed, 1037 insertions(+), 65 deletions(-) create mode 100644 web/src/editor/PlopInspector.tsx create mode 100644 web/src/editor/PlopLayer.tsx create mode 100644 web/src/editor/PlopMarker.tsx create mode 100644 web/src/editor/PlopOverlay.tsx create mode 100644 web/src/lib/plantings.test.ts create mode 100644 web/src/lib/plantings.ts diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index 93f1a4d..7614fef 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -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(null) const containerRef = useRef(null) const [size, setSize] = useState({ w: 0, h: 0 }) - const fittedForRef = useRef(null) + 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 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= 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 (
{rendered.map((o) => ( - + + + ))} - {selected && } + + + {selectedObject && } + {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. */} + {focusedObject && armedPlant && ( + + + + )} diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx index 7060e69..250a0b5 100644 --- a/web/src/editor/Inspector.tsx +++ b/web/src/editor/Inspector.tsx @@ -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({
+ {object.plantable && onFocus && ( + + )} + void + onClose: () => void +}) { + const update = useUpdatePlanting(gardenId) + const remove = useRemovePlanting(gardenId) + const rootRef = useRef(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[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 ( +
+
+

Plant

+ +
+ +
+ {plant ? ( + + ) : ( + ? + )} + {plant?.name ?? 'Unknown plant'} + +
+ +
+ setRadius(e.target.value)} + onBlur={commitRadius} + /> + setCount(e.target.value)} + onBlur={commitCount} + hint={plop.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'} + /> +
+ + setLabel(e.target.value)} + onBlur={() => label !== (plop.label ?? '') && patch({ label: label.trim() || null })} + /> + + setPlanted(e.target.value)} + onBlur={() => planted !== (plop.plantedAt ?? '') && planted !== '' && patch({ plantedAt: planted })} + /> + + +
+ ) +} diff --git a/web/src/editor/PlopLayer.tsx b/web/src/editor/PlopLayer.tsx new file mode 100644 index 0000000..496673d --- /dev/null +++ b/web/src/editor/PlopLayer.tsx @@ -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): string | null { + const freq = new Map() + 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 + scale: number + focusedObjectId: number | null + selectedPlantingId: number | null + onSelectPlop: (id: number) => void +}) { + const byObject = new Map() + 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 ( + + {tint && + (o.shape === 'circle' ? ( + + ) : ( + + ))} + {plops.map((p) => ( + + ))} + + ) + })} + + ) +}) diff --git a/web/src/editor/PlopMarker.tsx b/web/src/editor/PlopMarker.tsx new file mode 100644 index 0000000..d6aa566 --- /dev/null +++ b/web/src/editor/PlopMarker.tsx @@ -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 ( + + + {showIcon && ( + + {plant!.icon} + + )} + {showText && ( + + {plant!.name} · {effectiveCount(plop)} + + )} + + ) +}) diff --git a/web/src/editor/PlopOverlay.tsx b/web/src/editor/PlopOverlay.tsx new file mode 100644 index 0000000..b7ef140 --- /dev/null +++ b/web/src/editor/PlopOverlay.tsx @@ -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 +}) { + 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[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 ( + + {/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */} + + {/* Selection ring. */} + + {/* Radius handle at the local +x edge. */} + + + ) +} diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index f9ce84a..3ed3819 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -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((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 }), diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index f671e1d..8f95b4a 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -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 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 @@ -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 => + 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 => + serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)), + onMutate: async (patch) => { + await qc.cancelQueries({ queryKey: fullKey(gardenId) }) + const prev = qc.getQueryData(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 => { + 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(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(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 diff --git a/web/src/lib/plantings.test.ts b/web/src/lib/plantings.test.ts new file mode 100644 index 0000000..a8497d8 --- /dev/null +++ b/web/src/lib/plantings.test.ts @@ -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) + }) +}) diff --git a/web/src/lib/plantings.ts b/web/src/lib/plantings.ts new file mode 100644 index 0000000..a1d0261 --- /dev/null +++ b/web/src/lib/plantings.ts @@ -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 + +/** 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)) +} diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index ce138c0..ad552d8 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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

Loading garden…

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 (
@@ -62,18 +137,67 @@ export function GardenEditorPage() {

{garden.name}

- + {focusedObjectId == null && }
- + {focusedObject && ( +
+ + + {focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'} + + {armedPlant ? ( + + ) : ( + + )} +
+ )} +
- {selected && ( + {(selectedObject || selectedPlop) && (
- + {selectedObject && ( + { + setFocusedObject(selectedObject.id) + select(null) + }} + /> + )} + {selectedPlop && ( + setPicker('change')} + onClose={() => selectPlanting(null)} + /> + )}
)} + + {picker && ( + setPicker(null)} + onSelect={(p) => onPickPlant(p.id)} + /> + )} ) }