Skip to content

Instantly share code, notes, and snippets.

@kbouw
Created January 23, 2026 05:06
Show Gist options
  • Select an option

  • Save kbouw/c9c35d8b46dffc7a7b63e08ad3be31b7 to your computer and use it in GitHub Desktop.

Select an option

Save kbouw/c9c35d8b46dffc7a7b63e08ad3be31b7 to your computer and use it in GitHub Desktop.
<!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