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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Worker messages:
- `substitutionModeSteps` filters the trace down to visible substitutions.
- **Rendering always replays the full trace up to the current step's `step` number.** In substitution mode the scrubber moves through a filtered step list, so use list indices only for the scrubber, never as step numbers.
- **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. The outermost box says why it isn't replaced (the call-enter step's `boxReason`, `visualizerBoxReason`).
- **Tree mode («Baum», capability `tree`, embed `features=tree`):** `src/treeMode.ts`. `treeModeSteps` takes the substitution mode's steps and adds a step of its own (`treePhase` `'open'`/`'close'`, a copy of a nearby step with `step` set to the state it shows) before and after each statement expression's reductions (`TreeEpisode`: open in one frame between `openIndex` and `closeIndex`). It is also a substitution mode (`substitutionMode` is true), so inline calls, pending boxes and hosts work as there. `Frame` splices the open tree into its line like a child (`childRanges` entry with `frame: null`, `ExpressionTreeBox`) and hangs the boxes of the calls in it below. `buildExpressionTree` builds the tree from the AST and `buildSubstitutionsUpTo` (so it can't diverge from the chain; node keys `frameId:nodeId`, an inline call's body with the callee's frame). `episodeTreeLayout` lays a tree out where it opened or last grew, with room for every later text, so reductions move nothing; `TreeView` then takes that `layout`. Check changes with `test:tree` and the «Baum» cases in `test:console`.
- **Tree mode («Baum», capability `tree`, embed `features=tree`):** `src/treeMode.ts`. `treeModeSteps` takes the substitution mode's steps and adds a step of its own (`treePhase` `'open'`/`'close'`, a copy of a nearby step with `step` set to the state it shows) before and after each statement expression's reductions (`TreeEpisode`: open in one frame between `openIndex` and `closeIndex`). It is also a substitution mode (`substitutionMode` is true), so inline calls, pending boxes and hosts work as there. `Frame` splices the open tree into its line like a child (`childRanges` entry with `frame: null`, `ExpressionTreeBox`) and hangs the boxes of the calls in it below. `buildExpressionTree` builds the tree from the AST and `buildSubstitutionsUpTo` (so it can't diverge from the chain; node keys `frameId:nodeId`, an inline call's body with the callee's frame). `episodeTreeLayout` lays a tree out where it opened or last grew (an inline call replaced, a box opened or closed), with room for every later text, so reductions move nothing; `TreeView` then takes that `layout`. A call of the program's own labels its argument edges with the parameters (`edgeLabel`, from the episode's `calls`; `edgeLabelPlacement` in `treeLayout.ts`, room reserved by the layout). A call that stays a box is a `kind: 'box'` node of measured size (`ExpressionTreeBox` measures the drawn box in characters and rows, growth-only; the layout gives a box several rows), drawn by `TreeView`'s `renderBox`. Check changes with `test:tree` and the «Baum» cases in `test:console`.
- **Steady height (compact hosts):** while visualizing, the canvas keeps the height of the tallest step from the start (`steadyHeightCandidates`: up to 120 likely tallest steps, drawn hidden in `.visualizer-measure` through the same `rootFrame(stepViewAt(…))` as the visible step, and measured), and only grows; the console reserves the last step's output (`.visual-output-steady`). The embed caps the visualizer at 80% of `windowHeight` (the macro's). A step's view is `StepView`; compute it with `stepViewAt`, not inline, so the measurement and the visible canvas stay the same.
- **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons.
- **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop - except while `input()` waits: then Stop only cancels the wait (`EOFError`). The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`).
Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,32 @@ or `while`'s condition; not a bare name or literal):
2. **Reductions:** exactly the substitution mode's steps, each one a subtree
becoming its value; a node without a step of its own (a display, `-x`)
becomes its value with its last child. A call with a single
`return <expression>` is replaced by that expression's tree, any other call
stays a node whose box hangs below the tree (with the note why).
`return <expression>` is replaced by that expression's tree. Any other
call, once its box opens, has the box in its node's place - the parent's
edge leads to the box's top, its siblings stay on their row - with the
note why it isn't replaced at its top; inside it, each line has its own
tree and calls of their own boxes.
3. **Close:** one step from the trivial tree to the value in the line.

For a call of a function of the program's own, each edge to an argument is
labelled with the parameter it binds to (`summe(2 + 2, 3 * 4)` with
`def summe(x, y)`: `x` on the edge to `2 + 2`, `y` on the one to `3 * 4`),
while the node keeps the function's name. A keyword argument is labelled with
its parameter, a parameter left to its default has no edge, and there are no
labels after a `*args` or for `**kwargs`; built-in and library functions
(`len`, `max`) have none. The labels stay while the arguments are reduced,
until the call is replaced or its box opens. They sit in the gap above the
argument, beside the edge's end, in a smaller code font; the layout keeps
room for them, so they touch no node and no other label.

So `x = 2 + 3 * 4` takes 5 steps instead of 3. Labels are Python's (`+`,
`quadrat`, `[ ]`, a name is a leaf); there are no parentheses, so
`(2 + 3) * 4` and `2 + 3 * 4` differ only in shape. A tree keeps its nodes in
place from step to step: it is laid out where it opens (and where a call is
replaced) with room for the widest value each node takes. With the
place from step to step: it is laid out where it opens, where a call is
replaced and where a call's box opens and closes, with room for the widest
value each node takes. A box is measured as drawn; while it is open, the tree
moves only if the box grows (a line of it gets longer, a tree opens in it)
and never when it shrinks again. With the
visualizer's animations, a reduced subtree's nodes move up into it and fade
(not with reduced motion). For screen readers each tree is described by its
text at that step, as the → chain writes it; a tree wider than the visualizer
Expand Down
62 changes: 62 additions & 0 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,68 @@ try {
console.error(`Baum: ${label}: ${error.message}`)
}
}
// P44: a call of the program's own labels its arguments' edges with the
// parameters; a call that stays a box sits in its node's place. Every
// step: one height, nothing overlapping, and positions that change only
// where the box opens and closes.
const callState = () => frame.locator('.embed-app').evaluate((app) => {
const tree = app.querySelector('.visualizer-canvas > .frame-box > .frame-code .value-tree')
if (!tree) return { height: Math.round(app.getBoundingClientRect().height), labels: null, nodes: null, overlap: null, box: null }
const items = [...tree.querySelectorAll(':scope > [data-tree-key], :scope > .value-tree-edge-label')]
const rects = items.map((item) => ({ name: item.dataset.treeKey ?? `label ${item.textContent}`, rect: (item.dataset.treeBoxKey ? item.firstElementChild ?? item : item).getBoundingClientRect() }))
const overlap = rects.flatMap((a, i) => rects.slice(i + 1).filter((b) => a.rect.left < b.rect.right - 0.5 && b.rect.left < a.rect.right - 0.5 && a.rect.top < b.rect.bottom - 0.5 && b.rect.top < a.rect.bottom - 0.5).map((b) => `${a.name} / ${b.name}`))
const box = tree.querySelector(':scope > [data-tree-box-key]')
return {
height: Math.round(app.getBoundingClientRect().height),
labels: [...tree.querySelectorAll(':scope > .value-tree-edge-label')].map((label) => label.textContent),
nodes: Object.fromEntries([...tree.querySelectorAll(':scope > [data-tree-key]')].map((node) => [node.dataset.treeKey, `${node.style.left} ${node.style.top}`])),
overlap,
box: box ? { top: box.style.top, besides: [...tree.querySelectorAll(':scope > [data-tree-key]:not([data-tree-box-key])')].filter((node) => node.style.top === box.style.top).map((node) => node.textContent) } : null,
}
})
const allStates = async (program) => {
await page.evaluate((source) => window.send('visualize', source), program)
await frame.locator('.visualizer-step-count', { hasText: 'Vor der' }).waitFor({ timeout: 60000 })
await measured(frame)
await mode('Baum')
await measured(frame)
const first = frame.locator('.visualizer-controls button').nth(0)
if (await first.isEnabled()) await first.click()
const states = [await callState()]
const next = frame.locator('.visualizer-controls button').nth(2)
while (await next.isEnabled()) {
await next.click()
// After the step's animations (a box grows in, nodes glide).
await new Promise((resolve) => setTimeout(resolve, 600))
states.push(await callState())
}
return states
}
try {
const states = await allStates('def summe(x, y):\n return x + y\n\nsumme(2 + 2, 3 * 4)\nlen([1, 2]) + max(2, 5)\n')
const labelled = states.map((state) => state.labels?.join(',') ?? null).filter((labels) => labels !== null).filter((labels, index, all) => labels !== all[index - 1])
assert.deepEqual(labelled, ['x,y', '', ], 'x and y until the call is replaced, then none (nor for len and max)')
assert.deepEqual(states.filter((state) => state.labels?.join(',') === 'x,y').length, 3, 'on the three steps before the call is replaced')
assert.deepEqual(states.flatMap((state) => state.overlap ?? []), [], 'nothing overlaps')
assert.deepEqual([...new Set(states.map((state) => state.height))], [states[0].height], 'one height')
console.log('Baum: parameter names on the edges: ok')
} catch (error) {
failed += 1
console.error(`Baum: parameter names on the edges: ${error.message}`)
}
try {
const states = await allStates('def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5]) + 1\n')
const withBox = states.filter((state) => state.box)
assert.ok(withBox.length > 10, `the box is open for ${withBox.length} steps`)
assert.deepEqual([...new Set(withBox.map((state) => JSON.stringify(state.box)))], [JSON.stringify({ top: '2.8em', besides: ['1'] })], 'the box on the row below +, the leaf 1 beside it')
assert.deepEqual([...new Set(withBox.map((state) => JSON.stringify(state.nodes)))].length, 1, 'nothing moves while the box is open')
assert.deepEqual(states.flatMap((state) => state.overlap ?? []), [], 'nothing overlaps')
assert.deepEqual([...new Set(states.map((state) => state.height))], [states[0].height], 'one height')
console.log('Baum: the box in the call node\'s place: ok')
} catch (error) {
failed += 1
console.error(`Baum: the box in the call node's place: ${error.message}`)
}
// The hint at a call that stays a box, in both modes.
for (const name of ['Ersetzung', 'Baum']) {
try {
Expand Down
49 changes: 44 additions & 5 deletions scripts/check-tree-layout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import assert from 'node:assert/strict'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { layoutTree, nodeWidth } from '../src/treeLayout.ts'
import { edgeLabelPlacement, layoutTree, NODE_HEIGHT, nodeWidth, ROW_HEIGHT } from '../src/treeLayout.ts'

const { flextree } = createRequire(import.meta.url)('d3-flextree')
// The same gap between neighbouring nodes as treeLayout.ts.
Expand All @@ -23,9 +23,13 @@ const treeOf = (value) => Array.isArray(value) && typeof value[0] === 'string'
? { kind: 'node', label: value[0], children: value.slice(1).map(treeOf) }
: { kind: 'leaf', text: typeof value === 'string' ? `'${value}'` : JSON.stringify(value) }

// The room an edge label takes (as treeLayout's edgeLabelRoom).
const labelRoom = (node) => node.edgeLabel ? 2 * (0.4 + [...node.edgeLabel].length * 0.75) : 0
// flextree's positions, moved so that the tree's left edge is at 0, by key.
// A box is as many rows high as it says (only leaves are boxes, so every
// row starts where flextree starts it: below a parent one row high).
function oracle(tree) {
const layout = flextree({ children: (node) => (node.kind === 'node' && node.children.length > 0 ? node.children : null), nodeSize: (node) => [nodeWidth(node.data), 1], spacing: SIBLING_GAP })
const layout = flextree({ children: (node) => (node.kind === 'node' && node.children.length > 0 ? node.children : null), nodeSize: (node) => [Math.max(nodeWidth(node.data), labelRoom(node.data)), node.data.kind === 'box' ? node.data.rows : 1], spacing: SIBLING_GAP })
const root = layout.hierarchy(tree)
layout(root)
let left = Infinity
Expand All @@ -35,9 +39,9 @@ function oracle(tree) {
right = Math.max(right, node.x + node.xSize / 2)
})
const positions = new Map()
const visit = (node, key) => {
positions.set(key, { x: node.x - left, depth: node.depth })
node.children?.forEach((child, index) => visit(child, key === '' ? String(index) : `${key}.${index}`))
const visit = (node, path) => {
positions.set(node.data.key ?? path, { x: node.x - left, depth: node.depth })
node.children?.forEach((child, index) => visit(child, path === '' ? String(index) : `${path}.${index}`))
}
visit(root, '')
return { positions, width: right - left }
Expand Down Expand Up @@ -85,6 +89,40 @@ const randomTree = (depth) => {
const RANDOM_TREES = 500
for (let index = 0; index < RANDOM_TREES; index++) compare(`random tree ${index}`, randomTree(0))

// The tree mode's trees: some leaves are call boxes several rows high, some
// edges have labels (a parameter's name). Still flextree's positions, and
// no label touches a node or another label.
const randomCallTree = (depth) => {
const arity = depth >= 4 ? 0 : Math.floor(random() * 4)
const edgeLabel = random() < 0.5 ? 'x'.repeat(1 + Math.floor(random() * 6)) : undefined
if (arity === 0) return random() < 0.3
? { kind: 'box', key: `box${Math.floor(random() * 1e9)}`, width: 10 + Math.floor(random() * 40), rows: 1 + Math.floor(random() * 50) / 10, edgeLabel }
: { kind: 'leaf', text: randomText(), edgeLabel }
return { kind: 'node', label: randomText(), children: Array.from({ length: arity }, () => randomCallTree(depth + 1)), edgeLabel }
}
const rectsOf = (layout) => {
const byKey = new Map(layout.nodes.map((placed) => [placed.key, placed]))
const nodes = layout.nodes.map((placed) => ({ key: placed.key, left: placed.x - nodeWidth(placed.node) / 2, right: placed.x + nodeWidth(placed.node) / 2, top: placed.depth * ROW_HEIGHT, bottom: placed.depth * ROW_HEIGHT + (placed.node.kind === 'box' ? placed.rows * ROW_HEIGHT - (ROW_HEIGHT - NODE_HEIGHT) : NODE_HEIGHT) }))
const labels = layout.nodes.flatMap((placed) => {
const parent = byKey.get(placed.parentKey)
const place = parent ? edgeLabelPlacement(placed, parent) : null
return place ? [{ key: `label:${placed.key}`, ...place }] : []
})
return { nodes, labels }
}
const overlaps = (a, b) => a.left < b.right - 1e-9 && b.left < a.right - 1e-9 && a.top < b.bottom - 1e-9 && b.top < a.bottom - 1e-9
let labelled = 0
for (let index = 0; index < RANDOM_TREES; index++) {
const tree = randomCallTree(0)
compare(`random call tree ${index}`, tree)
const { nodes, labels } = rectsOf(layoutTree(tree))
labelled += labels.length
for (const label of labels) {
for (const other of [...nodes, ...labels]) if (other !== label) assert.ok(!overlaps(label, other), `random call tree ${index}: ${label.key} overlaps ${other.key}`)
}
}
assert.ok(labelled > RANDOM_TREES, 'labels were checked')

// Not in the builds, nor in the notices.
const maps = [
...(existsSync('dist/index.js.map') ? ['dist/index.js.map'] : []),
Expand All @@ -96,4 +134,5 @@ for (const map of maps) {
}
assert.ok(!/flextree|d3-hierarchy|WTFPL/i.test(readFileSync('THIRD_PARTY_NOTICES.md', 'utf8')), 'nothing of it in THIRD_PARTY_NOTICES.md')

console.log(`Tree layout checks passed (boxes and edge labels in ${RANDOM_TREES} call trees too)`)
console.log(`Tree layout checks passed (${courseTrees.length} trees of the course, ${RANDOM_TREES} random ones; ${maps.length} source maps without d3).`)
Loading
Loading