//| panel: center
(async () => {
// ===== Skeleton =====
const box = html`<div style="max-width:1200px;font:14px system-ui;">
<style>
.gd-side {font:12px system-ui; max-width:1200px}
.gd-side .row-h {display:flex; flex-wrap:wrap; gap:12px; align-items:flex-end}
.gd-side .ctrl {display:flex; flex-direction:column; gap:6px}
.gd-side input[type="number"]{width:110px; padding:2px 6px}
.gd-side input[type="range"]{width:240px}
.gd-side .oi-radio {display:flex; flex-wrap:wrap; gap:10px}
.gd-side .oi-radio label {font-weight:400; font-size:12px}
.gd-side button[disabled] {opacity:.5; cursor:not-allowed}
.topbar {
display:grid; grid-template-columns: 1fr auto;
align-items:start; gap:16px; margin-top:0; white-space:nowrap;
}
.kpi {font:12px system-ui; color:#333}
.kpi b{font-weight:600}
.ctrl-right{
display:grid; grid-auto-rows:auto; row-gap:6px;
justify-items:end; margin-top:-18px;
}
.ctrl-right .row{ display:flex; gap:12px; align-items:center; flex-wrap:wrap; justify-content:flex-end; }
.ctrl-right .label{ min-width:72px; text-align:right; font-weight:600 }
#row-step { margin-top:-4px; }
.gridR { display:grid; grid-template-columns: 1fr 1fr; grid-auto-rows:auto; gap:12px; margin-top:10px; }
#plot-scatter { grid-column:1; }
#plot-dendro { grid-column:2; }
#plot-scree { grid-column:1 / -1; }
</style>
<div id="ctrl" class="gd-side">
<div class="row-h" id="row-h"></div>
<div class="row-h" id="row-btn" style="margin-top:8px;"></div>
</div>
<div id="topbar" class="topbar">
<div id="kpi" class="kpi"></div>
<div id="right" class="ctrl-right">
<div class="row" id="row-linkage"><span class="label">Linkage</span></div>
<div class="row" id="row-step"><span class="label">Step</span></div>
</div>
</div>
<div id="gridR" class="gridR">
<div id="plot-scatter"></div>
<div id="plot-dendro"></div>
<div id="plot-scree"></div>
</div>
<div id="note" style="margin-top:8px;color:#444"></div>
</div>`;
const rowH = box.querySelector("#row-h");
const rowBtn = box.querySelector("#row-btn");
const kpiBox = box.querySelector("#kpi");
const rowLink= box.querySelector("#row-linkage");
const rowStep= box.querySelector("#row-step");
const gridR = box.querySelector("#gridR");
const divSca = box.querySelector("#plot-scatter");
const divDen = box.querySelector("#plot-dendro");
const divScr = box.querySelector("#plot-scree");
const note = box.querySelector("#note");
// ===== Inputs =====
const seedI = Inputs.number({label:"Seed", value:42, step:1, min:0});
const nI = Inputs.range([10, 120], {label:"Sample size (n)", value:40, step:2});
const kI = Inputs.range([2, 6], {label:"True clusters", value:3, step:1});
const spreadI = Inputs.range([0.05, 1.5], {label:"Cluster spread (sd)", value:0.35, step:0.05});
const newBtn = Inputs.button("🎲 New sample");
rowH.append(
html`<div class="ctrl" style="min-width:140px">${seedI}</div>`,
html`<div class="ctrl">${nI}</div>`,
html`<div class="ctrl">${kI}</div>`,
html`<div class="ctrl">${spreadI}</div>`
);
rowBtn.append(html`<div class="ctrl">${newBtn}</div>`);
const methodI = Inputs.radio(["single","complete","average","centroid","ward"], {label:"", value:"average"});
const stepI = Inputs.range([0, 39], {label:"", value:0, step:1});
rowLink.append(methodI);
rowStep.append(stepI);
// ===== Utils =====
const TAU = 2*Math.PI;
function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; }; }
function rnorm(rng, m=0, s=1){ let u=0,v=0; while(u===0) u=rng(); while(v===0) v=rng(); return m + s*Math.sqrt(-2*Math.log(u))*Math.cos(TAU*v); }
function centroid(points){ const n=points.length; if(!n) return {x:0,y:0}; let sx=0,sy=0; for(const p of points){ sx+=p.x; sy+=p.y; } return {x:sx/n,y:sy/n}; }
function sse(points){ if(!points.length) return 0; const c=centroid(points); let s=0; for(const p of points){ const dx=p.x-c.x, dy=p.y-c.y; s+=dx*dx+dy*dy; } return s; }
function dist(a,b){ return Math.hypot(a.x-b.x,a.y-b.y); }
// ===== Sampler =====
function makeData(seed,n,K,sd){
const rng = mulberry32(seed >>> 0);
const centers = Array.from({length:K}, (_,i)=>({
x:rnorm(rng,(i-(K-1)/2)*2.2,0.2),
y:rnorm(rng,(i%2?1:-1)*1.2,0.2)
}));
const data=[];
for(let i=0;i<n;i++){
const c = centers[Math.floor(rng()*K)];
data.push({id:i, x:rnorm(rng,c.x,sd), y:rnorm(rng,c.y,sd)});
}
return data;
}
// ===== Hierarchical clustering (with witness pairs) =====
function hclustAll(data, method){
const n = data.length;
let clusters = Array.from({length:n}, (_,i)=>[i]);
const states = [clusters.map(c=>c.slice())];
const merges = [];
const heights = [];
function pairwiseMinMax(Ci, Cj, pick){
let bestD = (pick==="min" ? Infinity : -Infinity);
let ia = -1, jb = -1;
for(const i of Ci){
for(const j of Cj){
const d = dist(data[i], data[j]);
if ((pick==="min" && d < bestD) || (pick==="max" && d > bestD)){
bestD = d; ia = i; jb = j;
}
}
}
return {d: bestD, ia, jb};
}
for(let step=0; step<n-1; step++){
let bestI=-1, bestJ=-1, bestH=Infinity;
let bestCentroids=null, bestWitness=null;
for(let i=0;i<clusters.length;i++){
for(let j=i+1;j<clusters.length;j++){
const Ci = clusters[i], Cj = clusters[j];
let h, witness=null, cents=null;
if(method==="single"){
const r = pairwiseMinMax(Ci, Cj, "min");
h = r.d; witness = {ia:r.ia, jb:r.jb};
} else if(method==="complete"){
const r = pairwiseMinMax(Ci, Cj, "max");
h = r.d; witness = {ia:r.ia, jb:r.jb};
} else if(method==="average"){
let s=0,cnt=0;
for(const a of Ci) for(const b of Cj){ s+=dist(data[a],data[b]); cnt++; }
h = s/Math.max(1,cnt);
cents = { ca: centroid(Ci.map(i=>data[i])), cb: centroid(Cj.map(j=>data[j])) };
} else if(method==="centroid"){
const ca = centroid(Ci.map(i=>data[i]));
const cb = centroid(Cj.map(j=>data[j]));
h = dist(ca, cb);
cents = {ca, cb};
} else if(method==="ward"){
const Pi=Ci.map(i=>data[i]), Pj=Cj.map(j=>data[j]);
h = sse(Pi.concat(Pj)) - sse(Pi) - sse(Pj); // ΔSSE
cents = { ca: centroid(Pi), cb: centroid(Pj) };
} else {
h = Infinity;
}
if(h < bestH){
bestH = h;
bestI = i; bestJ = j;
bestCentroids = cents;
bestWitness = witness;
}
}
}
const A=clusters[bestI], B=clusters[bestJ];
const merged=A.concat(B);
const next=[]; for(let k=0;k<clusters.length;k++) if(k!==bestI && k!==bestJ) next.push(clusters[k]); next.push(merged);
clusters=next;
merges.push({
aIdx:bestI, bIdx:bestJ, height:bestH,
pair:[A.slice(),B.slice()],
centroids: bestCentroids,
witness: bestWitness
});
heights.push(bestH);
states.push(clusters.map(c=>c.slice()));
}
return {states, merges, heights};
}
// ===== Dendrogram segments =====
function buildDendroSegments(data, result){
const n = data.length;
const leaves = Array.from({length:n}, (_,i)=>i).sort((i,j)=> (data[i].x - data[j].x) || (data[i].y - data[j].y));
const xPos = new Map(leaves.map((idx, k)=> [idx, k+1]));
const nodeKey = (arr)=> arr.slice().sort((a,b)=>a-b).join(",");
const nodes = new Map();
for(const i of leaves) nodes.set(nodeKey([i]), {x:xPos.get(i), y:0, size:1});
const verts=[], horiz=[];
result.merges.forEach((m, stepIdx)=>{
const A = m.pair[0], B = m.pair[1], h = m.height;
const kA = nodeKey(A), kB = nodeKey(B);
const nA = nodes.get(kA), nB = nodes.get(kB);
const xA = nA.x, yA = nA.y, sA = nA.size;
const xB = nB.x, yB = nB.y, sB = nB.size;
verts.push({x1:xA,y1:yA,x2:xA,y2:h, step:stepIdx+1});
verts.push({x1:xB,y1:yB,x2:xB,y2:h, step:stepIdx+1});
horiz.push({x1:Math.min(xA,xB), y1:h, x2:Math.max(xA,xB), y2:h, step:stepIdx+1});
const keyU = nodeKey(A.concat(B));
const xU = (xA*sA + xB*sB) / (sA+sB);
nodes.set(keyU, {x:xU, y:h, size:sA+sB});
});
const maxH = result.heights.length ? Math.max(...result.heights) : 1;
return {verts, horiz, maxH, n};
}
// ===== State =====
const state = { data:[], result:null, stepMax:0 };
function rebuild(){
state.data = makeData(+seedI.value, +nI.value, +kI.value, +spreadI.value);
state.result = hclustAll(state.data, methodI.value);
state.stepMax = state.result.states.length-1;
const rangeEl = stepI.querySelector('input[type="range"]');
if(rangeEl){ rangeEl.max = state.stepMax; }
if(+stepI.value > state.stepMax) stepI.value = state.stepMax;
draw();
}
function draw(){
divSca.innerHTML = ""; divDen.innerHTML = ""; divScr.innerHTML = "";
const colW = Math.max(320, Math.floor(gridR.clientWidth / 2) - 12);
const Hpair = Math.max(260, Math.min(800, Math.floor(colW * 0.9)));
const Wfull = gridR.clientWidth;
const t = +stepI.value;
const {states, merges, heights} = state.result;
const clusters = states[t];
// KPI
const total = state.data.length;
kpiBox.innerHTML = `
<span><b>Step:</b> ${t} / ${state.stepMax}</span>
<span>•</span>
<span><b>#Clusters:</b> ${clusters.length}</span>
<span>•</span>
<span><b>n:</b> ${total}</span>
${t>0 ? `<span>•</span><span><b>Height:</b> ${heights[t-1].toFixed(4)}</span>` : ``}
<span style="margin-left:12px;opacity:.8">(Linkage: <b>${methodI.value}</b>)</span>
`;
// ===== Scatter with numeric labels =====
const palette = d3.schemeTableau10;
const colorOfCluster = (ci)=> palette[ci % palette.length];
const pointRows = [];
clusters.forEach((clu, ci)=>{ for(const idx of clu) pointRows.push({x:state.data[idx].x, y:state.data[idx].y, ci}); });
const marksScatter = [Plot.frame()];
if(t===0){
marksScatter.push(Plot.dot(state.data.map(p=>({x:p.x,y:p.y})), {x:"x", y:"y", r:4, fill:"black"}));
} else {
const m = merges[t-1];
const {pair, centroids, witness} = m;
const setA=new Set(pair[0]), setB=new Set(pair[1]);
const hiA = state.data.filter((_,i)=> setA.has(i)).map(p=>({x:p.x,y:p.y}));
const hiB = state.data.filter((_,i)=> setB.has(i)).map(p=>({x:p.x,y:p.y}));
let explainMarks = [];
if(methodI.value==="single" || methodI.value==="complete"){
if(witness && witness.ia>=0 && witness.jb>=0){
const pa = state.data[witness.ia], pb = state.data[witness.jb];
const mid = {x:(pa.x+pb.x)/2, y:(pa.y+pb.y)/2};
const dval = dist(pa, pb);
explainMarks.push(
Plot.link([{x1:pa.x,y1:pa.y,x2:pb.x,y2:pb.y}],
{x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"black", strokeDasharray:"4,3", strokeWidth:1.5, tip:true}),
Plot.text([{x:mid.x, y:mid.y, t:`d = ${dval.toFixed(3)}`}],
{x:"x", y:"y", text:"t", dy:-8, fontWeight:700, tip:true})
);
}
} else if(methodI.value==="average"){
const h = m.height;
const mid = centroids ? {x:(centroids.ca.x+centroids.cb.x)/2, y:(centroids.ca.y+centroids.cb.y)/2} : {x:0,y:0};
explainMarks.push(
Plot.text([{x:mid.x, y:mid.y, t:`avg = ${h.toFixed(3)}`}],
{x:"x", y:"y", text:"t", dy:-10, fontWeight:700, tip:true})
);
} else if(methodI.value==="centroid"){
if(centroids){
const mid = {x:(centroids.ca.x+centroids.cb.x)/2, y:(centroids.ca.y+centroids.cb.y)/2};
const dcc = dist(centroids.ca, centroids.cb);
explainMarks.push(
Plot.link([{x1:centroids.ca.x,y1:centroids.ca.y,x2:centroids.cb.x,y2:centroids.cb.y}],
{x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"black", strokeDasharray:"4,3", strokeWidth:1.5, tip:true}),
Plot.text([{x:mid.x, y:mid.y, t:`‖cₐ − c_b‖ = ${dcc.toFixed(3)}`}],
{x:"x", y:"y", text:"t", dy:-10, fontWeight:700, tip:true})
);
}
} else { // ward
if(centroids){
const mid = {x:(centroids.ca.x+centroids.cb.x)/2, y:(centroids.ca.y+centroids.cb.y)/2};
const dval = m.height; // ΔSSE
explainMarks.push(
Plot.link([{x1:centroids.ca.x,y1:centroids.ca.y,x2:centroids.cb.x,y2:centroids.cb.y}],
{x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"black", strokeDasharray:"4,3", strokeWidth:1.5, tip:true}),
Plot.text([{x:mid.x, y:mid.y, t:`ΔSSE = ${dval.toFixed(3)}`}],
{x:"x", y:"y", text:"t", dy:-10, fontWeight:700, tip:true})
);
}
}
marksScatter.push(
Plot.dot(pointRows, {x:"x", y:"y", r:4, fill:d=>colorOfCluster(d.ci), stroke:"white"}),
...explainMarks,
Plot.dot(hiA, {x:"x", y:"y", r:4, stroke:"black", fill:"none"}),
Plot.dot(hiB, {x:"x", y:"y", r:4, stroke:"black", fill:"none"})
);
}
divSca.append(Plot.plot({
marks: marksScatter,
width: colW, height: Hpair, grid:true,
x:{label:"x"}, y:{label:"y"}
}));
// ===== Dendrogram =====
const den = buildDendroSegments(state.data, state.result);
const sNow = t>0 ? t : -1;
const vAll = den.verts, hAll = den.horiz;
const vHL = sNow>0 ? vAll.filter(d=>d.step===sNow) : [];
const hHL = sNow>0 ? hAll.filter(d=>d.step===sNow) : [];
divDen.append(Plot.plot({
marks: [
Plot.link(vAll, {x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"#bbb"}),
Plot.link(hAll, {x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"#bbb"}),
(sNow>0 ? Plot.link(vHL, {x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"black", strokeWidth:2}) : null),
(sNow>0 ? Plot.link(hHL, {x1:"x1",y1:"y1",x2:"x2",y2:"y2", stroke:"black", strokeWidth:2}) : null),
Plot.dot(Array.from({length:den.n}, (_,i)=>({x:i+1,y:0})), {x:"x", y:"y", r:2})
],
width: colW, height: Hpair, grid:true,
x:{label:"leaves (ordered by x)", domain:[0.5, den.n+0.5]},
y:{label: methodI.value==="ward" ? "ΔSSE" : "height", domain:[0, den.maxH*1.05]}
}));
// ===== Scree =====
const series = heights.map((h,i)=>({step:i+1, h}));
const cur = t>0 ? [{step:t, h:heights[t-1]}] : [];
divScr.innerHTML = "";
divScr.append(Plot.plot({
marks:[
Plot.ruleY([0]),
Plot.line(series, {x:"step", y:"h"}),
Plot.dot(series, {x:"step", y:"h"}),
(t>0 ? Plot.ruleX([t], {stroke:"crimson"}) : null),
(t>0 ? Plot.dot(cur, {x:"step", y:"h", r:6}) : null),
(t>0 ? Plot.text(cur, {x:"step", y:"h", dy:-8, text:d=>`${methodI.value==="ward" ? "ΔSSE" : "h"} = ${d.h.toFixed(3)}`, fontWeight:600}) : null)
],
width: gridR.clientWidth, height: Math.max(200, Math.floor(Hpair * 0.7)), grid:true,
x:{label:"merge step (1 … n-1)", domain:[1, state.stepMax]},
y:{label: methodI.value==="ward" ? "ΔSSE" : "height"}
}));
note.innerHTML = `แสดงตัวเลขการคำนวณใน scatter: single/complete → d, average → avg, centroid → ‖cₐ−c_b‖, ward → ΔSSE.`;
}
// ===== Events =====
newBtn.addEventListener("click", () => {
newBtn.disabled=true; setTimeout(()=>newBtn.disabled=false,600);
seedI.value = (+seedI.value || 0) + 1;
rebuild();
});
[seedI, nI, kI, spreadI].forEach(el => el.addEventListener("input", rebuild));
methodI.addEventListener("input", () => { state.result = hclustAll(state.data, methodI.value); draw(); });
stepI.addEventListener("input", draw);
window.addEventListener("resize", () => draw(), {passive:true});
// init
rebuild();
return box;
})()