knn scene: drag-to-rotate 3-D scatter + KNN/cluster color modes

Replace the SVG 2-D scatter with a canvas-based 3-D one. Three color
modes (phase / predicted / cluster) with a toggle; drag the surface
to rotate; reset button. Bounding cube draws faintly so the rotation
reads as 3-D rather than re-shuffled 2-D.

Embedding event gains optional z / predicted / cluster fields. 2-D
producers still work (z defaults to 0.5, no other behavior changes).

CSS adds .scatter3d-* rules; --theme-h-num exposed for cluster-color
hue arithmetic. Synthetic demo data is now 3-D Gaussian clusters with
~7% mislabeled "predictions" so the predicted-mode view differs from
ground truth at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Max Gorog 2026-05-08 12:55:31 -05:00
parent 9e38f78379
commit 12ac409ab2
4 changed files with 315 additions and 82 deletions

View file

@ -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 01, 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: 01, mapped to the plot's horizontal axis.
:param y: 01, 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 01, and
emit one event per point.
:param x: 01, mapped to the plot's first projected axis.
:param y: 01, mapped to the plot's second projected axis.
:param z: 01, 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)

View file

@ -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%;

View file

@ -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 = `<span class="swatch" style="background:var(--phase-${swatchVar})"></span>${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 = '<span style="opacity:.6">no cluster ids in current points</span>';
return;
}
ids.forEach(id => {
const li = document.createElement('span');
li.innerHTML = `<span class="swatch" style="background:${clusterColor(id)}"></span>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 = `<span class="swatch" style="background:var(--${v})"></span>${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) ────────────────────

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CIS490 — live</title>
<link rel="stylesheet" href="/static/dashboard.css?v=afecfcf3">
<link rel="stylesheet" href="/static/dashboard.css?v=3434f117">
</head>
<body>
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
@ -292,11 +292,21 @@
</div>
</div>
<!-- 11. knn -->
<!-- 11. knn — interactive 3-D scatter with mode toggle -->
<div class="stage-view" data-view="knn">
<div class="metric-stack">
<div class="metric-eyebrow">window features · 2-D projection</div>
<svg class="scatter" id="knn-scatter" viewBox="0 0 600 360" preserveAspectRatio="xMidYMid meet"></svg>
<div class="metric-eyebrow">window features · 3-D projection · drag to rotate</div>
<div class="scatter3d-controls">
<div class="scatter3d-modes">
<button class="scatter3d-mode active" data-mode="phase">phase (ground truth)</button>
<button class="scatter3d-mode" data-mode="predicted">KNN-predicted label</button>
<button class="scatter3d-mode" data-mode="cluster">cluster id</button>
</div>
<button class="scatter3d-reset">reset view</button>
</div>
<div class="scatter3d-wrap">
<canvas class="scatter3d" id="knn-scatter-canvas"></canvas>
</div>
<div class="phase-legend" id="knn-legend"></div>
</div>
</div>
@ -518,6 +528,6 @@
</article>
</div>
<script src="/static/dashboard.js?v=f2a8bda2"></script>
<script src="/static/dashboard.js?v=246d8985"></script>
</body>
</html>