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:
Amos
2026-09-01 19:02:18 +03:00
parent 826de071dc
commit 475726a976
4 changed files with 168 additions and 55 deletions
+49
View File
@@ -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);
});
});