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:
+429
-28
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsRightIcon,
|
||||
ListTreeIcon,
|
||||
MaximizeIcon,
|
||||
MinusIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core";
|
||||
|
||||
@@ -21,14 +29,23 @@ import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } fro
|
||||
* (PhaseDivider, message-level) marks the transition; THIS panel is the
|
||||
* surface for every plan_* part (they render nothing inline, by design).
|
||||
*
|
||||
* Quiet register (matches the phase dividers): hairline border, muted
|
||||
* text, one highlighted row for the current position. Collapses to a thin
|
||||
* rail; the preference persists in localStorage. Hidden below `xl`
|
||||
* (CSS-only).
|
||||
* Three modes, one persisted preference: the LIST (nested — the default,
|
||||
* and the always-visible "collapsed" view), the GRAPH (expanded: a
|
||||
* hand-built mermaid-style diagram — full styling control, no mermaid
|
||||
* renderer — with drag-pan + wheel-zoom whose FLOOR is fit-to-panel), and
|
||||
* the RAIL (thin strip). Quiet register throughout (matches the phase
|
||||
* dividers): hairline borders, muted text, one highlighted node for the
|
||||
* current position. Hidden below `xl` (CSS-only).
|
||||
*/
|
||||
|
||||
/** localStorage key for the collapse preference (matches lh:sidebar-open). */
|
||||
const STORAGE_KEY = "lh:tree-panel-collapsed";
|
||||
/** localStorage: the mode persists as "lh:tree-panel-mode"
|
||||
* ("rail" | "list" | "graph"); the pre-graph key
|
||||
* ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */
|
||||
const MODE_KEY = "lh:tree-panel-mode";
|
||||
const LEGACY_KEY = "lh:tree-panel-collapsed";
|
||||
|
||||
/** The three panel modes: rail (fully collapsed) | list (default) | graph. */
|
||||
type PanelMode = "rail" | "list" | "graph";
|
||||
|
||||
/**
|
||||
* Minimal structural slice of a UI message — anything with typed parts.
|
||||
@@ -148,25 +165,30 @@ export function TreePanel({
|
||||
}) {
|
||||
const tree = useMemo(() => latestTree(messages), [messages]);
|
||||
|
||||
// Start expanded so server and client first-render agree (no hydration
|
||||
// mismatch); the stored preference is applied after mount. Same pattern
|
||||
// as the sidebar.
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// Start in the list so server and client first-render agree (no
|
||||
// hydration mismatch); the stored preference is applied after mount.
|
||||
// Same pattern as the sidebar.
|
||||
const [mode, setMode] = useState<PanelMode>("list");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) setCollapsed(stored === "1");
|
||||
const stored = window.localStorage.getItem(MODE_KEY);
|
||||
if (stored === "rail" || stored === "list" || stored === "graph") {
|
||||
setMode(stored);
|
||||
} else {
|
||||
// legacy key (pre-graph): "1" = the collapsed rail
|
||||
const legacy = window.localStorage.getItem(LEGACY_KEY);
|
||||
if (legacy === "1") setMode("rail");
|
||||
}
|
||||
} catch {
|
||||
// private mode / storage disabled — the preference is simply not persisted
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
const go = (next: PanelMode) => {
|
||||
setMode(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
|
||||
window.localStorage.setItem(MODE_KEY, next);
|
||||
} catch {
|
||||
// see above
|
||||
}
|
||||
@@ -176,15 +198,19 @@ export function TreePanel({
|
||||
// the moment plan_init's output lands and updates live from there.
|
||||
if (!tree || tree.nodes.length === 0) return null;
|
||||
|
||||
return collapsed ? (
|
||||
<TreePanelRail
|
||||
doneCount={tree.doneNodeIds.length}
|
||||
totalCount={tree.nodes.length}
|
||||
onExpand={toggle}
|
||||
/>
|
||||
) : (
|
||||
<TreePanelBody tree={tree} onCollapse={toggle} />
|
||||
);
|
||||
if (mode === "rail") {
|
||||
return (
|
||||
<TreePanelRail
|
||||
doneCount={tree.doneNodeIds.length}
|
||||
totalCount={tree.nodes.length}
|
||||
onExpand={() => go("list")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mode === "graph") {
|
||||
return <TreeGraphPanel tree={tree} onShowList={() => go("list")} onCollapse={() => go("rail")} />;
|
||||
}
|
||||
return <TreePanelBody tree={tree} onShowGraph={() => go("graph")} onCollapse={() => go("rail")} />;
|
||||
}
|
||||
|
||||
/** The collapsed panel: a thin strip with the map icon + quiet progress. */
|
||||
@@ -221,12 +247,15 @@ export function TreePanelRail({
|
||||
);
|
||||
}
|
||||
|
||||
/** The expanded panel: header + the node list in topological order. */
|
||||
/** The expanded panel (list mode): header + the node list in topological order. */
|
||||
export function TreePanelBody({
|
||||
tree,
|
||||
onShowGraph,
|
||||
onCollapse,
|
||||
}: {
|
||||
tree: PlanTree;
|
||||
/** Expand into the mermaid-style graph view. */
|
||||
onShowGraph: () => void;
|
||||
onCollapse: () => void;
|
||||
}) {
|
||||
const { order, depth } = useMemo(() => topoLayout(tree), [tree]);
|
||||
@@ -250,6 +279,15 @@ export function TreePanelBody({
|
||||
>
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowGraph}
|
||||
title="Show plan graph"
|
||||
aria-label="Show plan graph"
|
||||
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<NetworkIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
@@ -352,3 +390,366 @@ function StatusMark({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── the graph (expanded view) ────────────────────────────────────────────────
|
||||
//
|
||||
// A hand-built mermaid-style diagram — NOT mermaid's renderer: the styling
|
||||
// stays in this app's design language (phase colors, quiet strokes) and the
|
||||
// status coloring maps 1:1 onto the list's marks. Boxes top-down (vertical
|
||||
// space is the cheap axis here), layered by topoLayout depth, authoring
|
||||
// order within a layer, edges as vertical bezier curves.
|
||||
|
||||
/** Graph geometry (px): node boxes and the gaps that carry the arrows. */
|
||||
export const NODE_W = 176;
|
||||
export const NODE_H = 48;
|
||||
export const GAP_X = 28;
|
||||
export const GAP_Y = 64;
|
||||
|
||||
/** Top-left position per node + the diagram's extent. */
|
||||
export interface GraphLayout {
|
||||
x: Map<string, number>;
|
||||
y: Map<string, number>;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layered positions: depth = row (top-down), authoring order within the
|
||||
* row, each row centered on the widest one. No crossing-reduction pass —
|
||||
* the teacher authors nodes in dependency order as a rule, and v1 keeps
|
||||
* the layout deterministic and readable over prettier.
|
||||
*/
|
||||
export function graphLayout(order: readonly PlanNode[], depth: ReadonlyMap<string, number>): GraphLayout {
|
||||
const layers: PlanNode[][] = [];
|
||||
for (const n of order) {
|
||||
const d = depth.get(n.id) ?? 0;
|
||||
if (!layers[d]) layers[d] = [];
|
||||
layers[d].push(n);
|
||||
}
|
||||
const rowWidth = (l: PlanNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X;
|
||||
let maxWidth = 0;
|
||||
for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l));
|
||||
const x = new Map<string, number>();
|
||||
const y = new Map<string, number>();
|
||||
layers.forEach((l, d) => {
|
||||
const start = (maxWidth - rowWidth(l)) / 2;
|
||||
l.forEach((n, i) => {
|
||||
x.set(n.id, start + i * (NODE_W + GAP_X));
|
||||
y.set(n.id, d * (NODE_H + GAP_Y));
|
||||
});
|
||||
});
|
||||
const height = layers.length * NODE_H + (layers.length - 1) * GAP_Y;
|
||||
return { x, y, width: maxWidth, height: Math.max(height, 0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a node title to at most `maxLines` lines of at most `maxChars`
|
||||
* (greedy word wrap); overflow ends in an ellipsis, a single oversized
|
||||
* word is hard-clamped. Pure — unit-tested.
|
||||
*/
|
||||
export function wrapTitle(title: string, maxChars = 22, maxLines = 2): string[] {
|
||||
const words = title.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [""];
|
||||
const lines: string[] = [];
|
||||
for (const w of words) {
|
||||
const last = lines[lines.length - 1];
|
||||
const fits = last !== undefined && last.length + 1 + w.length <= maxChars;
|
||||
if (fits) {
|
||||
lines[lines.length - 1] = `${last} ${w}`;
|
||||
} else if (lines.length < maxLines) {
|
||||
lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w);
|
||||
} else {
|
||||
// out of lines: mark the last one as truncated (once)
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine !== undefined && !lastLine.endsWith("…")) {
|
||||
lines[lines.length - 1] = lastLine.length >= maxChars
|
||||
? `${lastLine.slice(0, maxChars - 1)}…`
|
||||
: `${lastLine}…`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Box + label styling per status — the graph's face of the list's marks:
|
||||
* done = colored in (the teach-phase green), current = the position,
|
||||
* next = the unlocked ring, upcoming = quiet. */
|
||||
const GRAPH_BOX: Record<PlanNodeStatus, string> = {
|
||||
done: "fill-phase-teach/10 stroke-phase-teach/50",
|
||||
current: "fill-primary/10 stroke-primary",
|
||||
upcoming: "fill-card stroke-border",
|
||||
};
|
||||
const GRAPH_TEXT: Record<PlanNodeStatus, string> = {
|
||||
done: "fill-muted-foreground text-xs",
|
||||
current: "fill-foreground text-xs font-medium",
|
||||
upcoming: "fill-muted-foreground/80 text-xs",
|
||||
};
|
||||
|
||||
const GRAPH_HEADER_BTN =
|
||||
"flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground";
|
||||
const GRAPH_ZOOM_BTN =
|
||||
"pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground";
|
||||
|
||||
/** Zoom bounds: never below fit-to-panel (the graph can't disappear into
|
||||
* nothing — fully zoomed out IS the fit), never above 2×. */
|
||||
const ZOOM_MAX = 2;
|
||||
const ZOOM_FLOOR = 0.15;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function TreeGraph({ tree }: { tree: PlanTree }) {
|
||||
const { order, depth } = useMemo(() => topoLayout(tree), [tree]);
|
||||
const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]);
|
||||
const layout = useMemo(() => graphLayout(order, depth), [order, depth]);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The zoom floor: fully zoomed out, the whole diagram fits the panel.
|
||||
const fitZoom = useMemo(() => {
|
||||
const pad = 48;
|
||||
if (!size.w || !size.h || layout.width === 0) return 1;
|
||||
return Math.max(
|
||||
ZOOM_FLOOR,
|
||||
Math.min((size.w - pad) / layout.width, (size.h - pad) / layout.height, 1),
|
||||
);
|
||||
}, [size, layout]);
|
||||
|
||||
const [view, setView] = useState({ x: 0, y: 0, zoom: 1 });
|
||||
const userAdjustedRef = useRef(false);
|
||||
|
||||
// Auto-fit (first measure, layout changes) — suppressed once the learner
|
||||
// takes manual control, until they press fit again.
|
||||
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]);
|
||||
|
||||
// Wheel zoom to the cursor. Non-passive so the page never scrolls instead.
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
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 };
|
||||
});
|
||||
userAdjustedRef.current = true;
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [fitZoom]);
|
||||
|
||||
// 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);
|
||||
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 };
|
||||
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) }));
|
||||
};
|
||||
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current) {
|
||||
dragRef.current = null;
|
||||
userAdjustedRef.current = true;
|
||||
}
|
||||
e.currentTarget.classList.remove("cursor-grabbing");
|
||||
};
|
||||
|
||||
// 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 };
|
||||
});
|
||||
};
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
data-testid="plan-graph"
|
||||
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden select-none"
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 origin-top-left"
|
||||
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
|
||||
>
|
||||
<svg width={layout.width} height={layout.height} role="img" aria-label="Plan tree graph">
|
||||
<defs>
|
||||
<marker
|
||||
id="lh-plan-arrow"
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" />
|
||||
</marker>
|
||||
</defs>
|
||||
{tree.edges.map((e) => {
|
||||
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2;
|
||||
const y1 = (layout.y.get(e.from) ?? 0) + NODE_H;
|
||||
const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2;
|
||||
const y2 = layout.y.get(e.to) ?? 0;
|
||||
const mid = (y1 + y2) / 2;
|
||||
return (
|
||||
<path
|
||||
key={`${e.from}->${e.to}`}
|
||||
d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`}
|
||||
className="fill-none stroke-muted-foreground/40"
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#lh-plan-arrow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{order.map((n) => {
|
||||
const status: PlanNodeStatus = planNodeStatus(tree, n.id);
|
||||
const lines = wrapTitle(n.title);
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${layout.x.get(n.id) ?? 0}, ${layout.y.get(n.id) ?? 0})`}
|
||||
data-testid={`graph-node-${n.id}`}
|
||||
data-status={status}
|
||||
data-next={n.id === nextId ? "true" : undefined}
|
||||
>
|
||||
<title>{n.summary ? `${n.title} — ${n.summary}` : n.title}</title>
|
||||
<rect width={NODE_W} height={NODE_H} rx={10} className={GRAPH_BOX[status]} strokeWidth={1.5} />
|
||||
{status === "current" && (
|
||||
<circle cx={NODE_W - 12} cy={12} r={3} className="fill-primary" aria-hidden />
|
||||
)}
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={12}
|
||||
y={lines.length === 1 ? 29 : 19 + i * 14}
|
||||
className={GRAPH_TEXT[status]}
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Quiet affordances: the hint and the zoom controls. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||
<span className="text-[10px] text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" title="Zoom out" aria-label="Zoom out" onClick={() => stepZoom(1 / 1.25)} className={GRAPH_ZOOM_BTN}>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" title="Fit plan to panel" aria-label="Fit plan to panel" onClick={fitView} data-testid="graph-fit" className={GRAPH_ZOOM_BTN}>
|
||||
<MaximizeIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" title="Zoom in" aria-label="Zoom in" onClick={() => stepZoom(1.25)} className={GRAPH_ZOOM_BTN}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The expanded panel (graph mode): wider, the diagram, and the mode toggles. */
|
||||
export function TreeGraphPanel({
|
||||
tree,
|
||||
onShowList,
|
||||
onCollapse,
|
||||
}: {
|
||||
tree: PlanTree;
|
||||
onShowList: () => void;
|
||||
onCollapse: () => void;
|
||||
}) {
|
||||
const doneCount = tree.doneNodeIds.length;
|
||||
const totalCount = tree.nodes.length;
|
||||
return (
|
||||
<aside
|
||||
data-testid="tree-graph-panel"
|
||||
className="hidden w-96 shrink-0 flex-col border-l border-border/60 bg-card/30 xl:flex"
|
||||
>
|
||||
<header className="flex items-center gap-2 px-3 py-2.5">
|
||||
<NetworkIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Plan</span>
|
||||
<span
|
||||
className="ml-auto text-[11px] tabular-nums text-muted-foreground"
|
||||
title={`${doneCount} of ${totalCount} nodes done`}
|
||||
>
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowList}
|
||||
title="Show as list"
|
||||
aria-label="Show as list"
|
||||
className={GRAPH_HEADER_BTN}
|
||||
>
|
||||
<ListTreeIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title="Hide plan"
|
||||
aria-label="Hide plan"
|
||||
className={GRAPH_HEADER_BTN}
|
||||
>
|
||||
<ChevronsRightIcon className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
<TreeGraph tree={tree} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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."));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user