From b92237fd56768182d0077b9c4f0819871aa8f68d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 06:30:05 +0300 Subject: [PATCH] Plan-graph refinement round 3 (owner): higher zoom ceiling + eased zooming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZOOM_MAX 2× → 4×: node titles reach ~50px rendered, comfortable for close reading. Single tunable constant. - Smooth zoom: a 180ms ease-out CSS transition on the transform wrapper — CSS retargets from the current computed value, so chained wheel ticks and the fit/button zooms all glide instead of snapping. Disabled while dragging (per-frame pans must not rubber-band). panning is React state toggled at drag start/end. --- components/plan-block.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index c707d4d..5480951 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -332,9 +332,10 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null { /** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole * graph visible, whichever axis binds — owner, plan-graph round 2), never - * above 2×. The old 0.15 floor could sit ABOVE a true fit for wide graphs - * and break that rule; the fit itself is the floor now. */ -const ZOOM_MAX = 2; + * above ZOOM_MAX (raised 2×→4× for reading node titles closely, owner, + * plan-graph round 3). The old 0.15 floor could sit ABOVE a true fit for + * wide graphs; the fit itself is the floor now. */ +const ZOOM_MAX = 4; const FIT_PAD = 16; const FIT_ZOOM_MAX = 1.5; @@ -441,12 +442,17 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa return () => el.removeEventListener("wheel", onWheel); }, [fitZoom, box, size]); + // Smooth zoom: the transform eases between states (CSS retargets from the + // current computed value, so chained wheel ticks stay fluid). Panning is + // per-frame — the transition is off while dragging or it rubber-bands. + const [panning, setPanning] = useState(false); const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null); const startDrag = (e: React.PointerEvent) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("button")) return; if (atFloor) return; dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; + setPanning(true); e.currentTarget.classList.add("cursor-grabbing"); e.currentTarget.setPointerCapture(e.pointerId); }; @@ -464,6 +470,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa dragRef.current = null; if (d.moved) userAdjustedRef.current = true; } + setPanning(false); e.currentTarget.classList.remove("cursor-grabbing"); }; const stepZoom = (factor: number) => { @@ -517,7 +524,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa <>