<p class="hint">Depth-first search dives along one edge after another and backtracks only when it gets stuck. Each node shows <b>discovery/finish</b> times; each edge is coloured by its type as soon as the search inspects it.</p>
<div class="bar">
<label for="preset">Graph:</label>
<select id="preset">
<option value="dag">Acyclic (DAG)</option>
<option value="cycle">With a cycle</option>
<option value="maze">Maze (undirected)</option>
</select>
</div>
<svg id="g" viewBox="0 0 350 248" role="img" aria-label="Graph being explored by depth-first search"></svg>
<div class="stackrow">
<span class="lab">Stack (bottom → top):</span>
<span id="stack" class="stack"></span>
<span id="clock" class="clock"></span>
</div>
<div id="status" class="status">Press Step to begin at node A, or Run to animate.</div>
<div id="result" class="result"></div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span><i class="sw tree"></i>tree</span>
<span><i class="sw back"></i>back</span>
<span><i class="sw forward"></i>forward</span>
<span><i class="sw cross"></i>cross</span>
<span><i class="dot white"></i>unvisited</span>
<span><i class="dot grey"></i>on the stack</span>
<span><i class="dot black"></i>finished</span>
</div>
/* Colour tokens for light and dark mode; edge colours encode the edge type. */
:root { --fg: #1f2937; --muted: #4b5563; --bg: #ffffff; --panel: #f1f5f9; --line: #cbd5e1;
--unseen: #a3adb9; --tree: #2563eb; --back: #dc2626; --forward: #16a34a; --cross: #d97706;
--nwhite: #ffffff; --ngrey: #9ca3af; --nblack: #111827; --nstroke: #334155; --btn: #1d3557; --btnfg: #ffffff; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e5e7eb; --muted: #9ca3af; --bg: #111827; --panel: #1f2937; --line: #374151;
--unseen: #4b5563; --tree: #60a5fa; --back: #f87171; --forward: #4ade80; --cross: #fbbf24;
--nwhite: #f8fafc; --ngrey: #6b7280; --nblack: #020617; --nstroke: #cbd5e1; --btn: #93c5fd; --btnfg: #0b1220; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; }
.hint { font-size: .88rem; color: var(--muted); margin: 0 0 .5rem; line-height: 1.45; }
.bar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .3rem; font-size: .9rem; }
select { font: 14px system-ui, sans-serif; padding: .3rem .4rem; border-radius: 6px; border: 1px solid var(--line);
background: var(--panel); color: var(--fg); max-width: 100%; }
svg { width: 100%; max-width: 520px; height: auto; display: block; background: var(--panel); border-radius: 10px; }
.edge { stroke: var(--unseen); stroke-width: 2; fill: none; transition: stroke .2s; }
.edge.tree { stroke: var(--tree); stroke-width: 3.2; }
.edge.back { stroke: var(--back); stroke-width: 3; stroke-dasharray: 6 4; }
.edge.forward { stroke: var(--forward); stroke-width: 2.6; stroke-dasharray: 2 4; }
.edge.cross { stroke: var(--cross); stroke-width: 2.6; stroke-dasharray: 9 3 2 3; }
.edge.cur { stroke-width: 5; }
.mk-unseen { fill: var(--unseen); } .mk-tree { fill: var(--tree); } .mk-back { fill: var(--back); }
.mk-forward { fill: var(--forward); } .mk-cross { fill: var(--cross); }
.node circle { stroke: var(--nstroke); stroke-width: 2; transition: fill .2s; }
.node.white circle { fill: var(--nwhite); }
.node.grey circle { fill: var(--ngrey); }
.node.black circle { fill: var(--nblack); }
.node.top circle { stroke: var(--tree); stroke-width: 4; }
.node .nm { font: 700 14px system-ui, sans-serif; text-anchor: middle; dominant-baseline: central; fill: #111827; }
.node.black .nm { fill: #f8fafc; }
.node .tm { font: 600 11px ui-monospace, monospace; text-anchor: middle; fill: var(--fg); }
.stackrow { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; margin: .45rem 0 .2rem; font-size: .85rem; }
.lab { font-weight: 700; }
.stack { display: inline-flex; gap: 3px; flex-wrap: wrap; min-height: 1.6em; }
.chip { display: inline-block; min-width: 1.6em; text-align: center; padding: .1rem .35rem; border-radius: 5px;
background: var(--ngrey); color: #111827; font: 700 13px ui-monospace, monospace; }
.chip.top { outline: 2px solid var(--tree); }
.chip.none { background: transparent; color: var(--muted); font-weight: 400; }
.clock { margin-left: auto; color: var(--muted); font-family: ui-monospace, monospace; }
.status { font-size: .92rem; font-weight: 600; margin: .3rem 0; min-height: 2.7em; line-height: 1.35; }
.status.bad { color: var(--back); }
.status.ok { color: var(--forward); }
.result { font-size: .9rem; min-height: 1.3em; margin-bottom: .4rem; font-weight: 600; }
.result.bad { color: var(--back); }
.result.ok { color: var(--forward); }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid var(--btn);
background: var(--btn); color: var(--btnfg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--btn); }
button:disabled { opacity: .45; cursor: default; }
.legend { display: flex; flex-wrap: wrap; gap: .3rem .8rem; margin-top: .6rem; font-size: .78rem; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: .3rem; }
.sw { display: inline-block; width: 22px; height: 0; border-top: 3px solid; }
.sw.tree { border-color: var(--tree); } .sw.back { border-color: var(--back); border-top-style: dashed; }
.sw.forward { border-color: var(--forward); border-top-style: dotted; } .sw.cross { border-color: var(--cross); border-top-style: dashed; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--nstroke); }
.dot.white { background: var(--nwhite); } .dot.grey { background: var(--ngrey); } .dot.black { background: var(--nblack); }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Three small graphs. Neighbours are visited in alphabetical order so every run is reproducible.
const PRESETS = {
dag: { undirected: false,
nodes: { A: [40, 40], B: [130, 40], D: [220, 40], G: [310, 40], C: [40, 125], E: [130, 125], H: [220, 125], I: [310, 125], F: [130, 205] },
edges: ['AB', 'AC', 'AE', 'BD', 'BE', 'DG', 'EG', 'EH', 'CE', 'CF', 'FH', 'HI', 'GI'] },
cycle: { undirected: false,
nodes: { A: [40, 40], B: [130, 40], D: [220, 40], G: [310, 40], C: [40, 125], E: [130, 125], H: [220, 125], I: [310, 125], F: [130, 205] },
edges: ['AB', 'AC', 'AE', 'BD', 'BE', 'DG', 'EG', 'EH', 'CE', 'CF', 'FH', 'HI', 'GI', 'HB'] },
maze: { undirected: true,
nodes: { A: [60, 40], B: [175, 40], C: [290, 40], D: [60, 125], E: [175, 125], F: [290, 125], G: [60, 205], H: [175, 205], I: [290, 205] },
edges: ['AB', 'AD', 'BC', 'CF', 'DG', 'GH', 'HI', 'HE', 'EB'] }
};
// Iterative DFS with an explicit stack of frames (node, next-neighbour index). Each call to step() does exactly one thing: start a tree, inspect one edge, or finish one node.
function makeDFS(G) {
const names = Object.keys(G.nodes).sort();
const adj = {};
names.forEach(n => { adj[n] = []; });
G.edges.forEach((e, id) => {
adj[e[0]].push({ v: e[1], id });
if (G.undirected) adj[e[1]].push({ v: e[0], id });
});
names.forEach(n => adj[n].sort((a, b) => (a.v < b.v ? -1 : a.v > b.v ? 1 : a.id - b.id)));
const S = { names, adj, color: {}, d: {}, f: {}, kind: {}, stack: [], time: 0, finished: [], backEdges: [], done: false, cur: null };
names.forEach(n => { S.color[n] = 'white'; });
// A frame remembers which neighbour to try next, so we never push the same node twice.
function discover(u, pe) { S.d[u] = ++S.time; S.color[u] = 'grey'; S.stack.push({ u, i: 0, pe }); }
S.step = function () {
S.cur = null;
if (S.done) return { type: 'done' };
if (!S.stack.length) {
const r = names.find(n => S.color[n] === 'white');
if (r === undefined) { S.done = true; return { type: 'done' }; }
discover(r, -1);
return { type: 'start', u: r, d: S.d[r] };
}
const fr = S.stack[S.stack.length - 1];
const u = fr.u;
if (fr.i < adj[u].length) {
const { v, id } = adj[u][fr.i++];
S.cur = id;
// Undirected graphs: the edge back to the parent and edges already seen from the other end are not new edges.
if (G.undirected && id === fr.pe) return { type: 'parent', u, v, id };
if (G.undirected && S.kind[id]) return { type: 'seen', u, v, id };
// White = tree edge, grey = back edge (cycle), black = forward if v was discovered after u, otherwise cross.
const c = S.color[v];
if (c === 'white') { S.kind[id] = 'tree'; discover(v, id); return { type: 'tree', u, v, id, d: S.d[v] }; }
if (c === 'grey') { S.kind[id] = 'back'; S.backEdges.push(id); return { type: 'back', u, v, id }; }
const k = S.d[u] < S.d[v] ? 'forward' : 'cross';
S.kind[id] = k;
return { type: k, u, v, id };
}
// No neighbours left: assign the finish time, colour the node black and pop it (backtrack).
S.stack.pop();
S.f[u] = ++S.time;
S.color[u] = 'black';
S.finished.push(u);
return { type: 'finish', u, f: S.f[u] };
};
return S;
}
const NS = 'http://www.w3.org/2000/svg';
const R = 15;
const svg = document.getElementById('g');
const presetSel = document.getElementById('preset');
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const runBtn = document.getElementById('run');
const stepBtn = document.getElementById('step');
let G = PRESETS.dag, S = null, timer = null;
function el(tag, attrs, parent) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(e);
return e;
}
const sep = () => (G.undirected ? '–' : '→');
const edgeText = (id) => G.edges[id][0] + sep() + G.edges[id][1];
// Draw edges, then nodes with their discovery/finish stamps, then the stack.
function render() {
svg.innerHTML = '';
const defs = el('defs', {}, svg);
['unseen', 'tree', 'back', 'forward', 'cross'].forEach(k => {
const m = el('marker', { id: 'm-' + k, viewBox: '0 0 10 10', refX: '9', refY: '5', markerWidth: '6', markerHeight: '6', orient: 'auto-start-reverse' }, defs);
el('path', { d: 'M0,0 L10,5 L0,10 z', class: 'mk-' + k }, m);
});
const top = S.stack.length ? S.stack[S.stack.length - 1].u : null;
G.edges.forEach((e, id) => {
const [x1, y1] = G.nodes[e[0]], [x2, y2] = G.nodes[e[1]];
const len = Math.hypot(x2 - x1, y2 - y1), ux = (x2 - x1) / len, uy = (y2 - y1) / len;
// Trim each line so it stops at the circle border (and leaves room for the arrowhead).
const pad = G.undirected ? R : R + 2;
const k = S.kind[id] || 'unseen';
const attrs = { x1: x1 + ux * R, y1: y1 + uy * R, x2: x2 - ux * pad, y2: y2 - uy * pad,
class: 'edge ' + k + (S.cur === id ? ' cur' : '') };
if (!G.undirected) attrs['marker-end'] = 'url(#m-' + k + ')';
el('line', attrs, svg);
});
S.names.forEach(n => {
const [x, y] = G.nodes[n];
const g = el('g', { class: 'node ' + S.color[n] + (n === top ? ' top' : '') }, svg);
el('circle', { cx: x, cy: y, r: R }, g);
el('text', { x, y, class: 'nm' }, g).textContent = n;
if (S.d[n]) el('text', { x, y: y + R + 13, class: 'tm' }, g).textContent = S.d[n] + '/' + (S.f[n] || '·');
});
const st = document.getElementById('stack');
st.innerHTML = '';
if (!S.stack.length) {
const c = document.createElement('span'); c.className = 'chip none'; c.textContent = "empty"; st.appendChild(c);
}
S.stack.forEach((fr, i) => {
const c = document.createElement('span');
c.className = 'chip' + (i === S.stack.length - 1 ? ' top' : '');
c.textContent = fr.u; st.appendChild(c);
});
document.getElementById('clock').textContent = i18nFmt("time = %t%", { t: S.time });
}
function say(text, cls) { statusEl.textContent = text; statusEl.className = 'status ' + (cls || ''); }
function describe(ev) {
const e = ev.id !== undefined ? edgeText(ev.id) : '';
const p = { e, u: ev.u, v: ev.v, d: ev.d, f: ev.f };
switch (ev.type) {
case 'start': return [i18nFmt("The stack is empty — start a new DFS tree at %u% (d = %d%).", p), ''];
case 'tree': return [i18nFmt("%e%: %v% is white → tree edge. Go deeper (d = %d%).", p), ''];
case 'back': return [i18nFmt("%e%: %v% is grey, still on the stack → back edge. Cycle found!", p), 'bad'];
case 'forward': return [i18nFmt("%e%: %v% is black and a descendant of %u% → forward edge.", p), ''];
case 'cross': return [i18nFmt("%e%: %v% is black in a branch that already finished → cross edge.", p), ''];
case 'parent': return [i18nFmt("%e%: the corridor we just came through — skip it.", p), ''];
case 'seen': return [i18nFmt("%e%: already classified from the other end — skip it.", p), ''];
case 'finish': return [i18nFmt("%u% has no unexplored edges → finish it (f = %f%) and backtrack.", p), ''];
}
return ['', ''];
}
function showResult() {
if (S.done) {
if (G.undirected) {
resultEl.textContent = i18nFmt("Every cell reached. The blue tree edges form a spanning tree of the maze; back edges closing a loop: %n%.", { n: S.backEdges.length });
resultEl.className = 'result ok';
} else if (S.backEdges.length) {
resultEl.textContent = i18nFmt("Back edge %e% closes a cycle, so no topological order exists.", { e: edgeText(S.backEdges[0]) });
resultEl.className = 'result bad';
} else {
resultEl.textContent = i18nFmt("No back edge, so this is a DAG. Topological order (reverse finish times): %order%", { order: S.finished.slice().reverse().join(' ') });
resultEl.className = 'result ok';
}
} else if (S.backEdges.length) {
resultEl.textContent = G.undirected ? "⟲ Loop found (a back edge)" : "⟲ Cycle found (a back edge)";
resultEl.className = 'result bad';
} else {
resultEl.textContent = '';
resultEl.className = 'result';
}
}
function step() {
const ev = S.step();
if (ev.type === 'done') { stop(); say("Search complete: every node is black.", 'ok'); stepBtn.disabled = true; runBtn.disabled = true; }
else { const [t, c] = describe(ev); say(t, c); }
render();
showResult();
}
function stop() { if (timer) { clearInterval(timer); timer = null; } runBtn.textContent = "Run"; }
// Run calls step() on a timer until the search is done.
function toggleRun() {
if (timer) { stop(); return; }
runBtn.textContent = "Pause";
timer = setInterval(step, 650);
step();
}
function reset() {
stop();
G = PRESETS[presetSel.value] || PRESETS.dag;
S = makeDFS(G);
stepBtn.disabled = false; runBtn.disabled = false;
say("Press Step to begin at node A, or Run to animate.", '');
render();
showResult();
}
stepBtn.addEventListener('click', () => { stop(); step(); });
runBtn.addEventListener('click', toggleRun);
document.getElementById('reset').addEventListener('click', reset);
presetSel.addEventListener('change', reset);
reset();
<p class="hint">Depth-first search dives along one edge after another and backtracks only when it gets stuck. Each node shows <b>discovery/finish</b> times; each edge is coloured by its type as soon as the search inspects it.</p>
<div class="bar">
<label for="preset">Graph:</label>
<select id="preset">
<option value="dag">Acyclic (DAG)</option>
<option value="cycle">With a cycle</option>
<option value="maze">Maze (undirected)</option>
</select>
</div>
<svg id="g" viewBox="0 0 350 248" role="img" aria-label="Graph being explored by depth-first search"></svg>
<div class="stackrow">
<span class="lab">Stack (bottom → top):</span>
<span id="stack" class="stack"></span>
<span id="clock" class="clock"></span>
</div>
<div id="status" class="status">Press Step to begin at node A, or Run to animate.</div>
<div id="result" class="result"></div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span><i class="sw tree"></i>tree</span>
<span><i class="sw back"></i>back</span>
<span><i class="sw forward"></i>forward</span>
<span><i class="sw cross"></i>cross</span>
<span><i class="dot white"></i>unvisited</span>
<span><i class="dot grey"></i>on the stack</span>
<span><i class="dot black"></i>finished</span>
</div>
/* Colour tokens for light and dark mode; edge colours encode the edge type. */
:root { --fg: #1f2937; --muted: #4b5563; --bg: #ffffff; --panel: #f1f5f9; --line: #cbd5e1;
--unseen: #a3adb9; --tree: #2563eb; --back: #dc2626; --forward: #16a34a; --cross: #d97706;
--nwhite: #ffffff; --ngrey: #9ca3af; --nblack: #111827; --nstroke: #334155; --btn: #1d3557; --btnfg: #ffffff; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e5e7eb; --muted: #9ca3af; --bg: #111827; --panel: #1f2937; --line: #374151;
--unseen: #4b5563; --tree: #60a5fa; --back: #f87171; --forward: #4ade80; --cross: #fbbf24;
--nwhite: #f8fafc; --ngrey: #6b7280; --nblack: #020617; --nstroke: #cbd5e1; --btn: #93c5fd; --btnfg: #0b1220; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; }
.hint { font-size: .88rem; color: var(--muted); margin: 0 0 .5rem; line-height: 1.45; }
.bar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .3rem; font-size: .9rem; }
select { font: 14px system-ui, sans-serif; padding: .3rem .4rem; border-radius: 6px; border: 1px solid var(--line);
background: var(--panel); color: var(--fg); max-width: 100%; }
svg { width: 100%; max-width: 520px; height: auto; display: block; background: var(--panel); border-radius: 10px; }
.edge { stroke: var(--unseen); stroke-width: 2; fill: none; transition: stroke .2s; }
.edge.tree { stroke: var(--tree); stroke-width: 3.2; }
.edge.back { stroke: var(--back); stroke-width: 3; stroke-dasharray: 6 4; }
.edge.forward { stroke: var(--forward); stroke-width: 2.6; stroke-dasharray: 2 4; }
.edge.cross { stroke: var(--cross); stroke-width: 2.6; stroke-dasharray: 9 3 2 3; }
.edge.cur { stroke-width: 5; }
.mk-unseen { fill: var(--unseen); } .mk-tree { fill: var(--tree); } .mk-back { fill: var(--back); }
.mk-forward { fill: var(--forward); } .mk-cross { fill: var(--cross); }
.node circle { stroke: var(--nstroke); stroke-width: 2; transition: fill .2s; }
.node.white circle { fill: var(--nwhite); }
.node.grey circle { fill: var(--ngrey); }
.node.black circle { fill: var(--nblack); }
.node.top circle { stroke: var(--tree); stroke-width: 4; }
.node .nm { font: 700 14px system-ui, sans-serif; text-anchor: middle; dominant-baseline: central; fill: #111827; }
.node.black .nm { fill: #f8fafc; }
.node .tm { font: 600 11px ui-monospace, monospace; text-anchor: middle; fill: var(--fg); }
.stackrow { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; margin: .45rem 0 .2rem; font-size: .85rem; }
.lab { font-weight: 700; }
.stack { display: inline-flex; gap: 3px; flex-wrap: wrap; min-height: 1.6em; }
.chip { display: inline-block; min-width: 1.6em; text-align: center; padding: .1rem .35rem; border-radius: 5px;
background: var(--ngrey); color: #111827; font: 700 13px ui-monospace, monospace; }
.chip.top { outline: 2px solid var(--tree); }
.chip.none { background: transparent; color: var(--muted); font-weight: 400; }
.clock { margin-left: auto; color: var(--muted); font-family: ui-monospace, monospace; }
.status { font-size: .92rem; font-weight: 600; margin: .3rem 0; min-height: 2.7em; line-height: 1.35; }
.status.bad { color: var(--back); }
.status.ok { color: var(--forward); }
.result { font-size: .9rem; min-height: 1.3em; margin-bottom: .4rem; font-weight: 600; }
.result.bad { color: var(--back); }
.result.ok { color: var(--forward); }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid var(--btn);
background: var(--btn); color: var(--btnfg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--btn); }
button:disabled { opacity: .45; cursor: default; }
.legend { display: flex; flex-wrap: wrap; gap: .3rem .8rem; margin-top: .6rem; font-size: .78rem; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: .3rem; }
.sw { display: inline-block; width: 22px; height: 0; border-top: 3px solid; }
.sw.tree { border-color: var(--tree); } .sw.back { border-color: var(--back); border-top-style: dashed; }
.sw.forward { border-color: var(--forward); border-top-style: dotted; } .sw.cross { border-color: var(--cross); border-top-style: dashed; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--nstroke); }
.dot.white { background: var(--nwhite); } .dot.grey { background: var(--ngrey); } .dot.black { background: var(--nblack); }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Three small graphs. Neighbours are visited in alphabetical order so every run is reproducible.
const PRESETS = {
dag: { undirected: false,
nodes: { A: [40, 40], B: [130, 40], D: [220, 40], G: [310, 40], C: [40, 125], E: [130, 125], H: [220, 125], I: [310, 125], F: [130, 205] },
edges: ['AB', 'AC', 'AE', 'BD', 'BE', 'DG', 'EG', 'EH', 'CE', 'CF', 'FH', 'HI', 'GI'] },
cycle: { undirected: false,
nodes: { A: [40, 40], B: [130, 40], D: [220, 40], G: [310, 40], C: [40, 125], E: [130, 125], H: [220, 125], I: [310, 125], F: [130, 205] },
edges: ['AB', 'AC', 'AE', 'BD', 'BE', 'DG', 'EG', 'EH', 'CE', 'CF', 'FH', 'HI', 'GI', 'HB'] },
maze: { undirected: true,
nodes: { A: [60, 40], B: [175, 40], C: [290, 40], D: [60, 125], E: [175, 125], F: [290, 125], G: [60, 205], H: [175, 205], I: [290, 205] },
edges: ['AB', 'AD', 'BC', 'CF', 'DG', 'GH', 'HI', 'HE', 'EB'] }
};
// Iterative DFS with an explicit stack of frames (node, next-neighbour index). Each call to step() does exactly one thing: start a tree, inspect one edge, or finish one node.
function makeDFS(G) {
const names = Object.keys(G.nodes).sort();
const adj = {};
names.forEach(n => { adj[n] = []; });
G.edges.forEach((e, id) => {
adj[e[0]].push({ v: e[1], id });
if (G.undirected) adj[e[1]].push({ v: e[0], id });
});
names.forEach(n => adj[n].sort((a, b) => (a.v < b.v ? -1 : a.v > b.v ? 1 : a.id - b.id)));
const S = { names, adj, color: {}, d: {}, f: {}, kind: {}, stack: [], time: 0, finished: [], backEdges: [], done: false, cur: null };
names.forEach(n => { S.color[n] = 'white'; });
// A frame remembers which neighbour to try next, so we never push the same node twice.
function discover(u, pe) { S.d[u] = ++S.time; S.color[u] = 'grey'; S.stack.push({ u, i: 0, pe }); }
S.step = function () {
S.cur = null;
if (S.done) return { type: 'done' };
if (!S.stack.length) {
const r = names.find(n => S.color[n] === 'white');
if (r === undefined) { S.done = true; return { type: 'done' }; }
discover(r, -1);
return { type: 'start', u: r, d: S.d[r] };
}
const fr = S.stack[S.stack.length - 1];
const u = fr.u;
if (fr.i < adj[u].length) {
const { v, id } = adj[u][fr.i++];
S.cur = id;
// Undirected graphs: the edge back to the parent and edges already seen from the other end are not new edges.
if (G.undirected && id === fr.pe) return { type: 'parent', u, v, id };
if (G.undirected && S.kind[id]) return { type: 'seen', u, v, id };
// White = tree edge, grey = back edge (cycle), black = forward if v was discovered after u, otherwise cross.
const c = S.color[v];
if (c === 'white') { S.kind[id] = 'tree'; discover(v, id); return { type: 'tree', u, v, id, d: S.d[v] }; }
if (c === 'grey') { S.kind[id] = 'back'; S.backEdges.push(id); return { type: 'back', u, v, id }; }
const k = S.d[u] < S.d[v] ? 'forward' : 'cross';
S.kind[id] = k;
return { type: k, u, v, id };
}
// No neighbours left: assign the finish time, colour the node black and pop it (backtrack).
S.stack.pop();
S.f[u] = ++S.time;
S.color[u] = 'black';
S.finished.push(u);
return { type: 'finish', u, f: S.f[u] };
};
return S;
}
const NS = 'http://www.w3.org/2000/svg';
const R = 15;
const svg = document.getElementById('g');
const presetSel = document.getElementById('preset');
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const runBtn = document.getElementById('run');
const stepBtn = document.getElementById('step');
let G = PRESETS.dag, S = null, timer = null;
function el(tag, attrs, parent) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(e);
return e;
}
const sep = () => (G.undirected ? '–' : '→');
const edgeText = (id) => G.edges[id][0] + sep() + G.edges[id][1];
// Draw edges, then nodes with their discovery/finish stamps, then the stack.
function render() {
svg.innerHTML = '';
const defs = el('defs', {}, svg);
['unseen', 'tree', 'back', 'forward', 'cross'].forEach(k => {
const m = el('marker', { id: 'm-' + k, viewBox: '0 0 10 10', refX: '9', refY: '5', markerWidth: '6', markerHeight: '6', orient: 'auto-start-reverse' }, defs);
el('path', { d: 'M0,0 L10,5 L0,10 z', class: 'mk-' + k }, m);
});
const top = S.stack.length ? S.stack[S.stack.length - 1].u : null;
G.edges.forEach((e, id) => {
const [x1, y1] = G.nodes[e[0]], [x2, y2] = G.nodes[e[1]];
const len = Math.hypot(x2 - x1, y2 - y1), ux = (x2 - x1) / len, uy = (y2 - y1) / len;
// Trim each line so it stops at the circle border (and leaves room for the arrowhead).
const pad = G.undirected ? R : R + 2;
const k = S.kind[id] || 'unseen';
const attrs = { x1: x1 + ux * R, y1: y1 + uy * R, x2: x2 - ux * pad, y2: y2 - uy * pad,
class: 'edge ' + k + (S.cur === id ? ' cur' : '') };
if (!G.undirected) attrs['marker-end'] = 'url(#m-' + k + ')';
el('line', attrs, svg);
});
S.names.forEach(n => {
const [x, y] = G.nodes[n];
const g = el('g', { class: 'node ' + S.color[n] + (n === top ? ' top' : '') }, svg);
el('circle', { cx: x, cy: y, r: R }, g);
el('text', { x, y, class: 'nm' }, g).textContent = n;
if (S.d[n]) el('text', { x, y: y + R + 13, class: 'tm' }, g).textContent = S.d[n] + '/' + (S.f[n] || '·');
});
const st = document.getElementById('stack');
st.innerHTML = '';
if (!S.stack.length) {
const c = document.createElement('span'); c.className = 'chip none'; c.textContent = "empty"; st.appendChild(c);
}
S.stack.forEach((fr, i) => {
const c = document.createElement('span');
c.className = 'chip' + (i === S.stack.length - 1 ? ' top' : '');
c.textContent = fr.u; st.appendChild(c);
});
document.getElementById('clock').textContent = i18nFmt("time = %t%", { t: S.time });
}
function say(text, cls) { statusEl.textContent = text; statusEl.className = 'status ' + (cls || ''); }
function describe(ev) {
const e = ev.id !== undefined ? edgeText(ev.id) : '';
const p = { e, u: ev.u, v: ev.v, d: ev.d, f: ev.f };
switch (ev.type) {
case 'start': return [i18nFmt("The stack is empty — start a new DFS tree at %u% (d = %d%).", p), ''];
case 'tree': return [i18nFmt("%e%: %v% is white → tree edge. Go deeper (d = %d%).", p), ''];
case 'back': return [i18nFmt("%e%: %v% is grey, still on the stack → back edge. Cycle found!", p), 'bad'];
case 'forward': return [i18nFmt("%e%: %v% is black and a descendant of %u% → forward edge.", p), ''];
case 'cross': return [i18nFmt("%e%: %v% is black in a branch that already finished → cross edge.", p), ''];
case 'parent': return [i18nFmt("%e%: the corridor we just came through — skip it.", p), ''];
case 'seen': return [i18nFmt("%e%: already classified from the other end — skip it.", p), ''];
case 'finish': return [i18nFmt("%u% has no unexplored edges → finish it (f = %f%) and backtrack.", p), ''];
}
return ['', ''];
}
function showResult() {
if (S.done) {
if (G.undirected) {
resultEl.textContent = i18nFmt("Every cell reached. The blue tree edges form a spanning tree of the maze; back edges closing a loop: %n%.", { n: S.backEdges.length });
resultEl.className = 'result ok';
} else if (S.backEdges.length) {
resultEl.textContent = i18nFmt("Back edge %e% closes a cycle, so no topological order exists.", { e: edgeText(S.backEdges[0]) });
resultEl.className = 'result bad';
} else {
resultEl.textContent = i18nFmt("No back edge, so this is a DAG. Topological order (reverse finish times): %order%", { order: S.finished.slice().reverse().join(' ') });
resultEl.className = 'result ok';
}
} else if (S.backEdges.length) {
resultEl.textContent = G.undirected ? "⟲ Loop found (a back edge)" : "⟲ Cycle found (a back edge)";
resultEl.className = 'result bad';
} else {
resultEl.textContent = '';
resultEl.className = 'result';
}
}
function step() {
const ev = S.step();
if (ev.type === 'done') { stop(); say("Search complete: every node is black.", 'ok'); stepBtn.disabled = true; runBtn.disabled = true; }
else { const [t, c] = describe(ev); say(t, c); }
render();
showResult();
}
function stop() { if (timer) { clearInterval(timer); timer = null; } runBtn.textContent = "Run"; }
// Run calls step() on a timer until the search is done.
function toggleRun() {
if (timer) { stop(); return; }
runBtn.textContent = "Pause";
timer = setInterval(step, 650);
step();
}
function reset() {
stop();
G = PRESETS[presetSel.value] || PRESETS.dag;
S = makeDFS(G);
stepBtn.disabled = false; runBtn.disabled = false;
say("Press Step to begin at node A, or Run to animate.", '');
render();
showResult();
}
stepBtn.addEventListener('click', () => { stop(); step(); });
runBtn.addEventListener('click', toggleRun);
document.getElementById('reset').addEventListener('click', reset);
presetSel.addEventListener('change', reset);
reset();
Comments
Loading comments...