Created
January 23, 2026 05:06
-
-
Save kbouw/c9c35d8b46dffc7a7b63e08ad3be31b7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>K-Means Clustering — Interactive Simulation</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #0a0a0f; | |
| color: #e0e0e0; | |
| min-height: 100vh; | |
| padding: 2rem; | |
| } | |
| .container { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| h1 { | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| color: #fff; | |
| } | |
| .subtitle { | |
| color: #888; | |
| margin-bottom: 2rem; | |
| font-size: 0.95rem; | |
| } | |
| .simulation-area { | |
| background: #12121a; | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| .canvas-container { | |
| position: relative; | |
| width: 100%; | |
| aspect-ratio: 16 / 10; | |
| background: #0d0d12; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| cursor: crosshair; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .controls { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1.5rem; | |
| margin-top: 1.5rem; | |
| align-items: flex-end; | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .control-group label { | |
| font-size: 0.8rem; | |
| color: #888; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .control-group .value { | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| color: #fff; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| input[type="range"] { | |
| width: 140px; | |
| height: 6px; | |
| -webkit-appearance: none; | |
| appearance: none; | |
| background: #2a2a3a; | |
| border-radius: 3px; | |
| outline: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| background: #fff; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: transform 0.15s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.15); | |
| } | |
| .buttons { | |
| display: flex; | |
| gap: 0.75rem; | |
| margin-left: auto; | |
| } | |
| button { | |
| padding: 0.7rem 1.2rem; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| } | |
| .btn-primary { | |
| background: #3b82f6; | |
| color: #fff; | |
| } | |
| .btn-primary:hover { | |
| background: #2563eb; | |
| } | |
| .btn-secondary { | |
| background: #2a2a3a; | |
| color: #e0e0e0; | |
| } | |
| .btn-secondary:hover { | |
| background: #3a3a4a; | |
| } | |
| .btn-step { | |
| background: #22c55e; | |
| color: #fff; | |
| } | |
| .btn-step:hover { | |
| background: #16a34a; | |
| } | |
| .status-bar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-top: 1rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid #2a2a3a; | |
| } | |
| .status { | |
| font-size: 0.85rem; | |
| color: #888; | |
| } | |
| .status span { | |
| color: #fff; | |
| font-weight: 500; | |
| } | |
| .legend { | |
| display: flex; | |
| gap: 1.5rem; | |
| font-size: 0.85rem; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .legend-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| } | |
| .legend-centroid { | |
| width: 12px; | |
| height: 12px; | |
| border: 2px solid #fff; | |
| background: transparent; | |
| } | |
| .instructions { | |
| background: #1a1a24; | |
| border-radius: 8px; | |
| padding: 1rem 1.25rem; | |
| font-size: 0.85rem; | |
| color: #888; | |
| line-height: 1.6; | |
| } | |
| .instructions strong { | |
| color: #e0e0e0; | |
| } | |
| .phase-indicator { | |
| display: inline-block; | |
| padding: 0.25rem 0.6rem; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .phase-assign { | |
| background: rgba(251, 191, 36, 0.2); | |
| color: #fbbf24; | |
| } | |
| .phase-update { | |
| background: rgba(34, 197, 94, 0.2); | |
| color: #22c55e; | |
| } | |
| .phase-converged { | |
| background: rgba(59, 130, 246, 0.2); | |
| color: #3b82f6; | |
| } | |
| .phase-ready { | |
| background: rgba(156, 163, 175, 0.2); | |
| color: #9ca3af; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>K-Means Clustering</h1> | |
| <p class="subtitle">Watch the algorithm find natural groupings in data</p> | |
| <div class="simulation-area"> | |
| <div class="canvas-container"> | |
| <canvas id="canvas"></canvas> | |
| </div> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label>Clusters (k)</label> | |
| <div class="value" id="k-value">3</div> | |
| <input type="range" id="k-slider" min="2" max="7" value="3"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Points</label> | |
| <div class="value" id="points-value">80</div> | |
| <input type="range" id="points-slider" min="30" max="200" value="80" step="10"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Speed</label> | |
| <div class="value" id="speed-value">1×</div> | |
| <input type="range" id="speed-slider" min="1" max="5" value="2"> | |
| </div> | |
| <div class="buttons"> | |
| <button class="btn-secondary" id="reset-btn">Reset</button> | |
| <button class="btn-step" id="step-btn">Step</button> | |
| <button class="btn-primary" id="run-btn">Run</button> | |
| </div> | |
| </div> | |
| <div class="status-bar"> | |
| <div class="status"> | |
| Iteration: <span id="iteration">0</span> · | |
| <span class="phase-indicator phase-ready" id="phase">Ready</span> | |
| </div> | |
| <div class="legend"> | |
| <div class="legend-item"> | |
| <div class="legend-dot" style="background: #6b7280;"></div> | |
| <span>Data points</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-dot legend-centroid"></div> | |
| <span>Centroids</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="instructions"> | |
| <strong>How it works:</strong> K-Means alternates between two steps — | |
| <strong style="color: #fbbf24;">Assign</strong> (color each point by its nearest centroid) and | |
| <strong style="color: #22c55e;">Update</strong> (move centroids to the mean of their points). | |
| Click <strong>Step</strong> to advance one half-step, or <strong>Run</strong> to animate until convergence. | |
| Try different values of <strong>k</strong> to see how the clustering changes. | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // High DPI support | |
| function resizeCanvas() { | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| ctx.scale(dpr, dpr); | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| return { width: rect.width, height: rect.height }; | |
| } | |
| let canvasSize = resizeCanvas(); | |
| window.addEventListener('resize', () => { | |
| canvasSize = resizeCanvas(); | |
| draw(); | |
| }); | |
| // Cluster colors as RGB for interpolation | |
| // Bright colors for centroids | |
| const centroidColorsRGB = [ | |
| [244, 63, 94], // rose | |
| [59, 130, 246], // blue | |
| [34, 197, 94], // green | |
| [245, 158, 11], // amber | |
| [139, 92, 246], // violet | |
| [6, 182, 212], // cyan | |
| [236, 72, 153], // pink | |
| ]; | |
| // Darker colors for data points (60% brightness) | |
| const pointColorsRGB = centroidColorsRGB.map(c => c.map(v => Math.round(v * 0.6))); | |
| const unassignedRGB = [55, 65, 81]; // gray | |
| function rgbToString(rgb, alpha = 1) { | |
| return `rgba(${Math.round(rgb[0])}, ${Math.round(rgb[1])}, ${Math.round(rgb[2])}, ${alpha})`; | |
| } | |
| function lerpRGB(a, b, t) { | |
| return [ | |
| a[0] + (b[0] - a[0]) * t, | |
| a[1] + (b[1] - a[1]) * t, | |
| a[2] + (b[2] - a[2]) * t | |
| ]; | |
| } | |
| // Easing function for smooth animations | |
| function easeOutCubic(t) { | |
| return 1 - Math.pow(1 - t, 3); | |
| } | |
| // State | |
| let points = []; | |
| let centroids = []; | |
| let assignments = []; | |
| let k = 3; | |
| let numPoints = 80; | |
| let iteration = 0; | |
| let phase = 'ready'; | |
| let running = false; | |
| let speed = 2; | |
| // Animation state | |
| let animating = false; | |
| let animationStartTime = 0; | |
| let animationDuration = 400; | |
| let onAnimationComplete = null; | |
| // Generate clustered random points | |
| function generatePoints(n) { | |
| const pts = []; | |
| const padding = 60; | |
| const w = canvasSize.width - padding * 2; | |
| const h = canvasSize.height - padding * 2; | |
| const numClusters = 3 + Math.floor(Math.random() * 3); | |
| const clusterCenters = []; | |
| for (let i = 0; i < numClusters; i++) { | |
| clusterCenters.push({ | |
| x: padding + Math.random() * w, | |
| y: padding + Math.random() * h | |
| }); | |
| } | |
| for (let i = 0; i < n; i++) { | |
| const center = clusterCenters[Math.floor(Math.random() * numClusters)]; | |
| const spread = 40 + Math.random() * 60; | |
| const x = center.x + (Math.random() - 0.5) * spread * 2; | |
| const y = center.y + (Math.random() - 0.5) * spread * 2; | |
| pts.push({ | |
| x, y, | |
| displayX: x, | |
| displayY: y, | |
| startX: x, | |
| startY: y, | |
| opacity: 1, | |
| startOpacity: 1, | |
| targetOpacity: 1, | |
| colorRGB: [...unassignedRGB], | |
| startColorRGB: [...unassignedRGB], | |
| targetColorRGB: [...unassignedRGB] | |
| }); | |
| } | |
| return pts; | |
| } | |
| // Initialize centroids using k-means++ | |
| function initCentroids(k, pts) { | |
| const cents = []; | |
| if (pts.length === 0) return cents; | |
| const first = pts[Math.floor(Math.random() * pts.length)]; | |
| cents.push({ | |
| x: first.x, | |
| y: first.y, | |
| displayX: first.x, | |
| displayY: first.y, | |
| startX: first.x, | |
| startY: first.y, | |
| opacity: 1, | |
| startOpacity: 1, | |
| targetOpacity: 1 | |
| }); | |
| for (let i = 1; i < k; i++) { | |
| const distances = pts.map(p => { | |
| const minDist = Math.min(...cents.map(c => dist(p, c))); | |
| return minDist * minDist; | |
| }); | |
| const total = distances.reduce((a, b) => a + b, 0); | |
| let r = Math.random() * total; | |
| let chosen = pts[0]; | |
| for (let j = 0; j < pts.length; j++) { | |
| r -= distances[j]; | |
| if (r <= 0) { | |
| chosen = pts[j]; | |
| break; | |
| } | |
| } | |
| cents.push({ | |
| x: chosen.x, | |
| y: chosen.y, | |
| displayX: chosen.x, | |
| displayY: chosen.y, | |
| startX: chosen.x, | |
| startY: chosen.y, | |
| opacity: 1, | |
| startOpacity: 1, | |
| targetOpacity: 1 | |
| }); | |
| } | |
| return cents; | |
| } | |
| function dist(a, b) { | |
| return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); | |
| } | |
| function computeAssignments() { | |
| return points.map(p => { | |
| let minDist = Infinity; | |
| let closest = 0; | |
| centroids.forEach((c, i) => { | |
| const d = dist(p, c); | |
| if (d < minDist) { | |
| minDist = d; | |
| closest = i; | |
| } | |
| }); | |
| return closest; | |
| }); | |
| } | |
| function computeCentroids() { | |
| return centroids.map((c, i) => { | |
| const assigned = points.filter((_, j) => assignments[j] === i); | |
| if (assigned.length === 0) return { x: c.x, y: c.y }; | |
| return { | |
| x: assigned.reduce((s, p) => s + p.x, 0) / assigned.length, | |
| y: assigned.reduce((s, p) => s + p.y, 0) / assigned.length | |
| }; | |
| }); | |
| } | |
| function hasConverged(oldCentroids, newCentroids) { | |
| const threshold = 0.5; | |
| return oldCentroids.every((c, i) => { | |
| const nc = newCentroids[i]; | |
| return Math.sqrt((c.x - nc.x) ** 2 + (c.y - nc.y) ** 2) < threshold; | |
| }); | |
| } | |
| // Animation system | |
| function startAnimation(duration = 400, callback = null) { | |
| animating = true; | |
| animationStartTime = performance.now(); | |
| animationDuration = duration; | |
| onAnimationComplete = callback; | |
| requestAnimationFrame(animationLoop); | |
| } | |
| function animationLoop(currentTime) { | |
| const elapsed = currentTime - animationStartTime; | |
| const progress = Math.min(elapsed / animationDuration, 1); | |
| const easedProgress = easeOutCubic(progress); | |
| // Interpolate points | |
| points.forEach(p => { | |
| p.displayX = p.startX + (p.x - p.startX) * easedProgress; | |
| p.displayY = p.startY + (p.y - p.startY) * easedProgress; | |
| p.opacity = p.startOpacity + (p.targetOpacity - p.startOpacity) * easedProgress; | |
| p.colorRGB = lerpRGB(p.startColorRGB, p.targetColorRGB, easedProgress); | |
| }); | |
| // Interpolate centroids | |
| centroids.forEach(c => { | |
| c.displayX = c.startX + (c.x - c.startX) * easedProgress; | |
| c.displayY = c.startY + (c.y - c.startY) * easedProgress; | |
| c.opacity = c.startOpacity + (c.targetOpacity - c.startOpacity) * easedProgress; | |
| }); | |
| draw(); | |
| if (progress < 1) { | |
| requestAnimationFrame(animationLoop); | |
| } else { | |
| animating = false; | |
| // Snap to final values | |
| points.forEach(p => { | |
| p.displayX = p.x; | |
| p.displayY = p.y; | |
| p.opacity = p.targetOpacity; | |
| p.colorRGB = [...p.targetColorRGB]; | |
| p.startColorRGB = [...p.targetColorRGB]; | |
| }); | |
| centroids.forEach(c => { | |
| c.displayX = c.x; | |
| c.displayY = c.y; | |
| c.opacity = c.targetOpacity; | |
| }); | |
| draw(); | |
| if (onAnimationComplete) { | |
| onAnimationComplete(); | |
| } | |
| } | |
| } | |
| // Drawing | |
| function draw() { | |
| ctx.clearRect(0, 0, canvasSize.width, canvasSize.height); | |
| // Draw points | |
| points.forEach(p => { | |
| if (p.opacity <= 0.01) return; | |
| ctx.beginPath(); | |
| ctx.arc(p.displayX, p.displayY, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = rgbToString(p.colorRGB, p.opacity); | |
| ctx.fill(); | |
| }); | |
| // Draw centroids | |
| centroids.forEach((c, i) => { | |
| if (c.opacity <= 0.01) return; | |
| const color = rgbToString(centroidColorsRGB[i % centroidColorsRGB.length], c.opacity); | |
| ctx.beginPath(); | |
| ctx.arc(c.displayX, c.displayY, 14, 0, Math.PI * 2); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.arc(c.displayX, c.displayY, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.fillStyle = `rgba(255, 255, 255, ${c.opacity})`; | |
| ctx.font = 'bold 11px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(i + 1, c.displayX, c.displayY + 26); | |
| }); | |
| } | |
| function updateUI() { | |
| document.getElementById('iteration').textContent = iteration; | |
| const phaseEl = document.getElementById('phase'); | |
| phaseEl.textContent = phase.charAt(0).toUpperCase() + phase.slice(1); | |
| phaseEl.className = 'phase-indicator phase-' + phase; | |
| document.getElementById('run-btn').textContent = running ? 'Pause' : 'Run'; | |
| } | |
| // Algorithm step with animation | |
| function step() { | |
| if (phase === 'converged' || animating) return; | |
| if (phase === 'ready' || phase === 'update') { | |
| // Assignment step | |
| assignments = computeAssignments(); | |
| points.forEach((p, i) => { | |
| p.startX = p.displayX; | |
| p.startY = p.displayY; | |
| p.startColorRGB = [...p.colorRGB]; | |
| p.targetColorRGB = [...pointColorsRGB[assignments[i] % pointColorsRGB.length]]; | |
| }); | |
| centroids.forEach(c => { | |
| c.startX = c.displayX; | |
| c.startY = c.displayY; | |
| }); | |
| phase = 'assign'; | |
| iteration++; | |
| updateUI(); | |
| startAnimation(400, () => { | |
| if (running && phase !== 'converged') { | |
| const delay = [500, 300, 180, 80, 20][speed - 1]; | |
| setTimeout(() => { if (running) step(); }, delay); | |
| } | |
| }); | |
| } else if (phase === 'assign') { | |
| // Update step | |
| const newPositions = computeCentroids(); | |
| const oldCentroids = centroids.map(c => ({ x: c.x, y: c.y })); | |
| centroids.forEach((c, i) => { | |
| c.startX = c.displayX; | |
| c.startY = c.displayY; | |
| c.x = newPositions[i].x; | |
| c.y = newPositions[i].y; | |
| }); | |
| points.forEach(p => { | |
| p.startX = p.displayX; | |
| p.startY = p.displayY; | |
| p.startColorRGB = [...p.colorRGB]; | |
| p.targetColorRGB = [...p.colorRGB]; | |
| }); | |
| if (hasConverged(oldCentroids, newPositions)) { | |
| phase = 'converged'; | |
| } else { | |
| phase = 'update'; | |
| } | |
| updateUI(); | |
| startAnimation(400, () => { | |
| if (running && phase !== 'converged') { | |
| const delay = [500, 300, 180, 80, 20][speed - 1]; | |
| setTimeout(() => { if (running) step(); }, delay); | |
| } | |
| }); | |
| } | |
| } | |
| // Smooth reset | |
| function reset(animate = true) { | |
| running = false; | |
| updateUI(); | |
| if (animating) { | |
| // Wait for current animation to finish | |
| onAnimationComplete = () => reset(animate); | |
| return; | |
| } | |
| // Generate new data | |
| const newPoints = generatePoints(numPoints); | |
| const newCentroids = initCentroids(k, newPoints); | |
| if (animate && points.length > 0) { | |
| const maxPts = Math.max(points.length, newPoints.length); | |
| const maxCents = Math.max(centroids.length, newCentroids.length); | |
| // Setup point transitions | |
| const transitionPoints = []; | |
| for (let i = 0; i < maxPts; i++) { | |
| if (i < points.length && i < newPoints.length) { | |
| // Morph existing to new | |
| transitionPoints.push({ | |
| x: newPoints[i].x, | |
| y: newPoints[i].y, | |
| displayX: points[i].displayX, | |
| displayY: points[i].displayY, | |
| startX: points[i].displayX, | |
| startY: points[i].displayY, | |
| opacity: points[i].opacity, | |
| startOpacity: points[i].opacity, | |
| targetOpacity: 1, | |
| colorRGB: [...points[i].colorRGB], | |
| startColorRGB: [...points[i].colorRGB], | |
| targetColorRGB: [...unassignedRGB] | |
| }); | |
| } else if (i < newPoints.length) { | |
| // Fade in new point | |
| transitionPoints.push({ | |
| x: newPoints[i].x, | |
| y: newPoints[i].y, | |
| displayX: newPoints[i].x, | |
| displayY: newPoints[i].y, | |
| startX: newPoints[i].x, | |
| startY: newPoints[i].y, | |
| opacity: 0, | |
| startOpacity: 0, | |
| targetOpacity: 1, | |
| colorRGB: [...unassignedRGB], | |
| startColorRGB: [...unassignedRGB], | |
| targetColorRGB: [...unassignedRGB] | |
| }); | |
| } else { | |
| // Fade out old point | |
| transitionPoints.push({ | |
| x: points[i].displayX, | |
| y: points[i].displayY, | |
| displayX: points[i].displayX, | |
| displayY: points[i].displayY, | |
| startX: points[i].displayX, | |
| startY: points[i].displayY, | |
| opacity: points[i].opacity, | |
| startOpacity: points[i].opacity, | |
| targetOpacity: 0, | |
| colorRGB: [...points[i].colorRGB], | |
| startColorRGB: [...points[i].colorRGB], | |
| targetColorRGB: [...points[i].colorRGB] | |
| }); | |
| } | |
| } | |
| // Setup centroid transitions | |
| const transitionCentroids = []; | |
| for (let i = 0; i < maxCents; i++) { | |
| if (i < centroids.length && i < newCentroids.length) { | |
| transitionCentroids.push({ | |
| x: newCentroids[i].x, | |
| y: newCentroids[i].y, | |
| displayX: centroids[i].displayX, | |
| displayY: centroids[i].displayY, | |
| startX: centroids[i].displayX, | |
| startY: centroids[i].displayY, | |
| opacity: centroids[i].opacity, | |
| startOpacity: centroids[i].opacity, | |
| targetOpacity: 1 | |
| }); | |
| } else if (i < newCentroids.length) { | |
| transitionCentroids.push({ | |
| x: newCentroids[i].x, | |
| y: newCentroids[i].y, | |
| displayX: newCentroids[i].x, | |
| displayY: newCentroids[i].y, | |
| startX: newCentroids[i].x, | |
| startY: newCentroids[i].y, | |
| opacity: 0, | |
| startOpacity: 0, | |
| targetOpacity: 1 | |
| }); | |
| } else { | |
| transitionCentroids.push({ | |
| x: centroids[i].displayX, | |
| y: centroids[i].displayY, | |
| displayX: centroids[i].displayX, | |
| displayY: centroids[i].displayY, | |
| startX: centroids[i].displayX, | |
| startY: centroids[i].displayY, | |
| opacity: centroids[i].opacity, | |
| startOpacity: centroids[i].opacity, | |
| targetOpacity: 0 | |
| }); | |
| } | |
| } | |
| points = transitionPoints; | |
| centroids = transitionCentroids; | |
| assignments = []; | |
| iteration = 0; | |
| phase = 'ready'; | |
| updateUI(); | |
| startAnimation(500, () => { | |
| // Clean up: remove faded elements, keep only valid ones | |
| points = points.filter(p => p.targetOpacity > 0).map(p => ({ | |
| ...p, | |
| startOpacity: 1, | |
| opacity: 1 | |
| })); | |
| centroids = centroids.filter(c => c.targetOpacity > 0).map(c => ({ | |
| ...c, | |
| startOpacity: 1, | |
| opacity: 1 | |
| })); | |
| draw(); | |
| }); | |
| } else { | |
| // Immediate reset | |
| points = newPoints; | |
| centroids = newCentroids; | |
| assignments = []; | |
| iteration = 0; | |
| phase = 'ready'; | |
| updateUI(); | |
| draw(); | |
| } | |
| } | |
| // Event listeners | |
| document.getElementById('k-slider').addEventListener('input', (e) => { | |
| k = parseInt(e.target.value); | |
| document.getElementById('k-value').textContent = k; | |
| reset(true); | |
| }); | |
| document.getElementById('points-slider').addEventListener('input', (e) => { | |
| numPoints = parseInt(e.target.value); | |
| document.getElementById('points-value').textContent = numPoints; | |
| reset(true); | |
| }); | |
| document.getElementById('speed-slider').addEventListener('input', (e) => { | |
| speed = parseInt(e.target.value); | |
| document.getElementById('speed-value').textContent = speed + '×'; | |
| }); | |
| document.getElementById('reset-btn').addEventListener('click', () => reset(true)); | |
| document.getElementById('step-btn').addEventListener('click', () => { | |
| running = false; | |
| updateUI(); | |
| step(); | |
| }); | |
| document.getElementById('run-btn').addEventListener('click', () => { | |
| if (phase === 'converged') { | |
| reset(true); | |
| return; | |
| } | |
| running = !running; | |
| updateUI(); | |
| if (running && !animating) step(); | |
| }); | |
| // Initialize | |
| reset(false); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment