Canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9) #29

Merged
steve merged 2 commits from phase-3-canvas into main 2026-07-19 00:18:28 +00:00
5 changed files with 109 additions and 66 deletions
Showing only changes of commit af34ced208 - Show all commits
+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 type { Size } from '@/lib/geometry'
import { ObjectShape } from './ObjectShape' import { ObjectShape } from './ObjectShape'
import { useEditorStore } from './store' import { useEditorStore } from './store'
@@ -6,19 +7,21 @@ import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types' import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid 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
/** /**
Review

Doc says "fading 1 m grid" but grid only toggles with constant opacity (no fade)

maintainability · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:13 — doc comment says "a fading 1 m grid", but the grid just toggles on/off via showGrid (GRID_CM * viewport.scale >= GRID_MIN_CELL_PX, line 52) with constant strokeOpacity={0.18} (line 74). There is no fade. The comment will mislead maintainers. Either implement a fade (opacity as a function of cell px) or drop "fading". Verified.

🪰 Gadfly · advisory

⚪ **Doc says "fading 1 m grid" but grid only toggles with constant opacity (no fade)** _maintainability · flagged by 1 model_ - **`web/src/editor/GardenCanvas.tsx:13`** — doc comment says "a fading 1 m grid", but the grid just toggles on/off via `showGrid` (`GRID_CM * viewport.scale >= GRID_MIN_CELL_PX`, line 52) with constant `strokeOpacity={0.18}` (line 74). There is no fade. The comment will mislead maintainers. Either implement a fade (opacity as a function of cell px) or drop "fading". Verified. <sub>🪰 Gadfly · advisory</sub>
* The editor canvas: a full-size SVG with a single world <g> that pan/zoom/pinch * 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. * transforms. Renders a 1 m grid that fades in as you zoom, the garden boundary,
* Interactions beyond viewport + basic select (move/resize/rotate, palette) land * and its objects. Interactions beyond viewport + basic select (move/resize/
* in #11; this is the foundation, driven by mock data until then. * rotate, palette) land in #11; this is the foundation, driven by mock data.
*/ */
export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) { export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) {
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
Review

🟠 fittedRef prevents re-fit when garden prop changes

maintainability · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:21,45-50fittedRef gates the auto-fit effect so it only fires once per component lifetime. If the garden prop changes (which #11 will support), the canvas silently stays framed for the old garden. Track the last fitted garden ID and reset the flag when garden.id changes, or drive fit state from a stable key.

🪰 Gadfly · advisory

🟠 **fittedRef prevents re-fit when garden prop changes** _maintainability · flagged by 1 model_ - `web/src/editor/GardenCanvas.tsx:21,45-50` — `fittedRef` gates the auto-fit effect so it only fires once per component lifetime. If the `garden` prop changes (which #11 will support), the canvas silently stays framed for the old garden. Track the last fitted garden ID and reset the flag when `garden.id` changes, or drive fit state from a stable key. <sub>🪰 Gadfly · advisory</sub>
const [size, setSize] = useState<Size>({ w: 0, h: 0 }) 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 viewport = useEditorStore((s) => s.viewport)
const selectedId = useEditorStore((s) => s.selectedId) const selectedId = useEditorStore((s) => s.selectedId)
@@ -41,15 +44,20 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object
return () => ro.disconnect() return () => ro.disconnect()
}, []) }, [])
Review

🟠 Auto-fit effect lacks garden dimension validation, allowing NaN viewport

