diff --git a/training/dashboard/events.py b/training/dashboard/events.py index d5dad60..37442bf 100644 --- a/training/dashboard/events.py +++ b/training/dashboard/events.py @@ -202,19 +202,35 @@ class ModelMetric(_EventBase): @dataclass class Embedding(_EventBase): - """One projected feature vector for the KNN scatter plot. + """One projected feature vector for the KNN 3-D scatter plot. - Each event adds a single dot at ``(x, y)`` colored by ``phase``. - Run your projector (PCA / UMAP / t-SNE) on the per-window engineered - features, rescale x/y to 0–1, and emit one event per point. + Each event adds a single dot at ``(x, y, z)``. The scene has a + mode toggle that picks which categorical field colors the dot: + ``phase`` (ground truth), ``predicted`` (the KNN/ML output), or + ``cluster`` (an unsupervised cluster id). - :param x: 0–1, mapped to the plot's horizontal axis. - :param y: 0–1, mapped to the plot's vertical axis. - :param phase: Color class — picks one of the phase swatches. + Run your projector (PCA / UMAP / t-SNE in 3 components) on the + per-window engineered features, rescale each axis to 0–1, and + emit one event per point. + + :param x: 0–1, mapped to the plot's first projected axis. + :param y: 0–1, mapped to the plot's second projected axis. + :param z: 0–1, mapped to the plot's third projected axis. + Optional; omit for a 2-D event (renders at z=0.5). + :param phase: Ground-truth phase. Color when mode = ``phase``. + :param predicted: Model's predicted phase. Color when mode = + ``predicted``. Must be a canonical :data:`Phase` + string for the swatches to match. + :param cluster: Unsupervised cluster id (any non-negative int). + Color when mode = ``cluster``; the legend + auto-builds palette swatches per id. """ x: float y: float - phase: Phase + z: Optional[float] = None + phase: Optional[Phase] = None + predicted: Optional[Phase] = None + cluster: Optional[int] = None type: str = field(default="embedding", init=False, repr=False) diff --git a/training/dashboard/static/dashboard.css b/training/dashboard/static/dashboard.css index 00ff927..5b581f1 100644 --- a/training/dashboard/static/dashboard.css +++ b/training/dashboard/static/dashboard.css @@ -990,6 +990,43 @@ html, body { overflow-anchor: none; } .model-acc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(13px, 1vw, 15px); color: var(--fg-dim); text-align: right; } +/* ─── KNN 3-D scatter (canvas) ─────────────────────────────────────── */ +.scatter3d-controls { + display: flex; flex-wrap: wrap; gap: 12px; + align-items: center; justify-content: space-between; + margin-bottom: 8px; +} +.scatter3d-modes { display: flex; flex-wrap: wrap; gap: 6px; } +.scatter3d-mode, .scatter3d-reset { + background: transparent; color: var(--fg-dim); + border: 1px solid var(--line); border-radius: 16px; + padding: 4px 12px; font-size: 12px; cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + transition: color 120ms, border-color 120ms, background 120ms; +} +.scatter3d-mode:hover, .scatter3d-reset:hover { + color: var(--fg); border-color: var(--fg-mute); +} +.scatter3d-mode.active { + color: var(--accent); border-color: var(--accent); + background: var(--accent-soft, rgba(80, 140, 220, 0.12)); +} +.scatter3d-wrap { + position: relative; + background: var(--bg-elev, rgba(255, 255, 255, 0.03)); + border: 1px solid var(--line); border-radius: 4px; + width: 100%; + height: clamp(320px, 56vh, 640px); + overflow: hidden; + cursor: grab; + touch-action: none; +} +.scatter3d-wrap:active { cursor: grabbing; } +.scatter3d { + display: block; + width: 100%; height: 100%; +} + /* ─── Scatter plots (KNN, perf) ────────────────────────────────────── */ .scatter { width: 100%; diff --git a/training/dashboard/static/dashboard.js b/training/dashboard/static/dashboard.js index d8a691d..ed83270 100644 --- a/training/dashboard/static/dashboard.js +++ b/training/dashboard/static/dashboard.js @@ -426,6 +426,10 @@ root.setProperty('--theme-l-num', state.L); root.setProperty('--theme-c', state.C); root.setProperty('--theme-h', state.H); + // Unitless H, parallel to --theme-l-num — used by the KNN + // scatter's cluster-color generator (it needs to do + // arithmetic on H, can't with a degree-suffixed value). + root.setProperty('--theme-h-num', state.H); root.setProperty('--anim-speed', state.animSpeed); // Only apply the filter when there's something to blur — at // zero, applying `filter: blur(0px)` still creates a stacking @@ -1548,88 +1552,254 @@ for epoch in range(20): emptyState(); })(); - // ── KNN scatter — DEMO ONLY ─────────────────────────────────── + // ── KNN 3-D scatter (canvas) ────────────────────────────────── + // Drag-to-rotate 3-D scatter on the KNN scene. Listens for + // `embedding` events with optional z / predicted / cluster fields; + // mode toggle picks which categorical dimension colors each point + // (ground-truth phase, KNN-predicted label, cluster id). (function () { - const svg = document.getElementById('knn-scatter'); + const wrap = document.querySelector('.scatter3d-wrap'); + const canvas = document.getElementById('knn-scatter-canvas'); const legend = document.getElementById('knn-legend'); + if (!canvas || !wrap || !legend) return; + const ctx = canvas.getContext('2d'); + const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant']; - const phaseCenters = { - clean: [0.18, 0.75], - armed: [0.42, 0.58], - infecting: [0.72, 0.40], - infected_running: [0.85, 0.18], - dormant: [0.30, 0.30], + const phaseCenters3d = { + clean: [0.18, 0.75, 0.30], + armed: [0.42, 0.58, 0.65], + infecting: [0.72, 0.40, 0.55], + infected_running: [0.85, 0.18, 0.20], + dormant: [0.30, 0.30, 0.85], }; - const ns = 'http://www.w3.org/2000/svg'; - let W = 600, H = 360; - legend.innerHTML = ''; - PHASES.forEach(p => { - const li = document.createElement('span'); - const swatchVar = p === 'infected_running' ? 'running' : p; - li.innerHTML = `${p}`; - legend.appendChild(li); - }); + const points = []; + let mode = 'phase'; + let rotX = 0.45, rotY = 0.55; + const HOME_X = rotX, HOME_Y = rotY; + let dragging = false, lastPx = 0, lastPy = 0; + let didDrag = false; - function setupAxes() { - svg.innerHTML = ''; - const ax = document.createElementNS(ns, 'line'); - ax.setAttribute('x1', 40); ax.setAttribute('y1', H - 30); - ax.setAttribute('x2', W - 10); ax.setAttribute('y2', H - 30); - ax.setAttribute('class', 'axis'); svg.appendChild(ax); - const ay = document.createElementNS(ns, 'line'); - ay.setAttribute('x1', 40); ay.setAttribute('y1', 10); - ay.setAttribute('x2', 40); ay.setAttribute('y2', H - 30); - ay.setAttribute('class', 'axis'); svg.appendChild(ay); - const xl = document.createElementNS(ns, 'text'); - xl.setAttribute('x', W / 2); xl.setAttribute('y', H - 8); - xl.setAttribute('class', 'axis-label'); xl.setAttribute('text-anchor', 'middle'); - xl.textContent = 'feature 1'; svg.appendChild(xl); - const yl = document.createElementNS(ns, 'text'); - yl.setAttribute('transform', `translate(12,${H/2}) rotate(-90)`); - yl.setAttribute('class', 'axis-label'); yl.setAttribute('text-anchor', 'middle'); - yl.textContent = 'feature 2'; svg.appendChild(yl); + // Hidden probe to resolve var(--…) and oklch(…) to RGB for canvas. + const probe = document.createElement('div'); + probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none'; + document.body.appendChild(probe); + const colorCache = new Map(); + function cssColor(varExpr) { + if (colorCache.has(varExpr)) return colorCache.get(varExpr); + probe.style.color = varExpr; + const c = getComputedStyle(probe).color || 'rgb(150,150,150)'; + colorCache.set(varExpr, c); + return c; } - function emptyState() { - svg.innerHTML = ''; - const t = document.createElementNS(ns, 'text'); - t.setAttribute('x', W / 2); t.setAttribute('y', H / 2); - t.setAttribute('text-anchor', 'middle'); - t.setAttribute('class', 'axis-label'); - t.textContent = 'awaiting embedding events · turn demo on for examples'; - svg.appendChild(t); + function refreshColors() { colorCache.clear(); } + + function phaseColor(p) { + const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`; + return cssColor(`var(--${v})`); } - function project(x, y) { - const px = 40 + x * (W - 50); - const py = (H - 30) - y * (H - 40); - return [px, py]; + function clusterColor(i) { + const baseH = parseFloat(getComputedStyle(document.documentElement) + .getPropertyValue('--theme-h-num') || '250'); + const h = (baseH + i * 47) % 360; + return cssColor(`oklch(72% 0.18 ${h.toFixed(1)})`); } - function addPoint(x, y, phase) { - const c = document.createElementNS(ns, 'circle'); - const [px, py] = project(x, y); - c.setAttribute('cx', px.toFixed(1)); c.setAttribute('cy', py.toFixed(1)); - c.setAttribute('r', 4); - c.setAttribute('class', `point ${phase}`); - svg.appendChild(c); - } - function syntheticPoints() { - let seed = 7; - const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return ((seed & 0xffff) / 0xffff) - 0.5; }; - setupAxes(); + + function rebuildLegend() { + legend.innerHTML = ''; + if (mode === 'cluster') { + const ids = Array.from(new Set(points.map(p => p.cluster).filter(c => c != null))).sort((a,b)=>a-b); + if (ids.length === 0) { + legend.innerHTML = 'no cluster ids in current points'; + return; + } + ids.forEach(id => { + const li = document.createElement('span'); + li.innerHTML = `cluster ${id}`; + legend.appendChild(li); + }); + return; + } PHASES.forEach(p => { - const [cx, cy] = phaseCenters[p]; - for (let i = 0; i < 14; i++) addPoint(cx + rand() * 0.16, cy + rand() * 0.16, p); + const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`; + const li = document.createElement('span'); + li.innerHTML = `${p}`; + legend.appendChild(li); }); } - on('demo_start', syntheticPoints); - on('demo_stop', emptyState); - on('embedding', m => { - if (typeof m.x !== 'number' || typeof m.y !== 'number' || !m.phase) return; - // First real embedding wipes the awaiting note and sets up axes. - if (svg.querySelector('.axis-label') && !svg.querySelector('.point')) setupAxes(); - addPoint(m.x, m.y, m.phase); + + function pointColor(p) { + if (mode === 'phase' && p.phase) return phaseColor(p.phase); + if (mode === 'predicted' && p.predicted) return phaseColor(p.predicted); + if (mode === 'cluster' && p.cluster != null) return clusterColor(p.cluster); + return cssColor('var(--fg-mute)'); + } + + function resize() { + const r = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.floor(r.width * dpr)); + const h = Math.max(1, Math.floor(r.height * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; canvas.height = h; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + } + if (window.ResizeObserver) new ResizeObserver(resize).observe(canvas); + + // (x,y,z) ∈ [0,1]³ → canvas pixels: rotateY then rotateX, + // perspective from a fixed camera distance. + function project(p) { + const x = (p.x ?? 0.5) - 0.5; + const y = (p.y ?? 0.5) - 0.5; + const z = (p.z ?? 0.5) - 0.5; + const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY); + const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX); + const x1 = x * cy_ + z * sy_; + const z1 = -x * sy_ + z * cy_; + const y2 = y * cx_ - z1 * sx_; + const z2 = y * sx_ + z1 * cx_; + const camZ = 2.5; + const persp = camZ / (camZ - z2); + const w = canvas.clientWidth, h = canvas.clientHeight; + const span = Math.min(w, h) * 0.4; + return { + sx: w / 2 + x1 * span * persp, + sy: h / 2 + y2 * span * persp, + depth: z2, + scale: persp, + }; + } + + const cubeEdges = [ + [0,1],[1,3],[3,2],[2,0],[4,5],[5,7],[7,6],[6,4], + [0,4],[1,5],[2,6],[3,7], + ]; + function drawCube() { + const corners = []; + for (let i = 0; i < 8; i++) { + corners.push(project({ + x: (i & 1) ? 1 : 0, + y: (i & 2) ? 1 : 0, + z: (i & 4) ? 1 : 0, + })); + } + ctx.save(); + ctx.strokeStyle = cssColor('var(--line)'); + ctx.globalAlpha = 0.7; + ctx.lineWidth = 1; + for (const [a, b] of cubeEdges) { + ctx.beginPath(); + ctx.moveTo(corners[a].sx, corners[a].sy); + ctx.lineTo(corners[b].sx, corners[b].sy); + ctx.stroke(); + } + ctx.restore(); + } + + function tick() { + requestAnimationFrame(tick); + const sceneActive = document.querySelector('.stage-view[data-view="knn"][data-active]'); + if (!sceneActive) return; + resize(); + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); + drawCube(); + + const projected = points.map(p => ({ p, ...project(p) })); + projected.sort((a, b) => a.depth - b.depth); + for (const item of projected) { + const r = Math.max(2, 4.5 * item.scale); + ctx.fillStyle = pointColor(item.p); + ctx.globalAlpha = 0.55 + 0.4 * Math.min(1, item.scale); + ctx.beginPath(); + ctx.arc(item.sx, item.sy, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + requestAnimationFrame(tick); + + wrap.addEventListener('pointerdown', e => { + dragging = true; didDrag = false; + lastPx = e.clientX; lastPy = e.clientY; + try { wrap.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); }); - emptyState(); + wrap.addEventListener('pointermove', e => { + if (!dragging) return; + const dx = e.clientX - lastPx, dy = e.clientY - lastPy; + if (Math.hypot(dx, dy) > 2) didDrag = true; + rotY += dx * 0.008; + rotX += dy * 0.008; + rotX = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, rotX)); + lastPx = e.clientX; lastPy = e.clientY; + }); + function endDrag(e) { + dragging = false; + try { wrap.releasePointerCapture(e.pointerId); } catch {} + } + wrap.addEventListener('pointerup', endDrag); + wrap.addEventListener('pointercancel', endDrag); + // Eat the click that follows a drag so click-nav doesn't advance. + wrap.addEventListener('click', e => { if (didDrag) { e.stopPropagation(); didDrag = false; } }); + + document.querySelectorAll('.scatter3d-mode').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + mode = btn.dataset.mode; + document.querySelectorAll('.scatter3d-mode').forEach(b => b.classList.toggle('active', b === btn)); + rebuildLegend(); + }); + }); + document.querySelector('.scatter3d-reset')?.addEventListener('click', e => { + e.stopPropagation(); + rotX = HOME_X; rotY = HOME_Y; + refreshColors(); + }); + + // Synthetic 3-D demo: 5 phase clusters, deterministic LCG. ~7% + // mislabeled by the demo "predictor" so predicted-mode visibly + // differs from ground truth. + function loadSynthetic() { + points.length = 0; + let seed = 7; + const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return ((seed & 0xffff) / 0xffff) - 0.5; }; + const wrand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return (seed & 0xffff) / 0xffff; }; + PHASES.forEach((p, idx) => { + const [cx, cy, cz] = phaseCenters3d[p]; + for (let i = 0; i < 18; i++) { + const wrong = wrand() < 0.07; + const predicted = wrong + ? PHASES[(idx + 1 + Math.floor(wrand() * 4)) % PHASES.length] + : p; + points.push({ + x: cx + rand() * 0.18, + y: cy + rand() * 0.18, + z: cz + rand() * 0.18, + phase: p, + predicted, + cluster: idx, + }); + } + }); + rebuildLegend(); + } + + on('demo_start', loadSynthetic); + on('demo_stop', () => { points.length = 0; rebuildLegend(); }); + on('embedding', m => { + if (typeof m.x !== 'number' || typeof m.y !== 'number') return; + points.push({ + x: m.x, y: m.y, + z: typeof m.z === 'number' ? m.z : 0.5, + phase: m.phase, + predicted: m.predicted, + cluster: typeof m.cluster === 'number' ? m.cluster : undefined, + }); + rebuildLegend(); + }); + + rebuildLegend(); })(); // ── References viewer (scene: references) ──────────────────── diff --git a/training/dashboard/static/index.html b/training/dashboard/static/index.html index 4d5a205..3db3100 100644 --- a/training/dashboard/static/index.html +++ b/training/dashboard/static/index.html @@ -4,7 +4,7 @@ CIS490 — live - + +
-
window features · 2-D projection
- +
window features · 3-D projection · drag to rotate
+
+
+ + + +
+ +
+
+ +
@@ -518,6 +528,6 @@ - +