Trace the real code. Step through what it does.
Step-through algorithm figures. Write the real algorithm, call tracer hooks inside it, and stepthru records each step and plays it back as an SVG figure that readers can play, pause and scrub, with a caption for every step.
Demo and examples: https://kamaterasu.github.io/stepthru/
npm install stepthruimport { render, traceArray } from "stepthru";
import "stepthru/theme.css";
const t = traceArray([5, 2, 8, 1, 9]);
for (let i = 0; i < t.length; i++) {
for (let j = 0; j < t.length - i - 1; j++) {
if (t.compare(j, j + 1) > 0) t.swap(j, j + 1);
}
t.mark(t.length - i - 1, "sorted");
}
render(t, document.querySelector("#figure")!);No runtime dependencies. Works in any page or framework that can give it a DOM element.
- A tracer wraps a data structure. Each hook you call (
compare,swap,visit, …) changes the state and records a step: a full copy of the state plus a caption. render(trace, el)mounts a player intoel: the figure, the caption, buttons, a slider and a step counter.- Each tracer brings its own view, which draws one state as SVG.
Every hook writes a default caption ("Compare 5 and 2"). Pass your own as the last argument to any hook. Hooks that only read (get, neighbors, child, …) record nothing.
Show the code next to the figure, and the variables of each step:
const code = `for (let i = 0; i < a.length; i++) {
for (let j = 0; j < a.length - i - 1; j++) {
if (a[j] > a[j + 1]) swap(a, j, j + 1);
}
}`;
const t = traceArray([5, 2, 8, 1]);
for (let i = 0; i < t.length; i++) {
for (let j = 0; j < t.length - i - 1; j++) {
t.watch({ i, j }); // shown with every step from here on
t.at(3); // steps from here on highlight line 3 of `code`
if (t.compare(j, j + 1) > 0) t.swap(j, j + 1);
}
}
render(t, el, { code });t.at(line) and t.watch(vars) work on every tracer and record no step themselves; they tag the steps recorded after them. Line numbers are 1-based and refer to the code string you pass to render. watch merges with earlier calls; a value of undefined hides that variable.
The listing can be a cleaned-up copy of the algorithm, without the tracer calls. Keep a test that checks each tagged line still contains the code it should (see test/examples/targets.test.ts), so the two cannot drift apart.
const t = traceArray([1, 3, 5, 7], "optional first caption");| Hook | Records | What it does |
|---|---|---|
t.length, t.get(i) |
no | Read |
t.compare(i, j) |
yes | Highlights both cells. Returns a negative number, zero or a positive number, like a sort comparator |
t.swap(i, j) |
yes | Swaps two values. Moved cells are drawn dashed and slide to their new place |
t.set(i, value) |
yes | Writes a value, for example when a merge copies values back |
t.mark(i, name) |
yes | Marks a cell, for example "sorted" or "found" |
t.pointer(name, i) |
yes | Draws a named pointer under a cell |
t.range(name, lo, hi) |
yes | Draws a bracket over lo..hi and dims cells outside every range |
t.list(name, items) |
yes | Draws a row of values under the array, such as a merge buffer. One at a time |
t.clear(name) |
yes | Removes the pointer, the range and the list called name |
t.note(caption) |
yes | A step with only a caption |
Values can be numbers, strings or objects. Numbers and strings compare in their natural order. Objects need a comparator, and can have a label for their cell:
const t = traceArray(people, {
caption: "Sort by age",
compare: (a, b) => a.age - b.age,
label: (p) => p.name,
});An index outside the array throws a RangeError.
To draw an array as a binary heap, a tree above the array itself, pass heapView as the view. Values slide along both when swapped:
import { heapView, render } from "stepthru";
render(t, el, { view: (s) => heapView(s) });For dynamic programming tables, mazes and anything else laid out in rows and columns.
const t = traceGrid([[0, 1, 2], [1, null, null]], { rowLabels: ["", "a"], colLabels: ["", "b", "c"] });
const maze = traceGrid(rows, { wall: (v) => v === "#" });| Hook | Records | What it does |
|---|---|---|
t.rows, t.cols, t.get(r, c) |
no | Read. null is an empty cell |
t.set(r, c, value) |
yes | Writes a value. The cell is drawn dashed |
t.look(cells) |
yes | Highlights the cells a step reads, such as the neighbors a DP cell depends on |
t.mark(r, c, name) |
yes | Marks a cell, for example "path" or "answer" |
t.note(caption) |
yes | A step with only a caption |
Cells are [row, col], both from 0. Options: caption, rowLabels, colLabels, label for a value's text, and wall to draw some values as solid walls.
const t = traceGraph({ nodes: ["A", "B", "C"], edges: [["A", "B"], ["B", "C"]] });| Hook | Records | What it does |
|---|---|---|
t.neighbors(id), t.visited(id), t.weight(u, v) |
no | Read. Neighbors come in the order their edges were given. weight is 1 for an edge without one |
t.visit(id) |
yes | Marks a node visited |
t.focus(id) |
yes | Makes a node the current one |
t.edge(u, v, mark) |
yes | Marks an edge, for example "tree" |
t.tag(id, text) |
yes | Draws short text by a node, such as a distance. null removes it |
t.queue(items?) |
yes | Starts a queue drawn under the graph: push(id), shift(), length |
t.stack(items?) |
yes | Starts a stack drawn under the graph: push(id), pop(), length |
t.note(caption) |
yes | A step with only a caption |
shift() and pop() make the taken node current.
Edges can have a weight, drawn on the edge, and a graph can be directed, drawn with arrows:
const roads = traceGraph({ nodes: ["A", "B", "C"], edges: [["A", "B", 4], ["B", "C", 1]] });
const tasks = traceGraph({ nodes: ["wash", "dry", "fold"], edges: [["wash", "dry"], ["dry", "fold"]], directed: true });A directed graph with no cycle is drawn in layers, each node below everything that points to it. Other graphs are drawn in rows by distance from the first node.
const t = traceTree(); // empty
const u = traceTree({ value: 8, left: { value: 3 } }); // from a literal| Hook | Records | What it does |
|---|---|---|
t.root, t.value(id), t.child(id, "left") |
no | Read. Ids are numbers, null means no node |
t.compare(value, id) |
yes | Makes the node current. Returns value - node.value |
t.addRoot(value) |
yes | Adds the root of an empty tree, returns its id |
t.addChild(id, side, value) |
yes | Fills an empty "left" or "right" slot, returns the new id |
t.focus(id) |
yes | Makes a node the current one |
t.visit(id) |
yes | Adds the node to the visit order drawn under the tree |
t.note(caption) |
yes | A step with only a caption |
const player = render(t, el, { intervalMs: 800 });
player.play();render returns the player: seek(i), next(), prev(), play(), pause(), toggle(), setSpeed(n), state ({ index, playing, speed }) and destroy(). Call destroy() before removing a figure from the page: it stops playback and removes the figure's listeners.
- Keyboard, once the figure or a control button has focus: ← → step, space plays or pauses, Home and End jump to the first or last step. Each button's tooltip names its key.
- The speed button cycles 0.5×, 1×, 2× and 4×.
- Long code scrolls inside its panel, which keeps the current line in view.
- Shapes a step touches fade in their color, and array cells slide when a swap moves them, unless the reader prefers reduced motion. Your own views can slide shapes too: give a group a
data-keyand place it withtransform="translate(x y)". - Rendering into an element that already holds a figure destroys the old one first.
- Screen readers hear the caption of each step the reader moves to. During playback captions are not announced, so a step every 800 ms does not flood the reader.
- Playback pauses when the figure scrolls out of view.
- The figure keeps the size of its largest step, so it does not jump while a tree or graph grows.
- Nothing plays until the reader presses play.
The controls' screen reader text and tooltips are English by default. Translate any of them:
render(t, el, {
labels: { first: "Эхний алхам", prev: "Өмнөх алхам", play: "Тоглуулах", pause: "Зогсоох",
next: "Дараах алхам", last: "Сүүлийн алхам", step: "Алхам", code: "Код", variables: "Хувьсагч", speed: "Хурд" },
});The defaults are exported as LABELS.
The captions the tracers write, such as "Compare 5 and 2", can be translated too. Pass captions to the tracer; anything you leave out stays English:
const t = traceArray([5, 2, 8], {
captions: {
compare: (a, b) => `${a} ба ${b}-г харьцуулах`,
swap: (a, b) => `${a} ба ${b}-г солих`,
},
});Every tracer takes captions: traceArray, traceGraph, traceTree and traceGrid. The English defaults are exported as ARRAY_CAPTIONS, GRAPH_CAPTIONS, TREE_CAPTIONS and GRID_CAPTIONS, and show the arguments each caption gets.
stepthru/theme.css styles the player and the views. Its colors are CSS variables with defaults for light and dark (from the OS setting). Set them anywhere to match your site:
:root {
--stepthru-fg: var(--text);
--stepthru-bg: var(--background);
--stepthru-muted: var(--border);
--stepthru-accent: var(--brand); /* current and highlighted */
--stepthru-done: var(--success); /* marked and visited */
}Figures render at their natural size and only shrink on narrow screens. Add .stepthru-figure svg { width: 100%; } to scale them up.
Give the figure's element an id and pass link: true. The page URL then follows the step, as #<id>=<step>, and opening that URL shows the figure at that step:
render(t, document.querySelector("#bubble")!, { link: true }); // page.html#bubble=12 opens step 12The URL changes when the reader moves, not during playback, and never adds to the back button's history.
stepthru is plain ES modules with no dependencies, so a page can load it from a CDN:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/stepthru@0.4/dist/theme.css" />
<div id="figure"></div>
<script type="module">
import { traceArray, render } from "https://cdn.jsdelivr.net/npm/stepthru@0.4/dist/index.js";
const t = traceArray([5, 2, 8, 1, 9]);
for (let i = 0; i < t.length; i++) {
for (let j = 0; j < t.length - i - 1; j++) {
if (t.compare(j, j + 1) > 0) t.swap(j, j + 1);
}
}
render(t, document.querySelector("#figure"));
</script>https://esm.sh/stepthru@0.4 works too.
render takes any element and returns a destroy(), so each framework needs only a few lines. Import stepthru/theme.css once in your app.
React
import { useEffect, useRef } from "react";
import { render, type Trace } from "stepthru";
export function Figure<S>({ trace }: { trace: Trace<S> }) {
const el = useRef<HTMLDivElement>(null);
useEffect(() => render(trace, el.current!).destroy, [trace]);
return <div ref={el} />;
}Build the trace outside the component, or with useMemo, so it is not traced again on every render.
Vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { render, type RenderedFigure, type Trace } from "stepthru";
const props = defineProps<{ trace: Trace<unknown> }>();
const el = ref<HTMLDivElement>();
let figure: RenderedFigure | undefined;
onMounted(() => (figure = render(props.trace, el.value!)));
onBeforeUnmount(() => figure?.destroy());
</script>
<template><div ref="el" /></template>Svelte
<script lang="ts">
import { render, type Trace } from "stepthru";
export let trace: Trace<unknown>;
const figure = (el: HTMLElement) => ({ destroy: render(trace, el).destroy });
</script>
<div use:figure></div>Astro, or any static site
Render the first step at build time with toHTML, so the figure shows without JavaScript, then make it playable in the browser:
---
import { toHTML } from "stepthru";
import { bubbleSort } from "../algorithms/bubble-sort";
---
<div class="stepthru" id="bubble" set:html={toHTML(bubbleSort([5, 2, 8, 1, 9]))} />
<script>
import "stepthru/theme.css";
import { render } from "stepthru";
import { bubbleSort } from "../algorithms/bubble-sort";
render(bubbleSort([5, 2, 8, 1, 9]), document.querySelector("#bubble")!);
</script>toHTML(trace, options) returns the figure's first step as an HTML string. It needs no DOM, so it runs on the server or at build time. Put it inside an element with class stepthru, then call render on that element in the browser:
---
// Astro component: rendered at build time
const t = bubbleSort([5, 2, 8]);
---
<div class="stepthru" data-figure="bubble" set:html={toHTML(t, { code })} />
<script>
render(bubbleSort([5, 2, 8]), document.querySelector('[data-figure="bubble"]'), { code });
</script>Without JavaScript, readers see the first step, its caption and the code. The controls stay hidden until render runs, so there are no buttons that do nothing.
createTrace(initial) gives you steps and record(op, state, caption). Pass a view to render:
import { createTrace, render } from "stepthru";
const t = createTrace({ count: 0 }, "Start at zero");
for (let i = 1; i <= 3; i++) t.record("add", { count: i }, `Add one: ${i}`);
render(t, el, { view: (s) => `<svg width="60" height="40"><text x="10" y="25">${s.count}</text></svg>` });Views must escape any text they put into the markup. Use the exported escape:
import { escape } from "stepthru";
const view = (s: { name: string }) => `<svg width="80" height="40"><text x="10" y="25">${escape(s.name)}</text></svg>`;render draws every step once at the start to find the largest, so the figure does not jump. For traces with thousands of steps, give the trace a size(state) method that returns the [width, height] the view would draw, and render skips the drawing.
See CONTRIBUTING.md, including how to add an algorithm page.
npm install
npm run play # playground with every example
npm run coverage # tests with a coverage report in coverage/
npm run size # what stepthru adds to a bundle, minified and gzipped
npm run demo # rebuild brand/demo.svg, the animated figure in this README
npm run pages # rebuild the algorithm pages from playground/pages.json
npm test # unit and acceptance tests
npm run e2e # the built site in a real browser (first: npx playwright install chromium)
npm run typecheck
npm run buildsrc/
core/ trace recorder and player, no DOM
dom/ renders a trace into the page
structures/ one file per structure: its tracer and its view, plus shared SVG shapes (svg.ts)
styles/ theme.css, shipped as stepthru/theme.css
index.ts public API
test/ mirrors src/
examples/ the algorithms on the demo page, also run by the acceptance tests
playground/ the demo page (npm run play, deployed to GitHub Pages)
brand/ logo, one-color logo and social preview image (see BRAND.md)
Pushing a v* tag runs .github/workflows/release.yml: it checks the tag matches package.json, publishes to npm with trusted publishing (no npm token), and creates the GitHub release from CHANGELOG.md.
- Add a
## x.y.z (YYYY-MM-DD)section at the top ofCHANGELOG.mdand commit it. npm version x.y.z(orpatch,minor,major). It bumpspackage.json, commits and tagsvx.y.z.git push --follow-tags
See PLAN.md for scope, decisions and what comes next.
MIT