Files
2026-08-24 04:51:01 +00:00

537 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===== 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 = '<div class="nums-line ' + customClass + '">';
arr.forEach((val, i) => {
const hlClass = highlights[i] || 'default';
const widthStyle = width ? `width:${width}px;` : '';
html += '<span class="chip-group">';
html += `<span class="chip ${hlClass}" style="${widthStyle}">${val}</span>`;
if (indices) html += `<span class="chip-index">${i}</span>`;
html += '</span>';
if (separator !== null && i === separator - 1) {
html += '<span class="split">|</span>';
}
});
html += '</div>';
if (Object.keys(pointers).length > 0) {
html += '<div class="nums-line" style="margin-top:-4px;">';
const w = width || 40;
for (let i = 0; i < arr.length; i++) {
const label = Object.entries(pointers).find(([_, idx]) => idx === i);
if (label) {
html += `<span class="pointer-arrow" style="min-width:${w}px;">${label[0]}</span>`;
} else {
html += `<span style="min-width:${w}px;"></span>`;
}
}
html += '</div>';
}
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 = `
<span class="arr-val">${val}</span>
<div class="arr-bar ${cls}" style="height:${h}px;width:${barMinW}px;"></div>
${idxLabels ? `<span class="arr-idx">${i}</span>` : ''}
${tag ? `<span class="tag ${cls === 'min-price' || cls === 'best' ? 'tag-green' : cls === 'current' ? 'tag-orange' : cls === 'checking' ? 'tag-blue' : 'tag-orange'}">${tag}</span>` : ''}
`;
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 = `<div class="grid-viz" style="grid-template-columns:repeat(${cols}, ${cellSize}px);">`;
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 += `<div class="grid-cell ${extraCls} ${hlc}" style="width:${cellSize}px;height:${cellSize}px;${extraStyle}">${val}</div>`;
}
}
html += '</div>';
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 = `<div class="code-block">`;
if (lang) html += `<div class="lang-label">${lang}</div>`;
html += `<pre><code>`;
lines.forEach((line, i) => {
const cls = highlightLines.includes(i + 1) ? 'highlight' : '';
html += `<span class="code-line ${cls}">${_escapeHTML(line)}</span>`;
});
html += '</code></pre></div>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ================================================================
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 = '<div class="linked-list">';
nodes.forEach((node, i) => {
const cls = highlights[i] || '';
html += `<div class="ll-node ${cls}">`;
html += `<span class="val">${node}</span>`;
if (showNext) {
html += cls === 'null-node'
? `<span class="arrow">∅</span>`
: `<span class="arrow">→</span>`;
}
html += '</div>';
if (i < nodes.length - 1 && showNext) {
html += '<span class="ll-arrow">→</span>';
}
});
html += '</div>';
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 '<div class="tree-container"><em>空树</em></div>';
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 = `<div class="tree-container"><div style="max-width:${maxWidth}px;width:100%;">`;
levels.forEach((level, li) => {
const gap = Math.max(4, 60 / (li + 1));
html += `<div style="display:flex;justify-content:center;gap:${gap}px;margin-bottom:${li === 0 ? 0 : 12}px;">`;
level.forEach(val => {
if (val === null) { html += `<div style="width:40px;height:40px;"></div>`; }
else {
const cls = highlights[val] || '';
html += `<div class="tree-node"><div class="node-circle ${cls}">${val}</div></div>`;
}
});
html += '</div>';
});
html += '</div></div>';
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 = '<div class="stack-container">';
if (items.length === 0) html += '<span style="color:var(--text-muted);font-style:italic;">空栈</span>';
for (let i = items.length - 1; i >= 0; i--) {
let cls = '';
if (i === topIndex) cls = 'top';
if (i === poppedIndex) cls = 'popped';
html += `<div class="stack-item ${cls}">${items[i]}</div>`;
}
html += '</div>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* --- 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.$$ = $$;