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:
parent
9e38f78379
commit
12ac409ab2
4 changed files with 315 additions and 82 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue