From 475726a976d02a6bab6b65f9041bcc1862742b46 Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 1 Sep 2026 19:02:18 +0300 Subject: [PATCH] Layout + graph interaction fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat page: the tree panel is now a full-height sibling of the whole transcript column (header + scroller), exactly like the left sidebar — the session header (name + phase chip) spans from the left sidebar's edge to the panel's edge instead of riding over the panel. - Plan graph: the zoom controls sit inside the pan surface, so their pointerdown started a capture drag and the click never reached the button (felt dead). startDrag now ignores presses on buttons. - Fully zoomed out IS the fit view: zooming to the floor snaps to the centered fit (pure zoomedGraphView), pan is locked there (no drag begins, cursor-default instead of grab), and sitting at the floor re-arms auto-fit so later tree edits keep the graph fitting. - Fit padding 48px → 16px: the default view fits more graph. --- components/chat.tsx | 26 +++--- components/tree-panel.tsx | 144 ++++++++++++++++++++++--------- next-env.d.ts | 4 +- tests/render-tree-panel.test.tsx | 49 +++++++++++ 4 files changed, 168 insertions(+), 55 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 56b4e8f..0c8d9a5 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -203,17 +203,20 @@ export function Chat({ return ( void sendMessage({ text: t }) }}> -
-
- -
{title}
- -
+ {/* The session surface: the transcript column (header + scroller) and + the plan-tree panel as full-height SIBLINGS — the panel spans top to + bottom exactly like the left sidebar, and the header (name + phase + chip) spans from the left sidebar's edge to the panel's edge. The + dock stays sticky inside the scroller, untouched. */} +
+
+
+ +
{title}
+ +
- {/* The session row: transcript scroller + the persistent plan-tree panel - (W7, vision §2). The panel is a flex SIBLING of the scroller — the dock - stays sticky inside the scroller, untouched. */} -
+
@@ -315,9 +318,10 @@ export function Chat({ +
+
-
); } diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx index b0455a8..4e24dab 100644 --- a/components/tree-panel.tsx +++ b/components/tree-panel.tsx @@ -495,11 +495,52 @@ const GRAPH_ZOOM_BTN = const ZOOM_MAX = 2; const ZOOM_FLOOR = 0.15; +/** The graph's view transform: zoom + translation (viewport px). */ +export interface GraphView { + zoom: number; + x: number; + y: number; +} + +/** The fit-centered view: the whole bw×bh graph centered in the w×h + * viewport — fully zoomed out is EXACTLY this, nothing to pan. */ +export function fitGraphView( + box: { w: number; h: number }, + viewport: { w: number; h: number }, + zoom: number, +): GraphView { + return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 }; +} + +/** + * One zoom step toward `focus` (viewport px), clamped to [floor, ZOOM_MAX]. + * At the floor the view SNAPS to the fit-centered position: fully zoomed + * out is THE fit view — centered, pan locked (the component enforces it). + */ +export function zoomedGraphView( + view: GraphView, + factor: number, + floor: number, + focus: { x: number; y: number }, + box: { w: number; h: number }, + viewport: { w: number; h: number }, +): GraphView { + const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX); + if (zoom === view.zoom) return view; + if (zoom <= floor) return fitGraphView(box, viewport, floor); + const k = zoom / view.zoom; + return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k }; +} + /** * The interactive diagram: drag to pan, wheel to zoom (to the cursor), the - * fit button resets. Auto-fits on first measure and whenever the tree's - * layout changes — unless the learner has panned/zoomed on their own - * (their view is theirs). SSR-safe: all measurement lives in effects. + * fit button resets. The zoom floor is fit-to-panel — at it, the view is + * EXACTLY the centered fit and panning is locked (there is nothing to + * move). Auto-fits on first measure and on layout changes while the view + * sits at the floor; once the learner zooms in or pans, the view is theirs + * (until they return to the floor or press fit). SSR-safe: all measurement + * lives in effects. The zoom controls sit INSIDE the pan surface but + * never start a drag (pointer capture would swallow their clicks). */ export function TreeGraph({ tree }: { tree: PlanTree }) { const { order, depth } = useMemo(() => topoLayout(tree), [tree]); @@ -520,8 +561,9 @@ export function TreeGraph({ tree }: { tree: PlanTree }) { }, []); // The zoom floor: fully zoomed out, the whole diagram fits the panel. + // Pad 16: the fit view IS the default — slim margins fit more graph. const fitZoom = useMemo(() => { - const pad = 48; + const pad = 16; if (!size.w || !size.h || layout.width === 0) return 1; return Math.max( ZOOM_FLOOR, @@ -529,19 +571,24 @@ export function TreeGraph({ tree }: { tree: PlanTree }) { ); }, [size, layout]); - const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); + const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); const userAdjustedRef = useRef(false); + const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); + // Epsilon: zoom is float; "at the floor" needs a tolerance. + const atFloor = view.zoom <= fitZoom + 1e-9; + + // Being AT the floor re-arms auto-fit: fully zoomed out is the default + // view, so later layout changes (tree edits) keep it fitting. + useEffect(() => { + if (atFloor) userAdjustedRef.current = false; + }, [atFloor]); // Auto-fit (first measure, layout changes) — suppressed once the learner - // takes manual control, until they press fit again. + // takes manual control, until they return to the floor (or press fit). useEffect(() => { if (userAdjustedRef.current || !size.w || !size.h) return; - setView({ - zoom: fitZoom, - x: (size.w - layout.width * fitZoom) / 2, - y: (size.h - layout.height * fitZoom) / 2, - }); - }, [fitZoom, size, layout]); + setView(fitGraphView(box, size, fitZoom)); + }, [fitZoom, size, box]); // Wheel zoom to the cursor. Non-passive so the page never scrolls instead. useEffect(() => { @@ -551,39 +598,57 @@ export function TreeGraph({ tree }: { tree: PlanTree }) { e.preventDefault(); const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // Firefox line mode const factor = Math.exp(-dy * 0.0015); - setView((v) => { - const zoom = Math.min(Math.max(v.zoom * factor, fitZoom), ZOOM_MAX); - if (zoom === v.zoom) return v; - const rect = el.getBoundingClientRect(); - const cx = e.clientX - rect.left; - const cy = e.clientY - rect.top; - const k = zoom / v.zoom; - return { zoom, x: cx - (cx - v.x) * k, y: cy - (cy - v.y) * k }; - }); + const rect = el.getBoundingClientRect(); + setView((v) => + zoomedGraphView( + v, + factor, + fitZoom, + { x: e.clientX - rect.left, y: e.clientY - rect.top }, + box, + size, + ), + ); userAdjustedRef.current = true; }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); - }, [fitZoom]); + }, [fitZoom, box, size]); - // Drag pan (pointer capture; a click on nothing never selects text — - // the viewport is select-none). - const dragRef = useRef<{ px: number; py: number; x: number; y: number } | null>(null); + // Drag pan. Guards: the zoom controls sit inside the pan surface — their + // pointerdown must NOT capture (capture redirects the click away from + // the button — the bug that made them feel dead). And at the fit floor + // there is nothing to pan: don't even begin (the cursor stays honest). + 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; - dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y }; + 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 }; e.currentTarget.classList.add("cursor-grabbing"); e.currentTarget.setPointerCapture(e.pointerId); }; const moveDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; - setView((v) => ({ ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) })); + d.moved = true; + setView((v) => + v.zoom <= fitZoom + 1e-9 + ? v + : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, + ); }; const endDrag = (e: React.PointerEvent) => { - if (dragRef.current) { + const d = dragRef.current; + if (d) { dragRef.current = null; - userAdjustedRef.current = true; + if (d.moved) userAdjustedRef.current = true; } e.currentTarget.classList.remove("cursor-grabbing"); }; @@ -591,29 +656,24 @@ export function TreeGraph({ tree }: { tree: PlanTree }) { // Button zoom steps toward the viewport center (same math as wheel). const stepZoom = (factor: number) => { userAdjustedRef.current = true; - setView((v) => { - const zoom = Math.min(Math.max(v.zoom * factor, fitZoom), ZOOM_MAX); - const cx = size.w / 2; - const cy = size.h / 2; - const k = zoom / v.zoom; - return { zoom, x: cx - (cx - v.x) * k, y: cy - (cy - v.y) * k }; - }); + setView((v) => + zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size), + ); }; const fitView = () => { userAdjustedRef.current = false; if (!size.w || !size.h) return; - setView({ - zoom: fitZoom, - x: (size.w - layout.width * fitZoom) / 2, - y: (size.h - layout.height * fitZoom) / 2, - }); + setView(fitGraphView(box, size, fitZoom)); }; return (
/// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx index 579af50..9986d65 100644 --- a/tests/render-tree-panel.test.tsx +++ b/tests/render-tree-panel.test.tsx @@ -410,3 +410,52 @@ describe("TreeGraph rendering", () => { assert.ok(html.includes("Foundation — Where it all starts.")); }); }); + +// ─── graph view math (pan lock at fit, zoom-to-cursor) ──────────────────────── + +import { + fitGraphView, + zoomedGraphView, + type GraphView, +} from "../components/tree-panel.tsx"; + +describe("fitGraphView / zoomedGraphView", () => { + const box = { w: 1000, h: 800 }; + const viewport = { w: 300, h: 400 }; + + it("fit centers the whole graph in the viewport at the given zoom", () => { + const v = fitGraphView(box, viewport, 0.3); + assert.equal(v.zoom, 0.3); + assert.equal(v.x, (300 - 1000 * 0.3) / 2); + assert.equal(v.y, (400 - 800 * 0.3) / 2); + }); + + it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => { + const v: GraphView = { zoom: 0.5, x: -100, y: -50 }; + // world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300 + const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport); + assert.equal(next.zoom, 1); + // the same world point maps back to the cursor: (50 - next.x)/1 = 300 + assert.equal(50 - next.x, 300); + assert.equal(60 - next.y, 220); + }); + + it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => { + const v: GraphView = { zoom: 1.8, x: -900, y: -400 }; + const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport); + assert.equal(next.zoom, 0.3, "clamped at the floor"); + assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan"); + }); + + it("a no-op zoom returns the same view (no drift)", () => { + const v: GraphView = { zoom: 2, x: 10, y: 10 }; + // factor 1 → same zoom; at MAX already → clamped to same → same view + assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v); + }); + + it("zoom-in clamps at the ceiling (2×)", () => { + const v: GraphView = { zoom: 1.9, x: 0, y: 0 }; + const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport); + assert.equal(next.zoom, 2); + }); +});