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:
+46
-24
@@ -32,6 +32,8 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
|||||||
|
|
||||||
export const NODE_W = 160;
|
export const NODE_W = 160;
|
||||||
export const NODE_H = 44;
|
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_X = 24;
|
||||||
export const GAP_Y = 48;
|
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));
|
for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l));
|
||||||
const x = new Map<string, number>();
|
const x = new Map<string, number>();
|
||||||
const y = 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;
|
const start = (maxWidth - rowWidth(l)) / 2;
|
||||||
l.forEach((n, i) => {
|
l.forEach((n, i) => {
|
||||||
x.set(n.id, start + i * (NODE_W + GAP_X));
|
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;
|
const height = layers.length > 0 ? cursor - GAP_Y : 0;
|
||||||
return { x, y, width: maxWidth, height };
|
return { x, y, nodeH, width: maxWidth, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an
|
/** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the
|
||||||
* ellipsis, a single oversized word is hard-clamped. Pure. */
|
* node's box grows to fit every line (owner: show the full content of
|
||||||
export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] {
|
* 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);
|
const words = title.trim().split(/\s+/).filter(Boolean);
|
||||||
if (words.length === 0) return [""];
|
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 (const w of words) {
|
||||||
const last = lines[lines.length - 1];
|
for (let i = 0; i < w.length; i += maxChars) atoms.push(w.slice(i, i + maxChars));
|
||||||
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 {
|
|
||||||
const lastLine = lines[lines.length - 1];
|
|
||||||
if (lastLine !== undefined && !lastLine.endsWith("…")) {
|
|
||||||
lines[lines.length - 1] =
|
|
||||||
lastLine.length >= maxChars ? `${lastLine.slice(0, maxChars - 1)}…` : `${lastLine}…`;
|
|
||||||
}
|
}
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const a of atoms) {
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
if (last !== undefined && last.length + 1 + a.length <= maxChars) {
|
||||||
|
lines[lines.length - 1] = `${last} ${a}`;
|
||||||
|
} else {
|
||||||
|
lines.push(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
@@ -284,7 +298,7 @@ export function PlanDiagram({
|
|||||||
</defs>
|
</defs>
|
||||||
{edges.map((e) => {
|
{edges.map((e) => {
|
||||||
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2;
|
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;
|
const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2;
|
||||||
// Shortened by the tip overhang so the head's TIP lands on the
|
// Shortened by the tip overhang so the head's TIP lands on the
|
||||||
// node border while the line stops at the notch mouth.
|
// node border while the line stops at the notch mouth.
|
||||||
@@ -307,6 +321,9 @@ export function PlanDiagram({
|
|||||||
const status = statusOf(n.id);
|
const status = statusOf(n.id);
|
||||||
const glow = n.id === glowId && !monochrome;
|
const glow = n.id === glowId && !monochrome;
|
||||||
const lines = wrapTitle(n.title);
|
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 (
|
return (
|
||||||
<g
|
<g
|
||||||
key={n.id}
|
key={n.id}
|
||||||
@@ -321,7 +338,7 @@ export function PlanDiagram({
|
|||||||
x={-GLOW_MARGIN}
|
x={-GLOW_MARGIN}
|
||||||
y={-GLOW_MARGIN}
|
y={-GLOW_MARGIN}
|
||||||
width={NODE_W + 2 * GLOW_MARGIN}
|
width={NODE_W + 2 * GLOW_MARGIN}
|
||||||
height={NODE_H + 2 * GLOW_MARGIN}
|
height={h + 2 * GLOW_MARGIN}
|
||||||
className="fill-yellow-400/25"
|
className="fill-yellow-400/25"
|
||||||
filter={`url(#${markerId}-glow)`}
|
filter={`url(#${markerId}-glow)`}
|
||||||
/>
|
/>
|
||||||
@@ -329,7 +346,7 @@ export function PlanDiagram({
|
|||||||
{/* Rectangular — no corner rounding (owner). */}
|
{/* Rectangular — no corner rounding (owner). */}
|
||||||
<rect
|
<rect
|
||||||
width={NODE_W}
|
width={NODE_W}
|
||||||
height={NODE_H}
|
height={h}
|
||||||
className={monochrome ? MONO_BOX : BOX[status]}
|
className={monochrome ? MONO_BOX : BOX[status]}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
@@ -337,7 +354,7 @@ export function PlanDiagram({
|
|||||||
<text
|
<text
|
||||||
key={i}
|
key={i}
|
||||||
x={12}
|
x={12}
|
||||||
y={lines.length === 1 ? 27 : 18 + i * 13}
|
y={y0 + i * LINE_H}
|
||||||
className={monochrome ? MONO_TEXT : TEXT[status]}
|
className={monochrome ? MONO_TEXT : TEXT[status]}
|
||||||
>
|
>
|
||||||
{line}
|
{line}
|
||||||
@@ -510,7 +527,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userAdjustedRef.current || !size.w || !size.h) return;
|
if (userAdjustedRef.current || !size.w || !size.h) return;
|
||||||
setView(fitGraphView(box, size, fitZoom));
|
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]);
|
}, [fitZoom, size, box]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function planWindowViewBox(tree: PlanTree, current: string | null): strin
|
|||||||
: Math.max(layout.width, 1) / 2;
|
: Math.max(layout.width, 1) / 2;
|
||||||
const cy =
|
const cy =
|
||||||
focusId !== null
|
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;
|
: Math.max(layout.height, 1) / 2;
|
||||||
const clamp = (c: number, extent: number, view: number) =>
|
const clamp = (c: number, extent: number, view: number) =>
|
||||||
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
|
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
|
||||||
|
|||||||
@@ -58,10 +58,14 @@ describe("plan block layout (pure)", () => {
|
|||||||
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
|
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"), ["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("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
|
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user