<p class="hint">Three natural blobs of points, with <b>k = 3</b> centers (the big rings). Press <b>Step</b> to run one round of Lloyd’s algorithm: assign every point to its nearest center, then move each center to the average of its points. <b>Checking</b> a clustering’s score is instant — <b>finding</b> the start that reaches the best score is hard.</p>
<canvas id="cv" width="360" height="300"></canvas>
<div class="status" id="status">Press Step to begin.</div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run to convergence</button>
<button id="seed" type="button">New seeds</button>
<button id="reset" type="button" class="ghost">Reset points</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #fbfdff; width: 100%; max-width: 360px; height: auto; display: block; }
.status { font-size: .95rem; font-weight: 600; margin: .55rem 0; min-height: 1.4em; color: #1d3557; }
.status.done { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// k-means clustering with Lloyd's algorithm. Goal: pick k centers and assign each point to its nearest center, minimizing the within-cluster sum of squared distances (the "score"). Lloyd's loop (assign -> update) always converges, but only to a LOCAL optimum: different seeds -> different scores. Finding the global best is NP-hard (Aloise et al. 2009). Checking a score is instant.
const W = 360, H = 300, K = 3;
const COLORS = ['#e63946', '#2a9d8f', '#457b9d'];
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
let points = [], centers = [], assign = [], iter = 0, converged = false;
function rnd(a, b) { return a + Math.random() * (b - a); }
function blob(cx, cy, n, spread) {
for (let i = 0; i < n; i++) points.push({ x: cx + rnd(-spread, spread), y: cy + rnd(-spread, spread) });
}
function makePoints() {
points = [];
blob(95, 80, 18, 34);
blob(265, 95, 18, 34);
blob(170, 220, 18, 34);
}
function seed() {
// random initial centers chosen from the data points (forgy init)
centers = []; const used = new Set();
while (centers.length < K) {
const i = Math.floor(Math.random() * points.length);
if (used.has(i)) continue; used.add(i);
centers.push({ x: points[i].x, y: points[i].y });
}
assign = points.map(() => -1); iter = 0; converged = false;
}
const d2 = (p, c) => (p.x - c.x) * (p.x - c.x) + (p.y - c.y) * (p.y - c.y);
// ASSIGN: each point -> nearest center. Returns true if any assignment changed.
function assignStep() {
let changed = false;
for (let i = 0; i < points.length; i++) {
let best = 0, bd = Infinity;
for (let c = 0; c < centers.length; c++) {
const dd = d2(points[i], centers[c]);
if (dd < bd) { bd = dd; best = c; }
}
if (assign[i] !== best) { assign[i] = best; changed = true; }
}
return changed;
}
// UPDATE: each center -> mean of its assigned points.
function updateStep() {
for (let c = 0; c < centers.length; c++) {
let sx = 0, sy = 0, n = 0;
for (let i = 0; i < points.length; i++) if (assign[i] === c) { sx += points[i].x; sy += points[i].y; n++; }
if (n > 0) { centers[c].x = sx / n; centers[c].y = sy / n; }
}
}
// SCORE: within-cluster sum of squared distances (lower is better).
function score() {
let s = 0;
for (let i = 0; i < points.length; i++) s += d2(points[i], centers[assign[i]]);
return s;
}
function step() {
if (converged) return;
const changed = assignStep();
updateStep();
iter++;
if (!changed && iter > 1) converged = true;
draw();
const s = Math.round(score());
setStatus(fmt('%v% — score %s% (lower = tighter clusters).', { v: converged ? 'Converged' : fmt('Iteration %iter%', { iter: iter }), s: s }), converged);
}
function runAll() {
let guard = 0;
while (!converged && guard++ < 100) step();
}
function setStatus(t, done) { const el = document.getElementById('status'); el.textContent = t; el.className = 'status' + (done ? ' done' : ''); }
function draw() {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < points.length; i++) {
const a = assign[i];
ctx.fillStyle = a < 0 ? '#9aa5b1' : COLORS[a];
ctx.beginPath(); ctx.arc(points[i].x, points[i].y, 4, 0, 7); ctx.fill();
}
for (let c = 0; c < centers.length; c++) {
ctx.strokeStyle = COLORS[c]; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(centers[c].x, centers[c].y, 10, 0, 7); ctx.stroke();
ctx.fillStyle = COLORS[c];
ctx.beginPath(); ctx.arc(centers[c].x, centers[c].y, 3, 0, 7); ctx.fill();
}
}
document.getElementById('step').addEventListener('click', step);
document.getElementById('run').addEventListener('click', runAll);
document.getElementById('seed').addEventListener('click', () => { seed(); draw(); setStatus('New random seeds — press Step.', false); });
document.getElementById('reset').addEventListener('click', () => { makePoints(); seed(); draw(); setStatus('Press Step to begin.', false); });
makePoints(); seed(); draw();
<p class="hint">Three natural blobs of points, with <b>k = 3</b> centers (the big rings). Press <b>Step</b> to run one round of Lloyd’s algorithm: assign every point to its nearest center, then move each center to the average of its points. <b>Checking</b> a clustering’s score is instant — <b>finding</b> the start that reaches the best score is hard.</p>
<canvas id="cv" width="360" height="300"></canvas>
<div class="status" id="status">Press Step to begin.</div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run to convergence</button>
<button id="seed" type="button">New seeds</button>
<button id="reset" type="button" class="ghost">Reset points</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #fbfdff; width: 100%; max-width: 360px; height: auto; display: block; }
.status { font-size: .95rem; font-weight: 600; margin: .55rem 0; min-height: 1.4em; color: #1d3557; }
.status.done { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// k-means clustering with Lloyd's algorithm. Goal: pick k centers and assign each point to its nearest center, minimizing the within-cluster sum of squared distances (the "score"). Lloyd's loop (assign -> update) always converges, but only to a LOCAL optimum: different seeds -> different scores. Finding the global best is NP-hard (Aloise et al. 2009). Checking a score is instant.
const W = 360, H = 300, K = 3;
const COLORS = ['#e63946', '#2a9d8f', '#457b9d'];
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
let points = [], centers = [], assign = [], iter = 0, converged = false;
function rnd(a, b) { return a + Math.random() * (b - a); }
function blob(cx, cy, n, spread) {
for (let i = 0; i < n; i++) points.push({ x: cx + rnd(-spread, spread), y: cy + rnd(-spread, spread) });
}
function makePoints() {
points = [];
blob(95, 80, 18, 34);
blob(265, 95, 18, 34);
blob(170, 220, 18, 34);
}
function seed() {
// random initial centers chosen from the data points (forgy init)
centers = []; const used = new Set();
while (centers.length < K) {
const i = Math.floor(Math.random() * points.length);
if (used.has(i)) continue; used.add(i);
centers.push({ x: points[i].x, y: points[i].y });
}
assign = points.map(() => -1); iter = 0; converged = false;
}
const d2 = (p, c) => (p.x - c.x) * (p.x - c.x) + (p.y - c.y) * (p.y - c.y);
// ASSIGN: each point -> nearest center. Returns true if any assignment changed.
function assignStep() {
let changed = false;
for (let i = 0; i < points.length; i++) {
let best = 0, bd = Infinity;
for (let c = 0; c < centers.length; c++) {
const dd = d2(points[i], centers[c]);
if (dd < bd) { bd = dd; best = c; }
}
if (assign[i] !== best) { assign[i] = best; changed = true; }
}
return changed;
}
// UPDATE: each center -> mean of its assigned points.
function updateStep() {
for (let c = 0; c < centers.length; c++) {
let sx = 0, sy = 0, n = 0;
for (let i = 0; i < points.length; i++) if (assign[i] === c) { sx += points[i].x; sy += points[i].y; n++; }
if (n > 0) { centers[c].x = sx / n; centers[c].y = sy / n; }
}
}
// SCORE: within-cluster sum of squared distances (lower is better).
function score() {
let s = 0;
for (let i = 0; i < points.length; i++) s += d2(points[i], centers[assign[i]]);
return s;
}
function step() {
if (converged) return;
const changed = assignStep();
updateStep();
iter++;
if (!changed && iter > 1) converged = true;
draw();
const s = Math.round(score());
setStatus(fmt('%v% — score %s% (lower = tighter clusters).', { v: converged ? 'Converged' : fmt('Iteration %iter%', { iter: iter }), s: s }), converged);
}
function runAll() {
let guard = 0;
while (!converged && guard++ < 100) step();
}
function setStatus(t, done) { const el = document.getElementById('status'); el.textContent = t; el.className = 'status' + (done ? ' done' : ''); }
function draw() {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < points.length; i++) {
const a = assign[i];
ctx.fillStyle = a < 0 ? '#9aa5b1' : COLORS[a];
ctx.beginPath(); ctx.arc(points[i].x, points[i].y, 4, 0, 7); ctx.fill();
}
for (let c = 0; c < centers.length; c++) {
ctx.strokeStyle = COLORS[c]; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(centers[c].x, centers[c].y, 10, 0, 7); ctx.stroke();
ctx.fillStyle = COLORS[c];
ctx.beginPath(); ctx.arc(centers[c].x, centers[c].y, 3, 0, 7); ctx.fill();
}
}
document.getElementById('step').addEventListener('click', step);
document.getElementById('run').addEventListener('click', runAll);
document.getElementById('seed').addEventListener('click', () => { seed(); draw(); setStatus('New random seeds — press Step.', false); });
document.getElementById('reset').addEventListener('click', () => { makePoints(); seed(); draw(); setStatus('Press Step to begin.', false); });
makePoints(); seed(); draw();
Comments
Loading comments...