Plan graph: the expanded mermaid-style view of the plan tree

The nested list stays as the default/collapsed view; the new expand
toggle (list header) opens the graph — a hand-built SVG diagram
(mermaid STYLE, no mermaid renderer: full control of the styling so it
speaks this app's design language):

- Top-down layered layout (topoLayout depth = rows, authoring order
  within a row, rows centered on the widest) — vertical space is the
  cheap axis; boxes + vertical bezier arrows with arrowheads.
- Status coloring matches the list marks: done nodes are colored in
  (teach-phase green tint — the graph fills in as the session
  progresses), current = the highlighted position, next = unlocked
  ring, upcoming = quiet.
- Drag pan + wheel zoom (to the cursor, non-passive), +/- and
  fit-to-panel buttons. The zoom FLOOR is fit: fully zoomed out, the
  whole diagram always fits the panel — it can never disappear into
  nothing. Auto-fits on measure/layout change until the learner takes
  manual control; fit re-enables it.
- Panel widens to w-96 in graph mode; mode persists
  (lh:tree-panel-mode: rail|list|graph, legacy key migrated).
- wrapTitle + graphLayout pure, unit-tested; TreeGraph SSR-render
  tested (statuses, arrows, wrapping, data-next).
This commit is contained in:
Amos
2026-09-01 18:34:58 +03:00
parent fbe4f16305
commit 826de071dc
2 changed files with 569 additions and 28 deletions
+140
View File
@@ -270,3 +270,143 @@ describe("TreePanel rendering", () => {
assert.ok(!html.includes("Foundation"), "no node content in the strip");
});
});
// ─── the graph (expanded view) ────────────────────────────────────────────────
import {
TreeGraph,
graphLayout,
wrapTitle,
NODE_H,
NODE_W,
} from "../components/tree-panel.tsx";
describe("wrapTitle (box labels)", () => {
it("wraps to lines within the char budget", () => {
assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [
"Chain rule intuition",
"for composites",
]);
});
it("a title that fits stays one line", () => {
assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]);
});
it("overflowing titles end in an ellipsis", () => {
const lines = wrapTitle("Substitution and the differential of implicit functions");
assert.equal(lines.length, 2);
assert.ok(lines.at(-1)?.endsWith("…"));
});
it("a single oversized word is hard-clamped", () => {
const lines = wrapTitle("antidifferentiation-techniques-overview");
assert.ok(lines.every((l) => l.length <= 22));
assert.ok(lines.at(-1)?.endsWith("…"));
});
it("empty titles render one empty line (never an empty array)", () => {
assert.deepEqual(wrapTitle(""), [""]);
});
});
describe("graphLayout (layered, top-down)", () => {
it("one node per depth row, authoring order within the row, rows centered", () => {
const { order, depth } = topoLayout(chainTree);
const layout = graphLayout(order, depth);
assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps");
// b, a: depth 0; c: depth 1 → wait: a is authored second but is the root.
assert.equal(depth.get("a"), 0);
assert.equal(depth.get("b"), 1);
assert.equal(depth.get("c"), 2);
// each row of one node is centered on the (single-column) width.
assert.equal(layout.x.get("a"), 0);
assert.equal(layout.x.get("b"), 0);
assert.equal(layout.x.get("c"), 0);
assert.equal(layout.y.get("a"), 0);
assert.equal(layout.y.get("b"), NODE_H + 64);
});
it("two same-depth nodes sit side by side, centered over the widest row", () => {
const tree: PlanTree = {
nodes: [
{ id: "r", title: "Root" },
{ id: "x", title: "X" },
{ id: "y", title: "Y" },
],
edges: [
{ from: "r", to: "x" },
{ from: "r", to: "y" },
],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
const { order, depth } = topoLayout(tree);
const layout = graphLayout(order, depth);
const xX = layout.x.get("x")!;
const xY = layout.x.get("y")!;
assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart");
// row 1 is the widest → row 0 centers over it: the root sits exactly
// between the two dependents' centers (88 and 292 → 190 - 88 = 102).
assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row");
assert.equal(layout.width, 2 * NODE_W + 28);
});
});
describe("TreeGraph rendering", () => {
it("renders one box per node with its status, one arrow per edge", () => {
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
for (const [id, status] of [
["a", "done"],
["b", "current"],
["c", "upcoming"],
] as const) {
assert.ok(
new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html),
`${id} is ${status}`,
);
}
assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows");
});
it("colors progress: done boxes carry the teach-phase tint", () => {
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/);
assert.match(html, /data-status="current"[\s\S]*?stroke-primary/);
});
it("marks the next unlocked node", () => {
const tree: PlanTree = {
nodes: [
{ id: "a", title: "Foundation" },
{ id: "b", title: "Middle" },
],
edges: [{ from: "a", to: "b" }],
currentNodeId: null,
doneNodeIds: ["a"],
version: 2,
};
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/);
});
it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => {
const tree: PlanTree = {
nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }],
edges: [],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
const texts = html.match(/<text[^>]*>([^<]*)<\/text>/g) ?? [];
assert.equal(texts.length, 2, "two wrapped lines");
assert.ok(texts.at(-1)?.includes("…"));
});
it("hover shows title and summary", () => {
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
assert.ok(html.includes("Foundation — Where it all starts."));
});
});