Plan-graph refinement round 3 (owner): higher zoom ceiling + eased zooming

- 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.
This commit is contained in:
amosblomqvist
2026-09-14 06:30:05 +03:00
parent 6464b34a79
commit b92237fd56
+14 -4
View File
@@ -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 /** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole
* graph visible, whichever axis binds — owner, plan-graph round 2), never * 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 * above ZOOM_MAX (raised 2×→4× for reading node titles closely, owner,
* and break that rule; the fit itself is the floor now. */ * plan-graph round 3). The old 0.15 floor could sit ABOVE a true fit for
const ZOOM_MAX = 2; * wide graphs; the fit itself is the floor now. */
const ZOOM_MAX = 4;
const FIT_PAD = 16; const FIT_PAD = 16;
const FIT_ZOOM_MAX = 1.5; const FIT_ZOOM_MAX = 1.5;
@@ -441,12 +442,17 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
return () => el.removeEventListener("wheel", onWheel); return () => el.removeEventListener("wheel", onWheel);
}, [fitZoom, box, size]); }, [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 dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null);
const startDrag = (e: React.PointerEvent<HTMLDivElement>) => { const startDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return; if ((e.target as HTMLElement).closest("button")) return;
if (atFloor) return; if (atFloor) return;
dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; 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.classList.add("cursor-grabbing");
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
}; };
@@ -464,6 +470,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
dragRef.current = null; dragRef.current = null;
if (d.moved) userAdjustedRef.current = true; if (d.moved) userAdjustedRef.current = true;
} }
setPanning(false);
e.currentTarget.classList.remove("cursor-grabbing"); e.currentTarget.classList.remove("cursor-grabbing");
}; };
const stepZoom = (factor: number) => { const stepZoom = (factor: number) => {
@@ -517,7 +524,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
<> <>
<div <div
className="absolute top-0 left-0 origin-top-left" className="absolute top-0 left-0 origin-top-left"
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }} style={{
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
transition: panning ? "none" : "transform 180ms ease-out",
}}
> >
<PlanDiagram <PlanDiagram
nodes={tree.nodes} nodes={tree.nodes}