Address Gadfly review on #9: memoize shapes, fades, unique ids, robustness
Build image / build-and-push (push) Successful in 24s
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:
@@ -1,6 +1,8 @@
|
||||
import type { PointerEvent } from 'react'
|
||||
import { memo, type PointerEvent } from 'react'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_FILL = '#8a8a8a'
|
||||
|
||||
// Default fills by kind (overridable per object via object.color). Muted, earthy
|
||||
// tones so plops (added in #15) read clearly on top.
|
||||
const kindColors: Record<string, string> = {
|
||||
@@ -10,21 +12,29 @@ const kindColors: Record<string, string> = {
|
||||
in_ground: '#7a6a4a',
|
||||
tree: '#4f7a4f',
|
||||
path: '#b8b0a0',
|
||||
structure: '#8a8a8a',
|
||||
structure: DEFAULT_FILL,
|
||||
}
|
||||
|
||||
// Label font size = this fraction of the object's smaller side, clamped to a
|
||||
// readable cm range. Corner radius = this fraction of the smaller half-side.
|
||||
const LABEL_FONT_FACTOR = 0.28
|
||||
const LABEL_FONT_MIN_CM = 8
|
||||
const LABEL_FONT_MAX_CM = 40
|
||||
const CORNER_RADIUS_FACTOR = 0.06
|
||||
|
||||
function fillFor(o: EditorObject): string {
|
||||
return o.color ?? kindColors[o.kind] ?? '#8a8a8a'
|
||||
return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL
|
||||
}
|
||||
|
||||
/**
|
||||
* One garden object in world (cm) space: a centered rect or circle, rotated
|
||||
* about its center, with the object's name. The parent world <g> applies the
|
||||
* viewport scale, so geometry is authored in cm; strokes use
|
||||
* vector-effect=non-scaling-stroke to stay a constant pixel width at any zoom.
|
||||
* Full move/resize/rotate interactions come in #11.
|
||||
* One garden object in world (cm) space: a centered rect or ellipse (a circle
|
||||
* when width == height), rotated about its center, with the object's name. The
|
||||
* parent world <g> applies the viewport scale, so geometry is authored in cm and
|
||||
* strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at
|
||||
* any zoom. memo'd so a pan/zoom (which only changes the world <g> transform)
|
||||
* doesn't re-render every object. Full move/resize/rotate come in #11.
|
||||
*/
|
||||
export function ObjectShape({
|
||||
export const ObjectShape = memo(function ObjectShape({
|
||||
object,
|
||||
selected,
|
||||
onSelect,
|
||||
@@ -34,16 +44,21 @@ export function ObjectShape({
|
||||
onSelect: (id: number) => void
|
||||
}) {
|
||||
const fill = fillFor(object)
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
// Label size scales with the object but is clamped to a sensible cm range.
|
||||
const fontCm = Math.max(8, Math.min(40, Math.min(object.widthCm, object.heightCm) * 0.28))
|
||||
const halfW = Math.max(0, object.widthCm / 2)
|
||||
const halfH = Math.max(0, object.heightCm / 2)
|
||||
const fontCm = Math.max(
|
||||
LABEL_FONT_MIN_CM,
|
||||
Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR),
|
||||
)
|
||||
|
||||
function handleDown(e: PointerEvent) {
|
||||
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
|
||||
onSelect(object.id)
|
||||
}
|
||||
|
||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||
const strokeWidth = selected ? 2 : 1
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
|
||||
@@ -51,27 +66,28 @@ export function ObjectShape({
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
<circle
|
||||
<ellipse
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={halfW}
|
||||
rx={halfW}
|
||||
ry={halfH}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={selected ? '#2f7a3e' : '#00000033'}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={-halfW}
|
||||
y={-halfH}
|
||||
width={object.widthCm}
|
||||
height={object.heightCm}
|
||||
rx={Math.min(halfW, halfH) * 0.06}
|
||||
width={halfW * 2}
|
||||
height={halfH * 2}
|
||||
rx={Math.min(halfW, halfH) * CORNER_RADIUS_FACTOR}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={selected ? '#2f7a3e' : '#00000033'}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
@@ -91,4 +107,4 @@ export function ObjectShape({
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user