error-handling · flagged by 1 model

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **Auto-fit effect lacks garden dimension validation, allowing NaN viewport** _error-handling · flagged by 1 model_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
// 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(() => { useEffect(() => {
if (!fittedRef.current && size.w > 0 && size.h > 0) { const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0
fittedRef.current = true if (usable && fittedForRef.current !== garden.id) {
fittedForRef.current = garden.id
fitToRect(gardenRect, size) 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]) const ordered = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
return ( return (
1
@@ -63,21 +71,21 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object
onPointerDown={() => select(null)} onPointerDown={() => select(null)}
> >
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}> <g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{showGrid && ( {gridOpacity > 0.005 && (
<> <>
<defs> <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 <path
d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`} d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`}
fill="none" fill="none"
stroke="#808080" stroke="#808080"
strokeOpacity={0.18} strokeOpacity={gridOpacity}
strokeWidth={1} strokeWidth={1}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</pattern> </pattern>
</defs> </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})`} />
</> </>
)} )}
1
@@ -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"> <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 {viewport.scale.toFixed(2)} px/cm
</div> </div>
<div className="absolute bottom-3 right-3"> <Button
<button variant="ghost"
type="button" onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
onClick={() => size.w > 0 && fitToRect(gardenRect, size)} className="absolute bottom-3 right-3 bg-surface/90 py-1.5 shadow-sm backdrop-blur"
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
Fit </Button>
</button>
</div>
</div> </div>
) )
} }
+39 -23
View File
@@ -1,6 +1,8 @@
import type { PointerEvent } from 'react' import { memo, type PointerEvent } from 'react'
import type { EditorObject } from './types' import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a'
// Default fills by kind (overridable per object via object.color). Muted, earthy // Default fills by kind (overridable per object via object.color). Muted, earthy
Review

Duplicated #8a8a8a default fill (kindColors entry + fillFor fallback)

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Duplicated #8a8a8a default fill (kindColors entry + fillFor fallback)** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
// tones so plops (added in #15) read clearly on top. // tones so plops (added in #15) read clearly on top.
const kindColors: Record<string, string> = { const kindColors: Record<string, string> = {
@@ -10,21 +12,29 @@ const kindColors: Record<string, string> = {
in_ground: '#7a6a4a', in_ground: '#7a6a4a',
tree: '#4f7a4f', tree: '#4f7a4f',
path: '#b8b0a0', 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 { function fillFor(o: EditorObject): string {
return o.color ?? kindColors[o.kind] ?? '#8a8a8a' return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL
} }
Review

🟡 ObjectShape lacks memoization, causing unnecessary re-render of every object on each pan/zoom/pinch/fit-animation frame

performance · flagged by 2 models

  • web/src/editor/GardenCanvas.tsx:97-99 / web/src/editor/ObjectShape.tsx:27ObjectShape isn't memoized, so every object in the scene re-renders on every pan/wheel/pinch tick and on every frame of the fitToRect animation tween. GardenCanvas subscribes to viewport from the zustand store (GardenCanvas.tsx:23), and useViewport's onDrag/onWheel/onPinch handlers call setViewport on essentially every gesture event (useViewport.ts:42-58), while fitToRect's rAF loop calls it…

🪰 Gadfly · advisory

🟡 **ObjectShape lacks memoization, causing unnecessary re-render of every object on each pan/zoom/pinch/fit-animation frame** _performance · flagged by 2 models_ - `web/src/editor/GardenCanvas.tsx:97-99` / `web/src/editor/ObjectShape.tsx:27` — `ObjectShape` isn't memoized, so every object in the scene re-renders on every pan/wheel/pinch tick and on every frame of the `fitToRect` animation tween. `GardenCanvas` subscribes to `viewport` from the zustand store (`GardenCanvas.tsx:23`), and `useViewport`'s `onDrag`/`onWheel`/`onPinch` handlers call `setViewport` on essentially every gesture event (`useViewport.ts:42-58`), while `fitToRect`'s rAF loop calls it… <sub>🪰 Gadfly · advisory</sub>
/** /**
* One garden object in world (cm) space: a centered rect or circle, rotated * One garden object in world (cm) space: a centered rect or ellipse (a circle
* about its center, with the object's name. The parent world <g> applies the * when width == height), rotated about its center, with the object's name. The
* viewport scale, so geometry is authored in cm; strokes use * parent world <g> applies the viewport scale, so geometry is authored in cm and
* vector-effect=non-scaling-stroke to stay a constant pixel width at any zoom. * strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at
* Full move/resize/rotate interactions come in #11. * 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, object,
selected, selected,
onSelect, onSelect,
@@ -34,16 +44,21 @@ export function ObjectShape({
onSelect: (id: number) => void onSelect: (id: number) => void
}) { }) {
const fill = fillFor(object) const fill = fillFor(object)
const halfW = object.widthCm / 2 const halfW = Math.max(0, object.widthCm / 2)
const halfH = object.heightCm / 2 const halfH = Math.max(0, object.heightCm / 2)
// Label size scales with the object but is clamped to a sensible cm range. const fontCm = Math.max(
const fontCm = Math.max(8, Math.min(40, Math.min(object.widthCm, object.heightCm) * 0.28)) LABEL_FONT_MIN_CM,
Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR),
)
function handleDown(e: PointerEvent) { function handleDown(e: PointerEvent) {
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
onSelect(object.id) onSelect(object.id)
} }
const stroke = selected ? '#2f7a3e' : '#00000033'
const strokeWidth = selected ? 2 : 1
return ( return (
<g <g
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`} transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
@@ -51,27 +66,28 @@ export function ObjectShape({
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{object.shape === 'circle' ? ( {object.shape === 'circle' ? (
<circle <ellipse
cx={0} cx={0}
Review

Uncommented magic factors 0.06 (rx) and 0.28 (font) lack rationale

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Uncommented magic factors 0.06 (rx) and 0.28 (font) lack rationale** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
cy={0} cy={0}
r={halfW} rx={halfW}
ry={halfH}
fill={fill} fill={fill}
fillOpacity={0.85} fillOpacity={0.85}
stroke={selected ? '#2f7a3e' : '#00000033'} stroke={stroke}
strokeWidth={selected ? 2 : 1} strokeWidth={strokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
) : ( ) : (
<rect <rect
x={-halfW} x={-halfW}
y={-halfH} y={-halfH}
width={object.widthCm} width={halfW * 2}
height={object.heightCm} height={halfH * 2}
rx={Math.min(halfW, halfH) * 0.06} rx={Math.min(halfW, halfH) * CORNER_RADIUS_FACTOR}
fill={fill} fill={fill}
fillOpacity={0.85} fillOpacity={0.85}
stroke={selected ? '#2f7a3e' : '#00000033'} stroke={stroke}
strokeWidth={selected ? 2 : 1} strokeWidth={strokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
@@ -91,4 +107,4 @@ export function ObjectShape({
)} )}
</g> </g>
) )
} })
+3 -1
View File
@@ -1,6 +1,8 @@
// The object shape the canvas renders. A subset of the server's garden object // The object shape the canvas renders. A subset of the server's garden object
// (#10); #11 maps /full's objects onto this. Positioned by center in garden cm. // (#10); #11 maps /full's objects onto this. Positioned by center in garden cm.
import type { UnitPref } from '@/lib/units'
export type ObjectShapeKind = 'rect' | 'circle' export type ObjectShapeKind = 'rect' | 'circle'
export interface EditorObject { export interface EditorObject {
1
@@ -24,5 +26,5 @@ export interface EditorGarden {
name: string name: string
widthCm: number widthCm: number
Review

EditorGarden.unitPref re-declares the UnitPref union instead of importing it from lib/units.ts

maintainability · flagged by 1 model

  • web/src/editor/types.ts:27EditorGarden.unitPref is typed as the inline literal 'metric' | 'imperial' instead of reusing the existing UnitPref type exported from web/src/lib/units.ts:5, which web/src/lib/gardens.ts:7,40 already imports for the same purpose. Duplicated source of truth for a domain-relevant union. Fix: import type { UnitPref } from '@/lib/units' and use it in place of the inline literal.

🪰 Gadfly · advisory

⚪ **EditorGarden.unitPref re-declares the UnitPref union instead of importing it from lib/units.ts** _maintainability · flagged by 1 model_ - `web/src/editor/types.ts:27` — `EditorGarden.unitPref` is typed as the inline literal `'metric' | 'imperial'` instead of reusing the existing `UnitPref` type exported from `web/src/lib/units.ts:5`, which `web/src/lib/gardens.ts:7,40` already imports for the same purpose. Duplicated source of truth for a domain-relevant union. Fix: `import type { UnitPref } from '@/lib/units'` and use it in place of the inline literal. <sub>🪰 Gadfly · advisory</sub>
heightCm: number heightCm: number
unitPref: 'metric' | 'imperial' unitPref: UnitPref
} }
+17 -9
View File
@@ -1,15 +1,20 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react' import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { useGesture } from '@use-gesture/react' import { useGesture } from '@use-gesture/react'
import { zoomToFitRect, zoomViewportAt, type Point, type Rect, type Size } from '@/lib/geometry' import {
easeInOutCubic,
lerp,
zoomToFitRect,
zoomViewportAt,
type Point,
type Rect,
type Size,
} from '@/lib/geometry'
import { MAX_SCALE, MIN_SCALE, useEditorStore } from './store' import { MAX_SCALE, MIN_SCALE, useEditorStore } from './store'
const WHEEL_SENSITIVITY = 0.0015 // exponential zoom per wheel delta unit const WHEEL_SENSITIVITY = 0.0015 // exponential zoom per wheel delta unit
const FIT_PADDING = 32 // px margin around a fitted rect const FIT_PADDING = 32 // px margin around a fitted rect
const FIT_DURATION_MS = 350 const FIT_DURATION_MS = 350
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
/** /**
* Wires pan/zoom/pinch gestures onto the svg (via @use-gesture) and returns an * Wires pan/zoom/pinch gestures onto the svg (via @use-gesture) and returns an
* animated `fitToRect`. Wheel and pinch zoom toward the pointer; drag on empty * animated `fitToRect`. Wheel and pinch zoom toward the pointer; drag on empty
Review

Hook named useViewport returns only fitToRect, not viewport state — misleading name

maintainability · flagged by 1 model

  • web/src/editor/useViewport.ts:20 — the hook is named useViewport but returns only { fitToRect } (line 92); the viewport state itself lives in the zustand store (useEditorStore). A reader expecting useViewport() to surface viewport state will be surprised. Consider useViewportGestures or have it also return the viewport. Minor naming smell.

🪰 Gadfly · advisory

⚪ **Hook named `useViewport` returns only `fitToRect`, not viewport state — misleading name** _maintainability · flagged by 1 model_ - **`web/src/editor/useViewport.ts:20`** — the hook is named `useViewport` but returns only `{ fitToRect }` (line 92); the viewport state itself lives in the zustand store (`useEditorStore`). A reader expecting `useViewport()` to surface viewport state will be surprised. Consider `useViewportGestures` or have it also return the viewport. Minor naming smell. <sub>🪰 Gadfly · advisory</sub>
1
@@ -28,7 +33,8 @@ export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
} }
}, []) }, [])
const toLocal = useCallback( // Client (page) coords → coords within the svg, for anchoring zoom.
const clientToCanvas = useCallback(
(clientX: number, clientY: number): Point => { (clientX: number, clientY: number): Point => {
const rect = svgRef.current?.getBoundingClientRect() const rect = svgRef.current?.getBoundingClientRect()
return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY } return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }
1
@@ -49,12 +55,14 @@ export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
onWheelStart: cancelAnim, onWheelStart: cancelAnim,
onWheel: ({ event, delta: [, dy] }) => { onWheel: ({ event, delta: [, dy] }) => {
event.preventDefault() event.preventDefault()
const p = toLocal(event.clientX, event.clientY) if (!Number.isFinite(dy)) return // never let a bad delta corrupt the viewport
const p = clientToCanvas(event.clientX, event.clientY)
setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)) setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE))
}, },
onPinchStart: cancelAnim, onPinchStart: cancelAnim,
onPinch: ({ origin: [ox, oy], offset: [scale] }) => { onPinch: ({ origin: [ox, oy], offset: [scale] }) => {
const p = toLocal(ox, oy) if (!Number.isFinite(scale)) return
const p = clientToCanvas(ox, oy)
setViewport((vp) => zoomViewportAt(vp, p, scale, MIN_SCALE, MAX_SCALE)) setViewport((vp) => zoomViewportAt(vp, p, scale, MIN_SCALE, MAX_SCALE))
}, },
}, },
1
@@ -71,10 +79,10 @@ export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
) )
const fitToRect = useCallback( const fitToRect = useCallback(
(rect: Rect, viewport: Size) => { (rect: Rect, canvasSize: Size) => {
cancelAnim() cancelAnim()
const from = useEditorStore.getState().viewport const from = useEditorStore.getState().viewport
const to = zoomToFitRect(rect, viewport, FIT_PADDING, MIN_SCALE, MAX_SCALE) const to = zoomToFitRect(rect, canvasSize, FIT_PADDING, MIN_SCALE, MAX_SCALE)
const start = performance.now() const start = performance.now()
const step = (now: number) => { const step = (now: number) => {
const t = Math.min(1, (now - start) / FIT_DURATION_MS) const t = Math.min(1, (now - start) / FIT_DURATION_MS)
+20 -9
View File
1
@@ -68,6 +68,16 @@ export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale)) 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 * Re-scale the viewport while keeping the world point currently under
* `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The * `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The
1
@@ -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, * with `padding` px of margin on every side. Scale is clamped to [minScale,
Review

🟠 zoomToFitRect mishandles negative rect dimensions, mis-centering the viewport

error-handling · flagged by 1 model

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **zoomToFitRect mishandles negative rect dimensions, mis-centering the viewport** _error-handling · flagged by 1 model_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
* 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( export function zoomToFitRect(
rect: Rect, rect: Rect,
viewport: Size, canvasSize: Size,
padding: number, padding: number,
minScale: number, minScale: number,
maxScale: number, maxScale: number,
): Viewport { ): Viewport {
const availW = Math.max(1, viewport.w - padding * 2) const availW = Math.max(1, canvasSize.w - padding * 2)
const availH = Math.max(1, viewport.h - padding * 2) const availH = Math.max(1, canvasSize.h - padding * 2)
const rectW = Math.max(rect.w, 1e-6) const rectW = Math.max(Math.abs(rect.w), 1e-6)
const rectH = Math.max(rect.h, 1e-6) const rectH = Math.max(Math.abs(rect.h), 1e-6)
const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale) const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale)
const centerX = rect.x + rect.w / 2 const centerX = rect.x + rect.w / 2
const centerY = rect.y + rect.h / 2 const centerY = rect.y + rect.h / 2
return { return {
scale, scale,
tx: viewport.w / 2 - centerX * scale, tx: canvasSize.w / 2 - centerX * scale,
ty: viewport.h / 2 - centerY * scale, ty: canvasSize.h / 2 - centerY * scale,
} }
} }