Draw a directed graph and let Tarjan's algorithm find its strongly connected components. Click two nodes to add a directed edge between them, then press Find SCCs. Each colour is one component; nodes that form no cycle appear alone.
<p class="hint">Click two nodes to add a directed edge. Click an existing edge to remove it. Press <b>Find SCCs</b> to run Tarjan's algorithm โ each colour is one component.</p>
<canvas id="canvas" width="480" height="320"></canvas>
<div class="controls">
<button id="run" type="button">Find SCCs</button>
<button id="reset" type="button" class="ghost">Reset graph</button>
<button id="example" type="button" class="ghost">Load example</button>
</div>
<div class="status" id="status">Click two nodes to add a directed edge, then Find SCCs.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 10px;
background: #f7f9fb; cursor: crosshair; max-width: 100%; touch-action: none; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
button { font: 600 14px system-ui; padding: .42rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; margin-top: .5rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Tarjan's SCC algorithm โ interactive directed-graph demo.
// Nodes are fixed circles; click two nodes to toggle an edge.
// "Find SCCs" runs Tarjan and colours each component.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// SCC palette: 8 distinct colours
const PALETTE = ['#e63946','#2a9d8f','#e9c46a','#457b9d',
'#f4a261','#6d2b8f','#3d9a50','#c0392b'];
// 7 fixed node positions
const NODES = [
{x:80, y:80},
{x:240, y:60},
{x:400, y:80},
{x:80, y:240},
{x:240, y:200},
{x:400, y:240},
{x:240, y:300}
];
const N = NODES.length;
const R = 22; // node radius
// Adjacency: edges[u] = Set of v
let edges = Array.from({length: N}, () => new Set());
// SCC colours for each node (-1 = not computed)
let sccColor = new Array(N).fill(-1);
let sccCount = 0;
// Interaction state: first clicked node for edge creation
let firstClick = -1;
function setStatus(msg, cls='') {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawArrow(x1, y1, x2, y2, color) {
// Shorten line so arrowhead touches the circle boundary
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const ux = dx / len, uy = dy / len;
const sx = x1 + ux * R, sy = y1 + uy * R;
const ex = x2 - ux * (R + 4), ey = y2 - uy * (R + 4);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
// Arrowhead
const angle = Math.atan2(ey - sy, ex - sx);
const hw = 7, hl = 12;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - hl * Math.cos(angle - 0.4), ey - hl * Math.sin(angle - 0.4));
ctx.lineTo(ex - hl * Math.cos(angle + 0.4), ey - hl * Math.sin(angle + 0.4));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function render() {
ctx.clearRect(0, 0, W, H);
// Draw edges
for (let u = 0; u < N; u++) {
for (const v of edges[u]) {
const col = sccColor[u] >= 0 && sccColor[u] === sccColor[v]
? PALETTE[sccColor[u] % PALETTE.length] : '#8899aa';
drawArrow(NODES[u].x, NODES[u].y, NODES[v].x, NODES[v].y, col);
}
}
// Draw nodes
NODES.forEach((n, i) => {
const col = sccColor[i] >= 0 ? PALETTE[sccColor[i] % PALETTE.length] : '#1d3557';
ctx.beginPath();
ctx.arc(n.x, n.y, R, 0, Math.PI * 2);
ctx.fillStyle = sccColor[i] >= 0 ? col + '33' : '#e8eef3';
ctx.fill();
ctx.strokeStyle = col;
ctx.lineWidth = i === firstClick ? 3.5 : 2;
ctx.stroke();
ctx.fillStyle = col;
ctx.font = 'bold 14px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String.fromCharCode(65 + i), n.x, n.y);
});
}
// โโ Tarjan's SCC โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function tarjan() {
const index = new Array(N).fill(-1);
const lowlink= new Array(N).fill(0);
const onStack= new Array(N).fill(false);
const stack = [];
let idx = 0;
const comp = new Array(N).fill(-1);
let numComp = 0;
function strongconnect(v) {
index[v] = lowlink[v] = idx++;
stack.push(v);
onStack[v] = true;
for (const w of edges[v]) {
if (index[w] === -1) {
strongconnect(w);
lowlink[v] = Math.min(lowlink[v], lowlink[w]);
} else if (onStack[w]) {
lowlink[v] = Math.min(lowlink[v], index[w]);
}
}
if (lowlink[v] === index[v]) {
// Pop an SCC
let w;
do {
w = stack.pop();
onStack[w] = false;
comp[w] = numComp;
} while (w !== v);
numComp++;
}
}
for (let v = 0; v < N; v++) {
if (index[v] === -1) strongconnect(v);
}
return { comp, numComp };
}
// โโ Interaction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function hitNode(x, y) {
for (let i = 0; i < N; i++) {
const dx = NODES[i].x - x, dy = NODES[i].y - y;
if (dx * dx + dy * dy <= R * R) return i;
}
return -1;
}
function scaleXY(evt) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = evt.touches ? evt.touches[0] : evt;
return [(src.clientX - rect.left) * scaleX,
(src.clientY - rect.top) * scaleY];
}
function handleClick(x, y) {
const hit = hitNode(x, y);
if (hit < 0) { firstClick = -1; render(); return; }
if (firstClick < 0) {
firstClick = hit;
render();
setStatus(fmt('Now click a second node to add edge %fromCharCode% โ', { fromCharCode: String.fromCharCode(65 + hit) }));
return;
}
const u = firstClick, v = hit;
firstClick = -1;
sccColor.fill(-1); // reset colours on graph change
sccCount = 0;
if (u === v) { render(); setStatus('Self-loops are allowed but do not create SCCs.'); return; }
if (edges[u].has(v)) {
edges[u].delete(v);
setStatus(fmt('Removed edge %fromCharCode% โ %fromCharCode2%.', { fromCharCode: String.fromCharCode(65+u), fromCharCode2: String.fromCharCode(65+v) }));
} else {
edges[u].add(v);
setStatus(fmt('Added edge %fromCharCode% โ %fromCharCode2%. Press Find SCCs.', { fromCharCode: String.fromCharCode(65+u), fromCharCode2: String.fromCharCode(65+v) }));
}
render();
}
canvas.addEventListener('click', e => {
const [x, y] = scaleXY(e);
handleClick(x, y);
});
canvas.addEventListener('touchend', e => {
e.preventDefault();
const [x, y] = scaleXY(e.changedTouches ? {touches:[e.changedTouches[0]]} : e);
handleClick(x, y);
}, {passive: false});
document.getElementById('run').addEventListener('click', () => {
const {comp, numComp} = tarjan();
sccColor = comp;
sccCount = numComp;
render();
// Summarise
const groups = Array.from({length: numComp}, () => []);
comp.forEach((c, i) => groups[c].push(String.fromCharCode(65 + i)));
const singletons = groups.filter(g => g.length === 1).length;
const multis = groups.filter(g => g.length > 1);
let msg = fmt('%numComp% SCC%v% found.', { numComp: numComp, v: numComp > 1 ? 's' : '' });
if (multis.length) msg += " " + fmt('Cycles: %map%.', { map: multis.map(g => '{' + g.join(',') + '}').join(', ') });
setStatus(msg, 'ok');
console.log('SCCs:', groups.map((g,i)=>'SCC'+i+'='+g.join(',')).join(' | '));
});
document.getElementById('reset').addEventListener('click', () => {
edges = Array.from({length: N}, () => new Set());
sccColor.fill(-1); firstClick = -1;
render();
setStatus('Graph cleared. Click two nodes to add an edge.');
});
document.getElementById('example').addEventListener('click', () => {
edges = Array.from({length: N}, () => new Set());
sccColor.fill(-1); firstClick = -1;
// Example with 3 SCCs: {A,B,D}, {C,E,F}, {G}
// AโB, BโD, DโA (cycle)
edges[0].add(1); edges[1].add(3); edges[3].add(0);
// CโE, EโF, FโC (cycle)
edges[2].add(4); edges[4].add(5); edges[5].add(2);
// cross edges
edges[1].add(2); edges[4].add(6);
render();
setStatus('Example loaded. Press Find SCCs to colour the components.');
});
render();
setStatus('Click two nodes to add a directed edge, then Find SCCs.');
<p class="hint">Click two nodes to add a directed edge. Click an existing edge to remove it. Press <b>Find SCCs</b> to run Tarjan's algorithm โ each colour is one component.</p>
<canvas id="canvas" width="480" height="320"></canvas>
<div class="controls">
<button id="run" type="button">Find SCCs</button>
<button id="reset" type="button" class="ghost">Reset graph</button>
<button id="example" type="button" class="ghost">Load example</button>
</div>
<div class="status" id="status">Click two nodes to add a directed edge, then Find SCCs.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 10px;
background: #f7f9fb; cursor: crosshair; max-width: 100%; touch-action: none; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
button { font: 600 14px system-ui; padding: .42rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; margin-top: .5rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Tarjan's SCC algorithm โ interactive directed-graph demo.
// Nodes are fixed circles; click two nodes to toggle an edge.
// "Find SCCs" runs Tarjan and colours each component.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// SCC palette: 8 distinct colours
const PALETTE = ['#e63946','#2a9d8f','#e9c46a','#457b9d',
'#f4a261','#6d2b8f','#3d9a50','#c0392b'];
// 7 fixed node positions
const NODES = [
{x:80, y:80},
{x:240, y:60},
{x:400, y:80},
{x:80, y:240},
{x:240, y:200},
{x:400, y:240},
{x:240, y:300}
];
const N = NODES.length;
const R = 22; // node radius
// Adjacency: edges[u] = Set of v
let edges = Array.from({length: N}, () => new Set());
// SCC colours for each node (-1 = not computed)
let sccColor = new Array(N).fill(-1);
let sccCount = 0;
// Interaction state: first clicked node for edge creation
let firstClick = -1;
function setStatus(msg, cls='') {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawArrow(x1, y1, x2, y2, color) {
// Shorten line so arrowhead touches the circle boundary
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const ux = dx / len, uy = dy / len;
const sx = x1 + ux * R, sy = y1 + uy * R;
const ex = x2 - ux * (R + 4), ey = y2 - uy * (R + 4);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
// Arrowhead
const angle = Math.atan2(ey - sy, ex - sx);
const hw = 7, hl = 12;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - hl * Math.cos(angle - 0.4), ey - hl * Math.sin(angle - 0.4));
ctx.lineTo(ex - hl * Math.cos(angle + 0.4), ey - hl * Math.sin(angle + 0.4));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function render() {
ctx.clearRect(0, 0, W, H);
// Draw edges
for (let u = 0; u < N; u++) {
for (const v of edges[u]) {
const col = sccColor[u] >= 0 && sccColor[u] === sccColor[v]
? PALETTE[sccColor[u] % PALETTE.length] : '#8899aa';
drawArrow(NODES[u].x, NODES[u].y, NODES[v].x, NODES[v].y, col);
}
}
// Draw nodes
NODES.forEach((n, i) => {
const col = sccColor[i] >= 0 ? PALETTE[sccColor[i] % PALETTE.length] : '#1d3557';
ctx.beginPath();
ctx.arc(n.x, n.y, R, 0, Math.PI * 2);
ctx.fillStyle = sccColor[i] >= 0 ? col + '33' : '#e8eef3';
ctx.fill();
ctx.strokeStyle = col;
ctx.lineWidth = i === firstClick ? 3.5 : 2;
ctx.stroke();
ctx.fillStyle = col;
ctx.font = 'bold 14px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String.fromCharCode(65 + i), n.x, n.y);
});
}
// โโ Tarjan's SCC โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function tarjan() {
const index = new Array(N).fill(-1);
const lowlink= new Array(N).fill(0);
const onStack= new Array(N).fill(false);
const stack = [];
let idx = 0;
const comp = new Array(N).fill(-1);
let numComp = 0;
function strongconnect(v) {
index[v] = lowlink[v] = idx++;
stack.push(v);
onStack[v] = true;
for (const w of edges[v]) {
if (index[w] === -1) {
strongconnect(w);
lowlink[v] = Math.min(lowlink[v], lowlink[w]);
} else if (onStack[w]) {
lowlink[v] = Math.min(lowlink[v], index[w]);
}
}
if (lowlink[v] === index[v]) {
// Pop an SCC
let w;
do {
w = stack.pop();
onStack[w] = false;
comp[w] = numComp;
} while (w !== v);
numComp++;
}
}
for (let v = 0; v < N; v++) {
if (index[v] === -1) strongconnect(v);
}
return { comp, numComp };
}
// โโ Interaction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function hitNode(x, y) {
for (let i = 0; i < N; i++) {
const dx = NODES[i].x - x, dy = NODES[i].y - y;
if (dx * dx + dy * dy <= R * R) return i;
}
return -1;
}
function scaleXY(evt) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = evt.touches ? evt.touches[0] : evt;
return [(src.clientX - rect.left) * scaleX,
(src.clientY - rect.top) * scaleY];
}
function handleClick(x, y) {
const hit = hitNode(x, y);
if (hit < 0) { firstClick = -1; render(); return; }
if (firstClick < 0) {
firstClick = hit;
render();
setStatus(fmt('Now click a second node to add edge %fromCharCode% โ', { fromCharCode: String.fromCharCode(65 + hit) }));
return;
}
const u = firstClick, v = hit;
firstClick = -1;
sccColor.fill(-1); // reset colours on graph change
sccCount = 0;
if (u === v) { render(); setStatus('Self-loops are allowed but do not create SCCs.'); return; }
if (edges[u].has(v)) {
edges[u].delete(v);
setStatus(fmt('Removed edge %fromCharCode% โ %fromCharCode2%.', { fromCharCode: String.fromCharCode(65+u), fromCharCode2: String.fromCharCode(65+v) }));
} else {
edges[u].add(v);
setStatus(fmt('Added edge %fromCharCode% โ %fromCharCode2%. Press Find SCCs.', { fromCharCode: String.fromCharCode(65+u), fromCharCode2: String.fromCharCode(65+v) }));
}
render();
}
canvas.addEventListener('click', e => {
const [x, y] = scaleXY(e);
handleClick(x, y);
});
canvas.addEventListener('touchend', e => {
e.preventDefault();
const [x, y] = scaleXY(e.changedTouches ? {touches:[e.changedTouches[0]]} : e);
handleClick(x, y);
}, {passive: false});
document.getElementById('run').addEventListener('click', () => {
const {comp, numComp} = tarjan();
sccColor = comp;
sccCount = numComp;
render();
// Summarise
const groups = Array.from({length: numComp}, () => []);
comp.forEach((c, i) => groups[c].push(String.fromCharCode(65 + i)));
const singletons = groups.filter(g => g.length === 1).length;
const multis = groups.filter(g => g.length > 1);
let msg = fmt('%numComp% SCC%v% found.', { numComp: numComp, v: numComp > 1 ? 's' : '' });
if (multis.length) msg += " " + fmt('Cycles: %map%.', { map: multis.map(g => '{' + g.join(',') + '}').join(', ') });
setStatus(msg, 'ok');
console.log('SCCs:', groups.map((g,i)=>'SCC'+i+'='+g.join(',')).join(' | '));
});
document.getElementById('reset').addEventListener('click', () => {
edges = Array.from({length: N}, () => new Set());
sccColor.fill(-1); firstClick = -1;
render();
setStatus('Graph cleared. Click two nodes to add an edge.');
});
document.getElementById('example').addEventListener('click', () => {
edges = Array.from({length: N}, () => new Set());
sccColor.fill(-1); firstClick = -1;
// Example with 3 SCCs: {A,B,D}, {C,E,F}, {G}
// AโB, BโD, DโA (cycle)
edges[0].add(1); edges[1].add(3); edges[3].add(0);
// CโE, EโF, FโC (cycle)
edges[2].add(4); edges[4].add(5); edges[5].add(2);
// cross edges
edges[1].add(2); edges[4].add(6);
render();
setStatus('Example loaded. Press Find SCCs to colour the components.');
});
render();
setStatus('Click two nodes to add a directed edge, then Find SCCs.');
Notice how nodes on a directed cycle always land in the same colour. Add a back-edge to link two previously separate groups and they merge into one large SCC. The algorithm never restarts the DFS โ it sweeps the whole graph exactly once, using the low-link trick to spot the root of each component on the fly.
Comments
Loading comments...