Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on <html> by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react'
|
||||
import { clampScale, type Point } from '@/lib/geometry'
|
||||
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { formatSize } from '@/lib/units'
|
||||
import { kindDef, objectStyle, rectRadius } from './kinds'
|
||||
import {
|
||||
DIM_OBJECT,
|
||||
DIM_PLOP,
|
||||
MAX_SCALE,
|
||||
MIN_OBJECT_CM,
|
||||
MIN_SCALE,
|
||||
SNAP_CM,
|
||||
clampPlopLocal,
|
||||
defaultPlopRadius,
|
||||
objectAt,
|
||||
objectTransform,
|
||||
rotatedHalfHeight,
|
||||
snapPlopLocal,
|
||||
toLocal,
|
||||
toWorld,
|
||||
} from './shared'
|
||||
import { useEditorStore, type Viewport } from './store'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
|
||||
const WHEEL_SENSITIVITY = 0.0016
|
||||
const ANIM_MS = 520
|
||||
const REFIT_THRESHOLD_PX = 60
|
||||
const FT = 30.48
|
||||
|
||||
export interface CanvasHandle {
|
||||
zoomFit: () => void
|
||||
zoomIn: () => void
|
||||
zoomOut: () => void
|
||||
/** Frame a bed and enter focus mode (the page handles mode + URL). */
|
||||
focusObject: (o: EditorObject) => void
|
||||
}
|
||||
|
||||
type Drag =
|
||||
| { t: 'pan'; sx: number; sy: number; tx: number; ty: number; moved: boolean; th: number }
|
||||
| { t: 'obj'; base: EditorObject; sx: number; sy: number; moved: boolean; th: number }
|
||||
| { t: 'plop'; base: EditorPlanting; obj: EditorObject; sx: number; sy: number; moved: boolean; th: number }
|
||||
| { t: 'resize'; base: EditorObject; cx: number; cy: number; opp: Point; moved: boolean }
|
||||
|
||||
interface Pinch {
|
||||
d0: number
|
||||
cx0: number
|
||||
cy0: number
|
||||
s0: number
|
||||
tx0: number
|
||||
ty0: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The garden canvas: one SVG, world = garden cm under a single translate/scale
|
||||
* group. Everything the prototype does — wheel zoom to the cursor, two-finger
|
||||
* pinch about the centroid, one-finger pan, drag-to-move with a 3″ snap, tap to
|
||||
* select, semantic zoom on the plant markers, a ghost while a kind is armed,
|
||||
* drag-and-drop from the toolkit — lives here, with native SVG hit-testing
|
||||
* doing the picking. Drags commit ONE change on release (a PATCH with the row's
|
||||
* version); placement creates. Corner handles on the selected object resize it.
|
||||
*/
|
||||
export const Canvas = forwardRef<
|
||||
CanvasHandle,
|
||||
{
|
||||
garden: EditorGarden
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
letters: Map<number, string>
|
||||
canEdit: boolean
|
||||
isMobile: boolean
|
||||
}
|
||||
>(function Canvas({ garden, objects, plantings, plantsById, letters, canEdit, isMobile }, ref) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||
const fitSize = useRef({ w: 0, h: 0 })
|
||||
const fittedGarden = useRef<number | null>(null)
|
||||
const wasMobile = useRef(isMobile)
|
||||
const animTimer = useRef<number | null>(null)
|
||||
const pts = useRef(new Map<number, Point>())
|
||||
const drag = useRef<Drag | null>(null)
|
||||
const pinch = useRef<Pinch | null>(null)
|
||||
const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null)
|
||||
|
||||
const vp = useEditorStore((s) => s.vp)
|
||||
const anim = useEditorStore((s) => s.anim)
|
||||
const sel = useEditorStore((s) => s.sel)
|
||||
const focusId = useEditorStore((s) => s.focusId)
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const ghost = useEditorStore((s) => s.ghost)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
|
||||
const createObject = useCreateObject(garden.id)
|
||||
const createPlanting = useCreatePlanting(garden.id)
|
||||
const updateObject = useUpdateObject(garden.id)
|
||||
const updatePlanting = useUpdatePlanting(garden.id)
|
||||
|
||||
const GW = garden.widthCm
|
||||
const GH = garden.heightCm
|
||||
const s = vp.s
|
||||
const snapStep = garden.snapToGrid && garden.gridSizeCm > 0 ? garden.gridSizeCm : SNAP_CM
|
||||
|
||||
// Objects in draw order, with any in-flight drag geometry merged in.
|
||||
const rendered = useMemo(() => {
|
||||
const sorted = [...objects].sort((a, b) => a.zIndex - b.zIndex || a.id - b.id)
|
||||
return liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted
|
||||
}, [objects, liveObject])
|
||||
const renderedPlops = useMemo(
|
||||
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
|
||||
[plantings, livePlanting],
|
||||
)
|
||||
const byId = useMemo(() => new Map(rendered.map((o) => [o.id, o])), [rendered])
|
||||
// Latest lists for the pointer handlers, which must not close over a render.
|
||||
const latest = useRef({ rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep })
|
||||
latest.current = { rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep }
|
||||
|
||||
// ── camera ──────────────────────────────────────────────────────────────
|
||||
const camera = useCallback((next: Viewport, animate: boolean) => {
|
||||
useEditorStore.getState().setVp(next, animate)
|
||||
if (animTimer.current != null) window.clearTimeout(animTimer.current)
|
||||
if (animate) {
|
||||
animTimer.current = window.setTimeout(() => {
|
||||
animTimer.current = null
|
||||
useEditorStore.getState().setAnim(false)
|
||||
}, ANIM_MS)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const zoomFit = useCallback(() => {
|
||||
const el = svgRef.current
|
||||
if (!el) return
|
||||
const w = el.clientWidth
|
||||
const h = el.clientHeight
|
||||
const pad = latest.current.isMobile ? 20 : 40
|
||||
const { widthCm, heightCm } = latest.current.garden
|
||||
const ns = clampScale(Math.min((w - pad * 2) / widthCm, (h - pad * 2) / heightCm), MIN_SCALE, MAX_SCALE)
|
||||
if (!Number.isFinite(ns) || ns <= 0) return
|
||||
fitSize.current = { w, h }
|
||||
camera({ s: ns, tx: (w - widthCm * ns) / 2, ty: (h - heightCm * ns) / 2 }, true)
|
||||
}, [camera])
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(f: number) => {
|
||||
const el = svgRef.current
|
||||
if (!el) return
|
||||
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||
const ns = clampScale(cur * f, MIN_SCALE, MAX_SCALE)
|
||||
const px = el.clientWidth / 2
|
||||
const py = el.clientHeight / 2
|
||||
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, true)
|
||||
},
|
||||
[camera],
|
||||
)
|
||||
|
||||
const focusObject = useCallback(
|
||||
(o: EditorObject) => {
|
||||
const el = svgRef.current
|
||||
const st = useEditorStore.getState()
|
||||
const m = latest.current.isMobile
|
||||
st.setFocus(o.id)
|
||||
st.setSel(m ? null : { type: 'object', id: o.id })
|
||||
st.setArmedKind(null)
|
||||
st.setGhost(null)
|
||||
st.setTab('plot')
|
||||
if (!el) return
|
||||
const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm
|
||||
const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm
|
||||
const ns = Math.min(
|
||||
MAX_SCALE * 0.75,
|
||||
Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))),
|
||||
)
|
||||
camera({ s: ns, tx: el.clientWidth / 2 - o.xCm * ns, ty: el.clientHeight / 2 - o.yCm * ns + (m ? 0 : 10) }, true)
|
||||
},
|
||||
[camera],
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({ zoomFit, zoomIn: () => zoomBy(1.45), zoomOut: () => zoomBy(1 / 1.45), focusObject }), [
|
||||
zoomFit,
|
||||
zoomBy,
|
||||
focusObject,
|
||||
])
|
||||
|
||||
// Measure; refit on a real size change (>60px) or when the chrome flips.
|
||||
useEffect(() => {
|
||||
const el = hostRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (size.w === 0 || size.h === 0 || GW === 0 || GH === 0) return
|
||||
const first = fittedGarden.current !== garden.id
|
||||
const flipped = wasMobile.current !== isMobile
|
||||
const big =
|
||||
Math.abs(size.w - fitSize.current.w) > REFIT_THRESHOLD_PX || Math.abs(size.h - fitSize.current.h) > REFIT_THRESHOLD_PX
|
||||
wasMobile.current = isMobile
|
||||
if (!first && !flipped && !big) return
|
||||
fittedGarden.current = garden.id
|
||||
// A focused bed (a ?focus= deep link, or a chrome flip mid-planting) frames
|
||||
// its bed rather than the whole garden.
|
||||
const f = useEditorStore.getState().focusId
|
||||
const target = f != null ? latest.current.byId.get(f) : undefined
|
||||
if (target) {
|
||||
fitSize.current = { w: size.w, h: size.h }
|
||||
focusObject(target)
|
||||
} else zoomFit()
|
||||
}, [size, garden.id, GW, GH, isMobile, zoomFit, focusObject])
|
||||
|
||||
// Wheel zooms to the cursor. Non-passive so the page doesn't scroll.
|
||||
useEffect(() => {
|
||||
const el = svgRef.current
|
||||
if (!el) return
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (!Number.isFinite(e.deltaY)) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||
const ns = clampScale(cur * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)
|
||||
const px = e.clientX - r.left
|
||||
const py = e.clientY - r.top
|
||||
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, false)
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [camera])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (animTimer.current != null) window.clearTimeout(animTimer.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── pointer math ────────────────────────────────────────────────────────
|
||||
const world = (e: { clientX: number; clientY: number }): Point => {
|
||||
const r = svgRef.current!.getBoundingClientRect()
|
||||
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||
return { x: (e.clientX - r.left - tx) / cur, y: (e.clientY - r.top - ty) / cur }
|
||||
}
|
||||
const snap = (v: number) => Math.round(v / latest.current.snapStep) * latest.current.snapStep
|
||||
const thresh = (e: { pointerType?: string }) => (e.pointerType === 'touch' ? 7 : 3)
|
||||
const track = (e: ReactPointerEvent) => {
|
||||
const r = svgRef.current!.getBoundingClientRect()
|
||||
pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
|
||||
try {
|
||||
svgRef.current!.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* not all pointers capture */
|
||||
}
|
||||
}
|
||||
const pinchStart = () => {
|
||||
const [a, b] = [...pts.current.values()]
|
||||
drag.current = null
|
||||
const { s: cur, tx, ty } = useEditorStore.getState().vp
|
||||
pinch.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: cur, tx0: tx, ty0: ty }
|
||||
}
|
||||
|
||||
// ── placement ───────────────────────────────────────────────────────────
|
||||
const placeObject = (kind: string, w: Point) => {
|
||||
const def = kindDef(kind)
|
||||
const st = useEditorStore.getState()
|
||||
st.setArmedKind(null)
|
||||
st.setGhost(null)
|
||||
if (!def || !latest.current.canEdit) return
|
||||
const n = latest.current.rendered.filter((o) => o.kind === def.kind).length + 1
|
||||
createObject.mutate(
|
||||
{
|
||||
kind: def.kind,
|
||||
shape: def.shape,
|
||||
name: `${def.label} ${n}`,
|
||||
xCm: snap(w.x),
|
||||
yCm: snap(w.y),
|
||||
widthCm: def.widthCm,
|
||||
heightCm: def.heightCm,
|
||||
zIndex: def.defaultZ,
|
||||
plantable: def.plantable,
|
||||
},
|
||||
{ onSuccess: (o) => useEditorStore.getState().setSel({ type: 'object', id: o.id }) },
|
||||
)
|
||||
}
|
||||
|
||||
const placePlop = (o: EditorObject, local: Point, plant: Plant, lotId: number | null) => {
|
||||
if (!latest.current.canEdit || !o.plantable) return
|
||||
const r = defaultPlopRadius(plant)
|
||||
const c = clampPlopLocal(o, snapPlopLocal(o, local), r)
|
||||
createPlanting.mutate(
|
||||
{ objectId: o.id, plantId: plant.id, xCm: c.x, yCm: c.y, radiusCm: r, seedLotId: lotId ?? undefined },
|
||||
{
|
||||
onSuccess: (p) => {
|
||||
// Desktop selects what it just placed; the phone keeps the strip's plant
|
||||
// armed and the peek closed so the next tap plants again.
|
||||
if (!latest.current.isMobile) useEditorStore.getState().setSel({ type: 'plop', id: p.id })
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── pointer handlers ────────────────────────────────────────────────────
|
||||
const onCanvasDown = (e: ReactPointerEvent) => {
|
||||
track(e)
|
||||
if (pts.current.size === 2) return pinchStart()
|
||||
const st = useEditorStore.getState()
|
||||
const w = world(e)
|
||||
if (st.armedKind) return placeObject(st.armedKind, w)
|
||||
if (st.armedPlant) {
|
||||
const o = st.focusId != null ? latest.current.byId.get(st.focusId) : objectAt(latest.current.rendered, w)
|
||||
if (o?.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
|
||||
return
|
||||
}
|
||||
if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) }
|
||||
}
|
||||
|
||||
const objDown = (o: EditorObject) => (e: ReactPointerEvent) => {
|
||||
e.stopPropagation()
|
||||
track(e)
|
||||
if (pts.current.size === 2) return pinchStart()
|
||||
const st = useEditorStore.getState()
|
||||
const w = world(e)
|
||||
if (st.armedKind) return placeObject(st.armedKind, w)
|
||||
if (st.armedPlant) {
|
||||
if (o.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
|
||||
return
|
||||
}
|
||||
// Dimmed siblings stay inert inside a focused bed.
|
||||
if (st.focusId != null && o.id !== st.focusId) return
|
||||
if (!latest.current.canEdit) {
|
||||
st.setSel({ type: 'object', id: o.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
drag.current = { t: 'obj', base: o, sx: w.x, sy: w.y, moved: false, th: thresh(e) }
|
||||
}
|
||||
|
||||
const plopDown = (p: EditorPlanting) => (e: ReactPointerEvent) => {
|
||||
const st = useEditorStore.getState()
|
||||
if (st.armedKind || st.armedPlant) return // bubbles to the bed / canvas, which places
|
||||
e.stopPropagation()
|
||||
const o = latest.current.byId.get(p.objectId)
|
||||
if (!o) return
|
||||
// Outside focus a plop is just part of its bed: grabbing it grabs the bed.
|
||||
if (st.focusId !== p.objectId) return objDown(o)(e)
|
||||
track(e)
|
||||
if (pts.current.size === 2) return pinchStart()
|
||||
if (!latest.current.canEdit) {
|
||||
st.setSel({ type: 'plop', id: p.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
const l = toLocal(o, world(e))
|
||||
drag.current = { t: 'plop', base: p, obj: o, sx: l.x, sy: l.y, moved: false, th: thresh(e) }
|
||||
}
|
||||
|
||||
const handleDown = (o: EditorObject, cx: number, cy: number) => (e: ReactPointerEvent) => {
|
||||
e.stopPropagation()
|
||||
track(e)
|
||||
if (!latest.current.canEdit) return
|
||||
// The corner opposite the dragged one stays put, in the object's own frame.
|
||||
drag.current = { t: 'resize', base: o, cx, cy, opp: { x: -cx * (o.widthCm / 2), y: -cy * (o.heightCm / 2) }, moved: false }
|
||||
}
|
||||
|
||||
const onCanvasMove = (e: ReactPointerEvent) => {
|
||||
const r = svgRef.current!.getBoundingClientRect()
|
||||
if (pts.current.has(e.pointerId)) pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
|
||||
const st = useEditorStore.getState()
|
||||
if (pinch.current && pts.current.size >= 2) {
|
||||
const [a, b] = [...pts.current.values()]
|
||||
const p = pinch.current
|
||||
const d1 = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const cx = (a.x + b.x) / 2
|
||||
const cy = (a.y + b.y) / 2
|
||||
const ns = clampScale(p.s0 * (d1 / Math.max(1, p.d0)), MIN_SCALE, MAX_SCALE)
|
||||
const wx = (p.cx0 - p.tx0) / p.s0
|
||||
const wy = (p.cy0 - p.ty0) / p.s0
|
||||
camera({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns }, false)
|
||||
return
|
||||
}
|
||||
const d = drag.current
|
||||
if (!d) {
|
||||
if (st.armedKind && e.pointerType !== 'touch') {
|
||||
const w = world(e)
|
||||
st.setGhost({ x: snap(w.x), y: snap(w.y) })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (d.t === 'pan') {
|
||||
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > d.th) d.moved = true
|
||||
camera({ ...st.vp, tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy }, false)
|
||||
} else if (d.t === 'obj') {
|
||||
const w = world(e)
|
||||
if (Math.hypot(w.x - d.sx, w.y - d.sy) > d.th / st.vp.s) d.moved = true
|
||||
if (!d.moved) return
|
||||
st.setLiveObject({ ...d.base, xCm: snap(d.base.xCm + w.x - d.sx), yCm: snap(d.base.yCm + w.y - d.sy) })
|
||||
} else if (d.t === 'plop') {
|
||||
const l = toLocal(d.obj, world(e))
|
||||
if (Math.hypot(l.x - d.sx, l.y - d.sy) > d.th / st.vp.s) d.moved = true
|
||||
if (!d.moved) return
|
||||
const next = clampPlopLocal(
|
||||
d.obj,
|
||||
snapPlopLocal(d.obj, { x: d.base.xCm + l.x - d.sx, y: d.base.yCm + l.y - d.sy }),
|
||||
d.base.radiusCm,
|
||||
)
|
||||
st.setLivePlanting({ ...d.base, xCm: next.x, yCm: next.y })
|
||||
} else if (d.t === 'resize') {
|
||||
const p = toLocal(d.base, world(e))
|
||||
d.moved = true
|
||||
let newW = Math.max(MIN_OBJECT_CM, Math.abs(p.x - d.opp.x))
|
||||
let newH = Math.max(MIN_OBJECT_CM, Math.abs(p.y - d.opp.y))
|
||||
if (latest.current.garden.snapToGrid) {
|
||||
const g = latest.current.snapStep
|
||||
newW = Math.max(g, Math.round(newW / g) * g)
|
||||
newH = Math.max(g, Math.round(newH / g) * g)
|
||||
}
|
||||
const dragged = { x: d.opp.x + d.cx * newW, y: d.opp.y + d.cy * newH }
|
||||
const c = toWorld(d.base, { x: (d.opp.x + dragged.x) / 2, y: (d.opp.y + dragged.y) / 2 })
|
||||
st.setLiveObject({ ...d.base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH })
|
||||
}
|
||||
}
|
||||
|
||||
const onCanvasUp = (e: ReactPointerEvent) => {
|
||||
pts.current.delete(e.pointerId)
|
||||
if (pinch.current) {
|
||||
if (pts.current.size < 2) pinch.current = null
|
||||
return
|
||||
}
|
||||
const d = drag.current
|
||||
drag.current = null
|
||||
if (!d) return
|
||||
const st = useEditorStore.getState()
|
||||
if (d.t === 'pan') {
|
||||
if (!d.moved) st.setSel(null)
|
||||
return
|
||||
}
|
||||
if (d.t === 'obj') {
|
||||
if (!d.moved) {
|
||||
st.setSel({ type: 'object', id: d.base.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
const final = st.liveObject
|
||||
st.setLiveObject(null)
|
||||
if (final && (final.xCm !== d.base.xCm || final.yCm !== d.base.yCm))
|
||||
updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
|
||||
} else if (d.t === 'plop') {
|
||||
if (!d.moved) {
|
||||
st.setSel({ type: 'plop', id: d.base.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
const final = st.livePlanting
|
||||
st.setLivePlanting(null)
|
||||
if (final) updatePlanting.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
|
||||
} else if (d.t === 'resize') {
|
||||
const final = st.liveObject
|
||||
st.setLiveObject(null)
|
||||
if (final && d.moved)
|
||||
updateObject.mutate({
|
||||
id: final.id,
|
||||
version: final.version,
|
||||
xCm: final.xCm,
|
||||
yCm: final.yCm,
|
||||
widthCm: final.widthCm,
|
||||
heightCm: final.heightCm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HTML5 drag-and-drop from the toolkit (desktop).
|
||||
const onDragOver = (e: DragEvent) => e.preventDefault()
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
const data = e.dataTransfer.getData('text/plain')
|
||||
if (!data) return
|
||||
const w = world(e)
|
||||
const [t, id] = data.split(':')
|
||||
if (t === 'kind') placeObject(id, w)
|
||||
if (t === 'plant' && dropPlant.current) {
|
||||
const o = objectAt(latest.current.rendered, w)
|
||||
if (o?.plantable) placePlop(o, toLocal(o, w), dropPlant.current.plant, dropPlant.current.lotId)
|
||||
}
|
||||
}
|
||||
// The plant being dragged from the toolkit rides along in the store (the
|
||||
// dataTransfer only carries its id); the armed plant is what's being dragged.
|
||||
dropPlant.current = armedPlant ? { plant: armedPlant, lotId: useEditorStore.getState().armedLotId } : null
|
||||
|
||||
// ── derived drawing data ───────────────────────────────────────────────
|
||||
const grid = useMemo(() => {
|
||||
const imperial = garden.unitPref === 'imperial'
|
||||
let step = imperial ? FT : 25
|
||||
const majorEvery = imperial ? 5 : 4
|
||||
while (Math.max(GW, GH) / step > 400) step *= majorEvery // a 100 m field still draws a sane number of lines
|
||||
const minor: string[] = []
|
||||
const major: string[] = []
|
||||
for (let i = 1; i * step < GW; i++) (i % majorEvery ? minor : major).push(`M${(i * step).toFixed(2)} 0V${GH}`)
|
||||
for (let i = 1; i * step < GH; i++) (i % majorEvery ? minor : major).push(`M0 ${(i * step).toFixed(2)}H${GW}`)
|
||||
return { minor: minor.join(''), major: major.join('') }
|
||||
}, [GW, GH, garden.unitPref])
|
||||
|
||||
const selectedObject = sel?.type === 'object' ? (byId.get(sel.id) ?? null) : null
|
||||
const showLabels = s > 0.28
|
||||
const handlePx = isMobile ? 14 : 9
|
||||
const cursor = armedKind || armedPlant ? 'crosshair' : 'default'
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className="absolute inset-0">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ display: 'block', touchAction: 'none', cursor, userSelect: 'none' }}
|
||||
onPointerDown={onCanvasDown}
|
||||
onPointerMove={onCanvasMove}
|
||||
onPointerUp={onCanvasUp}
|
||||
onPointerCancel={onCanvasUp}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
role="application"
|
||||
aria-label={`${garden.name} — the plan. Tap a bed to select it; double-click to plant it.`}
|
||||
>
|
||||
<title>{garden.name} plan</title>
|
||||
<g
|
||||
className={anim ? 'camera-anim' : undefined}
|
||||
style={{ transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${s})`, transformOrigin: '0 0' }}
|
||||
>
|
||||
<rect x={0} y={0} width={GW} height={GH} rx={18} fill="var(--p-field)" stroke="var(--color-accent-2-500)" strokeWidth={3 / s} />
|
||||
<path d={grid.minor} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.06} fill="none" />
|
||||
<path d={grid.major} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.12} fill="none" />
|
||||
|
||||
{rendered.map((o) => {
|
||||
const st = objectStyle(o)
|
||||
const isSel = sel?.type === 'object' && sel.id === o.id
|
||||
const dim = focusId != null && o.id !== focusId
|
||||
const common = {
|
||||
fill: st.fill,
|
||||
stroke: isSel ? 'var(--color-accent)' : st.stroke,
|
||||
strokeWidth: (isSel ? 3.5 : 2.5) / s,
|
||||
strokeDasharray: st.dash,
|
||||
}
|
||||
return (
|
||||
<g
|
||||
key={o.id}
|
||||
data-object-id={o.id}
|
||||
transform={objectTransform(o)}
|
||||
opacity={dim ? DIM_OBJECT : 1}
|
||||
style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }}
|
||||
onPointerDown={objDown(o)}
|
||||
onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined}
|
||||
>
|
||||
{o.shape === 'circle' ? (
|
||||
<ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} />
|
||||
) : (
|
||||
<rect x={-o.widthCm / 2} y={-o.heightCm / 2} width={o.widthCm} height={o.heightCm} rx={rectRadius(o.widthCm)} {...common} />
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{renderedPlops.map((p) => {
|
||||
const o = byId.get(p.objectId)
|
||||
if (!o) return null
|
||||
const plant = plantsById.get(p.plantId)
|
||||
const w = toWorld(o, { x: p.xCm, y: p.yCm })
|
||||
const isSel = sel?.type === 'plop' && sel.id === p.id
|
||||
const inFocus = focusId === p.objectId
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
data-plop-id={p.id}
|
||||
transform={`translate(${w.x} ${w.y})`}
|
||||
opacity={focusId != null && !inFocus ? DIM_PLOP : 0.94}
|
||||
style={{ cursor: inFocus ? (canEdit ? 'grab' : 'pointer') : 'inherit' }}
|
||||
onPointerDown={plopDown(p)}
|
||||
>
|
||||
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
|
||||
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
|
||||
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Labels — semantic zoom. pointer-events none so they never catch a tap. */}
|
||||
<g style={{ pointerEvents: 'none' }}>
|
||||
{showLabels &&
|
||||
rendered.map((o) => {
|
||||
if (!o.name || Math.max(o.widthCm, o.heightCm) * s <= 54 || (focusId != null && focusId !== o.id)) return null
|
||||
const bh = rotatedHalfHeight(o)
|
||||
return o.plantable ? (
|
||||
<text
|
||||
key={`ol${o.id}`}
|
||||
x={o.xCm}
|
||||
y={o.yCm - bh - 9 / s}
|
||||
textAnchor="middle"
|
||||
fontSize={13 / s}
|
||||
fill="var(--p-ink-soft)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' }}
|
||||
>
|
||||
{o.name}
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
key={`ol${o.id}`}
|
||||
x={o.xCm}
|
||||
y={o.yCm}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={13 / s}
|
||||
fill="var(--p-ink-mute)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' }}
|
||||
>
|
||||
{o.name}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
{renderedPlops.map((p) => {
|
||||
const o = byId.get(p.objectId)
|
||||
if (!o || (focusId != null && p.objectId !== focusId)) return null
|
||||
const plant = plantsById.get(p.plantId)
|
||||
const r = p.radiusCm
|
||||
if (r * s < 9) return null
|
||||
const w = toWorld(o, { x: p.xCm, y: p.yCm })
|
||||
return (
|
||||
<g key={`pl${p.id}`}>
|
||||
<text
|
||||
x={w.x}
|
||||
y={w.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={r * 1.05}
|
||||
fill="var(--color-paper)"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
{letters.get(p.plantId) ?? '?'}
|
||||
</text>
|
||||
{r * s >= 34 && plant && (
|
||||
<text
|
||||
x={w.x}
|
||||
y={w.y + r + 13 / s}
|
||||
textAnchor="middle"
|
||||
fontSize={11 / s}
|
||||
fill="var(--p-ink-strong)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 600 }}
|
||||
>
|
||||
{plant.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{selectedObject && (
|
||||
<text
|
||||
x={selectedObject.xCm}
|
||||
y={selectedObject.yCm + rotatedHalfHeight(selectedObject) + 22 / s}
|
||||
textAnchor="middle"
|
||||
fontSize={12.5 / s}
|
||||
fill="var(--color-accent-700)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 700 }}
|
||||
>
|
||||
{formatSize(selectedObject.widthCm, selectedObject.heightCm, garden.unitPref)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Selection: a dashed accent outline offset from the object, and —
|
||||
for editors — corner handles to resize it. */}
|
||||
{selectedObject && (
|
||||
<g transform={objectTransform(selectedObject)}>
|
||||
{selectedObject.shape === 'circle' ? (
|
||||
<ellipse
|
||||
rx={selectedObject.widthCm / 2 + 7 / s}
|
||||
ry={selectedObject.heightCm / 2 + 7 / s}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1.8 / s}
|
||||
strokeDasharray={`${8 / s} ${6 / s}`}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={-selectedObject.widthCm / 2 - 7 / s}
|
||||
y={-selectedObject.heightCm / 2 - 7 / s}
|
||||
width={selectedObject.widthCm + 14 / s}
|
||||
height={selectedObject.heightCm + 14 / s}
|
||||
rx={16 / s}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1.8 / s}
|
||||
strokeDasharray={`${8 / s} ${6 / s}`}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
{canEdit &&
|
||||
!liveObject &&
|
||||
(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
] as const
|
||||
).map(([cx, cy]) => (
|
||||
<rect
|
||||
key={`${cx},${cy}`}
|
||||
x={cx * (selectedObject.widthCm / 2 + 7 / s) - handlePx / 2 / s}
|
||||
y={cy * (selectedObject.heightCm / 2 + 7 / s) - handlePx / 2 / s}
|
||||
width={handlePx / s}
|
||||
height={handlePx / s}
|
||||
rx={2 / s}
|
||||
fill="var(--color-neutral-100)"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1.5 / s}
|
||||
style={{ cursor: cx * cy > 0 ? 'nwse-resize' : 'nesw-resize' }}
|
||||
onPointerDown={handleDown(selectedObject, cx, cy)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{ghost && armedKind && (() => {
|
||||
const def = kindDef(armedKind)
|
||||
if (!def) return null
|
||||
const st = objectStyle({ kind: def.kind })
|
||||
return (
|
||||
<g transform={`translate(${ghost.x} ${ghost.y})`} opacity={0.55} style={{ pointerEvents: 'none' }}>
|
||||
{def.shape === 'circle' ? (
|
||||
<circle r={def.widthCm / 2} fill={st.fill} stroke="var(--color-accent)" strokeWidth={2 / s} strokeDasharray={`${7 / s} ${5 / s}`} />
|
||||
) : (
|
||||
<rect
|
||||
x={-def.widthCm / 2}
|
||||
y={-def.heightCm / 2}
|
||||
width={def.widthCm}
|
||||
height={def.heightCm}
|
||||
rx={12}
|
||||
fill={st.fill}
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2 / s}
|
||||
strokeDasharray={`${7 / s} ${5 / s}`}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user