วิทยาลัยนานาชาตินวัตกรรมดิจิทัล มหาวิทยาลัยเชียงใหม่
14 พฤศจิกายน 2568
(() => {
// ===== Canvas & DPI =====
const CSS_W = 720, CSS_H = 540;
const DPR = Math.max(1, Math.min(3, devicePixelRatio || 1));
const box = html`<div style="max-width:1200px; font:14px system-ui; color:#0f172a; margin:0 auto;"> <div id="ctrl" style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-bottom:10px;"></div> <div style="display:grid; grid-template-columns:${CSS_W}px 1fr; gap:14px;"> <canvas id="cv" style="border:1px solid #e5e7eb; border-radius:10px; cursor:crosshair; width:${CSS_W}px; height:${CSS_H}px;"></canvas> <div> <div id="note" style="line-height:1.45"></div> <div id="legend" style="margin-top:10px;"></div> </div> </div>
</div>`;
const ctrl = box.querySelector("#ctrl");
const cv = box.querySelector("#cv");
const ctx = cv.getContext("2d", {alpha:false});
cv.width = Math.floor(CSS_W * DPR);
cv.height = Math.floor(CSS_H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // วาดด้วยหน่วย CSS px
const note = box.querySelector("#note");
const legend = box.querySelector("#legend");
// ===== Default values (ใช้กับ Reset) =====
const DEF = { dataset:"blobs", seed:1234, n:280, jitter:0.02, eps:0.20, minPts:6 };
// ===== Controls =====
// แก้ object object: ใช้ Map<Label, Value>
const datasetOptions = new Map([
["Blobs", "blobs"],
["Two Moons", "moons"],
["Circles", "circles"]
]);
const datasetSel = Inputs.select(datasetOptions, {label:"Dataset", value: DEF.dataset});
const seedSlider = Inputs.range([1, 9999], {label:"Seed", step:1, value:DEF.seed});
const nSlider = Inputs.range([40, 800], {label:"Points (n)", step:10, value:DEF.n});
const jitterSlider = Inputs.range([0, 0.25], {label:"Jitter σ (noise)", step:0.005, value:DEF.jitter});
const epsSlider = Inputs.range([0.02, 1.20], {label:"ε radius (data units)", step:0.005, value:DEF.eps});
const minPtsSlider = Inputs.range([3, 30], {label:"minPts", step:1, value:DEF.minPts});
const btnSuggestMed= Inputs.button("Suggest ε (median k-dist)");
const btnSuggestP90= Inputs.button("Suggest ε (P90 k-dist)");
const btnReset = Inputs.button("Reset");
ctrl.append(
datasetSel, seedSlider, nSlider,
jitterSlider, epsSlider, minPtsSlider,
btnSuggestMed, btnSuggestP90, btnReset
);
// ===== World / Scales =====
const W = CSS_W, H = CSS_H, pad = 36;
const xlim = [-1.1, 1.1], ylim = [-1.05, 1.05];
const sx = (W - 2*pad) / (xlim[1] - xlim[0]);
const sy = (H - 2*pad) / (ylim[1] - ylim[0]);
const Sx = x => pad + (x - xlim[0]) * sx;
const Sy = y => H - (pad + (y - ylim[0]) * sy);
const IX = X => xlim[0] + (X - pad) / sx;
const IY = Y => ylim[0] + (H - pad - Y) / sy;
// ===== RNG / Helpers =====
const TAU = 2*Math.PI;
const rngOf = seed => { let a=seed|0; return () => { a|=0; a+=0x6D2B79F5; let t=a; 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);}
const dist = (a,b) => Math.hypot(a.x-b.x, a.y-b.y);
const hue = i => (i*67)%360;
const coreFill = h => `hsl(${h} 75% 45%)`; // Core: เข้ม
const borderFill = h => `hsl(${h} 75% 70%)`; // Border: จางลง
const gray = "#9ca3af";
// ===== Data Generators =====
function genBlobs(n, rng){
const centers = [{x:-0.55,y:-0.25},{x:0.55,y:0.35},{x:0.05,y:-0.1}];
const sigmas = [0.16, 0.16, 0.13];
const pNoise = 0.06;
const X = [];
for(let i=0;i<n;i++){
if(rng() < pNoise){
X.push({x:(rng()*2.2 - 1.1), y:(rng()*2.1 - 1.05)});
}else{
const k = (i % centers.length);
X.push({x: centers[k].x + sigmas[k]*rnorm(rng), y: centers[k].y + sigmas[k]*rnorm(rng)});
}
}
return X;
}
function genMoons(n, rng){
const X = [], n2 = Math.floor(n/2), gap=0.25;
for(let i=0;i<n2;i++){ const t=i/(n2-1)*Math.PI; X.push({x:0.55*Math.cos(t), y:0.35*Math.sin(t)}); }
for(let i=0;i<n-n2;i++){ const t=i/(n-n2-1)*Math.PI; X.push({x:0.55*Math.cos(t)+0.35, y:-0.35*Math.sin(t)-gap}); }
return X;
}
function genCircles(n, rng){
const X=[], n2=Math.floor(n/2);
for(let i=0;i<n2;i++){ const a=rng()*TAU, r=0.36+(rng()-0.5)*0.02; X.push({x:r*Math.cos(a), y:r*Math.sin(a)}); }
for(let i=0;i<n-n2;i++){ const a=rng()*TAU, r=0.70+(rng()-0.5)*0.02; X.push({x:r*Math.cos(a), y:r*Math.sin(a)});}
return X;
}
const addJitter = (X, s, rng) => (s>0 ? X.map(p => ({x:p.x + rnorm(rng,0,s), y:p.y + rnorm(rng,0,s)})) : X);
// ===== DBSCAN =====
const neighborsOf = (X, i, eps) => {
const p = X[i], N = [];
for(let j=0;j<X.length;j++) if(dist(p, X[j]) <= eps) N.push(j);
return N;
};
function computeRoles(X, eps, minPts){
const n = X.length;
const neigh = Array.from({length:n}, (_,i)=>neighborsOf(X,i,eps));
const core = new Array(n).fill(false);
const border= new Array(n).fill(false);
for(let i=0;i<n;i++) if(neigh[i].length >= minPts) core[i] = true;
for(let i=0;i<n;i++) if(!core[i]) for(const j of neigh[i]) if(core[j]){ border[i]=true; break; }
const noise = core.map((c,i)=>!c && !border[i]);
return {neigh, core, border, noise};
}
function runDBSCAN_all(X, eps, minPts){
const n = X.length, labels = new Array(n).fill(-1);
const visited = new Array(n).fill(false);
const roles = computeRoles(X, eps, minPts);
let cid = 0;
for(let i=0;i<n;i++){
if(visited[i]) continue;
visited[i] = true;
if(!roles.core[i]) continue;
const Q=[i]; labels[i] = cid;
for(let q=0;q<Q.length;q++){
const u=Q[q]; if(!roles.core[u]) continue;
for(const v of roles.neigh[u]){
if(!visited[v]){ visited[v]=true; labels[v]=cid; if(roles.core[v]) Q.push(v); }
else if(labels[v]===-1) labels[v]=cid;
}
}
cid++;
}
return {labels, roles, numClusters: cid};
}
// ===== Epsilon suggestion (k = minPts) =====
function kDistances(X, k){
const n=X.length, kd=new Array(n);
for(let i=0;i<n;i++){
const dists=[];
for(let j=0;j<n;j++) if(i!==j) dists.push(dist(X[i], X[j]));
dists.sort((a,b)=>a-b);
kd[i] = dists[Math.min(k-1, dists.length-1)];
}
kd.sort((a,b)=>a-b);
return kd;
}
function percentile(arr, p){
if(arr.length===0) return 0;
const idx = Math.min(arr.length-1, Math.max(0, Math.round(p*(arr.length-1))));
return arr[idx];
}
function suggestEpsilon(kind="median"){
const X = state.X;
const k = Math.max(1, (minPtsSlider.value|0));
const kd = kDistances(X, k);
let s = (kind==="p90") ? percentile(kd, 0.90) : percentile(kd, 0.50);
const min = parseFloat(epsSlider.min ?? 0.02), max = parseFloat(epsSlider.max ?? 1.20);
s = Math.min(max, Math.max(min, s));
epsSlider.value = +s.toFixed(3);
rerun(); render();
}
// ===== State =====
let state = { X:[], labels:[], roles:null, numClusters:0, hoverIndex:-1 };
// ===== Build & Rerun =====
function rebuild(){
const rng = rngOf(seedSlider.value|0);
const ds = datasetSel.value; // จาก Map → value เป็น string ตรง ๆ
let X;
if (ds === "blobs") X = genBlobs(nSlider.value|0, rng);
else if (ds === "moons") X = genMoons(nSlider.value|0, rng);
else X = genCircles(nSlider.value|0, rng);
X = addJitter(X, jitterSlider.value, rng);
state.X = X;
const {labels, roles, numClusters} = runDBSCAN_all(X, epsSlider.value, minPtsSlider.value|0);
state.labels = labels; state.roles = roles; state.numClusters = numClusters;
}
function rerun(){
const {labels, roles, numClusters} = runDBSCAN_all(state.X, epsSlider.value, minPtsSlider.value|0);
state.labels = labels; state.roles = roles; state.numClusters = numClusters;
}
// ===== Axes & Drawing =====
function drawAxes(){
ctx.clearRect(0,0,W,H);
ctx.fillStyle="#f9fafb"; ctx.fillRect(0,0,W,H);
// grid
ctx.strokeStyle="#e5e7eb"; ctx.lineWidth=1;
for(let x=Math.ceil(xlim[0]*10)/10; x<=xlim[1]; x+=0.2){
const X=Sx(x); ctx.beginPath(); ctx.moveTo(X,pad); ctx.lineTo(X,H-pad); ctx.stroke();
}
for(let y=Math.ceil(ylim[0]*10)/10; y<=ylim[1]; y+=0.2){
const Y=Sy(y); ctx.beginPath(); ctx.moveTo(pad,Y); ctx.lineTo(W-pad,Y); ctx.stroke();
}
// axes
ctx.strokeStyle="#94a3b8"; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(pad,Sy(0)); ctx.lineTo(W-pad,Sy(0)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(Sx(0),pad); ctx.lineTo(Sx(0),H-pad); ctx.stroke();
// ticks + labels
ctx.fillStyle="#475569"; ctx.font="12px system-ui";
// x
ctx.textAlign="center"; ctx.textBaseline="top";
for(let x=-1.0; x<=1.0+1e-9; x+=0.2){
const X=Sx(x); ctx.beginPath(); ctx.moveTo(X,Sy(0)-4); ctx.lineTo(X,Sy(0)+4); ctx.stroke();
ctx.fillText(x.toFixed(1), X, H-pad+6);
}
// y
ctx.textAlign="right"; ctx.textBaseline="middle";
for(let y=-1.0; y<=1.0+1e-9; y+=0.2){
const Y=Sy(y); ctx.beginPath(); ctx.moveTo(Sx(0)-4,Y); ctx.lineTo(Sx(0)+4,Y); ctx.stroke();
ctx.fillText(y.toFixed(1), pad-6, Y);
}
}
function drawHoverEpsilon(){
if(state.hoverIndex < 0) return;
const p = state.X[state.hoverIndex];
const rx = epsSlider.value * sx, ry = epsSlider.value * sy;
ctx.beginPath();
ctx.ellipse(Sx(p.x), Sy(p.y), rx, ry, 0, 0, TAU);
ctx.fillStyle="rgba(37,99,235,0.08)";
ctx.fill();
ctx.setLineDash([6,5]); ctx.strokeStyle="#2563eb"; ctx.lineWidth=1.2; ctx.stroke();
ctx.setLineDash([]);
}
function drawPoints(){
if(!state.X || state.X.length===0) return;
for(let i=0;i<state.X.length;i++){
const p = state.X[i];
const lab = state.labels[i]; // -1 = noise/unassigned
const isCore = state.roles?.core?.[i] || false;
const isBorder = state.roles?.border?.[i] || false;
let fill = gray, stroke="#fff", rpx=3.6, lw=1;
if (lab >= 0){
const h = hue(lab);
fill = isCore ? coreFill(h) : (isBorder ? borderFill(h) : gray);
// ขอขอบดำให้ Core point ชัดเจน
if (isCore) { stroke = "#111"; lw = 1.8; } // ขอบดำหนากว่า
}
ctx.beginPath(); ctx.arc(Sx(p.x), Sy(p.y), rpx, 0, TAU);
ctx.fillStyle = fill; ctx.fill();
ctx.lineWidth = lw; ctx.strokeStyle = stroke; ctx.stroke();
}
}
function summarize(){
const eps = epsSlider.value, minPts = minPtsSlider.value|0;
const epsPxX = Math.round(eps * sx), epsPxY = Math.round(eps * sy);
const coreCnt = state.labels.reduce((a,lab,i)=>a + ((lab>=0 && state.roles.core[i])?1:0), 0);
const bordCnt = state.labels.reduce((a,lab,i)=>a + ((lab>=0 && state.roles.border[i])?1:0), 0);
const noiseCnt = state.labels.reduce((a,lab,i)=>a + ((lab>=0 && !state.roles.core[i] && !state.roles.border[i])?1:0), 0);
note.innerHTML = `
<b>DBSCAN (All points)</b>
— ε = <b>${eps.toFixed(3)}</b> (≈ ${epsPxX}×${epsPxY} px), minPts = <b>${minPts}</b><br>
Clusters: <b>${state.numClusters}</b> | Core: <b>${coreCnt}</b> | Border: <b>${bordCnt}</b> | Noise: <b>${noiseCnt}</b>
`;
const counts = new Map();
for(let i=0;i<state.labels.length;i++){
const lab=state.labels[i];
if(lab>=0) counts.set(lab, (counts.get(lab)||0)+1);
}
legend.innerHTML = [...counts.entries()]
.sort((a,b)=>b[1]-a[1])
.map(([c,s])=>`<span style="display:inline-block; margin-right:10px;">
<span style="color:${coreFill(hue(c))}">●</span> C${c}: ${s}
</span>`)
.join("") || '<span style="color:#64748b">No clusters (ใช้ปุ่ม Suggest ε หรือเพิ่ม ε / ลด minPts).</span>';
}
function render(){
drawAxes();
drawHoverEpsilon();
drawPoints();
summarize();
}
// ===== Hover (DPI-safe) =====
const getXY = evt => {
const r = cv.getBoundingClientRect();
return { x: evt.clientX - r.left, y: evt.clientY - r.top }; // CSS px
};
const pickIndex = (px,py) => {
if(!state.X || state.X.length===0) return -1;
const p = {x: IX(px), y: IY(py)};
let best=-1, bd=1e9;
for(let i=0;i<state.X.length;i++){
const d = dist(p, state.X[i]);
if(d < bd){ bd = d; best = i; }
}
const scr = Math.hypot(Sx(state.X[best].x)-px, Sy(state.X[best].y)-py);
return scr <= 8 ? best : -1;
};
cv.addEventListener('pointermove', e=>{
const {x,y} = getXY(e);
state.hoverIndex = pickIndex(x,y);
render();
});
cv.addEventListener('pointerleave', ()=>{ state.hoverIndex = -1; render(); });
// ===== Inputs & Buttons =====
const onBtn = (el, fn) => { el.addEventListener('click', fn); el.addEventListener('input', fn); };
const onInputChange = (el, fn) => { el.addEventListener('input', fn); el.addEventListener('change', fn); };
onInputChange(datasetSel, ()=>{ rebuild(); render(); }); // select ต้องฟัง 'change'
[seedSlider, nSlider, jitterSlider].forEach(el => onInputChange(el, ()=>{ rebuild(); render(); }));
[epsSlider, minPtsSlider].forEach(el => onInputChange(el, ()=>{ rerun(); render(); }));
onBtn(btnSuggestMed, ()=>suggestEpsilon("median"));
onBtn(btnSuggestP90, ()=>suggestEpsilon("p90"));
onBtn(btnReset, ()=>{
datasetSel.value = DEF.dataset;
seedSlider.value = DEF.seed;
nSlider.value = DEF.n;
jitterSlider.value = DEF.jitter;
epsSlider.value = DEF.eps;
minPtsSlider.value = DEF.minPts;
rebuild(); render();
});
// First paint
requestAnimationFrame(()=>{ rebuild(); render(); });
return box;
})();DBSCAN (Density-Based Spatial Clustering of Applications with Noise) เป็นอัลกอริธึมการจัดกลุ่มข้อมูล (Clustering) แบบ Density-Based ซึ่งใช้ในการค้นหากลุ่มข้อมูลที่มีความหนาแน่นสูงและสามารถระบุจุดข้อมูลที่เป็น Noise (สัญญาณรบกวนหรือตัวแปลกปลอม) ได้ โดยไม่จำเป็นต้องระบุจำนวนกลุ่มล่วงหน้าเหมือน K-means
Core Point: จุดที่มีจุดอื่น ๆ อยู่ภายในรัศมีที่กำหนด (ε - epsilon) และมีจำนวนเพื่อนบ้านอย่างน้อยเท่ากั บค่า MinPts (จำนวนจุดขั้นต่ำที่ต้องการ)
Border Point: จุดที่อยู่ในรัศมีของ Core Point แต่มีจุดเพื่อนบ้านน้อยกว่าค่า MinPts
Noise Point: จุดที่ไม่ใช่ทั้ง Core Point และ Border Point
เลือกจุดเริ่มต้นแบบสุ่ม
ตรวจสอบจุดที่อยู่ภายในรัศมี ε (epsilon) จากจุดเริ่มต้น
ถ้าจำนวนจุดในรัศมีนั้น ≥ MinPts → เป็น Core Point และขยายกลุ่มจากจุดนี้
ถ้าจำนวนน้อยกว่า MinPts → เป็น Noise Point หรือ Border Point
ขยายกลุ่ม (Cluster) โดยการเชื่อมต่อ Core Point ที่อยู่ใกล้กัน
ทำซ้ำจนกว่าทุกจุดจะถูกจัดกลุ่มหรือถูกจัดเป็น Noise
ไม่จำเป็นต้องระบุจำนวนกลุ่มล่วงหน้า
จัดการกับกลุ่มข้อมูลที่มีรูปร่างซับซ้อนได้ดี (เช่น รูปร่างไม่เป็นทรงกลมหรือวงรี)
สามารถระบุ Noise ได้ ซึ่งเหมาะสำหรับข้อมูลที่มีตัวแปรแปลกปลอม
การเลือกค่าพารามิเตอร์ ε และ MinPts มีความสำคัญมาก หากเลือกไม่เหมาะสม อาจได้ผลลัพธ์ที่ไม่ดี
ประสิทธิภาพลดลงเมื่อใช้กับข้อมูลที่มีมิติสูง (High-dimensional data)
ไม่เหมาะกับข้อมูลที่มีความหนาแน่นไม่สม่ำเสมอ
แสดงข้อมูลดิบ ซึ่งข้อมูลจะกระจายตัวเป็น 3 กลุ่มชัดเจน
แยกประเภทจุดเป็น Core Point (เขียว), Border Point (ส้ม), และ Noise (แดง)
แสดงการขยายกลุ่ม โดย Core Point ที่เชื่อมต่อกันจะเป็นกลุ่มเดียวกัน
หมายเหตุ
การเลือกพารามิเตอร์ eps และ minPts มีผลต่อผลลัพธ์
คำว่า “noise” (สัญญาณรบกวน) หมายถึง จุดข้อมูลที่ไม่สามารถจัดอยู่ในกลุ่มใดกลุ่มหนึ่งได้ เพราะมีความหนาแน่นไม่เพียงพอ โดยมีลักษณะดังนี้:
epsilon หรือ ε) หรือ มีจำนวนน้อยกว่า minPts (จำนวนจุดขั้นต่ำที่ต้องการเพื่อสร้างกลุ่ม)ตัวอย่าง:
หากตั้งค่า ε = 0.5 และ minPts = 5 แล้วจุดข้อมูลจุดหนึ่งมีเพียง 2 จุดอยู่ใกล้ๆ กันภายในระยะ ε จุดนั้นจะถูกระบุว่าเป็น noise เพราะไม่ถึงเกณฑ์ minPts ที่กำหนดไว้
การระบุ noise ใน DBSCAN ช่วยให้การจัดกลุ่มมีความทนทานต่อ outliers และทำให้ได้กลุ่มข้อมูลที่มีความหนาแน่นสูงจริงๆ โดยไม่ถูกรบกวนจากจุดที่กระจัดกระจาย
(() => {
// ================= Canvas & Layout =================
const CSS_W = 760, CSS_H = 560;
const DPR = Math.max(1, Math.min(3, devicePixelRatio || 1));
const box = html`<div style="max-width:1280px; font:14px system-ui; color:#0f172a; margin:0 auto;"> <div id="ctrl" style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-bottom:10px;"></div> <div style="display:grid; grid-template-columns:${CSS_W}px 1fr; gap:16px;"> <canvas id="cv" style="border:1px solid #e5e7eb; border-radius:10px; width:${CSS_W}px; height:${CSS_H}px; background:#f9fafb;"></canvas> <div style="display:flex; flex-direction:column; gap:12px;"> <div id="panel" style="background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:12px;"> <div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px;"> <div style="font-weight:700;">DBSCAN – Concurrent / Manual Seeds</div> <div style="display:flex; gap:8px; flex-wrap:wrap;"> <button id="prev" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">◀ Prev</button> <button id="next" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">Next ▶</button> <button id="auto" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">▶ Auto</button> <button id="reset" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">↺ Reset</button> </div> </div> <div id="caption" style="line-height:1.55; color:#0f172a"></div> </div>
<div style="background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:12px;">
<div style="font-weight:700; margin-bottom:8px;">State</div>
<div id="state" style="font-family: ui-monospace, Menlo, Consolas, monospace; font-size:12px; white-space:pre-wrap; color:#334155"></div>
</div>
</div>
</div>
</div>`;
const ctrl = box.querySelector("#ctrl");
const cv = box.querySelector("#cv");
const ctx = cv.getContext("2d", {alpha:false});
cv.width = Math.floor(CSS_W * DPR);
cv.height = Math.floor(CSS_H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // วาดด้วยหน่วย "CSS px"
const caption = box.querySelector("#caption");
const stateEl = box.querySelector("#state");
const btnPrev = box.querySelector("#prev");
const btnNext = box.querySelector("#next");
const btnAuto = box.querySelector("#auto");
const btnReset = box.querySelector("#reset");
// ================= Controls =================
const datasetOptions = new Map([
["Blobs", "blobs"],
["Two Moons", "moons"],
["Circles", "circles"]
]);
const datasetSel = Inputs.select(datasetOptions, {label:"Dataset", value:"blobs"});
const seedS = Inputs.range([1, 9999], {label:"Seed", step:1, value:1234});
const nS = Inputs.range([60, 800], {label:"Points (n)", step:10, value:210});
const jitterS = Inputs.range([0, 0.25], {label:"Jitter σ", step:0.005, value:0.015});
const epsS = Inputs.range([0.02, 1.20], {label:"ε radius", step:0.005, value:0.18});
const minPtsS = Inputs.range([3, 30], {label:"minPts", step:1, value:6});
const autoSpeedS = Inputs.range([80, 2000], {label:"Auto speed (ms)", step:20, value:400});
const initSeedsS = Inputs.range([0, 8], {label:"Initial core seeds (auto)", step:1, value:3});
const seedModeSel = Inputs.select(new Map([["Auto seeds", "auto"], ["Manual seeds (click)", "manual"]]),
{label:"Seed mode", value:"auto"});
const btnNewData = Inputs.button("New Data");
const btnClearSeeds = Inputs.button("Clear Seeds");
ctrl.append(
datasetSel, seedS, nS,
jitterS, epsS, minPtsS,
autoSpeedS, initSeedsS, seedModeSel,
btnNewData, btnClearSeeds
);
// ================= World / Scales =================
const W = CSS_W, H = CSS_H, pad = 36, TAU=2*Math.PI;
const xlim = [-1.1, 1.1], ylim = [-1.05, 1.05];
const sx = (W - 2*pad) / (xlim[1] - xlim[0]);
const sy = (H - 2*pad) / (ylim[1] - ylim[0]);
const Sx = x => pad + (x - xlim[0]) * sx;
const Sy = y => H - (pad + (y - ylim[0]) * sy);
const IX = X => xlim[0] + (X - pad) / sx;
const IY = Y => ylim[0] + (H - pad - Y) / sy;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
// ================= Utils =================
const rngOf=s=>{let a=s|0;return()=>{a|=0;a+=0x6D2B79F5;let t=a;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);}
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const hue=i=>(i*67)%360;
const coreFill = h => `hsl(${h} 75% 45%)`; // core: เข้ม
const borderFill = h => `hsl(${h} 75% 72%)`; // border: จาง
const gray = "#9ca3af";
// ================= Data Generators (Blobs confined & exact n) =================
function genBlobs(n, rng){
const centers=[{x:-0.55,y:-0.20},{x:0.55,y:0.35},{x:0.05,y:-0.15}];
const sig=[0.14,0.14,0.11];
const X=[];
for(let i=0;i<n;i++){
const k = i % centers.length;
const px = centers[k].x + sig[k]*rnorm(rng);
const py = centers[k].y + sig[k]*rnorm(rng);
X.push({x: clamp(px, xlim[0]+0.02, xlim[1]-0.02), y: clamp(py, ylim[0]+0.02, ylim[1]-0.02)});
}
return X;
}
function genMoons(n, rng){
const X=[], n2=Math.floor(n/2), gap=0.25;
for(let i=0;i<n2;i++){const t=i/(n2-1)*Math.PI; X.push({x:0.55*Math.cos(t), y:0.35*Math.sin(t)});}
for(let i=0;i<n-n2;i++){const t=i/(n-n2-1)*Math.PI; X.push({x:0.55*Math.cos(t)+0.35, y:-0.35*Math.sin(t)-gap});}
return X;
}
function genCircles(n, rng){
const X=[], n2=Math.floor(n/2);
for(let i=0;i<n2;i++){const a=rng()*TAU, r=0.36+(rng()-0.5)*0.02; X.push({x:r*Math.cos(a), y:r*Math.sin(a)});}
for(let i=0;i<n-n2;i++){const a=rng()*TAU, r=0.70+(rng()-0.5)*0.02; X.push({x:r*Math.cos(a), y:r*Math.sin(a)});}
return X;
}
const addJitter=(X,s,rng)=> s>0 ? X.map(p=>({x:clamp(p.x+rnorm(rng,0,s), xlim[0]+0.02, xlim[1]-0.02),
y:clamp(p.y+rnorm(rng,0,s), ylim[0]+0.02, ylim[1]-0.02)})) : X;
// ================= DBSCAN utilities =================
function neighborsOf(X, i, eps){ const p=X[i], N=[]; for(let j=0;j<X.length;j++) if(dist(p,X[j])<=eps) N.push(j); return N; }
function precompute(X, eps, minPts){
const neigh=Array.from({length:X.length},(_,i)=>neighborsOf(X,i,eps));
const core=new Array(X.length).fill(false), border=new Array(X.length).fill(false);
for(let i=0;i<X.length;i++) if(neigh[i].length>=minPts) core[i]=true;
for(let i=0;i<X.length;i++) if(!core[i]) for(const j of neigh[i]) if(core[j]){ border[i]=true; break; }
return {neigh, core, border};
}
function makeDSU(){
return {
par:[], sz:[],
find(x){while(this.par[x]!==x){this.par[x]=this.par[this.par[x]];x=this.par[x];}return x;},
unite(a,b){a=this.find(a); b=this.find(b); if(a===b) return a; if(this.sz[b]>this.sz[a]) [a,b]=[b,a]; this.par[b]=a; this.sz[a]+=this.sz[b]; return a;}
};
}
// ================= State (Concurrent two-phase) =================
let st=null;
function initClustersFromSeeds(seeds){
st.labels = new Array(st.X.length).fill(-1);
st.visited= new Array(st.X.length).fill(false);
st.clusters = [];
st.nextCid = 0;
st.dsu = makeDSU();
st.history = [];
const okSeeds = [];
for(const s of seeds){
if(!st.roles.core[s]) continue;
okSeeds.push(s);
const cid = st.nextCid++;
st.dsu.par[cid]=cid; st.dsu.sz[cid]=1;
st.labels[s]=cid; st.visited[s]=true;
const Ni = st.roles.neigh[s];
for(const v of Ni){ if(st.labels[v]===-1) st.labels[v]=cid; }
const q = Ni.filter(v=>!st.visited[v]);
st.clusters.push({cid, seed:s, queue:q.slice(), cur:-1, active:true});
}
caption.innerHTML = okSeeds.length
? `Manual seeds: ${okSeeds.map(i=>'p'+i).join(', ')}`
: `โหมด Manual: คลิกเลือกจุด (เฉพาะ Core) เพื่อเริ่มคลัสเตอร์ หรือสลับไปโหมด Auto`;
}
function resetState(){
const rng=rngOf(seedS.value|0);
let X;
const ds=datasetSel.value;
if(ds==="blobs") X=genBlobs(nS.value|0, rng);
else if(ds==="moons") X=genMoons(nS.value|0, rng);
else X=genCircles(nS.value|0, rng);
X=addJitter(X, jitterS.value, rng);
const eps=epsS.value, minPts=minPtsS.value|0;
const roles=precompute(X, eps, minPts);
st = {
X, eps, minPts, roles,
labels: new Array(X.length).fill(-1),
visited:new Array(X.length).fill(false),
clusters: [],
nextCid: 0,
dsu: makeDSU(),
autoTimer: null,
history: [],
manualSeeds: new Set()
};
if(seedModeSel.value === "auto"){
const coreIdx = Array.from(X.keys()).filter(i=>roles.core[i]).sort((a,b)=>roles.neigh[b].length - roles.neigh[a].length);
const need = initSeedsS.value|0;
const seeds=[];
outer: for(const i of coreIdx){
for(const j of seeds){ if(dist(X[i], X[j]) < eps*0.6) continue outer; }
seeds.push(i); if(seeds.length>=need) break;
}
initClustersFromSeeds(seeds);
}else{
caption.innerHTML = `โหมด Manual: คลิกบนกราฟเพื่อเลือกจุดเป็น seed (เฉพาะ Core) • ปุ่ม Clear Seeds เพื่อล้าง`;
}
render(); showState();
}
function mergeIfNeeded(cidA, cidB){
if(cidA===cidB) return cidA;
const ra = st.dsu.find(cidA), rb = st.dsu.find(cidB);
if(ra===rb) return ra;
const r = st.dsu.unite(ra, rb);
for(let i=0;i<st.labels.length;i++){
if(st.labels[i]>=0) st.labels[i] = st.dsu.find(st.labels[i]);
}
for(const cl of st.clusters){
const root = st.dsu.find(cl.cid);
cl.active = (root === cl.cid);
cl.cid = root;
}
return r;
}
function snapshot(){
st.history.push({
labels: st.labels.slice(),
visited: st.visited.slice(),
clusters: st.clusters.map(c=>({cid:c.cid, seed:c.seed, queue:c.queue.slice(), cur:c.cur, active:c.active})),
nextCid: st.nextCid,
dsuPar: st.dsu.par.slice(),
dsuSz: st.dsu.sz.slice()
});
}
function restoreLast(){
if(!st.history.length) return;
const h = st.history.pop();
st.labels = h.labels.slice();
st.visited= h.visited.slice();
st.clusters = h.clusters.map(c=>({cid:c.cid, seed:c.seed, queue:c.queue.slice(), cur:c.cur, active:c.active}));
st.nextCid = h.nextCid;
st.dsu.par = h.dsuPar.slice();
st.dsu.sz = h.dsuSz.slice();
}
// ================= Step (two-phase per cluster) =================
function stepConcurrent(){
snapshot();
let progressed = false;
for(const cl of st.clusters){
if(!cl.active) continue;
if(cl.cur === -1 && cl.queue.length === 0) continue;
// Phase 1: pick from queue
if(cl.cur === -1){
cl.cur = cl.queue.shift();
progressed = true;
continue; // ให้ render วาดวงที่ cur ใหม่ก่อน
}
// Phase 2: visit / expand
const u = cl.cur;
if(!st.visited[u]){
st.visited[u] = true;
const Nu = st.roles.neigh[u];
if(st.roles.core[u]){
for(const v of Nu){
if(st.labels[v] === -1){
st.labels[v] = cl.cid;
}else if(st.labels[v] !== cl.cid){
mergeIfNeeded(cl.cid, st.labels[v]);
}
}
for(const v of Nu){
if(!st.visited[v] && cl.queue.indexOf(v)===-1) cl.queue.push(v);
}
}
progressed = true;
}
cl.cur = -1; // ปล่อยให้รอบถัดไปเลือกตัวใหม่
}
// ถ้าไม่คืบหน้าเลย → auto bootstrap (เฉพาะโหมด Auto)
if(!progressed && seedModeSel.value==="auto"){
const i = st.visited.findIndex(v=>!v);
if(i>=0){
if(st.roles.core[i]){
const cid = st.nextCid++;
st.dsu.par[cid]=cid; st.dsu.sz[cid]=1;
st.visited[i]=true; st.labels[i]=cid;
const Ni = st.roles.neigh[i];
for(const v of Ni){
if(st.labels[v]===-1) st.labels[v]=cid;
else if(st.labels[v]!==cid) mergeIfNeeded(cid, st.labels[v]);
}
const q = Ni.filter(v=>!st.visited[v]);
st.clusters.push({cid, seed:i, queue:q.slice(), cur:-1, active:true});
caption.innerHTML = `เริ่มคลัสเตอร์ใหม่จาก p${i}`;
progressed = true;
}else{
st.visited[i]=true;
progressed = true;
}
}
}
return progressed;
}
// ================= Picking (Manual seeds) — DPR-safe =================
function getXY(evt){
const r = cv.getBoundingClientRect();
const scaleX = cv.width / r.width; // รวม DPR
const scaleY = cv.height / r.height;
const bx = (evt.clientX - r.left) * scaleX;
const by = (evt.clientY - r.top) * scaleY;
// setTransform(DPR,...) → 1 หน่วยวาด = 1 CSS px
return { x: bx / DPR, y: by / DPR };
}
function pickIndex(px,py){
if(!st?.X?.length) return -1;
const p={x:IX(px), y:IY(py)};
let best=-1, bd=1e9;
for(let i=0;i<st.X.length;i++){
const d=dist(p, st.X[i]);
if(d<bd){ bd=d; best=i; }
}
const scr = Math.hypot(Sx(st.X[best].x)-px, Sy(st.X[best].y)-py); // ตรวจในจอ (CSS px)
return scr<=10 ? best : -1;
}
cv.addEventListener('click', e=>{
if(seedModeSel.value !== "manual") return;
const {x,y} = getXY(e);
const idx = pickIndex(x,y);
if(idx<0) return;
if(st.manualSeeds.has(idx)) st.manualSeeds.delete(idx);
else st.manualSeeds.add(idx);
initClustersFromSeeds([...st.manualSeeds]);
render();
});
// ================= Rendering =================
function drawAxes(){
ctx.fillStyle="#f9fafb"; ctx.fillRect(0,0,W,H);
// grid & axes
ctx.strokeStyle="#e5e7eb"; ctx.lineWidth=1;
for(let x=Math.ceil(xlim[0]*10)/10; x<=xlim[1]; x+=0.2){
const X=Sx(x); ctx.beginPath(); ctx.moveTo(X, pad); ctx.lineTo(X, H-pad); ctx.stroke();
}
for(let y=Math.ceil(ylim[0]*10)/10; y<=ylim[1]; y+=0.2){
const Y=Sy(y); ctx.beginPath(); ctx.moveTo(pad, Y); ctx.lineTo(W-pad, Y); ctx.stroke();
}
ctx.strokeStyle="#94a3b8"; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(pad, Sy(0)); ctx.lineTo(W-pad, Sy(0)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(Sx(0), pad); ctx.lineTo(Sx(0), H-pad); ctx.stroke();
// ticks
ctx.fillStyle="#475569"; ctx.font="12px system-ui";
ctx.textAlign="center"; ctx.textBaseline="top";
for(let x=-1.0;x<=1.0+1e-9;x+=0.2){ const X=Sx(x); ctx.beginPath(); ctx.moveTo(X,Sy(0)-4); ctx.lineTo(X,Sy(0)+4); ctx.stroke(); ctx.fillText(x.toFixed(1), X, H-pad+6); }
ctx.textAlign="right"; ctx.textBaseline="middle";
for(let y=-1.0;y<=1.0+1e-9;y+=0.2){ const Y=Sy(y); ctx.beginPath(); ctx.moveTo(Sx(0)-4,Y); ctx.lineTo(Sx(0)+4,Y); ctx.stroke(); ctx.fillText(y.toFixed(1), pad-6, Y); }
}
function drawCircles(){
// วาดวง ε ใช้จุด cur ถ้ามี ไม่งั้นใช้ seed
for(const cl of st.clusters){
if(!cl.active) continue;
const idx = (cl.cur >= 0 ? cl.cur : cl.seed);
if(idx == null || idx < 0) continue;
const p = st.X[idx], rx=st.eps*sx, ry=st.eps*sy;
ctx.beginPath();
ctx.ellipse(Sx(p.x), Sy(p.y), rx, ry, 0, 0, TAU);
ctx.fillStyle="rgba(37,99,235,0.06)"; ctx.fill();
ctx.setLineDash([6,5]); ctx.strokeStyle="#2563eb"; ctx.lineWidth=1.2; ctx.stroke(); ctx.setLineDash([]);
}
// โหมด Manual: ยังไม่มีคลัสเตอร์ แต่มี seeds ที่เลือก → แสดง hint
if(seedModeSel.value==="manual" && st.clusters.length===0 && st.manualSeeds.size){
ctx.setLineDash([4,3]); ctx.strokeStyle="#93c5fd"; ctx.lineWidth=1;
for(const i of st.manualSeeds){
const p = st.X[i], rx=st.eps*sx, ry=st.eps*sy;
ctx.beginPath(); ctx.ellipse(Sx(p.x), Sy(p.y), rx, ry, 0, 0, TAU); ctx.stroke();
}
ctx.setLineDash([]);
}
}
function drawPoints(){
for(let i=0;i<st.X.length;i++){
const p = st.X[i];
const lab = st.labels[i];
const isCore = st.roles.core[i];
const isBorder = st.roles.border[i];
let fill=gray, stroke="#fff", lw=1, r=3.6, alpha=1;
if(lab>=0){
const h=hue(lab);
fill = isCore ? coreFill(h) : (isBorder ? borderFill(h) : gray);
if(isCore){ stroke="#111"; lw=1.8; } // Core = ขอบดำ
}else{
alpha = st.visited[i] ? 1 : 0.85;
}
if(seedModeSel.value==="manual" && st.manualSeeds.has(i) && st.clusters.length===0){
r = 4.5; stroke = "#111"; lw = 2;
}
ctx.beginPath();
ctx.arc(Sx(p.x), Sy(p.y), r, 0, TAU);
ctx.fillStyle=fill; if(alpha<1){ ctx.globalAlpha=alpha; } ctx.fill(); ctx.globalAlpha=1;
ctx.lineWidth=lw; ctx.strokeStyle=stroke; ctx.stroke();
}
}
function render(){
ctx.clearRect(0,0,W,H);
drawAxes();
drawCircles();
drawPoints();
showState();
}
function showState(){
const coreCnt = st.labels.reduce((a,lab,i)=>a+((lab>=0 && st.roles.core[i])?1:0),0);
const bordCnt = st.labels.reduce((a,lab,i)=>a+((lab>=0 && st.roles.border[i])?1:0),0);
const noiseCnt = st.labels.reduce((a,lab,i)=>a+((lab<0)?1:0),0);
const activeClusters = new Set(st.clusters.filter(c=>c.active).map(c=>c.cid)).size;
const totalClusters = new Set(st.labels.filter(l=>l>=0)).size;
const manualInfo = (seedModeSel.value==="manual")
? ` | manual seeds: [${[...st.manualSeeds].join(", ")}]`
: "";
caption.innerHTML = `คลัสเตอร์ที่ขยายอยู่: <b>${activeClusters}</b> | ทั้งหมด: <b>${totalClusters}</b>${manualInfo}
<br>ทิป: ถ้าคลัสเตอร์ควบรวมช้า ลองเพิ่ม ε หรือเพิ่มจำนวน seeds`;
stateEl.textContent =
`n: ${st.X.length} | ε: ${st.eps.toFixed(3)} | minPts: ${st.minPts}
clusters(total): ${totalClusters} | active: ${activeClusters}
visited: ${st.visited.reduce((a,b)=>a+(b?1:0),0)} / ${st.visited.length}
core: ${coreCnt}, border: ${bordCnt}, unlabeled/noise: ${noiseCnt}
queues: ${st.clusters.map(c=>`C${c.cid}${c.active?'*':''}[${c.queue.length}]`).join(' ')}
`;
}
// ================= Auto / Buttons =================
function tick(){
const progressed = stepConcurrent();
render();
if(st.autoTimer && !progressed){
toggleAuto();
caption.innerHTML += `<br><b>เสร็จสิ้น:</b> ไม่มีจุดให้ขยายต่อ`;
}
}
function toggleAuto(){
if(st.autoTimer){ clearInterval(st.autoTimer); st.autoTimer=null; btnAuto.textContent="▶ Auto"; return; }
btnAuto.textContent="⏸ Pause";
st.autoTimer = setInterval(tick, autoSpeedS.value|0);
}
const onChange = (el, fn) => { el.addEventListener('input', fn); el.addEventListener('change', fn); };
onChange(datasetSel, ()=>{ if(st.autoTimer) toggleAuto(); resetState(); });
[seedS, nS, jitterS, epsS, minPtsS, initSeedsS].forEach(el=>onChange(el, ()=>{ if(st.autoTimer) toggleAuto(); resetState(); }));
onChange(autoSpeedS, ()=>{ if(st.autoTimer){ clearInterval(st.autoTimer); st.autoTimer=setInterval(tick, autoSpeedS.value|0); } });
onChange(seedModeSel, ()=>{ if(st.autoTimer) toggleAuto(); resetState(); });
btnNext.addEventListener('click', ()=> tick());
btnPrev.addEventListener('click', ()=> { if(st.history?.length){ restoreLast(); render(); }});
btnAuto.addEventListener('click', ()=> toggleAuto());
btnReset.addEventListener('click', ()=> { if(st.autoTimer) toggleAuto(); resetState(); });
btnNewData.addEventListener('click', ()=>{
const r = Math.floor(1 + Math.random()*9999);
seedS.value = r;
if(st.autoTimer) toggleAuto();
resetState();
});
btnClearSeeds.addEventListener('click', ()=>{
if(seedModeSel.value !== "manual") return;
st.manualSeeds.clear();
initClustersFromSeeds([]);
render();
});
// ================= Init =================
resetState();
return box;
})();PCA (Principal Component Analysis) เป็นเทคนิคทางสถิติที่ใช้สำหรับการลดมิติ (Dimensionality Reduction) และการวิเคราะห์ข้อมูล โดยมีวัตถุประสงค์หลักเพื่อ:
ลดจำนวนตัวแปร (Dimensions) โดยไม่สูญเสียข้อมูลสำคัญมากเกินไป
หาทิศทางหลัก (Principal Components) ที่อธิบายความแปรปรวนในข้อมูลได้มากที่สุด
PCA จะทำการแปลงตัวแปรต้นฉบับ (ซึ่งอาจมีความสัมพันธ์กัน) ให้กลายเป็นชุดของตัวแปรใหม่ที่เรียกว่า “Principal Components” (PCs) ซึ่งเป็นอิสระต่อกัน (Orthogonal)
PC1 (Component ที่ 1) จะมีความแปรปรวนสูงสุด หมายความว่าอธิบายความผันผวนในข้อมูลได้มากที่สุด
PC2, PC3, … จะเรียงตามลำดับความสำคัญ โดยแต่ละตัวจะแปรผันในทิศทางที่ไม่ซ้ำกับตัวก่อนหน้า
ลดมิติของข้อมูลเพื่อให้สามารถวิเคราะห์หรือสร้างโมเดลได้ง่ายขึ้น (เช่น การทำ Visualization แบบ 2D หรือ 3D)
ขจัดปัญหา Multicollinearity ในการสร้างโมเดลทางสถิติ
ใช้ใน Machine Learning เพื่อปรับปรุงประสิทธิภาพของโมเดลโดยลดจำนวน Features ที่ไม่จำเป็น
eigen values
eigen vector
| ลูกศร | ทิศทาง/ความสัมพันธ์ | การตีความ |
|---|---|---|
| Petal Length & Petal Width | ชี้ไปทางขวา (แกน PC1) และอยู่ใกล้กัน | มีความสัมพันธ์บวกสูงกัน → ทั้งสองบ่งชี้ขนาดของกลีบดอก เป็นตัวแปรที่กำหนด PC1 มากที่สุด |
| Sepal Length | ชี้ทางขวาเฉียงลง | สัมพันธ์บวกกับ PC1 เช่นกัน แต่ไม่แรงเท่ากลีบดอก (ช่วยอธิบายความแตกต่างของชนิด versicolor / virginica) |
| Sepal Width | ชี้ไปทางซ้าย (ตรงข้ามกับตัวอื่น) | สัมพันธ์ทางลบกับ PC1 → ดอกที่กลีบใหญ่ (ค่า Petal สูง) มักมี Sepal Width แคบ กว่า |
Setosa (สีชมพู) อยู่ด้านซ้ายสุดของกราฟ → มีค่า Petal Length / Width ต่ำมาก แต่ Sepal Width สูง → แตกต่างชัดเจนจากกลุ่มอื่นบน PC1
Versicolor (สีเขียว) อยู่กึ่งกลาง → ขนาดกลีบปานกลาง ค่าของ Petal สูงกว่า setosa แต่ยังต่ำกว่า virginica
Virginica (สีน้ำเงิน) อยู่ทางขวาสุด → กลีบใหญ่ที่สุด ค่า Petal Length และ Width สูง → คะแนน PC1 สูง
แกน PC1 ≈ “ขนาดกลีบดอก (petal size dimension)”
แกน PC2 ≈ “สัดส่วน sepal–petal หรือ sepal width dimension”
ลูกศรยาวและขนานกันมาก → ตัวแปรสำคัญและสัมพันธ์กันสูง
ลูกศรตรงข้าม → ตัวแปรมีความสัมพันธ์ทางลบ
Petal Length และ Petal Width เป็นตัวกำหนดหลักของ PC1
ทำให้ “setosa–versicolor–virginica” แยกออกจากกันอย่างชัดเจนตามแนวแกนนอน
ส่วน Sepal Width ช่วยแยกความแตกต่างย่อยในแนวตั้ง (PC2)
ดังนั้นภาพนี้แสดงให้เห็นชัดว่า
🌸 การแยกชนิดของดอกไม้ใน Iris dataset ส่วนใหญ่ถูกอธิบายด้วย “ขนาดกลีบดอก”
Ester, M., Kriegel, H. P., Sander, J., & Xu, X. (1996). A density-based algorithm for discovering clusters in large spatial databases with noise. In Proceedings of the Second International Conference on Knowledge Discovery and Data Mining (KDD-96) (pp. 226–231).
Zhu, D., & Tian, Y. (2019). NS-DBSCAN: A density-based clustering algorithm in network space. ISPRS International Journal of Geo-Information, 8(5), 218. https://doi.org/10.3390/ijgi8050218
Zhu, D., & Tian, Y. (2021). MDST-DBSCAN: A density-based clustering method for multidimensional spatiotemporal data. ISPRS International Journal of Geo-Information, 10(6), 391. https://doi.org/10.3390/ijgi10060391
Pearson, K. (1901). On lines and planes of closest fit to systems of points in space. The London, Edinburgh, and Dublin Philosophical Magazine and Journal of Science, 2(11), 559–572. https://doi.org/10.1080/14786440109462720
Hotelling, H. (1933). Analysis of a complex of statistical variables into principal components. Journal of Educational Psychology, 24(6), 417–441. https://doi.org/10.1037/h0071325
Jolliffe, I. T., & Cadima, J. (2016). Principal component analysis: A review and recent developments. Philosophical Transactions of the Royal Society A: Mathematical, Physical and Engineering Sciences, 374(2065), 20150202. https://doi.org/10.1098/rsta.2015.0202