Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ the available metadata supports that interpretation. See [limitations](limitatio

### Recursion tree

Each node shows the call with its argument values, such as
`towerOfHanoi(2, "B", "C", "A")`. Methods drop their class name and the hidden
`this` pointer, so a LeetCode `Solution::` method reads like a plain function.
Nodes widen to fit the arguments (up to 32 characters); hover a node for its full label.

Calls branch in invocation order and retain recorded return values. L/R labels
mean first/second child calls, not necessarily `left`/`right` fields. The active
path and step note help you follow the running call.
Expand Down
15 changes: 9 additions & 6 deletions web/src/CallTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {useZoom, ZoomControl} from './zoom';
type Dims = {width: number; height: number; gap: number; level: number};
// Full nodes carry a status line; compact ones are a single, larger line for wide trees.
const FULL: Dims = {width: 168, height: 44, gap: 22, level: 96};
// Full labels: 16px bold monospace is about 9.7px per character.
const FULL_CHARS = 15;
// Full labels: 16px bold monospace is about 9.7px per character. Nodes grow with their longest
// label, from 15 characters up to FULL_CHARS, so the arguments are not cut off.
const FULL_CHARS = 32, FULL_CHAR_WIDTH = 9.7;
const COMPACT: Dims = {width: 116, height: 38, gap: 12, level: 70};
const MAX_NODES = 250;
// Below this, labels stop being readable: the tree scrolls instead, following the running call.
Expand All @@ -20,7 +21,7 @@ const MIN_FIT = 0.8;
// Switch to compact nodes when full ones would have to shrink this far to fit.
const COMPACT_BELOW = 0.8;
// Compact labels: 15px monospace is about 8.3px per character.
const COMPACT_CHARS = 24, CHAR_WIDTH = 8.4;
const COMPACT_CHARS = 32, CHAR_WIDTH = 8.4;
// Steps closer together than this are a burst, not a step to watch.
const RAPID_STEP_MS = 250;
// Width added to compact nodes so ×N never overprints the label.
Expand Down Expand Up @@ -120,14 +121,16 @@ export function CallTree({trace, index, onSelect, selectedCall, baseline = null}
visible.forEach(node => frequencies.set(node.label, (frequencies.get(node.label) ?? 0) + 1));
// Room for a hand-lettered ×N inside the node when any call repeats, so it never meets an edge label.
const anyRepeat = visible.some(node => (frequencies.get(node.label) ?? 0) > 1 && node.frame.locals.some(local => local.is_argument));
const fullDims = {...FULL, width: FULL.width + (anyRepeat ? REPEAT_ROOM : 0)};
const fullChars = Math.min(FULL_CHARS, Math.max(15, ...visible.map(node => node.label.length)));
// The width left for text: padding, the tick of a returned call and room for ×N.
const fullDims = {...FULL, width: Math.max(FULL.width, Math.round(fullChars * FULL_CHAR_WIDTH + 44)) + (anyRepeat ? REPEAT_ROOM : 0)};
const full = layout(history, order, fullDims);
const fullFit = box.width ? Math.min(1, (box.width - 34) / full.width, (box.height - 34) / full.height) : 1;
const compact = (mode === 'fit' ? fullFit : zoom) < COMPACT_BELOW;
// Compact nodes are as wide as their longest label (up to a cap), so "insert(null, 5) → a3"
// is never cut to "insert(null, …": the arguments are the point of the label.
const longest = Math.min(COMPACT_CHARS, Math.max(8, ...visible.map(node => compactLabel(node).length)));
const compactDims = {...COMPACT, width: Math.round(longest * CHAR_WIDTH + 24 + (anyRepeat ? REPEAT_ROOM : 0))};
const compactDims = {...COMPACT, width: Math.round(longest * CHAR_WIDTH + 32 + (anyRepeat ? REPEAT_ROOM : 0))};
const dims = compact ? compactDims : fullDims;
const {positions, labels, width, height} = compact ? layout(history, order, compactDims) : full;
const seek = (node: CallNode) => onSelect(node.frame, node.last);
Expand Down Expand Up @@ -222,7 +225,7 @@ export function CallTree({trace, index, onSelect, selectedCall, baseline = null}
<text x={compact ? (dims.width - (anyRepeat ? REPEAT_ROOM : 0)) / 2
: ((anyRepeat ? REPEAT_ROOM : 0) + dims.width - (node.state === 'completed' ? 18 : 0)) / 2} y={dims.height / 2 + 5.5} textAnchor="middle"
className={`node-label ${compact ? 'compact' : ''}`}>
{compact ? truncate(compactLabel(node), COMPACT_CHARS) : truncate(node.label, FULL_CHARS)}</text>
{compact ? truncate(compactLabel(node), COMPACT_CHARS) : truncate(node.label, fullChars)}</text>
{/* A returned call is ticked off inside its box, so "done" never rests on colour alone. */}
{/* Compact labels already say "→ value", so only full nodes carry the tick. */}
{!compact && node.state === 'completed' && <path className="node-tick" d={`M${dims.width - 22},${dims.height / 2} l4 4.5 l8.5 -9.5`} />}
Expand Down
11 changes: 9 additions & 2 deletions web/src/callHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ function returned(value: string | null, stop: Snapshot) {
return shortValue(value).text;
}

/** A method named without its class, `towerOfHanoi` rather than `Solution::towerOfHanoi`:
* the tree has room for the name or the values, and the values are the point. */
export function shortName(name: string) {
return name.replace(/^(?:[A-Za-z_]\w*::)+(?=[A-Za-z_~])/, '');
}

/** Replay only the prefix: backward seeking must not reveal future calls. */
export function callHistory(trace: Trace, index: number) {
const nodes = new Map<string, CallNode>();
Expand All @@ -53,9 +59,10 @@ export function callHistory(trace: Trace, index: number) {
// Display observed parameter values, not guessed locals or inferred returns.
// A call's first stop is its entry address, before the prologue stores arguments.
const entering = i === node.first && i > 0;
const args = frame.locals.filter(local => local.is_argument).map(local =>
// `this` is an address the reader never passed, so the label leaves it out.
const args = frame.locals.filter(local => local.is_argument && local.name !== 'this').map(local =>
!entering && local.status === 'readable' ? argument(local, stop) : '?');
node.label = `${frame.function}(${args.join(', ')})`;
node.label = `${shortName(frame.function)}(${args.join(', ')})`;
path.push(node);
}
for (const {call_id, value} of stop.returns ?? []) {
Expand Down
6 changes: 3 additions & 3 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {Icon, SproutMark} from './Icon';
import {StackTree} from './StackTree';
import {CallTree} from './CallTree';
import {repeatedWork, type WorkSummary} from './RepeatReport';
import {callHistory} from './callHistory';
import {callHistory, shortName} from './callHistory';
import {GraphView} from './GraphView';
import {graphsAt, traceHasGraph} from './graph';
import {MemoryGraph} from './MemoryGraph';
Expand Down Expand Up @@ -384,11 +384,11 @@ function App() {

{/* Announces where you are when stepping; silent during playback, which would chatter. */}
<div className="execution-context" aria-live={playing ? 'off' : 'polite'}>
<span className={`context-dot ${playing ? 'is-playing' : ''}`} /><strong>{step.frames[0]?.function ?? (step.event === 'exit' ? 'Finished' : 'Stopped')}</strong>
<span className={`context-dot ${playing ? 'is-playing' : ''}`} /><strong title={step.frames[0]?.function}>{step.frames[0] ? shortName(step.frames[0].function) : step.event === 'exit' ? 'Finished' : 'Stopped'}</strong>
<span>{step.location ? `Line ${step.location.line}` : step.event.replace('_', ' ')}</span><span className="context-stat">{step.frames.length} active {step.frames.length === 1 ? 'call' : 'calls'}</span>
{changes.heap.includes(index) && <span className="change-tag">Memory changed</span>}{changes.output.includes(index) && <span className="change-tag">New output</span>}
{inspected && <button className="inspection-chip" onClick={() => setSelectedCall(null)} title="Clear call selection">
Inspecting {inspected.function} · line {inspected.location.line}<Icon name="close" size={14} /></button>}
Inspecting {shortName(inspected.function)} · line {inspected.location.line}<Icon name="close" size={14} /></button>}
<span className="shortcut-hint">Space play · ← → step · n over · f out</span></div>
</div>
<div className="topbar-actions">
Expand Down
Loading