Layout + graph interaction fixes
- 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.
This commit is contained in:
+15
-11
@@ -203,17 +203,20 @@ export function Chat({
|
||||
|
||||
return (
|
||||
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-3 px-3 py-2">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||
</header>
|
||||
{/* 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. */}
|
||||
<div className="flex h-dvh">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-3 px-3 py-2">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||
</header>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<Conversation className="h-full min-w-0 flex-1" initial="instant">
|
||||
<ConversationContent className="min-h-full gap-0 p-0">
|
||||
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
||||
@@ -315,9 +318,10 @@ export function Chat({
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
</div>
|
||||
<TreePanel messages={messages} />
|
||||
</div>
|
||||
</div>
|
||||
</ChatActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+102
-42
@@ -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<GraphView>({ 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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
data-testid="plan-graph"
|
||||
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden select-none"
|
||||
className={cn(
|
||||
"relative min-h-0 flex-1 touch-none overflow-hidden select-none",
|
||||
atFloor ? "cursor-default" : "cursor-grab",
|
||||
)}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user