/* ===== 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 += `
${val}
`; } } 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 += `
${val}
`; } }); 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.$$ = $$;