Below are two classes of points, blue and orange. The shaded band is the street — the widest gap that separates the two colors — and the dashed lines are its curbs. The points touching a curb are the support vectors, highlighted with a ring.
<p class="hint">Two classes of points: <b class="bl">blue</b> and <b class="og">orange</b>. The shaded band is the widest <b>street</b> separating them; the points on its curbs, ringed in black, are the <b>support vectors</b>. Drag any point and watch it re-shape.</p>
<canvas id="cv" width="460" height="340"></canvas>
<div class="status" id="status"></div>
<div class="btns">
<button id="reset" type="button">Reset points</button>
<button id="add" type="button" class="ghost">Add blue / orange</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; }
.hint .bl { color: #2a64d6; }
.hint .og { color: #e08a1e; }
#cv { border: 1px solid #cdd9e3; border-radius: 10px; background: #f7fafc;
touch-action: none; max-width: 100%; cursor: grab; }
#cv.drag { cursor: grabbing; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Maximum-margin (hard-margin) SVM in 2D, computed geometrically.
// The maximum-margin separator is the perpendicular bisector of the shortest
// segment between the convex hulls of the two classes. We find the closest
// pair of points (one in each hull) by alternating projections: repeatedly
// project one anchor onto the other class's hull until it stops moving.
// The support vectors are the original points whose hull edge the closest points land on. Convex, single global optimum (Cortes & Vapnik, 1995).
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, R = 9;
let A = [[90,90],[120,200],[80,260],[150,120]]; // blue (class +1)
let B = [[350,110],[320,240],[380,190],[300,160]]; // orange (class -1)
const A0 = A.map(p=>p.slice()), B0 = B.map(p=>p.slice());
const sub=(p,q)=>[p[0]-q[0],p[1]-q[1]];
const dot=(p,q)=>p[0]*q[0]+p[1]*q[1];
const dist2=(p,q)=>{const d=sub(p,q);return dot(d,d);};
// Closest point to T on segment PQ.
function closestOnSeg(P,Q,T){
const pq=sub(Q,P); const l2=dot(pq,pq);
if(l2===0) return P.slice();
let t=dot(sub(T,P),pq)/l2; t=Math.max(0,Math.min(1,t));
return [P[0]+t*pq[0], P[1]+t*pq[1]];
}
// Closest point to T on the convex hull (boundary) of set S — approximated by
// the closest point over all hull edges; for the separating-distance problem
// the nearest point of the hull to an external T lies on its boundary.
function hullPoints(S){
if(S.length<3) return S.map(p=>p.slice());
const pts=S.map(p=>p.slice()).sort((a,b)=>a[0]-b[0]||a[1]-b[1]);
const cross=(o,a,b)=>(a[0]-o[0])*(b[1]-o[1])-(a[1]-o[1])*(b[0]-o[0]);
const lower=[]; for(const p of pts){ while(lower.length>=2 && cross(lower[lower.length-2],lower[lower.length-1],p)<=0) lower.pop(); lower.push(p); }
const upper=[]; for(let i=pts.length-1;i>=0;i--){ const p=pts[i]; while(upper.length>=2 && cross(upper[upper.length-2],upper[upper.length-1],p)<=0) upper.pop(); upper.push(p); }
lower.pop(); upper.pop(); return lower.concat(upper);
}
function closestOnHull(S,T){
const h=hullPoints(S); let best=h[0],bd=Infinity;
for(let i=0;i<h.length;i++){ const c=closestOnSeg(h[i],h[(i+1)%h.length],T); const d=dist2(c,T); if(d<bd){bd=d;best=c;} }
return best;
}
// Alternating projection between the two hulls -> closest pair (a* in hull A, b* in hull B).
function closestPair(){
let a=A[0].slice();
for(let it=0;it<60;it++){
const b=closestOnHull(B,a);
const a2=closestOnHull(A,b);
if(dist2(a2,a)<1e-7){ a=a2; return [a,b]; }
a=a2;
}
const b=closestOnHull(B,a); return [a,b];
}
// A class is linearly separable iff the hulls don't intersect, i.e. the closest
// pair has positive distance.
function solve(){
const [a,b]=closestPair();
const w=sub(b,a); // normal, pointing from A toward B
const wn=Math.hypot(w[0],w[1]);
const mid=[(a[0]+b[0])/2,(a[1]+b[1])/2];
const margin=wn; // full street width (a* to b*)
const sep = wn>1e-3;
return {a,b,w,wn,mid,margin,sep};
}
// signed side of a point relative to the boundary line through mid with normal w
function side(p,sol){ return dot(sub(p,sol.mid),sol.w); }
function draw(){
ctx.clearRect(0,0,W,H);
const s=solve();
if(s.sep){
const ux=s.w[0]/s.wn, uy=s.w[1]/s.wn; // unit normal
const px=-uy, py=ux; // boundary direction
const half=s.wn/2; // half-street
const L=900;
// street band (two curbs through a and b)
ctx.fillStyle='rgba(120,160,210,0.16)';
ctx.beginPath();
ctx.moveTo(s.a[0]-px*L, s.a[1]-py*L);
ctx.lineTo(s.a[0]+px*L, s.a[1]+py*L);
ctx.lineTo(s.b[0]+px*L, s.b[1]+py*L);
ctx.lineTo(s.b[0]-px*L, s.b[1]-py*L);
ctx.closePath(); ctx.fill();
// curbs (dashed)
ctx.setLineDash([6,5]); ctx.strokeStyle='#7f93ad'; ctx.lineWidth=1.5;
for(const c of [s.a,s.b]){ ctx.beginPath(); ctx.moveTo(c[0]-px*L,c[1]-py*L); ctx.lineTo(c[0]+px*L,c[1]+py*L); ctx.stroke(); }
// boundary (solid)
ctx.setLineDash([]); ctx.strokeStyle='#1d3557'; ctx.lineWidth=2.2;
ctx.beginPath(); ctx.moveTo(s.mid[0]-px*L,s.mid[1]-py*L); ctx.lineTo(s.mid[0]+px*L,s.mid[1]+py*L); ctx.stroke();
}
// identify support vectors: original points within ~R of a curb
const isSV=(p)=>{ if(!s.sep) return false; const sa=Math.abs(side(p,s)-side(s.a,s)); const sb=Math.abs(side(p,s)-side(s.b,s)); return Math.min(sa,sb) < s.wn*R*0.9; };
const drawPt=(p,fill,sv)=>{
ctx.beginPath(); ctx.arc(p[0],p[1],R,0,7); ctx.fillStyle=fill; ctx.fill();
if(sv){ ctx.lineWidth=3; ctx.strokeStyle='#111'; ctx.stroke(); }
else { ctx.lineWidth=1.5; ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.stroke(); }
};
A.forEach(p=>drawPt(p,'#2a64d6', isSV(p)));
B.forEach(p=>drawPt(p,'#e08a1e', isSV(p)));
const st=document.getElementById('status');
if(s.sep){ st.textContent=fmt('Separable — street width ≈ %margin% px. Drag a support vector (ringed) to change it.', { margin: s.margin.toFixed(0) }); st.className='status ok'; }
else { st.textContent='Not separable — the classes overlap, so no clean street exists.'; st.className='status bad'; }
}
// dragging
let drag=null;
function pos(e){ const r=cv.getBoundingClientRect(); const t=e.touches?e.touches[0]:e; return [(t.clientX-r.left)*(W/r.width),(t.clientY-r.top)*(H/r.height)]; }
function pick(m){ for(const arr of [A,B]) for(const p of arr) if(dist2(p,m)<=(R+4)*(R+4)) return p; return null; }
function down(e){ const m=pos(e); drag=pick(m); if(drag){ cv.classList.add('drag'); e.preventDefault(); } }
function move(e){ if(!drag) return; const m=pos(e); drag[0]=Math.max(R,Math.min(W-R,m[0])); drag[1]=Math.max(R,Math.min(H-R,m[1])); draw(); e.preventDefault(); }
function up(){ drag=null; cv.classList.remove('drag'); }
cv.addEventListener('mousedown',down); window.addEventListener('mousemove',move); window.addEventListener('mouseup',up);
cv.addEventListener('touchstart',down,{passive:false}); cv.addEventListener('touchmove',move,{passive:false}); cv.addEventListener('touchend',up);
document.getElementById('reset').addEventListener('click',()=>{ A=A0.map(p=>p.slice()); B=B0.map(p=>p.slice()); draw(); });
document.getElementById('add').addEventListener('click',()=>{ A.push([60+Math.random()*120,60+Math.random()*220]); B.push([300+Math.random()*120,60+Math.random()*220]); draw(); });
draw();
<p class="hint">Two classes of points: <b class="bl">blue</b> and <b class="og">orange</b>. The shaded band is the widest <b>street</b> separating them; the points on its curbs, ringed in black, are the <b>support vectors</b>. Drag any point and watch it re-shape.</p>
<canvas id="cv" width="460" height="340"></canvas>
<div class="status" id="status"></div>
<div class="btns">
<button id="reset" type="button">Reset points</button>
<button id="add" type="button" class="ghost">Add blue / orange</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; }
.hint .bl { color: #2a64d6; }
.hint .og { color: #e08a1e; }
#cv { border: 1px solid #cdd9e3; border-radius: 10px; background: #f7fafc;
touch-action: none; max-width: 100%; cursor: grab; }
#cv.drag { cursor: grabbing; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Maximum-margin (hard-margin) SVM in 2D, computed geometrically.
// The maximum-margin separator is the perpendicular bisector of the shortest
// segment between the convex hulls of the two classes. We find the closest
// pair of points (one in each hull) by alternating projections: repeatedly
// project one anchor onto the other class's hull until it stops moving.
// The support vectors are the original points whose hull edge the closest points land on. Convex, single global optimum (Cortes & Vapnik, 1995).
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, R = 9;
let A = [[90,90],[120,200],[80,260],[150,120]]; // blue (class +1)
let B = [[350,110],[320,240],[380,190],[300,160]]; // orange (class -1)
const A0 = A.map(p=>p.slice()), B0 = B.map(p=>p.slice());
const sub=(p,q)=>[p[0]-q[0],p[1]-q[1]];
const dot=(p,q)=>p[0]*q[0]+p[1]*q[1];
const dist2=(p,q)=>{const d=sub(p,q);return dot(d,d);};
// Closest point to T on segment PQ.
function closestOnSeg(P,Q,T){
const pq=sub(Q,P); const l2=dot(pq,pq);
if(l2===0) return P.slice();
let t=dot(sub(T,P),pq)/l2; t=Math.max(0,Math.min(1,t));
return [P[0]+t*pq[0], P[1]+t*pq[1]];
}
// Closest point to T on the convex hull (boundary) of set S — approximated by
// the closest point over all hull edges; for the separating-distance problem
// the nearest point of the hull to an external T lies on its boundary.
function hullPoints(S){
if(S.length<3) return S.map(p=>p.slice());
const pts=S.map(p=>p.slice()).sort((a,b)=>a[0]-b[0]||a[1]-b[1]);
const cross=(o,a,b)=>(a[0]-o[0])*(b[1]-o[1])-(a[1]-o[1])*(b[0]-o[0]);
const lower=[]; for(const p of pts){ while(lower.length>=2 && cross(lower[lower.length-2],lower[lower.length-1],p)<=0) lower.pop(); lower.push(p); }
const upper=[]; for(let i=pts.length-1;i>=0;i--){ const p=pts[i]; while(upper.length>=2 && cross(upper[upper.length-2],upper[upper.length-1],p)<=0) upper.pop(); upper.push(p); }
lower.pop(); upper.pop(); return lower.concat(upper);
}
function closestOnHull(S,T){
const h=hullPoints(S); let best=h[0],bd=Infinity;
for(let i=0;i<h.length;i++){ const c=closestOnSeg(h[i],h[(i+1)%h.length],T); const d=dist2(c,T); if(d<bd){bd=d;best=c;} }
return best;
}
// Alternating projection between the two hulls -> closest pair (a* in hull A, b* in hull B).
function closestPair(){
let a=A[0].slice();
for(let it=0;it<60;it++){
const b=closestOnHull(B,a);
const a2=closestOnHull(A,b);
if(dist2(a2,a)<1e-7){ a=a2; return [a,b]; }
a=a2;
}
const b=closestOnHull(B,a); return [a,b];
}
// A class is linearly separable iff the hulls don't intersect, i.e. the closest
// pair has positive distance.
function solve(){
const [a,b]=closestPair();
const w=sub(b,a); // normal, pointing from A toward B
const wn=Math.hypot(w[0],w[1]);
const mid=[(a[0]+b[0])/2,(a[1]+b[1])/2];
const margin=wn; // full street width (a* to b*)
const sep = wn>1e-3;
return {a,b,w,wn,mid,margin,sep};
}
// signed side of a point relative to the boundary line through mid with normal w
function side(p,sol){ return dot(sub(p,sol.mid),sol.w); }
function draw(){
ctx.clearRect(0,0,W,H);
const s=solve();
if(s.sep){
const ux=s.w[0]/s.wn, uy=s.w[1]/s.wn; // unit normal
const px=-uy, py=ux; // boundary direction
const half=s.wn/2; // half-street
const L=900;
// street band (two curbs through a and b)
ctx.fillStyle='rgba(120,160,210,0.16)';
ctx.beginPath();
ctx.moveTo(s.a[0]-px*L, s.a[1]-py*L);
ctx.lineTo(s.a[0]+px*L, s.a[1]+py*L);
ctx.lineTo(s.b[0]+px*L, s.b[1]+py*L);
ctx.lineTo(s.b[0]-px*L, s.b[1]-py*L);
ctx.closePath(); ctx.fill();
// curbs (dashed)
ctx.setLineDash([6,5]); ctx.strokeStyle='#7f93ad'; ctx.lineWidth=1.5;
for(const c of [s.a,s.b]){ ctx.beginPath(); ctx.moveTo(c[0]-px*L,c[1]-py*L); ctx.lineTo(c[0]+px*L,c[1]+py*L); ctx.stroke(); }
// boundary (solid)
ctx.setLineDash([]); ctx.strokeStyle='#1d3557'; ctx.lineWidth=2.2;
ctx.beginPath(); ctx.moveTo(s.mid[0]-px*L,s.mid[1]-py*L); ctx.lineTo(s.mid[0]+px*L,s.mid[1]+py*L); ctx.stroke();
}
// identify support vectors: original points within ~R of a curb
const isSV=(p)=>{ if(!s.sep) return false; const sa=Math.abs(side(p,s)-side(s.a,s)); const sb=Math.abs(side(p,s)-side(s.b,s)); return Math.min(sa,sb) < s.wn*R*0.9; };
const drawPt=(p,fill,sv)=>{
ctx.beginPath(); ctx.arc(p[0],p[1],R,0,7); ctx.fillStyle=fill; ctx.fill();
if(sv){ ctx.lineWidth=3; ctx.strokeStyle='#111'; ctx.stroke(); }
else { ctx.lineWidth=1.5; ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.stroke(); }
};
A.forEach(p=>drawPt(p,'#2a64d6', isSV(p)));
B.forEach(p=>drawPt(p,'#e08a1e', isSV(p)));
const st=document.getElementById('status');
if(s.sep){ st.textContent=fmt('Separable — street width ≈ %margin% px. Drag a support vector (ringed) to change it.', { margin: s.margin.toFixed(0) }); st.className='status ok'; }
else { st.textContent='Not separable — the classes overlap, so no clean street exists.'; st.className='status bad'; }
}
// dragging
let drag=null;
function pos(e){ const r=cv.getBoundingClientRect(); const t=e.touches?e.touches[0]:e; return [(t.clientX-r.left)*(W/r.width),(t.clientY-r.top)*(H/r.height)]; }
function pick(m){ for(const arr of [A,B]) for(const p of arr) if(dist2(p,m)<=(R+4)*(R+4)) return p; return null; }
function down(e){ const m=pos(e); drag=pick(m); if(drag){ cv.classList.add('drag'); e.preventDefault(); } }
function move(e){ if(!drag) return; const m=pos(e); drag[0]=Math.max(R,Math.min(W-R,m[0])); drag[1]=Math.max(R,Math.min(H-R,m[1])); draw(); e.preventDefault(); }
function up(){ drag=null; cv.classList.remove('drag'); }
cv.addEventListener('mousedown',down); window.addEventListener('mousemove',move); window.addEventListener('mouseup',up);
cv.addEventListener('touchstart',down,{passive:false}); cv.addEventListener('touchmove',move,{passive:false}); cv.addEventListener('touchend',up);
document.getElementById('reset').addEventListener('click',()=>{ A=A0.map(p=>p.slice()); B=B0.map(p=>p.slice()); draw(); });
document.getElementById('add').addEventListener('click',()=>{ A.push([60+Math.random()*120,60+Math.random()*220]); B.push([300+Math.random()*120,60+Math.random()*220]); draw(); });
draw();
Comments
Loading comments...