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
+20 -9
View File
@@ -68,6 +68,16 @@ export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale))
}
/** Linear interpolation. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Cubic ease-in-out for viewport tweens. */
export function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
/**
* Re-scale the viewport while keeping the world point currently under
* `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The
@@ -90,28 +100,29 @@ export function zoomViewportAt(
}
/**
* The viewport that frames `rect` centered in a viewport of `viewport` pixels,
* The viewport that frames `rect` centered in a canvas of `canvasSize` pixels,
* with `padding` px of margin on every side. Scale is clamped to [minScale,
* maxScale] so a tiny/huge rect still lands in a sane zoom.
* maxScale] so a tiny/huge rect still lands in a sane zoom. Rect dimensions are
* taken by magnitude, so a degenerate (0/negative) size can't corrupt the math.
*/
export function zoomToFitRect(
rect: Rect,
viewport: Size,
canvasSize: Size,
padding: number,
minScale: number,
maxScale: number,
): Viewport {
const availW = Math.max(1, viewport.w - padding * 2)
const availH = Math.max(1, viewport.h - padding * 2)
const rectW = Math.max(rect.w, 1e-6)
const rectH = Math.max(rect.h, 1e-6)
const availW = Math.max(1, canvasSize.w - padding * 2)
const availH = Math.max(1, canvasSize.h - padding * 2)
const rectW = Math.max(Math.abs(rect.w), 1e-6)
const rectH = Math.max(Math.abs(rect.h), 1e-6)
const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale)
const centerX = rect.x + rect.w / 2
const centerY = rect.y + rect.h / 2
return {
scale,
tx: viewport.w / 2 - centerX * scale,
ty: viewport.h / 2 - centerY * scale,
tx: canvasSize.w / 2 - centerX * scale,
ty: canvasSize.h / 2 - centerY * scale,
}
}