537 lines
20 KiB
JavaScript
537 lines
20 KiB
JavaScript
/* ===== 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, '&').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 = '<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, '&').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.$$ = $$;
|