<div class="controls">
<label>Add points as:
<select id="classSelect">
<option value="0">● Blue (class A)</option>
<option value="1">● Red (class B)</option>
</select>
</label>
<label>k = <span id="kVal">3</span>
<input type="range" id="kSlider" min="1" max="9" value="3" step="2">
</label>
<button id="resetBtn" type="button" class="ghost">Clear all</button>
</div>
<canvas id="canvas" width="460" height="300"></canvas>
<div class="result" id="result">Click the canvas to add points, then place the ★ query.</div>
<div class="legend">
<span class="dot blue"></span> Class A
<span class="dot red"></span> Class B
<span class="star">★</span> Query
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; gap: .8rem; flex-wrap: wrap; align-items: center; margin-bottom: .5rem; font-size: .88rem; }
label { display: flex; gap: .4rem; align-items: center; }
select, input[type=range] { font-size: .88rem; }
#canvas { display: block; width: 100%; border: 1px solid #cdd9e3; border-radius: 8px;
background: #f6f9fc; cursor: crosshair; touch-action: none; }
.result { font-weight: 600; font-size: .95rem; margin: .5rem 0 .3rem; min-height: 1.4em; }
.result.a { color: #1a73e8; }
.result.b { color: #e8311a; }
.result.tie { color: #7d5a00; }
.legend { font-size: .8rem; color: #555; display: flex; gap: .3rem; align-items: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.dot.blue { background: #1a73e8; }
.dot.red { background: #e8311a; }
.star { color: #d4a017; font-size: 1rem; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// k-Nearest Neighbors demo
// Points are stored as {x, y, cls} where cls=0 (blue/A) or cls=1 (red/B).
// A special "query" point (★) is placed with a long-press/right-click or
// Ctrl+click. Ordinary clicks add training points.
// On each update we recompute the k nearest training points to the query
// (Euclidean distance), tally votes, and draw connection lines.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const CW = 460, CH = 300;
const COLORS = ['#1a73e8','#e8311a'];
const LIGHT = ['#d0e4fc','#fcd0cc'];
const NAMES = ['A','B'];
let points = []; // {x,y,cls}
let query = null; // {x,y}
let k = 3;
// Seed with a few example points so the demo is not empty
const SEED = [
{x:80, y:80, cls:0},{x:120, y:60, cls:0},{x:60, y:130, cls:0},
{x:150, y:100, cls:0},{x:100, y:150, cls:0},
{x:320, y:80, cls:1},{x:360, y:120, cls:1},{x:300, y:150, cls:1},
{x:380, y:60, cls:1},{x:340, y:170, cls:1}
];
points = SEED.slice();
query = {x:230, y:160};
function dist2(a, b){ return (a.x-b.x)**2 + (a.y-b.y)**2; }
function knn(){
if(!query || points.length === 0) return null;
const sorted = points
.map(p => ({p, d: Math.sqrt(dist2(p, query))}))
.sort((a,b) => a.d - b.d);
const neighbors = sorted.slice(0, k);
const votes = [0, 0];
neighbors.forEach(({p}) => votes[p.cls]++);
return { neighbors, votes };
}
function draw(){
ctx.clearRect(0,0,CW,CH);
const res = knn();
// Draw distance lines to neighbors
if(res && query){
res.neighbors.forEach(({p, d}) => {
ctx.beginPath();
ctx.moveTo(query.x, query.y);
ctx.lineTo(p.x, p.y);
ctx.strokeStyle = COLORS[p.cls] + '55';
ctx.lineWidth = 1.5;
ctx.setLineDash([4,3]);
ctx.stroke();
ctx.setLineDash([]);
// distance label
const mx = (query.x + p.x)/2, my = (query.y + p.y)/2;
ctx.fillStyle = '#666';
ctx.font = '10px system-ui';
ctx.fillText(d.toFixed(1), mx+3, my-3);
});
}
// Draw training points
points.forEach(p => {
// highlight if a neighbor
const isNeighbor = res && res.neighbors.some(n => n.p === p);
ctx.beginPath();
ctx.arc(p.x, p.y, isNeighbor ? 9 : 7, 0, Math.PI*2);
ctx.fillStyle = isNeighbor ? COLORS[p.cls] : LIGHT[p.cls];
ctx.strokeStyle = COLORS[p.cls];
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
});
// Draw query star
if(query){
drawStar(ctx, query.x, query.y, 10, '#d4a017', '#7d5a00');
}
// Update result text
const el = document.getElementById('result');
if(!query){
el.textContent = 'Ctrl+click (or right-click) to place the ★ query.';
el.className = 'result';
} else if(points.length === 0){
el.textContent = 'Add training points first.';
el.className = 'result';
} else if(!res){
el.textContent = '';
el.className = 'result';
} else {
const [va, vb] = res.votes;
if(va === vb){
el.textContent = fmt('Tie! %va% votes each — k = %k% is even for 2 classes; try an odd k.', { va: va, k: k });
el.className = 'result tie';
} else {
const winner = va > vb ? 0 : 1;
el.textContent = fmt('★ classified as Class %v% (%v2% of %k% neighbors).', { v: NAMES[winner], v2: res.votes[winner], k: k });
el.className = 'result ' + (winner === 0 ? 'a' : 'b');
}
}
}
function drawStar(ctx, cx, cy, r, fill, stroke){
const pts = 5, inner = r*0.45;
ctx.beginPath();
for(let i=0;i<pts*2;i++){
const angle = (i*Math.PI/pts) - Math.PI/2;
const rr = i%2===0 ? r : inner;
i===0 ? ctx.moveTo(cx+rr*Math.cos(angle), cy+rr*Math.sin(angle))
: ctx.lineTo(cx+rr*Math.cos(angle), cy+rr*Math.sin(angle));
}
ctx.closePath();
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.fill();
ctx.stroke();
}
function canvasPos(e){
const rect = canvas.getBoundingClientRect();
const scaleX = CW / rect.width;
const scaleY = CH / rect.height;
const src = e.touches ? e.touches[0] : e;
return { x: (src.clientX - rect.left)*scaleX, y: (src.clientY - rect.top)*scaleY };
}
canvas.addEventListener('click', e => {
if(e.ctrlKey || e.metaKey){
query = canvasPos(e);
} else {
const cls = parseInt(document.getElementById('classSelect').value);
points.push({...canvasPos(e), cls});
}
draw();
});
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
query = canvasPos(e);
draw();
});
// Touch: long-press to place query
let touchTimer = null;
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const pos = canvasPos(e);
touchTimer = setTimeout(() => {
query = pos;
draw();
touchTimer = null;
}, 500);
}, {passive:false});
canvas.addEventListener('touchend', e => {
if(touchTimer){ clearTimeout(touchTimer); touchTimer=null; }
});
document.getElementById('kSlider').addEventListener('input', e => {
k = parseInt(e.target.value);
document.getElementById('kVal').textContent = k;
draw();
});
document.getElementById('resetBtn').addEventListener('click', () => {
points = [];
query = null;
draw();
});
draw();
<div class="controls">
<label>Add points as:
<select id="classSelect">
<option value="0">● Blue (class A)</option>
<option value="1">● Red (class B)</option>
</select>
</label>
<label>k = <span id="kVal">3</span>
<input type="range" id="kSlider" min="1" max="9" value="3" step="2">
</label>
<button id="resetBtn" type="button" class="ghost">Clear all</button>
</div>
<canvas id="canvas" width="460" height="300"></canvas>
<div class="result" id="result">Click the canvas to add points, then place the ★ query.</div>
<div class="legend">
<span class="dot blue"></span> Class A
<span class="dot red"></span> Class B
<span class="star">★</span> Query
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; gap: .8rem; flex-wrap: wrap; align-items: center; margin-bottom: .5rem; font-size: .88rem; }
label { display: flex; gap: .4rem; align-items: center; }
select, input[type=range] { font-size: .88rem; }
#canvas { display: block; width: 100%; border: 1px solid #cdd9e3; border-radius: 8px;
background: #f6f9fc; cursor: crosshair; touch-action: none; }
.result { font-weight: 600; font-size: .95rem; margin: .5rem 0 .3rem; min-height: 1.4em; }
.result.a { color: #1a73e8; }
.result.b { color: #e8311a; }
.result.tie { color: #7d5a00; }
.legend { font-size: .8rem; color: #555; display: flex; gap: .3rem; align-items: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.dot.blue { background: #1a73e8; }
.dot.red { background: #e8311a; }
.star { color: #d4a017; font-size: 1rem; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// k-Nearest Neighbors demo
// Points are stored as {x, y, cls} where cls=0 (blue/A) or cls=1 (red/B).
// A special "query" point (★) is placed with a long-press/right-click or
// Ctrl+click. Ordinary clicks add training points.
// On each update we recompute the k nearest training points to the query
// (Euclidean distance), tally votes, and draw connection lines.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const CW = 460, CH = 300;
const COLORS = ['#1a73e8','#e8311a'];
const LIGHT = ['#d0e4fc','#fcd0cc'];
const NAMES = ['A','B'];
let points = []; // {x,y,cls}
let query = null; // {x,y}
let k = 3;
// Seed with a few example points so the demo is not empty
const SEED = [
{x:80, y:80, cls:0},{x:120, y:60, cls:0},{x:60, y:130, cls:0},
{x:150, y:100, cls:0},{x:100, y:150, cls:0},
{x:320, y:80, cls:1},{x:360, y:120, cls:1},{x:300, y:150, cls:1},
{x:380, y:60, cls:1},{x:340, y:170, cls:1}
];
points = SEED.slice();
query = {x:230, y:160};
function dist2(a, b){ return (a.x-b.x)**2 + (a.y-b.y)**2; }
function knn(){
if(!query || points.length === 0) return null;
const sorted = points
.map(p => ({p, d: Math.sqrt(dist2(p, query))}))
.sort((a,b) => a.d - b.d);
const neighbors = sorted.slice(0, k);
const votes = [0, 0];
neighbors.forEach(({p}) => votes[p.cls]++);
return { neighbors, votes };
}
function draw(){
ctx.clearRect(0,0,CW,CH);
const res = knn();
// Draw distance lines to neighbors
if(res && query){
res.neighbors.forEach(({p, d}) => {
ctx.beginPath();
ctx.moveTo(query.x, query.y);
ctx.lineTo(p.x, p.y);
ctx.strokeStyle = COLORS[p.cls] + '55';
ctx.lineWidth = 1.5;
ctx.setLineDash([4,3]);
ctx.stroke();
ctx.setLineDash([]);
// distance label
const mx = (query.x + p.x)/2, my = (query.y + p.y)/2;
ctx.fillStyle = '#666';
ctx.font = '10px system-ui';
ctx.fillText(d.toFixed(1), mx+3, my-3);
});
}
// Draw training points
points.forEach(p => {
// highlight if a neighbor
const isNeighbor = res && res.neighbors.some(n => n.p === p);
ctx.beginPath();
ctx.arc(p.x, p.y, isNeighbor ? 9 : 7, 0, Math.PI*2);
ctx.fillStyle = isNeighbor ? COLORS[p.cls] : LIGHT[p.cls];
ctx.strokeStyle = COLORS[p.cls];
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
});
// Draw query star
if(query){
drawStar(ctx, query.x, query.y, 10, '#d4a017', '#7d5a00');
}
// Update result text
const el = document.getElementById('result');
if(!query){
el.textContent = 'Ctrl+click (or right-click) to place the ★ query.';
el.className = 'result';
} else if(points.length === 0){
el.textContent = 'Add training points first.';
el.className = 'result';
} else if(!res){
el.textContent = '';
el.className = 'result';
} else {
const [va, vb] = res.votes;
if(va === vb){
el.textContent = fmt('Tie! %va% votes each — k = %k% is even for 2 classes; try an odd k.', { va: va, k: k });
el.className = 'result tie';
} else {
const winner = va > vb ? 0 : 1;
el.textContent = fmt('★ classified as Class %v% (%v2% of %k% neighbors).', { v: NAMES[winner], v2: res.votes[winner], k: k });
el.className = 'result ' + (winner === 0 ? 'a' : 'b');
}
}
}
function drawStar(ctx, cx, cy, r, fill, stroke){
const pts = 5, inner = r*0.45;
ctx.beginPath();
for(let i=0;i<pts*2;i++){
const angle = (i*Math.PI/pts) - Math.PI/2;
const rr = i%2===0 ? r : inner;
i===0 ? ctx.moveTo(cx+rr*Math.cos(angle), cy+rr*Math.sin(angle))
: ctx.lineTo(cx+rr*Math.cos(angle), cy+rr*Math.sin(angle));
}
ctx.closePath();
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.fill();
ctx.stroke();
}
function canvasPos(e){
const rect = canvas.getBoundingClientRect();
const scaleX = CW / rect.width;
const scaleY = CH / rect.height;
const src = e.touches ? e.touches[0] : e;
return { x: (src.clientX - rect.left)*scaleX, y: (src.clientY - rect.top)*scaleY };
}
canvas.addEventListener('click', e => {
if(e.ctrlKey || e.metaKey){
query = canvasPos(e);
} else {
const cls = parseInt(document.getElementById('classSelect').value);
points.push({...canvasPos(e), cls});
}
draw();
});
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
query = canvasPos(e);
draw();
});
// Touch: long-press to place query
let touchTimer = null;
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const pos = canvasPos(e);
touchTimer = setTimeout(() => {
query = pos;
draw();
touchTimer = null;
}, 500);
}, {passive:false});
canvas.addEventListener('touchend', e => {
if(touchTimer){ clearTimeout(touchTimer); touchTimer=null; }
});
document.getElementById('kSlider').addEventListener('input', e => {
k = parseInt(e.target.value);
document.getElementById('kVal').textContent = k;
draw();
});
document.getElementById('resetBtn').addEventListener('click', () => {
points = [];
query = null;
draw();
});
draw();
Comments
Loading comments...