Plops editor: focus mode, place/move/resize, semantic zoom (#15)
Build image / build-and-push (push) Successful in 14s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #34.
This commit is contained in:
2026-07-19 03:22:51 +00:00
committed by steve
parent f4e5dab98c
commit 48ba08e8f2
14 changed files with 1084 additions and 73 deletions
+121 -47
View File
@@ -1,9 +1,14 @@
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { screenToWorld, type Rect, type Size } from '@/lib/geometry' import { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry'
import { useCreateObject } from '@/lib/objects' import { useCreateObject, useCreatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { ObjectShape } from './ObjectShape' import { ObjectShape } from './ObjectShape'
import { PlopLayer } from './PlopLayer'
import { PlopOverlay } from './PlopOverlay'
import { SelectionOverlay } from './SelectionOverlay' import { SelectionOverlay } from './SelectionOverlay'
import { kindDef } from './kinds' import { kindDef } from './kinds'
import { DIMMED_OPACITY, objectTransform } from './shared'
import { useEditorStore } from './store' import { useEditorStore } from './store'
import { useViewport } from './useViewport' import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types' import type { EditorGarden, EditorObject } from './types'
@@ -12,33 +17,48 @@ const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6 const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18 const GRID_MAX_OPACITY = 0.18
/** 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 * The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
* armed palette kind, click to select, and — for the selected object — * rotate an object, and — the #15 core — focus into a plantable object to place,
* move/resize/rotate via SelectionOverlay. The object being dragged is rendered * move, resize and remove plops with semantic-zoom rendering. The object/plop
* from liveObject for instant feedback; the PATCH fires on release. * being dragged renders from liveObject/livePlanting for instant feedback; the
* PATCH fires on release.
*/ */
export function GardenCanvas({ export function GardenCanvas({
garden, garden,
objects, objects,
focusId, plantings,
plantsById,
}: { }: {
garden: EditorGarden garden: EditorGarden
objects: EditorObject[] objects: EditorObject[]
focusId?: number plantings: EditorPlanting[]
plantsById: Map<number, Plant>
}) { }) {
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 }) 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 gridId = 'garden-grid-' + useId().replace(/:/g, '')
const viewport = useEditorStore((s) => s.viewport) const viewport = useEditorStore((s) => s.viewport)
const selectedId = useEditorStore((s) => s.selectedId) const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select) 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 liveObject = useEditorStore((s) => s.liveObject)
const livePlanting = useEditorStore((s) => s.livePlanting)
const { fitToRect } = useViewport(svgRef) const { fitToRect } = useViewport(svgRef)
const create = useCreateObject(garden.id) const createObject = useCreateObject(garden.id)
const createPlanting = useCreatePlanting(garden.id)
const gardenRect: Rect = useMemo( const gardenRect: Rect = useMemo(
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }), () => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
@@ -53,61 +73,83 @@ export function GardenCanvas({
return () => ro.disconnect() 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(() => { useEffect(() => {
const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0 if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return
if (usable && fittedForRef.current !== garden.id) { const key = `${garden.id}:${focusedObjectId}`
fittedForRef.current = garden.id if (fitKeyRef.current === key) return
// ?focus=<id> frames that object (groundwork for #15's focus mode); const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined
// otherwise frame the whole garden. if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is
const focus = focusId != null ? objects.find((o) => o.id === focusId) : undefined fitKeyRef.current = key
const target: Rect = focus fitToRect(target ? objectRect(target) : gardenRect, size)
? { x: focus.xCm - focus.widthCm / 2, y: focus.yCm - focus.heightCm / 2, w: focus.widthCm, h: focus.heightCm } }, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
: gardenRect
fitToRect(target, size)
}
}, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect, focusId, objects])
const cellPx = GRID_CM * viewport.scale 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)) 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]) 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( const rendered = useMemo(
() => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted), () => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
[sorted, liveObject], [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 // A pointerdown reaching the svg is empty space: place the armed object kind,
// deselect. (Objects/handles stopPropagation.) // or exit focus mode, or just deselect.
function onCanvasPointerDown(e: ReactPointerEvent) { function onCanvasPointerDown(e: ReactPointerEvent) {
const armed = useEditorStore.getState().armedKind const armed = useEditorStore.getState().armedKind
if (!armed) { if (armed) {
select(null) 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 return
} }
const def = kindDef(armed) // Empty-space tap while focused exits focus (and any placement); otherwise
useEditorStore.getState().setArmedKind(null) // just clears the selection.
if (!def) return 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 || !focusedObject.plantable) return
const rect = svgRef.current?.getBoundingClientRect() const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return if (!rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
create.mutate( 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))
kind: def.kind, const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y))
shape: def.shape, const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
xCm: world.x, // Stay armed for repeat-placement; don't select (the placement sheet covers
yCm: world.y, // the object, so a selection would be hidden until placement ends anyway).
widthCm: def.widthCm, createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
heightCm: def.heightCm,
zIndex: def.defaultZ,
},
{ onSuccess: (o) => select(o.id) },
)
} }
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
return ( return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg"> <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg <svg
@@ -131,10 +173,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" /> <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) => ( {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 && 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> </g>
</svg> </svg>
+8
View File
@@ -26,10 +26,12 @@ export function Inspector({
object, object,
gardenId, gardenId,
unit, unit,
onFocus,
}: { }: {
object: EditorObject object: EditorObject
gardenId: number gardenId: number
unit: UnitPref unit: UnitPref
onFocus?: () => void
}) { }) {
const update = useUpdateObject(gardenId) const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId) const del = useDeleteObject(gardenId)
@@ -94,6 +96,12 @@ export function Inspector({
</button> </button>
</div> </div>
{object.plantable && onFocus && (
<Button onClick={onFocus} className="w-full">
🌱 Plant here
</Button>
)}
<TextField <TextField
label="Name" label="Name"
name="name" name="name"
+2 -5
View File
@@ -1,4 +1,5 @@
import { memo, type PointerEvent } from 'react' import { memo, type PointerEvent } from 'react'
import { objectTransform } from './shared'
import type { EditorObject } from './types' import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a' const DEFAULT_FILL = '#8a8a8a'
@@ -60,11 +61,7 @@ export const ObjectShape = memo(function ObjectShape({
const strokeWidth = selected ? 2 : 1 const strokeWidth = selected ? 2 : 1
return ( return (
<g <g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
onPointerDown={handleDown}
style={{ cursor: 'pointer' }}
>
{object.shape === 'circle' ? ( {object.shape === 'circle' ? (
<ellipse <ellipse
cx={0} cx={0}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField'
import { PlantIcon } from '@/components/plants/PlantIcon'
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 { MIN_RADIUS_CM } from './shared'
import { useEditorStore } from './store'
/**
* 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. While
* the plop is dragged/resized on the canvas, it reads livePlanting for live
* feedback (subscribed here, not at the page level, so a drag only re-renders
* this panel).
*/
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 livePlanting = useEditorStore((s) => s.livePlanting)
const rootRef = useRef<HTMLDivElement>(null)
// The plop with any in-flight drag/resize geometry applied.
const p = livePlanting && livePlanting.id === plop.id ? livePlanting : plop
const [radius, setRadius] = useState(String(spacingFromCm(p.radiusCm, unit)))
const [count, setCount] = useState(p.count != null ? String(p.count) : '')
const [label, setLabel] = useState(p.label ?? '')
const [planted, setPlanted] = useState(p.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(p.radiusCm, unit)))
setCount(p.count != null ? String(p.count) : '')
setLabel(p.label ?? '')
setPlanted(p.plantedAt ?? '')
}, [p.version, p.radiusCm, p.count, p.label, p.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(p.radiusCm, plant.spacingCm) : p.derivedCount
function commitRadius() {
const v = Number(radius)
if (radius.trim() === '' || !Number.isFinite(v)) {
setRadius(String(spacingFromCm(p.radiusCm, unit))) // reset stale/invalid text
return
}
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
if (cm !== p.radiusCm) patch({ radiusCm: cm })
}
function commitCount() {
if (count.trim() === '') {
if (p.count != null) patch({ count: null }) // restore derived
return
}
const n = Number(count)
if (!Number.isInteger(n) || n < 1) {
setCount(p.count != null ? String(p.count) : '') // reject invalid, restore
return
}
if (n !== p.count) patch({ count: n })
}
function commitPlanted() {
const next = planted === '' ? null : planted
if (next !== (p.plantedAt ?? null)) patch({ plantedAt: next })
}
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={p.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 !== (p.label ?? '') && patch({ label: label.trim() || null })}
/>
<TextField
label="Planted"
name="planted"
type="date"
value={planted}
onChange={(e) => setPlanted(e.target.value)}
onBlur={commitPlanted}
/>
<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, useMemo } from 'react'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { PlopMarker, SEMANTIC_FAR } from './PlopMarker'
import { DIMMED_OPACITY, objectTransform } from './shared'
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
}) {
// Group plops by object once per plops change (not on every pan/zoom frame).
const byObject = useMemo(() => {
const m = new Map<number, EditorPlanting[]>()
for (const p of plantings) {
const arr = m.get(p.objectId)
if (arr) arr.push(p)
else m.set(p.objectId, [p])
}
return m
}, [plantings])
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={objectTransform(o)} opacity={dimmed ? DIMMED_OPACITY : 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}
onSelect={onSelectPlop}
/>
))}
</g>
)
})}
</>
)
})
+88
View File
@@ -0,0 +1,88 @@
import { memo, type PointerEvent } from 'react'
import type { Plant } from '@/lib/plants'
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
import { SELECT_COLOR } from './shared'
// 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 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. The count is
* computed live from the current radius so it updates while resizing. 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,
onSelect,
}: {
plop: EditorPlanting
plant?: Plant
scale: number
selected: 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
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
function down(e: PointerEvent) {
e.stopPropagation()
onSelect(plop.id)
}
return (
<g transform={`translate(${plop.xCm} ${plop.yCm})`} 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} · {count}
</text>
)}
</g>
)
})
+164
View File
@@ -0,0 +1,164 @@
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 { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
const MAX_RADIUS_CM = 10_000
/**
* 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={objectTransform(object)}>
{/* 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>
)
}
+2 -3
View File
@@ -1,14 +1,13 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react' import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry' import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdateObject } from '@/lib/objects' import { useUpdateObject } from '@/lib/objects'
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
import { useEditorStore } from './store' import { useEditorStore } from './store'
import type { EditorObject } from './types' import type { EditorObject } from './types'
const HANDLE_PX = 12 // on-screen size of the resize handles
const ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object const ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object
const MIN_OBJ_CM = 1 // smallest allowed dimension const MIN_OBJ_CM = 1 // smallest allowed dimension
const ROTATE_SNAP_DEG = 15 const ROTATE_SNAP_DEG = 15
const SELECT_COLOR = '#2f7a3e'
const corners: [number, number][] = [ const corners: [number, number][] = [
[-1, -1], [-1, -1],
@@ -162,7 +161,7 @@ export function SelectionOverlay({
} }
return ( return (
<g transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}> <g transform={objectTransform(object)}>
{/* Transparent body: drag to move. */} {/* Transparent body: drag to move. */}
{object.shape === 'circle' ? ( {object.shape === 'circle' ? (
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} /> <ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
+14
View File
@@ -0,0 +1,14 @@
// Shared editor UI constants + tiny helpers used across the object/plop markers
// and overlays, so colors, handle sizes, and the object-local transform stay in
// one place instead of drifting between files.
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
export const MIN_RADIUS_CM = 1 // smallest plop radius
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
/** SVG transform placing content in an object's local frame: its center point
* then its clockwise rotation. Plops and overlays render inside this. */
export function objectTransform(o: { xCm: number; yCm: number; rotationDeg: number }): string {
return `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
}
+26 -1
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Viewport } from '@/lib/geometry' import type { Viewport } from '@/lib/geometry'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import type { EditorObject } from './types' import type { EditorObject } from './types'
// Ephemeral editor state only (per DESIGN § State): the viewport, the current // Ephemeral editor state only (per DESIGN § State): the viewport, the current
@@ -13,17 +15,31 @@ interface EditorState {
viewport: Viewport viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void 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 selectedId: number | null
select: (id: number | null) => void select: (id: number | null) => void
selectedPlantingId: number | null
selectPlanting: (id: number | null) => void
focusedObjectId: number | null focusedObjectId: number | null
setFocusedObject: (id: number | null) => void 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 // 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. // canvas renders it instantly; the PATCH fires only on gesture end.
liveObject: EditorObject | null liveObject: EditorObject | null
setLiveObject: (o: EditorObject | null) => void 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 // The palette kind armed for tap-to-place (mobile-friendly; also set while
// dragging a kind from the palette). null when nothing is armed. // dragging a kind from the palette). null when nothing is armed.
armedKind: string | null 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 })), setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
selectedId: null, 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, focusedObjectId: null,
setFocusedObject: (id) => set({ focusedObjectId: id }), setFocusedObject: (id) => set({ focusedObjectId: id }),
armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }),
liveObject: null, liveObject: null,
setLiveObject: (o) => set({ liveObject: o }), setLiveObject: (o) => set({ liveObject: o }),
livePlanting: null,
setLivePlanting: (p) => set({ livePlanting: p }),
armedKind: null, armedKind: null,
setArmedKind: (kind) => set({ armedKind: kind }), setArmedKind: (kind) => set({ armedKind: kind }),
+146 -2
View File
@@ -7,6 +7,8 @@ import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient }
import { z } from 'zod' import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
import { gardenSchema } from './gardens' import { gardenSchema } from './gardens'
import { plantSchema } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import type { EditorObject, ObjectShapeKind } from '@/editor/types' import type { EditorObject, ObjectShapeKind } from '@/editor/types'
@@ -35,8 +37,8 @@ export type ServerObject = z.infer<typeof serverObjectSchema>
export const fullGardenSchema = z.object({ export const fullGardenSchema = z.object({
garden: gardenSchema, garden: gardenSchema,
objects: z.array(serverObjectSchema), objects: z.array(serverObjectSchema),
plantings: z.array(z.unknown()), // typed in #14 plantings: z.array(serverPlantingSchema),
plants: z.array(z.unknown()), // typed in #12 plants: z.array(plantSchema),
}) })
export type FullGarden = z.infer<typeof fullGardenSchema> export type FullGarden = z.infer<typeof fullGardenSchema>
@@ -181,12 +183,154 @@ 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)
// On a 409, adopt the server's current row so the plop reappears with a
// fresh version — otherwise a retry keeps 409-ing on the stale version.
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 — try removing it again.')
} else {
toast.error(objectErrorMessage(err, 'Could not remove that plant.'))
}
},
})
}
// --- helpers --------------------------------------------------------------- // --- helpers ---------------------------------------------------------------
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) { function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full)) 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 } : {}),
...(patch.removedAt !== undefined ? { removedAt: patch.removedAt } : {}),
}
}
/** 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 /** 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 * 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 * 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))
}
+147 -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 { getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas' import { GardenCanvas } from '@/editor/GardenCanvas'
import { Inspector } from '@/editor/Inspector' import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette' import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store' import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types' 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') const routeApi = getRouteApi('/gardens/$gardenId')
@@ -14,29 +19,90 @@ export function GardenEditorPage() {
const { gardenId } = routeApi.useParams() const { gardenId } = routeApi.useParams()
const gid = Number(gardenId) const gid = Number(gardenId)
const { focus } = routeApi.useSearch() const { focus } = routeApi.useSearch()
const navigate = routeApi.useNavigate()
const full = useGardenFull(gid) const full = useGardenFull(gid)
const selectedId = useEditorStore((s) => s.selectedId) const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select) 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 setArmedKind = useEditorStore((s) => s.setArmedKind)
const setLiveObject = useEditorStore((s) => s.setLiveObject) const setLiveObject = useEditorStore((s) => s.setLiveObject)
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 serverObjects = full.data?.objects
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects]) 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(() => { useEffect(() => {
const reset = () => { const clear = () => {
select(null) select(null)
selectPlanting(null)
setArmedKind(null) setArmedKind(null)
setArmedPlant(null)
setLiveObject(null) setLiveObject(null)
setLivePlanting(null)
} }
reset() clear()
return reset setFocusedObject(focus ?? null)
}, [gid, select, setArmedKind, setLiveObject]) 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])
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
// focus mode so the canvas isn't stuck dimmed with no way out.
useEffect(() => {
if (focusedObjectId != null && full.data && !objects.some((o) => o.id === focusedObjectId)) {
setFocusedObject(null)
}
}, [focusedObjectId, objects, full.data, setFocusedObject])
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.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError) if (full.isError)
@@ -54,7 +120,22 @@ export function GardenEditorPage() {
heightCm: g.heightCm, heightCm: g.heightCm,
unitPref: g.unitPref, 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
// Committed selected plop; PlopInspector applies live drag geometry itself.
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
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 ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row"> <div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
@@ -62,18 +143,69 @@ export function GardenEditorPage() {
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}> <h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name} {garden.name}
</h1> </h1>
<Palette /> {focusedObjectId == null && <Palette />}
</div> </div>
<div className="relative min-h-0 flex-1"> <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>
{!focusedObject.plantable ? (
<span className="text-xs text-muted">Not plantable</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} plantsById={plantsById} />
</div> </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"> <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> </div>
)} )}
{picker && (
<PlantPicker
unit={garden.unitPref}
onClose={() => setPicker(null)}
onSelect={(p) => onPickPlant(p.id)}
/>
)}
</div> </div>
) )
} }