/* ===== Illustrated Algorithm – Shared Visualization Library ===== */
/* Supports BOTH API styles:
* Style A (string-returning): renderArray(arr, {highlights}) → HTML string
* Style B (DOM-mutating): renderArray(container, items, opts) → mutates container
*/
"use strict";
/* --- Utility --- */
const $ = id => document.getElementById(id);
const $$ = sel => document.querySelectorAll(sel);
/* --- CSS variable defaults --- */
const _CSS = {
blue: '#3b82f6', green: '#16a34a', purple: '#8b5cf6',
red: '#ef4444', orange: '#f59e0b', cyan: '#06b6d4',
text: '#0f172a', text2: '#475569', muted: '#94a3b8',
border: '#e2e8f0', surface2: '#f1f5f9',
mono: 'Menlo,Consolas,monospace',
};
/* ================================================================
StepController — supports BOTH constructor signatures:
new StepController(opts) → Style A (deferred steps)
new StepController(steps, opts) → Style B (immediate steps)
================================================================ */
class StepController {
constructor(stepsOrOpts, maybeOpts) {
// Detect which style
if (Array.isArray(stepsOrOpts)) {
// Style B: new StepController(steps, opts)
this.steps = stepsOrOpts;
this.opts = maybeOpts || {};
} else {
// Style A: new StepController(opts)
this.steps = [];
this.opts = stepsOrOpts || {};
}
this.idx = -1;
this.autoTimer = null;
this.autoDelay = this.opts.autoDelay || this.opts.autoInterval || 800;
this.onRender = this.opts.onRender || this.opts.onStep || (() => {});
this.onDone = this.opts.onDone || this.opts.onComplete || (() => {});
this.stepCounterEl = this.opts.stepCounterEl || null;
}
/* --- Style A helpers --- */
setSteps(steps) { this.steps = steps; this.idx = -1; this.updateCounter(); }
set onStep(fn) { this.onRender = fn; }
get onStep() { return this.onRender; }
/* --- Navigation --- */
next() {
if (this.idx < this.steps.length - 1) { this.idx++; this._render(); }
else this.stopAuto();
}
prev() {
if (this.idx > 0) { this.idx--; this._render(); }
}
jumpToEnd() { this.jumpEnd(); } // Style A alias
jumpEnd() { // Style B name
this.idx = this.steps.length - 1;
this._render();
this.stopAuto();
}
jumpTo(i) {
if (i >= 0 && i < this.steps.length) { this.idx = i; this._render(); }
}
reset() {
this.idx = -1;
this.stopAuto();
this._renderPipeline();
if (this.onRender) this.onRender(null);
this.updateCounter();
}
/* --- Auto-play --- */
toggleAuto() { // Style A name
if (this.autoTimer) { this.stopAuto(); return false; }
this.startAuto();
return true;
}
startAuto() {
this.stopAuto();
if (this.idx < 0) this.idx = 0;
this._render();
this.autoTimer = setInterval(() => {
if (this.idx < this.steps.length - 1) { this.idx++; this._render(); }
else { this.stopAuto(); if (this.onDone) this.onDone(); }
}, this.autoDelay);
}
stopAuto() {
if (this.autoTimer) { clearInterval(this.autoTimer); this.autoTimer = null; }
}
/* --- Internal --- */
_render() {
const step = this.idx >= 0 && this.idx < this.steps.length ? this.steps[this.idx] : null;
if (this.onRender) {
// Style A pages pass (index, step), Style B passes just step
// Try calling with both and let the page's handler decide
try { this.onRender(this.idx, step); } catch(e) {
try { this.onRender(step); } catch(e2) {}
}
}
this._renderPipeline();
this.updateCounter();
}
_renderPipeline() {
const el = $('pipeline');
if (!el) return;
// Don't overwrite if the page manages its own pipeline HTML
// Only render step-dots if pipeline is empty or previously rendered by us
if (el.dataset.managed === 'page') return;
}
updateCounter() {
if (this.stepCounterEl) {
this.stepCounterEl.textContent = `${this.idx + 1} / ${this.steps.length}`;
}
}
/* --- State queries --- */
get current() { return this.idx >= 0 && this.idx < this.steps.length ? this.steps[this.idx] : null; }
get isEnd() { return this.idx >= this.steps.length - 1; }
get total() { return this.steps.length; }
isFirst() { return this.idx <= 0; }
isLast() { return this.idx >= this.steps.length - 1; }
}
/* ================================================================
renderArray — supports BOTH signatures:
Style A: renderArray(arr, {highlights, pointers, indices, ...}) → HTML string
Style B: renderArray(container, items, opts) → mutates container
================================================================ */
function renderArray(a, b, c) {
// Style B: first arg is DOM element
if (a && a.nodeType === 1) {
return _renderArrayB(a, b, c);
}
// Style A: first arg is plain array, returns HTML string
return _renderArrayA(a, b);
}
/* Style A: returns HTML string */
function _renderArrayA(arr, options = {}) {
const {
highlights = {},
pointers = {},
indices = true,
separator = null,
customClass = '',
width = null,
} = options;
let html = '
';
arr.forEach((val, i) => {
const hlClass = highlights[i] || 'default';
const widthStyle = width ? `width:${width}px;` : '';
html += '';
html += `${val}`;
if (indices) html += `${i}`;
html += '';
if (separator !== null && i === separator - 1) {
html += '|';
}
});
html += '
';
if (Object.keys(pointers).length > 0) {
html += '';
const w = width || 40;
for (let i = 0; i < arr.length; i++) {
const label = Object.entries(pointers).find(([_, idx]) => idx === i);
if (label) {
html += `${label[0]}`;
} else {
html += ``;
}
}
html += '
';
}
return html;
}
/* Style B: mutates container */
function _renderArrayB(container, items, opts = {}) {
const {
barMaxH = 120, barMinW = 28, barGap = 4,
classes = [], tags = [], idxLabels = true
} = opts || {};
const maxVal = Math.max(...items.map(it => typeof it === 'object' ? it.val : it), 1);
container.innerHTML = '';
const row = document.createElement('div');
row.className = 'arr-row';
items.forEach((item, i) => {
const val = typeof item === 'object' ? item.val : item;
const cls = typeof item === 'object' ? (item.cls || '') : (classes[i] || '');
const tag = typeof item === 'object' ? (item.tag || '') : (tags[i] || '');
const h = Math.max(8, (val / maxVal) * barMaxH);
const cell = document.createElement('div');
cell.className = 'arr-cell';
cell.innerHTML = `
${val}
${idxLabels ? `${i}` : ''}
${tag ? `${tag}` : ''}
`;
row.appendChild(cell);
});
container.appendChild(row);
}
/* ================================================================
renderGrid — supports BOTH:
Style A: renderGrid(matrix, {highlights, cellClass, ...}) → HTML string
Style B: renderGrid(container, rows, cols, cellData, opts) → mutates container
================================================================ */
function renderGrid(a, b, c, d, e) {
// Style B: first arg is DOM element
if (a && a.nodeType === 1) {
return _renderGridB(a, b, c, d, e);
}
// Style A: first arg is 2D array
return _renderGridA(a, b);
}
function _renderGridA(matrix, options = {}) {
const {
cellClass = (val, r, c) => '',
cellStyle = (val, r, c) => '',
highlights = {},
cellSize = 40
} = options;
const rows = matrix.length;
const cols = matrix[0]?.length || 0;
let html = ``;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const val = matrix[r][c];
const key = `${r},${c}`;
const hlc = highlights[key] || '';
const extraCls = cellClass(val, r, c);
const extraStyle = cellStyle(val, r, c);
html += ``;
}
}
html += '
';
return html;
}
function _renderGridB(container, rows, cols, cellData, opts = {}) {
const { cellSize = 48 } = opts;
container.innerHTML = '';
const grid = document.createElement('div');
grid.className = 'grid-vis';
grid.style.gridTemplateColumns = `repeat(${cols}, ${cellSize}px)`;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const d = cellData && cellData[i] && cellData[i][j] ? cellData[i][j] : { val: '', cls: '' };
const cell = document.createElement('div');
cell.className = 'grid-cell ' + (d.cls || '');
cell.textContent = d.val !== undefined && d.val !== null ? d.val : '';
grid.appendChild(cell);
}
}
container.appendChild(grid);
}
/* ================================================================
renderCode — supports BOTH:
Style A: renderCode(code, {lang, highlightLines}) → HTML string
Style B: renderCode(container, code, highlightLine) → mutates container
================================================================ */
function renderCode(a, b, c) {
// Style B: first arg is DOM element
if (a && a.nodeType === 1) {
return _renderCodeB(a, b, c);
}
// Style A: first arg is string
return _renderCodeA(a, b);
}
function _renderCodeA(code, options = {}) {
const { highlightLines = [], lang = '' } = options;
const lines = code.split('\n');
let html = ``;
if (lang) html += `
${lang}
`;
html += `
`;
lines.forEach((line, i) => {
const cls = highlightLines.includes(i + 1) ? 'highlight' : '';
html += `${_escapeHTML(line)}`;
});
html += '
';
return html;
}
function _renderCodeB(container, code, highlightLine = -1) {
container.innerHTML = '';
const pre = document.createElement('pre');
pre.className = 'code-block';
const lines = code.split('\n');
lines.forEach((line, i) => {
if (i === highlightLine) {
const span = document.createElement('span');
span.className = 'hl';
span.textContent = line;
pre.appendChild(span);
} else {
pre.appendChild(document.createTextNode(line + '\n'));
}
});
container.appendChild(pre);
}
function _escapeHTML(str) {
return str.replace(/&/g, '&').replace(//g, '>');
}
/* ================================================================
renderLinkedList — supports BOTH:
Style A: renderLinkedList(nodes, {highlights}) → HTML string
Style B: renderLinkedList(container, nodes, opts) → mutates container
================================================================ */
function renderLinkedList(a, b, c) {
if (a && a.nodeType === 1) {
return _renderLinkedListB(a, b, c);
}
return _renderLinkedListA(a, b);
}
function _renderLinkedListA(nodes, options = {}) {
const { highlights = {}, showNext = true, nullLabel = 'NULL' } = options;
let html = '';
nodes.forEach((node, i) => {
const cls = highlights[i] || '';
html += `
`;
html += `${node}`;
if (showNext) {
html += cls === 'null-node'
? `∅`
: `→`;
}
html += '
';
if (i < nodes.length - 1 && showNext) {
html += '
→';
}
});
html += '
';
return html;
}
function _renderLinkedListB(container, nodes, opts = {}) {
container.innerHTML = '';
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;justify-content:center;';
nodes.forEach((n, i) => {
const box = document.createElement('div');
box.style.cssText = `padding:6px 12px;border-radius:6px;font-family:${_CSS.mono};font-size:.85rem;border:2px solid ${_CSS.border};background:${_CSS.surface2};transition:all .2s;${n.cls === 'highlight' ? 'border-color:var(--orange);background:var(--orange-dim);' : n.cls === 'done' ? 'border-color:var(--green);background:var(--green-dim);' : ''}`;
box.textContent = n.val;
row.appendChild(box);
if (i < nodes.length - 1) {
const arrow = document.createElement('span');
arrow.textContent = '→';
arrow.style.cssText = `color:${_CSS.text2};font-size:1.1rem;`;
row.appendChild(arrow);
}
});
container.appendChild(row);
}
/* ================================================================
renderBinaryTree — supports BOTH:
Style A: renderBinaryTree(root, {highlights}) → HTML string
Style B: renderBinaryTree(container, tree, opts) → mutates container
================================================================ */
function renderBinaryTree(a, b, c) {
if (a && a.nodeType === 1) {
return _renderBinaryTreeB(a, b, c);
}
return _renderBinaryTreeA(a, b);
}
function _renderBinaryTreeA(root, options = {}) {
const { highlights = {}, maxWidth = 900 } = options;
if (!root) return '空树
';
function buildLevels(node) {
const levels = [];
const queue = [node];
while (queue.length) {
const level = [];
const nextQueue = [];
let hasNonNull = false;
for (const n of queue) {
if (n) { level.push(n.val); nextQueue.push(n.left, n.right); if (n.left || n.right) hasNonNull = true; }
else { level.push(null); nextQueue.push(null, null); }
}
levels.push(level);
queue.length = 0;
if (hasNonNull) queue.push(...nextQueue);
}
return levels;
}
const levels = buildLevels(root);
let html = ``;
levels.forEach((level, li) => {
const gap = Math.max(4, 60 / (li + 1));
html += `
`;
level.forEach(val => {
if (val === null) { html += `
`; }
else {
const cls = highlights[val] || '';
html += `
`;
}
});
html += '
';
});
html += '
';
return html;
}
function _renderBinaryTreeB(container, tree, opts = {}) {
container.innerHTML = '';
if (!tree) return;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 500 280');
svg.style.width = '100%';
svg.style.maxWidth = '500px';
function draw(node, x, y, spread) {
if (!node) return;
const ls = spread / 2;
if (node.left) {
const lx = x - ls, ly = y + 60;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x); line.setAttribute('y1', y);
line.setAttribute('x2', lx); line.setAttribute('y2', ly);
line.setAttribute('stroke', '#2e3347'); line.setAttribute('stroke-width', '2');
svg.appendChild(line);
draw(node.left, lx, ly, ls);
}
if (node.right) {
const rx = x + ls, ry = y + 60;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x); line.setAttribute('y1', y);
line.setAttribute('x2', rx); line.setAttribute('y2', ry);
line.setAttribute('stroke', '#2e3347'); line.setAttribute('stroke-width', '2');
svg.appendChild(line);
draw(node.right, rx, ry, ls);
}
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', x); circle.setAttribute('cy', y); circle.setAttribute('r', '18');
const cls = node.cls || '';
circle.setAttribute('fill', cls === 'highlight' ? '#f5a623' : cls === 'done' ? '#3dd68c' : '#242836');
circle.setAttribute('stroke', cls === 'highlight' ? '#f5a623' : cls === 'done' ? '#3dd68c' : '#6c7eff');
circle.setAttribute('stroke-width', '2');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x); text.setAttribute('y', y + 5);
text.setAttribute('text-anchor', 'middle'); text.setAttribute('fill', '#e4e6f0');
text.setAttribute('font-size', '12'); text.setAttribute('font-family', _CSS.mono);
text.textContent = node.val;
g.appendChild(circle); g.appendChild(text);
svg.appendChild(g);
}
draw(tree, 250, 30, 200);
container.appendChild(svg);
}
/* ================================================================
renderStack — supports BOTH:
Style A: renderStack(items, {topIndex, poppedIndex}) → HTML string
Style B: renderStack(container, items, opts) → mutates container
================================================================ */
function renderStack(a, b, c) {
if (a && a.nodeType === 1) {
return _renderStackB(a, b, c);
}
return _renderStackA(a, b);
}
function _renderStackA(items, options = {}) {
const { topIndex = items.length - 1, poppedIndex = -1 } = options;
let html = '';
if (items.length === 0) html += '
空栈';
for (let i = items.length - 1; i >= 0; i--) {
let cls = '';
if (i === topIndex) cls = 'top';
if (i === poppedIndex) cls = 'popped';
html += `
${items[i]}
`;
}
html += '
';
return html;
}
function _renderStackB(container, items, opts = {}) {
container.innerHTML = '';
const stack = document.createElement('div');
stack.style.cssText = 'display:flex;flex-direction:column-reverse;align-items:center;gap:3px;';
items.forEach((item, i) => {
const d = typeof item === 'object' ? item : { val: item, cls: '' };
const el = document.createElement('div');
el.style.cssText = `padding:6px 20px;border-radius:4px;font-family:${_CSS.mono};font-size:.82rem;border:1px solid ${_CSS.border};background:${_CSS.surface2};min-width:60px;text-align:center;${d.cls === 'top' ? 'border-color:var(--orange);background:var(--orange-dim);' : ''}`;
el.textContent = d.val;
stack.appendChild(el);
});
container.appendChild(stack);
}
/* ================================================================
highlightRange — returns an highlights object for array indices
================================================================ */
function highlightRange(arr, start, end, cls = 'active') {
const highlights = {};
for (let i = start; i <= end && i < arr.length; i++) {
highlights[i] = cls;
}
return highlights;
}
/* ================================================================
escapeHTML — utility
================================================================ */
function escapeHTML(str) {
return str.replace(/&/g, '&').replace(//g, '>');
}
/* --- Global exports --- */
window.StepController = StepController;
window.renderArray = renderArray;
window.renderLinkedList = renderLinkedList;
window.renderBinaryTree = renderBinaryTree;
window.renderGrid = renderGrid;
window.renderStack = renderStack;
window.renderCode = renderCode;
window.highlightRange = highlightRange;
window.escapeHTML = escapeHTML;
window.$ = $;
window.$$ = $$;