Plan-graph refinement round 7 (owner): no fly-in on open, nodes never truncate

Fly-in, take two: round 4 kept settled OFF until the first fit, but the
flag flipped in the SAME commit as the transform change — the browser saw
the new transform + newly-added transition in one style update and
animated from the initial top-left zoom:1 state. The transition now
enables one paint AFTER the fit (requestAnimationFrame), so the popup
opens settled and only glides for user input afterwards.

Nodes show their FULL content (owner): wrapTitle no longer caps lines
or ellipsizes — oversized words are hard-chunked, nothing is dropped.
Boxes grow with their wrapped lines (NODE_H + LINE_H per extra line);
layout rows take their tallest node, edges leave from the source's real
bottom, the glow rect and the plan-window's centering follow the grown
height, and the text block centers in the grown box.
This commit is contained in:
amosblomqvist
2026-09-14 07:12:38 +03:00
parent 9eb30edb40
commit 7c766753cc
3 changed files with 52 additions and 26 deletions
+45 -23
View File
@@ -32,6 +32,8 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
export const NODE_W = 160;
export const NODE_H = 44;
/** Baseline-to-baseline distance of node titles. */
export const LINE_H = 13;
export const GAP_X = 24;
export const GAP_Y = 48;
@@ -95,36 +97,48 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{
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) => {
// Per-node heights: boxes grow to show the FULL wrapped content —
// nothing is ever truncated (owner, plan-graph round 7).
const nodeH = new Map<string, number>();
let cursor = 0;
layers.forEach((l) => {
// The row's height = its tallest node.
let rowH = NODE_H;
for (const n of l) {
const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H;
nodeH.set(n.id, h);
rowH = Math.max(rowH, h);
}
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));
y.set(n.id, cursor);
});
cursor += rowH + GAP_Y;
});
const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y;
return { x, y, width: maxWidth, height };
const height = layers.length > 0 ? cursor - GAP_Y : 0;
return { x, y, nodeH, width: maxWidth, height };
}
/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an
* ellipsis, a single oversized word is hard-clamped. Pure. */
export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] {
/** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the
* node's box grows to fit every line (owner: show the full content of
* each node). A single oversized word is hard-chunked across lines at
* maxChars. Pure. */
export function wrapTitle(title: string, maxChars = 20): string[] {
const words = title.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [""];
const lines: string[] = [];
// Oversized words can never fit a line — chunk them so no content is lost.
const atoms: string[] = [];
for (const w of words) {
for (let i = 0; i < w.length; i += maxChars) atoms.push(w.slice(i, i + maxChars));
}
const lines: string[] = [];
for (const a of atoms) {
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);
if (last !== undefined && last.length + 1 + a.length <= maxChars) {
lines[lines.length - 1] = `${last} ${a}`;
} else {
const lastLine = lines[lines.length - 1];
if (lastLine !== undefined && !lastLine.endsWith("…")) {
lines[lines.length - 1] =
lastLine.length >= maxChars ? `${lastLine.slice(0, maxChars - 1)}…` : `${lastLine}…`;
}
lines.push(a);
}
}
return lines;
@@ -284,7 +298,7 @@ export function PlanDiagram({
</defs>
{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 y1 = (layout.y.get(e.from) ?? 0) + (layout.nodeH.get(e.from) ?? NODE_H);
const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2;
// Shortened by the tip overhang so the head's TIP lands on the
// node border while the line stops at the notch mouth.
@@ -307,6 +321,9 @@ export function PlanDiagram({
const status = statusOf(n.id);
const glow = n.id === glowId && !monochrome;
const lines = wrapTitle(n.title);
// The box grows with its wrapped content; text centers in it.
const h = layout.nodeH.get(n.id) ?? NODE_H;
const y0 = (h - lines.length * LINE_H) / 2 + 10;
return (
<g
key={n.id}
@@ -321,7 +338,7 @@ export function PlanDiagram({
x={-GLOW_MARGIN}
y={-GLOW_MARGIN}
width={NODE_W + 2 * GLOW_MARGIN}
height={NODE_H + 2 * GLOW_MARGIN}
height={h + 2 * GLOW_MARGIN}
className="fill-yellow-400/25"
filter={`url(#${markerId}-glow)`}
/>
@@ -329,7 +346,7 @@ export function PlanDiagram({
{/* Rectangular — no corner rounding (owner). */}
<rect
width={NODE_W}
height={NODE_H}
height={h}
className={monochrome ? MONO_BOX : BOX[status]}
strokeWidth={1.5}
/>
@@ -337,7 +354,7 @@ export function PlanDiagram({
<text
key={i}
x={12}
y={lines.length === 1 ? 27 : 18 + i * 13}
y={y0 + i * LINE_H}
className={monochrome ? MONO_TEXT : TEXT[status]}
>
{line}
@@ -510,7 +527,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
useEffect(() => {
if (userAdjustedRef.current || !size.w || !size.h) return;
setView(fitGraphView(box, size, fitZoom));
setSettled(true);
// Enable the transition only AFTER the fit has painted: flipping
// settled in the SAME commit lets the browser animate from the
// initial top-left zoom:1 state — the fly-in the owner keeps
// seeing (round 4's gate wasn't enough; round 7 fix).
const raf = requestAnimationFrame(() => setSettled(true));
return () => cancelAnimationFrame(raf);
}, [fitZoom, size, box]);
useEffect(() => {
+1 -1
View File
@@ -52,7 +52,7 @@ export function planWindowViewBox(tree: PlanTree, current: string | null): strin
: Math.max(layout.width, 1) / 2;
const cy =
focusId !== null
? (layout.y.get(focusId) ?? 0) + NODE_H / 2
? (layout.y.get(focusId) ?? 0) + (layout.nodeH.get(focusId) ?? NODE_H) / 2
: Math.max(layout.height, 1) / 2;
const clamp = (c: number, extent: number, view: number) =>
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
+6 -2
View File
@@ -58,10 +58,14 @@ describe("plan block layout (pure)", () => {
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
});
it("wrapTitle wraps greedily and ellipsizes overflow", () => {
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]);
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped
// Oversized word hard-chunked, nothing lost (owner, round 7).
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [
"supercalifragilistic",
"expialidocious word",
]);
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
});
});