Address Gadfly review on #9: memoize shapes, fades, unique ids, robustness
Build image / build-and-push (push) Successful in 24s

Fixes from the PR #29 adversarial review (considered; not graded).

Performance / correctness
- ObjectShape is memo'd, so a pan/zoom (which only mutates the world <g>
  transform) no longer re-renders every object.
- 'circle' objects render as an <ellipse> (rx/ry), honoring both dims — a
  circle when width == height — instead of ignoring heightCm.
- The 1 m grid now actually fades in (opacity ramps as cells grow past the
  visibility threshold), matching its description.
- Unique SVG pattern id (useId) so multiple canvases can't collide.
- The canvas re-fits when the garden id changes (not just once), so #11
  switching gardens reframes.

Robustness
- geometry.zoomToFitRect takes rect size by magnitude; wheel/pinch ignore
  non-finite deltas; ObjectShape clamps dimensions to >= 0 (no invalid SVG).

Maintainability
- Renamed the Size params from `viewport` to `canvasSize` (4 models); moved
  easeInOutCubic/lerp into geometry.ts; renamed toLocal → clientToCanvas;
  named constants for the label/corner factors; DEFAULT_FILL const;
  EditorGarden.unitPref imports UnitPref; the Fit control uses the shared
  Button. focusedObjectId/plantable are intentionally reserved for #11/#15.

tsc + 17 vitest tests green; re-verified in a browser (ellipses render,
unique pattern id, wheel zoom + Fit).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 20:16:53 -04:00
co-authored by Claude Opus 4.8
parent 30b36b7033
commit af34ced208
5 changed files with 109 additions and 66 deletions
+30 -24
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/Button'
import type { Size } from '@/lib/geometry'
import { ObjectShape } from './ObjectShape'
import { useEditorStore } from './store'
@@ -6,19 +7,21 @@ import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6 // hide the grid when cells get this small (zoomed out)
const GRID_MIN_CELL_PX = 6 // grid is invisible below this cell size (zoomed out)
const GRID_MAX_OPACITY = 0.18
/**
* The editor canvas: a full-size SVG with a single world <g> that pan/zoom/pinch
* transforms. Renders a fading 1 m grid, the garden boundary, and its objects.
* Interactions beyond viewport + basic select (move/resize/rotate, palette) land
* in #11; this is the foundation, driven by mock data until then.
* transforms. Renders a 1 m grid that fades in as you zoom, the garden boundary,
* and its objects. Interactions beyond viewport + basic select (move/resize/
* rotate, palette) land in #11; this is the foundation, driven by mock data.
*/
export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fittedRef = useRef(false)
const fittedForRef = useRef<number | null>(null)
const gridId = 'garden-grid-' + useId().replace(/:/g, '')
const viewport = useEditorStore((s) => s.viewport)
const selectedId = useEditorStore((s) => s.selectedId)
@@ -41,15 +44,20 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object
return () => ro.disconnect()
}, [])
// Frame the garden the first time we know the canvas size.
// Frame the garden once the canvas size is known, and again if we switch to a
// different garden.
useEffect(() => {
if (!fittedRef.current && size.w > 0 && size.h > 0) {
fittedRef.current = true
const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0
if (usable && fittedForRef.current !== garden.id) {
fittedForRef.current = garden.id
fitToRect(gardenRect, size)
}
}, [size, gardenRect, fitToRect])
}, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect])
// Grid fades in as cells grow from GRID_MIN_CELL_PX to 2× that.
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 showGrid = GRID_CM * viewport.scale >= GRID_MIN_CELL_PX
const ordered = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
return (
@@ -63,21 +71,21 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object
onPointerDown={() => select(null)}
>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{showGrid && (
{gridOpacity > 0.005 && (
<>
<defs>
<pattern id="garden-grid" width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
<pattern id={gridId} width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
<path
d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`}
fill="none"
stroke="#808080"
strokeOpacity={0.18}
strokeOpacity={gridOpacity}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="url(#garden-grid)" />
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
</>
)}
@@ -104,15 +112,13 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">
{viewport.scale.toFixed(2)} px/cm
</div>
<div className="absolute bottom-3 right-3">
<button
type="button"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="rounded-md border border-border bg-surface/90 px-3 py-1.5 text-sm font-medium text-fg shadow-sm outline-none backdrop-blur transition-colors hover:bg-border/50 focus-visible:ring-2 focus-visible:ring-accent/40"
>
Fit
</button>
</div>
<Button
variant="ghost"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="absolute bottom-3 right-3 bg-surface/90 py-1.5 shadow-sm backdrop-blur"
>
Fit
</Button>
</div>
)
}