<div class="controls">
<label for="strInput">String:</label>
<input id="strInput" type="text" value="banana" maxlength="12" spellcheck="false" autocomplete="off" />
<button id="btnReset" type="button">Reset</button>
<button id="btnStep" type="button">Step →</button>
<button id="btnAll" type="button">Build all</button>
</div>
<div class="progress-row">
<span id="progressLabel">Ready. Press Step or Build all.</span>
</div>
<div class="canvas-wrap">
<canvas id="treeCanvas" width="560" height="300"></canvas>
</div>
<div class="info-row">
<span id="infoLabel"></span>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
label { font-size: .85rem; font-weight: 600; }
input[type=text] { font: 700 15px ui-monospace, monospace; padding: .3rem .5rem; border: 1px solid #adb1b8;
border-radius: 6px; width: 130px; letter-spacing: .05em; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button#btnReset { background: #fff; color: #1d3557; }
.progress-row { font-size: .82rem; color: #555; margin-bottom: .3rem; min-height: 1.3em; }
.canvas-wrap { background: #f4f7fa; border: 1px solid #dde3ea; border-radius: 10px;
overflow: hidden; width: 100%; }
canvas { display: block; width: 100%; height: auto; }
.info-row { font-size: .8rem; color: #666; margin-top: .4rem; min-height: 1.2em; font-style: italic; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simplified Ukkonen's suffix tree — step-by-step visualizer.
// We build the tree phase by phase (one character at a time) and
// draw the resulting tree after each phase using a canvas.
// For correctness we implement the three rules exactly:
// Rule 1: leaf edges grow automatically (open end).
// Rule 2: create new internal node + new leaf when mismatch.
// Rule 3: path exists — stop (remainder > 0, move to next phase).
const INF = 1e9;
class Node {
constructor(start, end) {
this.start = start;
this.end = end; // number or reference to {val} for leaves
this.children = {}; // char -> node index
this.suffixLink = 0; // index (0 = root)
this.id = -1;
}
edgeLen(phase) {
return Math.min(typeof this.end === 'object' ? phase + 1 : this.end, INF) - this.start;
}
}
let nodes, text, phase, remainder, activeNode, activeEdge, activeLength;
let built = false;
function init() {
const s = document.getElementById('strInput').value.replace(/[^a-z0-9]/gi,'');
if (!s) return;
// Append terminal character
text = s + '
Notice how adding one character never rebuilds the whole tree — the algorithm only updates the edges and nodes affected by that new character. The suffix links (dashed arrows) are the secret: they tell the algorithm exactly where to continue after creating a new internal node, avoiding redundant traversals. The total number of node and edge operations across the entire string is O(n).
;
nodes = [];
// node 0 = root
nodes.push(new Node(-1, -1)); nodes[0].id = 0;
phase = -1;
remainder = 0;
activeNode = 0;
activeEdge = -1;
activeLength = 0;
built = false;
document.getElementById('progressLabel').textContent = 'Ready. Press Step or Build all.';
document.getElementById('infoLabel').textContent = '';
drawTree();
}
function newNode(start, end) {
const n = new Node(start, end);
n.id = nodes.length;
nodes.push(n);
return n.id;
}
// Walk down from node using activeEdge/activeLength (canonicalize)
function walkDown(nodeIdx) {
const n = nodes[nodeIdx];
const len = n.edgeLen(phase);
if (activeLength >= len) {
activeEdge += len;
activeLength -= len;
activeNode = nodeIdx;
return true;
}
return false;
}
function step() {
if (built) return;
phase++;
if (phase >= text.length) { built = true; return; }
const c = text[phase];
// Extend all leaves (implicit via open end — tracked by `phase`)
remainder++;
let lastNewInternal = -1;
while (remainder > 0) {
if (activeLength === 0) activeEdge = phase; // active edge = current char
const ae = text[activeEdge];
if (nodes[activeNode].children[ae] === undefined) {
// Rule 2: no edge from activeNode starting with ae — create leaf
const leaf = newNode(phase, {val: INF}); // open end
nodes[activeNode].children[ae] = leaf;
if (lastNewInternal !== -1) {
nodes[lastNewInternal].suffixLink = activeNode;
lastNewInternal = -1;
}
} else {
// Edge exists
let next = nodes[activeNode].children[ae];
if (walkDown(next)) continue; // Rule 3 shortcut: re-check after walk-down
const nextNode = nodes[next];
if (text[nextNode.start + activeLength] === c) {
// Rule 3: character already on edge — just extend activeLength
activeLength++;
if (lastNewInternal !== -1) {
nodes[lastNewInternal].suffixLink = activeNode;
}
break; // stop for this phase
}
// Rule 2: split edge
const splitEnd = nextNode.start + activeLength;
const split = newNode(nextNode.start, splitEnd);
nodes[activeNode].children[ae] = split;
// New leaf for current character
const leaf = newNode(phase, {val: INF});
nodes[split].children[c] = leaf;
// Old edge continues from split point
nextNode.start = splitEnd;
nodes[split].children[text[splitEnd]] = next;
if (lastNewInternal !== -1) {
nodes[lastNewInternal].suffixLink = split;
}
lastNewInternal = split;
}
remainder--;
if (activeNode === 0 && activeLength > 0) {
activeLength--;
activeEdge = phase - remainder + 1;
} else if (nodes[activeNode].suffixLink > 0) {
activeNode = nodes[activeNode].suffixLink;
} else {
activeNode = 0;
}
}
updateProgress();
drawTree();
}
function buildAll() {
while (!built && phase < text.length - 1) step();
if (!built) step(); // last step (terminal $)
}
function updateProgress() {
const done = phase + 1;
const total = text.length;
const shown = text.slice(0, done);
document.getElementById('progressLabel').textContent =
fmt('Phase %done%/%total%: processed "%shown%" — nodes: %length%, remainder: %remainder%', { done: done, total: total, shown: shown, length: nodes.length, remainder: remainder });
document.getElementById('infoLabel').textContent =
phase < text.length - 1
? fmt('Active node: %activeNode%, active edge char: "%v%", active length: %activeLength%', { activeNode: activeNode, v: text[activeEdge] || '—', activeLength: activeLength })
: 'Tree complete. Every suffix is a root-to-leaf path.';
}
// ── Drawing ──────────────────────────────────────────────────────────────────
function drawTree() {
const canvas = document.getElementById('treeCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// BFS to assign positions
const pos = {}; // nodeId -> {x,y}
// Count leaves under each node for width allocation
function countLeaves(id) {
const n = nodes[id];
const ckeys = Object.keys(n.children);
if (ckeys.length === 0) return 1;
let s = 0;
for (const k of ckeys) s += countLeaves(n.children[k]);
return s;
}
const totalLeaves = Math.max(countLeaves(0), 1);
const MARGIN = 28, TOP = 24, BOT = H - 20;
function layout(id, xL, xR, depth, maxDepth) {
const x = (xL + xR) / 2;
const y = TOP + (depth / maxDepth) * (BOT - TOP);
pos[id] = {x, y};
const n = nodes[id];
const ckeys = Object.keys(n.children).sort();
let cx = xL;
for (const k of ckeys) {
const childId = n.children[k];
const frac = countLeaves(childId) / totalLeaves;
layout(childId, cx, cx + frac * (W - 2 * MARGIN), depth + 1, maxDepth);
cx += frac * (W - 2 * MARGIN);
}
}
// Compute tree depth
function maxDepth(id, d) {
const n = nodes[id];
const ckeys = Object.keys(n.children);
if (ckeys.length === 0) return d;
return Math.max(...ckeys.map(k => maxDepth(n.children[k], d + 1)));
}
const md = Math.max(maxDepth(0, 0), 1);
layout(0, MARGIN, W - MARGIN, 0, md);
// Draw edges
ctx.lineWidth = 1.4;
function drawEdges(id) {
const n = nodes[id];
const {x: px, y: py} = pos[id];
for (const k of Object.keys(n.children)) {
const childId = n.children[k];
const {x: cx, y: cy} = pos[childId];
ctx.strokeStyle = '#7090b0';
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(cx, cy); ctx.stroke();
// Edge label
const child = nodes[childId];
const end = typeof child.end === 'object' ? phase : child.end;
const label = text.slice(child.start, end + 1);
const mx = (px + cx) / 2, my = (py + cy) / 2;
ctx.save();
ctx.font = '10px ui-monospace, monospace';
ctx.fillStyle = '#1d3557';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label.length > 8 ? label.slice(0, 7) + '…' : label, mx, my);
ctx.restore();
drawEdges(childId);
}
}
drawEdges(0);
// Draw suffix links (dashed)
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#e08c30';
ctx.lineWidth = 1;
for (const n of nodes) {
if (n.suffixLink > 0 && pos[n.id] && pos[n.suffixLink]) {
const {x: ax, y: ay} = pos[n.id];
const {x: bx, y: by} = pos[n.suffixLink];
ctx.beginPath();
ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
ctx.stroke();
}
}
ctx.setLineDash([]);
// Draw nodes
for (const n of nodes) {
if (!pos[n.id]) continue;
const {x, y} = pos[n.id];
const isLeaf = Object.keys(n.children).length === 0;
const isActive = n.id === activeNode;
ctx.beginPath();
ctx.arc(x, y, isLeaf ? 4 : 6, 0, 2 * Math.PI);
ctx.fillStyle = isActive ? '#e63946' : (isLeaf ? '#9fb2c8' : '#1d3557');
ctx.fill();
if (!isLeaf) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
// Legend
ctx.font = '10px system-ui';
ctx.fillStyle = '#888';
ctx.textAlign = 'left';
ctx.fillText('● active node', 8, H - 8);
ctx.fillStyle = '#e08c30';
ctx.fillText('— suffix link', 95, H - 8);
}
// Wire up
document.getElementById('btnReset').addEventListener('click', init);
document.getElementById('btnStep').addEventListener('click', step);
document.getElementById('btnAll').addEventListener('click', buildAll);
document.getElementById('strInput').addEventListener('input', init);
init();
<div class="controls">
<label for="strInput">String:</label>
<input id="strInput" type="text" value="banana" maxlength="12" spellcheck="false" autocomplete="off" />
<button id="btnReset" type="button">Reset</button>
<button id="btnStep" type="button">Step →</button>
<button id="btnAll" type="button">Build all</button>
</div>
<div class="progress-row">
<span id="progressLabel">Ready. Press Step or Build all.</span>
</div>
<div class="canvas-wrap">
<canvas id="treeCanvas" width="560" height="300"></canvas>
</div>
<div class="info-row">
<span id="infoLabel"></span>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
label { font-size: .85rem; font-weight: 600; }
input[type=text] { font: 700 15px ui-monospace, monospace; padding: .3rem .5rem; border: 1px solid #adb1b8;
border-radius: 6px; width: 130px; letter-spacing: .05em; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button#btnReset { background: #fff; color: #1d3557; }
.progress-row { font-size: .82rem; color: #555; margin-bottom: .3rem; min-height: 1.3em; }
.canvas-wrap { background: #f4f7fa; border: 1px solid #dde3ea; border-radius: 10px;
overflow: hidden; width: 100%; }
canvas { display: block; width: 100%; height: auto; }
.info-row { font-size: .8rem; color: #666; margin-top: .4rem; min-height: 1.2em; font-style: italic; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simplified Ukkonen's suffix tree — step-by-step visualizer.
// We build the tree phase by phase (one character at a time) and
// draw the resulting tree after each phase using a canvas.
// For correctness we implement the three rules exactly:
// Rule 1: leaf edges grow automatically (open end).
// Rule 2: create new internal node + new leaf when mismatch.
// Rule 3: path exists — stop (remainder > 0, move to next phase).
const INF = 1e9;
class Node {
constructor(start, end) {
this.start = start;
this.end = end; // number or reference to {val} for leaves
this.children = {}; // char -> node index
this.suffixLink = 0; // index (0 = root)
this.id = -1;
}
edgeLen(phase) {
return Math.min(typeof this.end === 'object' ? phase + 1 : this.end, INF) - this.start;
}
}
let nodes, text, phase, remainder, activeNode, activeEdge, activeLength;
let built = false;
function init() {
const s = document.getElementById('strInput').value.replace(/[^a-z0-9]/gi,'');
if (!s) return;
// Append terminal character
text = s + '
Notice how adding one character never rebuilds the whole tree — the algorithm only updates the edges and nodes affected by that new character. The suffix links (dashed arrows) are the secret: they tell the algorithm exactly where to continue after creating a new internal node, avoiding redundant traversals. The total number of node and edge operations across the entire string is O(n).
;
nodes = [];
// node 0 = root
nodes.push(new Node(-1, -1)); nodes[0].id = 0;
phase = -1;
remainder = 0;
activeNode = 0;
activeEdge = -1;
activeLength = 0;
built = false;
document.getElementById('progressLabel').textContent = 'Ready. Press Step or Build all.';
document.getElementById('infoLabel').textContent = '';
drawTree();
}
function newNode(start, end) {
const n = new Node(start, end);
n.id = nodes.length;
nodes.push(n);
return n.id;
}
// Walk down from node using activeEdge/activeLength (canonicalize)
function walkDown(nodeIdx) {
const n = nodes[nodeIdx];
const len = n.edgeLen(phase);
if (activeLength >= len) {
activeEdge += len;
activeLength -= len;
activeNode = nodeIdx;
return true;
}
return false;
}
function step() {
if (built) return;
phase++;
if (phase >= text.length) { built = true; return; }
const c = text[phase];
// Extend all leaves (implicit via open end — tracked by `phase`)
remainder++;
let lastNewInternal = -1;
while (remainder > 0) {
if (activeLength === 0) activeEdge = phase; // active edge = current char
const ae = text[activeEdge];
if (nodes[activeNode].children[ae] === undefined) {
// Rule 2: no edge from activeNode starting with ae — create leaf
const leaf = newNode(phase, {val: INF}); // open end
nodes[activeNode].children[ae] = leaf;
if (lastNewInternal !== -1) {
nodes[lastNewInternal].suffixLink = activeNode;
lastNewInternal = -1;
}
} else {
// Edge exists
let next = nodes[activeNode].children[ae];
if (walkDown(next)) continue; // Rule 3 shortcut: re-check after walk-down
const nextNode = nodes[next];
if (text[nextNode.start + activeLength] === c) {
// Rule 3: character already on edge — just extend activeLength
activeLength++;
if (lastNewInternal !== -1) {
nodes[lastNewInternal].suffixLink = activeNode;
}
break; // stop for this phase
}
// Rule 2: split edge
const splitEnd = nextNode.start + activeLength;
const split = newNode(nextNode.start, splitEnd);
nodes[activeNode].children[ae] = split;
// New leaf for current character
const leaf = newNode(phase, {val: INF});
nodes[split].children[c] = leaf;
// Old edge continues from split point
nextNode.start = splitEnd;
nodes[split].children[text[splitEnd]] = next;
if (lastNewInternal !== -1) {
nodes[lastNewInternal].suffixLink = split;
}
lastNewInternal = split;
}
remainder--;
if (activeNode === 0 && activeLength > 0) {
activeLength--;
activeEdge = phase - remainder + 1;
} else if (nodes[activeNode].suffixLink > 0) {
activeNode = nodes[activeNode].suffixLink;
} else {
activeNode = 0;
}
}
updateProgress();
drawTree();
}
function buildAll() {
while (!built && phase < text.length - 1) step();
if (!built) step(); // last step (terminal $)
}
function updateProgress() {
const done = phase + 1;
const total = text.length;
const shown = text.slice(0, done);
document.getElementById('progressLabel').textContent =
fmt('Phase %done%/%total%: processed "%shown%" — nodes: %length%, remainder: %remainder%', { done: done, total: total, shown: shown, length: nodes.length, remainder: remainder });
document.getElementById('infoLabel').textContent =
phase < text.length - 1
? fmt('Active node: %activeNode%, active edge char: "%v%", active length: %activeLength%', { activeNode: activeNode, v: text[activeEdge] || '—', activeLength: activeLength })
: 'Tree complete. Every suffix is a root-to-leaf path.';
}
// ── Drawing ──────────────────────────────────────────────────────────────────
function drawTree() {
const canvas = document.getElementById('treeCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// BFS to assign positions
const pos = {}; // nodeId -> {x,y}
// Count leaves under each node for width allocation
function countLeaves(id) {
const n = nodes[id];
const ckeys = Object.keys(n.children);
if (ckeys.length === 0) return 1;
let s = 0;
for (const k of ckeys) s += countLeaves(n.children[k]);
return s;
}
const totalLeaves = Math.max(countLeaves(0), 1);
const MARGIN = 28, TOP = 24, BOT = H - 20;
function layout(id, xL, xR, depth, maxDepth) {
const x = (xL + xR) / 2;
const y = TOP + (depth / maxDepth) * (BOT - TOP);
pos[id] = {x, y};
const n = nodes[id];
const ckeys = Object.keys(n.children).sort();
let cx = xL;
for (const k of ckeys) {
const childId = n.children[k];
const frac = countLeaves(childId) / totalLeaves;
layout(childId, cx, cx + frac * (W - 2 * MARGIN), depth + 1, maxDepth);
cx += frac * (W - 2 * MARGIN);
}
}
// Compute tree depth
function maxDepth(id, d) {
const n = nodes[id];
const ckeys = Object.keys(n.children);
if (ckeys.length === 0) return d;
return Math.max(...ckeys.map(k => maxDepth(n.children[k], d + 1)));
}
const md = Math.max(maxDepth(0, 0), 1);
layout(0, MARGIN, W - MARGIN, 0, md);
// Draw edges
ctx.lineWidth = 1.4;
function drawEdges(id) {
const n = nodes[id];
const {x: px, y: py} = pos[id];
for (const k of Object.keys(n.children)) {
const childId = n.children[k];
const {x: cx, y: cy} = pos[childId];
ctx.strokeStyle = '#7090b0';
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(cx, cy); ctx.stroke();
// Edge label
const child = nodes[childId];
const end = typeof child.end === 'object' ? phase : child.end;
const label = text.slice(child.start, end + 1);
const mx = (px + cx) / 2, my = (py + cy) / 2;
ctx.save();
ctx.font = '10px ui-monospace, monospace';
ctx.fillStyle = '#1d3557';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label.length > 8 ? label.slice(0, 7) + '…' : label, mx, my);
ctx.restore();
drawEdges(childId);
}
}
drawEdges(0);
// Draw suffix links (dashed)
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#e08c30';
ctx.lineWidth = 1;
for (const n of nodes) {
if (n.suffixLink > 0 && pos[n.id] && pos[n.suffixLink]) {
const {x: ax, y: ay} = pos[n.id];
const {x: bx, y: by} = pos[n.suffixLink];
ctx.beginPath();
ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
ctx.stroke();
}
}
ctx.setLineDash([]);
// Draw nodes
for (const n of nodes) {
if (!pos[n.id]) continue;
const {x, y} = pos[n.id];
const isLeaf = Object.keys(n.children).length === 0;
const isActive = n.id === activeNode;
ctx.beginPath();
ctx.arc(x, y, isLeaf ? 4 : 6, 0, 2 * Math.PI);
ctx.fillStyle = isActive ? '#e63946' : (isLeaf ? '#9fb2c8' : '#1d3557');
ctx.fill();
if (!isLeaf) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
// Legend
ctx.font = '10px system-ui';
ctx.fillStyle = '#888';
ctx.textAlign = 'left';
ctx.fillText('● active node', 8, H - 8);
ctx.fillStyle = '#e08c30';
ctx.fillText('— suffix link', 95, H - 8);
}
// Wire up
document.getElementById('btnReset').addEventListener('click', init);
document.getElementById('btnStep').addEventListener('click', step);
document.getElementById('btnAll').addEventListener('click', buildAll);
document.getElementById('strInput').addEventListener('input', init);
init();