KNN-Explorer / index.html
mlnjsh's picture
Upload folder using huggingface_hub
17cb2ed verified
Raw
History Blame Contribute Delete
24.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KNN Explorer β€” How KNN Learns | ML-II Book</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:'Segoe UI',system-ui,sans-serif;overflow-x:hidden}
.header{text-align:center;padding:24px 16px 8px;background:linear-gradient(180deg,#161b22 0%,#0d1117 100%)}
.header h1{font-size:1.6rem;background:linear-gradient(135deg,#58a6ff,#3fb950);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
.header p{color:#8b949e;font-size:0.85rem}
.main{display:flex;flex-wrap:wrap;gap:16px;padding:16px;max-width:1400px;margin:0 auto}
.canvas-panel{flex:1 1 720px;min-width:320px}
.controls-panel{flex:0 0 340px;min-width:280px}
canvas{width:100%;border-radius:12px;border:1px solid #30363d;background:#0d1117;cursor:crosshair}
.card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:16px;margin-bottom:12px}
.card h3{font-size:0.95rem;color:#58a6ff;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.card h3 .dot{width:8px;height:8px;border-radius:50%;display:inline-block}
label{display:block;color:#8b949e;font-size:0.8rem;margin-bottom:4px}
.slider-row{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.slider-row input[type=range]{flex:1;accent-color:#58a6ff;height:6px}
.slider-val{background:#21262d;color:#58a6ff;font-weight:700;padding:2px 10px;border-radius:6px;font-size:1.1rem;min-width:40px;text-align:center}
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.btn{padding:8px 14px;border-radius:8px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:0.8rem;transition:all .15s}
.btn:hover{background:#30363d;border-color:#58a6ff}
.btn.active{background:#1a3a5c;border-color:#58a6ff;color:#58a6ff}
.btn.green{border-color:#3fb950;color:#3fb950}.btn.green:hover,.btn.green.active{background:#0d2818;border-color:#3fb950}
.btn.red{border-color:#f85149;color:#f85149}.btn.red:hover,.btn.red.active{background:#2d1014;border-color:#f85149}
.btn.orange{border-color:#f0883e;color:#f0883e}.btn.orange:hover{background:#2a1a08}
.btn.purple{border-color:#bc8cff;color:#bc8cff}.btn.purple:hover{background:#1a0d2e}
.legend{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px}
.legend-item{display:flex;align-items:center;gap:6px;font-size:0.78rem;color:#8b949e}
.legend-item .swatch{width:12px;height:12px;border-radius:3px}
.metric-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.metric{background:#21262d;border-radius:8px;padding:10px;text-align:center}
.metric .val{font-size:1.3rem;font-weight:700;color:#3fb950}
.metric .lbl{font-size:0.7rem;color:#8b949e;margin-top:2px}
.metric .val.warn{color:#d29922}
.metric .val.bad{color:#f85149}
.animate-bar{height:4px;background:#21262d;border-radius:2px;margin-top:8px;overflow:hidden}
.animate-bar .fill{height:100%;background:linear-gradient(90deg,#58a6ff,#3fb950);width:0%;transition:width .3s;border-radius:2px}
.info-box{background:#0d2818;border:1px solid #238636;border-radius:8px;padding:10px;margin-top:10px;font-size:0.78rem;color:#3fb950;line-height:1.5}
.speed-row{display:flex;align-items:center;gap:8px;margin-top:6px}
.speed-row .btn{padding:4px 10px;font-size:0.75rem}
select{background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:6px 10px;font-size:0.8rem;width:100%;margin-bottom:10px}
.footer{text-align:center;padding:16px;color:#484f58;font-size:0.75rem}
.footer a{color:#58a6ff;text-decoration:none}
@media(max-width:800px){.controls-panel{flex:1 1 100%}}
</style>
</head>
<body>
<div class="header">
<h1>KNN Explorer</h1>
<p>Interactive K-Nearest Neighbors Visualization &mdash; ML-II Book by Dr Milan Amrut Joshi</p>
</div>
<div class="main">
<div class="canvas-panel">
<canvas id="canvas" width="720" height="560"></canvas>
<div class="legend">
<div class="legend-item"><div class="swatch" style="background:#3fb950"></div> Train β€” Class A</div>
<div class="legend-item"><div class="swatch" style="background:#f85149"></div> Train β€” Class B</div>
<div class="legend-item"><div class="swatch" style="background:#6e7681"></div> Test points</div>
<div class="legend-item"><div class="swatch" style="background:#58a6ff"></div> Predicted A</div>
<div class="legend-item"><div class="swatch" style="background:#f0883e"></div> Predicted B</div>
<div class="legend-item"><div class="swatch" style="background:rgba(88,166,255,0.12)"></div> Decision region</div>
</div>
</div>
<div class="controls-panel">
<div class="card">
<h3><span class="dot" style="background:#58a6ff"></span> K Value</h3>
<div class="slider-row">
<input type="range" id="kSlider" min="1" max="25" value="3">
<div class="slider-val" id="kVal">3</div>
</div>
<div class="animate-bar"><div class="fill" id="animBar"></div></div>
<div class="speed-row">
<button class="btn purple" id="animBtn" onclick="toggleAnimate()">Animate K: 1β†’15</button>
<button class="btn" onclick="setSpeed(0.5)">0.5x</button>
<button class="btn active" id="sp1" onclick="setSpeed(1)">1x</button>
<button class="btn" onclick="setSpeed(2)">2x</button>
</div>
</div>
<div class="card">
<h3><span class="dot" style="background:#3fb950"></span> Dataset</h3>
<select id="datasetSel" onchange="changeDataset()">
<option value="moons">Two Moons</option>
<option value="circles">Concentric Circles</option>
<option value="blobs">Gaussian Blobs</option>
<option value="spiral">Spiral</option>
<option value="xor">XOR Pattern</option>
</select>
<label>Training samples</label>
<div class="slider-row">
<input type="range" id="nSlider" min="20" max="200" value="80" step="10">
<div class="slider-val" id="nVal">80</div>
</div>
<label>Noise level</label>
<div class="slider-row">
<input type="range" id="noiseSlider" min="0" max="50" value="15">
<div class="slider-val" id="noiseVal">15%</div>
</div>
<div class="btn-row">
<button class="btn green" onclick="regenerate()">New Data</button>
<button class="btn orange" onclick="clearTest()">Clear Test</button>
<button class="btn" onclick="toggleBoundary()">Toggle Boundary</button>
</div>
</div>
<div class="card">
<h3><span class="dot" style="background:#d29922"></span> Distance Metric</h3>
<div class="btn-row">
<button class="btn active" id="distEuc" onclick="setDist('euclidean')">Euclidean</button>
<button class="btn" id="distMan" onclick="setDist('manhattan')">Manhattan</button>
<button class="btn" id="distMinkowski" onclick="setDist('minkowski')">Minkowski p=3</button>
</div>
<div class="btn-row" style="margin-top:4px">
<button class="btn" id="weightUni" onclick="setWeight('uniform')">Uniform votes</button>
<button class="btn active" id="weightDist" onclick="setWeight('distance')">Distance-weighted</button>
</div>
</div>
<div class="card">
<h3><span class="dot" style="background:#f85149"></span> Metrics</h3>
<div class="metric-grid">
<div class="metric"><div class="val" id="accVal">β€”</div><div class="lbl">Test Accuracy</div></div>
<div class="metric"><div class="val" id="kCur">3</div><div class="lbl">Current K</div></div>
<div class="metric"><div class="val" id="trainN">80</div><div class="lbl">Train Points</div></div>
<div class="metric"><div class="val" id="testN">0</div><div class="lbl">Test Points</div></div>
</div>
<div class="info-box" id="infoBox">
Click anywhere on the canvas to add a test point (grey). KNN will classify it using the K nearest training neighbors.
</div>
</div>
</div>
</div>
<div class="footer">
ML-II Book: Supervised Learning Classification &mdash; <a href="https://github.com/drmilanajoshi" target="_blank">Dr Milan Amrut Joshi</a> &mdash; Great Learning
</div>
<script>
// ═══════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════
const C = document.getElementById('canvas');
const ctx = C.getContext('2d');
let W, H, dpr;
let K = 3, distMetric = 'euclidean', weightMode = 'distance';
let trainData = [], testData = [];
let showBoundary = true;
let animating = false, animSpeed = 1, animTimer = null;
let boundaryCache = null, boundaryCacheDirty = true;
function resize() {
const rect = C.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = rect.width; H = rect.height;
C.width = W * dpr; C.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
boundaryCacheDirty = true;
}
window.addEventListener('resize', () => { resize(); draw(); });
// ═══════════════════════════════════════════════════════════════
// DATA GENERATION
// ═══════════════════════════════════════════════════════════════
function rand() { return Math.random(); }
function randn() { let u=0,v=0; while(!u) u=rand(); while(!v) v=rand(); return Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v); }
function generateData(type, n, noise) {
const data = [];
const ns = noise / 100;
const half = Math.floor(n / 2);
if (type === 'moons') {
for (let i = 0; i < half; i++) {
const t = Math.PI * i / half;
data.push({ x: Math.cos(t) + randn()*ns*0.4, y: Math.sin(t) + randn()*ns*0.4, cls: 0 });
}
for (let i = 0; i < n - half; i++) {
const t = Math.PI * i / (n - half);
data.push({ x: 1 - Math.cos(t) + randn()*ns*0.4, y: 0.5 - Math.sin(t) + randn()*ns*0.4, cls: 1 });
}
} else if (type === 'circles') {
for (let i = 0; i < half; i++) {
const a = 2*Math.PI*rand();
const r = 0.3 + randn()*ns*0.15;
data.push({ x: r*Math.cos(a)+1, y: r*Math.sin(a)+0.5, cls: 0 });
}
for (let i = 0; i < n - half; i++) {
const a = 2*Math.PI*rand();
const r = 0.8 + randn()*ns*0.15;
data.push({ x: r*Math.cos(a)+1, y: r*Math.sin(a)+0.5, cls: 1 });
}
} else if (type === 'blobs') {
for (let i = 0; i < half; i++) {
data.push({ x: 0.5 + randn()*0.25*(1+ns), y: 0.7 + randn()*0.25*(1+ns), cls: 0 });
}
for (let i = 0; i < n - half; i++) {
data.push({ x: 1.5 + randn()*0.25*(1+ns), y: 0.3 + randn()*0.25*(1+ns), cls: 1 });
}
} else if (type === 'spiral') {
for (let i = 0; i < half; i++) {
const t = 1.5*Math.PI*i/half + 0.5;
const r = 0.4*t/(1.5*Math.PI);
data.push({ x: r*Math.cos(t)+1+randn()*ns*0.12, y: r*Math.sin(t)+0.5+randn()*ns*0.12, cls: 0 });
}
for (let i = 0; i < n - half; i++) {
const t = 1.5*Math.PI*i/(n-half) + 0.5;
const r = 0.4*t/(1.5*Math.PI);
data.push({ x: -r*Math.cos(t)+1+randn()*ns*0.12, y: -r*Math.sin(t)+0.5+randn()*ns*0.12, cls: 1 });
}
} else if (type === 'xor') {
for (let i = 0; i < n; i++) {
const x = rand()*2, y = rand();
const cls = ((x > 1) ^ (y > 0.5)) ? 1 : 0;
data.push({ x: x + randn()*ns*0.15, y: y + randn()*ns*0.15, cls });
}
}
return data;
}
// ═══════════════════════════════════════════════════════════════
// DISTANCE & KNN
// ═══════════════════════════════════════════════════════════════
function dist(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
if (distMetric === 'euclidean') return Math.sqrt(dx*dx + dy*dy);
if (distMetric === 'manhattan') return Math.abs(dx) + Math.abs(dy);
if (distMetric === 'minkowski') { const p=3; return Math.pow(Math.pow(Math.abs(dx),p)+Math.pow(Math.abs(dy),p),1/p); }
return Math.sqrt(dx*dx+dy*dy);
}
function knnClassify(point, k) {
if (trainData.length === 0) return { cls: -1, neighbors: [], conf: 0 };
const dists = trainData.map((t, i) => ({ i, d: dist(point, t), cls: t.cls }));
dists.sort((a, b) => a.d - b.d);
const neighbors = dists.slice(0, Math.min(k, dists.length));
let scores = [0, 0];
if (weightMode === 'uniform') {
neighbors.forEach(n => scores[n.cls]++);
} else {
neighbors.forEach(n => {
const w = n.d < 1e-9 ? 1e6 : 1 / n.d;
scores[n.cls] += w;
});
}
const total = scores[0] + scores[1];
const cls = scores[0] >= scores[1] ? 0 : 1;
const conf = total > 0 ? Math.max(scores[0], scores[1]) / total : 0.5;
return { cls, neighbors, conf };
}
// ═══════════════════════════════════════════════════════════════
// COORDINATE TRANSFORMS
// ═══════════════════════════════════════════════════════════════
const pad = 40;
function toCanvas(pt) {
return { x: pad + (pt.x - viewMinX) / (viewMaxX - viewMinX) * (W - 2*pad),
y: (H - pad) - (pt.y - viewMinY) / (viewMaxY - viewMinY) * (H - 2*pad) };
}
function fromCanvas(cx, cy) {
return { x: viewMinX + (cx - pad) / (W - 2*pad) * (viewMaxX - viewMinX),
y: viewMinY + (H - pad - cy) / (H - 2*pad) * (viewMaxY - viewMinY) };
}
let viewMinX = -0.5, viewMaxX = 2.5, viewMinY = -0.5, viewMaxY = 1.5;
function fitView() {
if (trainData.length === 0) { viewMinX=-0.5; viewMaxX=2.5; viewMinY=-0.5; viewMaxY=1.5; return; }
let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;
trainData.forEach(p => { mnx=Math.min(mnx,p.x); mxx=Math.max(mxx,p.x); mny=Math.min(mny,p.y); mxy=Math.max(mxy,p.y); });
const mx = (mxx-mnx)*0.15 || 0.5, my = (mxy-mny)*0.15 || 0.5;
viewMinX=mnx-mx; viewMaxX=mxx+mx; viewMinY=mny-my; viewMaxY=mxy+my;
// Ensure aspect ratio
const aspect = W / H;
const cx = (viewMinX+viewMaxX)/2, cy = (viewMinY+viewMaxY)/2;
let rw = (viewMaxX-viewMinX)/2, rh = (viewMaxY-viewMinY)/2;
if (rw/rh < aspect) rw = rh*aspect; else rh = rw/aspect;
viewMinX=cx-rw; viewMaxX=cx+rw; viewMinY=cy-rh; viewMaxY=cy+rh;
}
// ═══════════════════════════════════════════════════════════════
// DRAWING
// ═══════════════════════════════════════════════════════════════
function draw() {
ctx.clearRect(0, 0, W, H);
// Grid
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 0.5;
for (let i = 0; i <= 10; i++) {
const x = pad + i*(W-2*pad)/10, y = pad + i*(H-2*pad)/10;
ctx.beginPath(); ctx.moveTo(x, pad); ctx.lineTo(x, H-pad); ctx.stroke();
ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(W-pad, y); ctx.stroke();
}
// Axes
ctx.strokeStyle = '#30363d'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad, pad); ctx.lineTo(pad, H-pad); ctx.lineTo(W-pad, H-pad); ctx.stroke();
// Decision boundary (background)
if (showBoundary && trainData.length > 0) drawBoundary();
// Train data
trainData.forEach(p => {
const cp = toCanvas(p);
ctx.beginPath(); ctx.arc(cp.x, cp.y, 6, 0, 2*Math.PI);
ctx.fillStyle = p.cls === 0 ? '#3fb950' : '#f85149';
ctx.globalAlpha = 0.85; ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = p.cls === 0 ? '#238636' : '#da3633'; ctx.lineWidth = 1.5; ctx.stroke();
});
// Test data with neighbor lines
testData.forEach(tp => {
const cp = toCanvas(tp);
const res = knnClassify(tp, K);
tp._cls = res.cls; tp._conf = res.conf;
// Neighbor connection lines
res.neighbors.forEach((n, idx) => {
const np = toCanvas(trainData[n.i]);
const alpha = 0.6 - idx * 0.04;
ctx.strokeStyle = n.cls === 0 ? `rgba(63,185,80,${alpha})` : `rgba(248,81,73,${alpha})`;
ctx.lineWidth = 2 - idx * 0.1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(cp.x, cp.y); ctx.lineTo(np.x, np.y); ctx.stroke();
ctx.setLineDash([]);
});
// Test point
ctx.beginPath(); ctx.arc(cp.x, cp.y, 8, 0, 2*Math.PI);
ctx.fillStyle = '#6e7681'; ctx.globalAlpha = 0.5; ctx.fill(); ctx.globalAlpha = 1;
// Prediction ring
ctx.strokeStyle = res.cls === 0 ? '#58a6ff' : '#f0883e'; ctx.lineWidth = 2.5; ctx.stroke();
// K-circle (distance to Kth neighbor)
if (res.neighbors.length === K) {
const kthDist = res.neighbors[K-1].d;
const radiusPx = kthDist / (viewMaxX - viewMinX) * (W - 2*pad);
ctx.beginPath(); ctx.arc(cp.x, cp.y, radiusPx, 0, 2*Math.PI);
ctx.strokeStyle = 'rgba(188,140,255,0.3)'; ctx.lineWidth = 1; ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash([]);
}
// Confidence label
ctx.font = '10px system-ui'; ctx.fillStyle = '#c9d1d9';
ctx.textAlign = 'center';
ctx.fillText(`${(res.conf*100).toFixed(0)}%`, cp.x, cp.y - 13);
ctx.textAlign = 'left';
});
updateMetrics();
}
function drawBoundary() {
const res = 3;
const stepsX = Math.ceil((W - 2*pad) / res);
const stepsY = Math.ceil((H - 2*pad) / res);
for (let ix = 0; ix < stepsX; ix++) {
for (let iy = 0; iy < stepsY; iy++) {
const cx = pad + ix * res + res/2;
const cy = pad + iy * res + res/2;
const pt = fromCanvas(cx, cy);
const r = knnClassify(pt, K);
if (r.cls === 0) {
ctx.fillStyle = `rgba(63,185,80,${0.04 + r.conf*0.08})`;
} else {
ctx.fillStyle = `rgba(248,81,73,${0.04 + r.conf*0.08})`;
}
ctx.fillRect(cx - res/2, cy - res/2, res, res);
}
}
}
function updateMetrics() {
document.getElementById('kCur').textContent = K;
document.getElementById('trainN').textContent = trainData.length;
document.getElementById('testN').textContent = testData.length;
if (testData.length === 0) {
document.getElementById('accVal').textContent = 'β€”';
document.getElementById('accVal').className = 'val';
return;
}
// We don't have ground truth for click-added test points, show avg confidence
const avgConf = testData.reduce((s, t) => s + (t._conf || 0.5), 0) / testData.length;
const pct = (avgConf * 100).toFixed(1) + '%';
const el = document.getElementById('accVal');
el.textContent = pct;
el.className = avgConf > 0.75 ? 'val' : avgConf > 0.5 ? 'val warn' : 'val bad';
// Update info text
const predA = testData.filter(t => t._cls === 0).length;
const predB = testData.filter(t => t._cls === 1).length;
document.getElementById('infoBox').innerHTML =
`K=${K}: ${predA} points β†’ Class A, ${predB} points β†’ Class B<br>` +
`Avg confidence: ${pct} | Metric: ${distMetric} | Weight: ${weightMode}`;
}
// ═══════════════════════════════════════════════════════════════
// INTERACTIONS
// ═══════════════════════════════════════════════════════════════
C.addEventListener('click', e => {
const rect = C.getBoundingClientRect();
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
const pt = fromCanvas(cx, cy);
testData.push(pt);
draw();
});
// Sliders
document.getElementById('kSlider').addEventListener('input', e => {
K = parseInt(e.target.value);
document.getElementById('kVal').textContent = K;
draw();
});
document.getElementById('nSlider').addEventListener('input', e => {
document.getElementById('nVal').textContent = e.target.value;
});
document.getElementById('nSlider').addEventListener('change', regenerate);
document.getElementById('noiseSlider').addEventListener('input', e => {
document.getElementById('noiseVal').textContent = e.target.value + '%';
});
document.getElementById('noiseSlider').addEventListener('change', regenerate);
function regenerate() {
const type = document.getElementById('datasetSel').value;
const n = parseInt(document.getElementById('nSlider').value);
const noise = parseInt(document.getElementById('noiseSlider').value);
trainData = generateData(type, n, noise);
testData = [];
fitView();
draw();
}
function changeDataset() { regenerate(); }
function clearTest() { testData = []; draw(); }
function toggleBoundary() {
showBoundary = !showBoundary;
draw();
}
function setDist(m) {
distMetric = m;
['distEuc','distMan','distMinkowski'].forEach(id => document.getElementById(id).classList.remove('active'));
if (m === 'euclidean') document.getElementById('distEuc').classList.add('active');
else if (m === 'manhattan') document.getElementById('distMan').classList.add('active');
else document.getElementById('distMinkowski').classList.add('active');
draw();
}
function setWeight(w) {
weightMode = w;
document.getElementById('weightUni').classList.toggle('active', w==='uniform');
document.getElementById('weightDist').classList.toggle('active', w==='distance');
draw();
}
// ═══════════════════════════════════════════════════════════════
// ANIMATION: Sweep K from 1 to 15
// ═══════════════════════════════════════════════════════════════
function toggleAnimate() {
if (animating) { stopAnimate(); return; }
animating = true;
document.getElementById('animBtn').textContent = 'Stop Animation';
document.getElementById('animBtn').classList.add('active');
// Add test points if none exist
if (testData.length === 0) {
for (let i = 0; i < 30; i++) {
testData.push({
x: viewMinX + rand() * (viewMaxX - viewMinX),
y: viewMinY + rand() * (viewMaxY - viewMinY)
});
}
}
let kAnim = 1;
const maxK = Math.min(15, trainData.length);
const interval = 1200 / animSpeed;
function step() {
if (!animating) return;
K = kAnim;
document.getElementById('kSlider').value = K;
document.getElementById('kVal').textContent = K;
document.getElementById('animBar').style.width = ((kAnim / maxK) * 100) + '%';
draw();
kAnim++;
if (kAnim > maxK) kAnim = 1;
animTimer = setTimeout(step, interval);
}
step();
}
function stopAnimate() {
animating = false;
clearTimeout(animTimer);
document.getElementById('animBtn').textContent = 'Animate K: 1β†’15';
document.getElementById('animBtn').classList.remove('active');
document.getElementById('animBar').style.width = '0%';
}
function setSpeed(s) {
animSpeed = s;
document.querySelectorAll('.speed-row .btn').forEach(b => b.classList.remove('active'));
if (s === 0.5) document.querySelectorAll('.speed-row .btn')[1].classList.add('active');
else if (s === 1) document.getElementById('sp1').classList.add('active');
else document.querySelectorAll('.speed-row .btn')[3].classList.add('active');
if (animating) { stopAnimate(); toggleAnimate(); }
}
// ═══════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════
resize();
regenerate();
</script>
</body>
</html>