Spaces:
Running
Running
| /** | |
| * Teachable LLM - Interactive Deep Learning Visualizer | |
| * Inspired by bbycroft.net/llm, Transformer Explainer, and GAN Lab. | |
| */ | |
| // 1. Principal Component Analysis (2D PCA) for High-Dimensional Vectors | |
| export class PCA2D { | |
| constructor() { | |
| this.mean = null; | |
| this.components = null; | |
| this.std = null; | |
| } | |
| fitTransform(vectors) { | |
| if (!vectors || vectors.length === 0) return []; | |
| const n = vectors.length; | |
| const d = vectors[0].length; | |
| if (n === 1) { | |
| return [{ x: 0, y: 0 }]; | |
| } | |
| // 1. Calculate column means | |
| this.mean = new Float64Array(d); | |
| for (let i = 0; i < n; i++) { | |
| for (let j = 0; j < d; j++) { | |
| this.mean[j] += vectors[i][j]; | |
| } | |
| } | |
| for (let j = 0; j < d; j++) { | |
| this.mean[j] /= n; | |
| } | |
| // 2. Mean-center matrix Y (n x d) | |
| const Y = vectors.map(v => { | |
| const centered = new Float64Array(d); | |
| for (let j = 0; j < d; j++) centered[j] = v[j] - this.mean[j]; | |
| return centered; | |
| }); | |
| // 3. Compute Gram Matrix G = Y * Y^T (n x n) | |
| const G = Array.from({ length: n }, () => new Float64Array(n)); | |
| for (let i = 0; i < n; i++) { | |
| for (let j = i; j < n; j++) { | |
| let dot = 0; | |
| for (let k = 0; k < d; k++) dot += Y[i][k] * Y[j][k]; | |
| G[i][j] = dot; | |
| G[j][i] = dot; | |
| } | |
| } | |
| // 4. Power Iteration to find top 2 Gram eigenvectors | |
| const e1 = this.powerIteration(G, n); | |
| const G2 = this.deflateMatrix(G, e1, n); | |
| const e2 = this.powerIteration(G2, n); | |
| // Project vectors onto principal components | |
| let coords = []; | |
| for (let i = 0; i < n; i++) { | |
| coords.push({ x: e1[i], y: e2[i] }); | |
| } | |
| // 5. Normalize coordinates to [-0.85, 0.85] range for canvas plotting | |
| return this.normalizeCoords(coords); | |
| } | |
| powerIteration(G, n, numIter = 40) { | |
| let v = new Float64Array(n); | |
| for (let i = 0; i < n; i++) v[i] = Math.sin(i + 1); // Deterministic init | |
| for (let iter = 0; iter < numIter; iter++) { | |
| let nextV = new Float64Array(n); | |
| for (let i = 0; i < n; i++) { | |
| for (let j = 0; j < n; j++) { | |
| nextV[i] += G[i][j] * v[j]; | |
| } | |
| } | |
| // Normalize | |
| let norm = 0; | |
| for (let i = 0; i < n; i++) norm += nextV[i] * nextV[i]; | |
| norm = Math.sqrt(norm) || 1e-8; | |
| for (let i = 0; i < n; i++) v[i] = nextV[i] / norm; | |
| } | |
| return v; | |
| } | |
| deflateMatrix(G, e, n) { | |
| // G_deflated = G - lambda * e * e^T | |
| let lambda = 0; | |
| for (let i = 0; i < n; i++) { | |
| for (let j = 0; j < n; j++) { | |
| lambda += e[i] * G[i][j] * e[j]; | |
| } | |
| } | |
| const G2 = Array.from({ length: n }, () => new Float64Array(n)); | |
| for (let i = 0; i < n; i++) { | |
| for (let j = 0; j < n; j++) { | |
| G2[i][j] = G[i][j] - lambda * e[i] * e[j]; | |
| } | |
| } | |
| return G2; | |
| } | |
| normalizeCoords(coords) { | |
| if (coords.length === 0) return []; | |
| let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; | |
| coords.forEach(c => { | |
| if (c.x < minX) minX = c.x; | |
| if (c.x > maxX) maxX = c.x; | |
| if (c.y < minY) minY = c.y; | |
| if (c.y > maxY) maxY = c.y; | |
| }); | |
| const rangeX = (maxX - minX) || 1; | |
| const rangeY = (maxY - minY) || 1; | |
| return coords.map(c => ({ | |
| x: ((c.x - minX) / rangeX) * 1.6 - 0.8, | |
| y: ((c.y - minY) / rangeY) * 1.6 - 0.8 | |
| })); | |
| } | |
| } | |
| // 2. Tokenizer & Subword Visualizer Component | |
| export class TokenizerViz { | |
| constructor(containerId) { | |
| this.container = document.getElementById(containerId); | |
| } | |
| render(text, tokens = null) { | |
| if (!this.container) return; | |
| this.container.innerHTML = ''; | |
| if (!text) { | |
| this.container.innerHTML = '<div class="empty-viz">Type a sentence to visualize tokenization...</div>'; | |
| return; | |
| } | |
| // Generate synthetic tokens if raw subword tokenizer array not supplied | |
| if (!tokens) { | |
| const rawWords = text.trim().split(/\s+/); | |
| tokens = ['[CLS]']; | |
| rawWords.forEach(w => { | |
| if (w.length > 6) { | |
| tokens.push(w.slice(0, 4)); | |
| tokens.push('##' + w.slice(4)); | |
| } else { | |
| tokens.push(w); | |
| } | |
| }); | |
| tokens.push('[SEP]'); | |
| } | |
| const header = document.createElement('div'); | |
| header.className = 'viz-sub-header'; | |
| header.innerHTML = `<span>Input Token Sequence (${tokens.length} tokens):</span>`; | |
| this.container.appendChild(header); | |
| const tokenList = document.createElement('div'); | |
| tokenList.className = 'token-chip-container'; | |
| tokens.forEach((token, idx) => { | |
| const chip = document.createElement('div'); | |
| chip.className = 'token-chip'; | |
| if (token.startsWith('[')) chip.classList.add('special-token'); | |
| else if (token.startsWith('##')) chip.classList.add('subword-token'); | |
| chip.innerHTML = ` | |
| <span class="token-str">${escapeHtml(token)}</span> | |
| <span class="token-idx">#${idx}</span> | |
| `; | |
| // Hover tooltip for token details | |
| chip.addEventListener('mouseenter', (e) => { | |
| showTooltip(e, `Token #${idx}: <strong>${escapeHtml(token)}</strong><br/>Subword Type: ${token.startsWith('##') ? 'Continuation (##)' : token.startsWith('[') ? 'Special Control' : 'Word Root'}`); | |
| }); | |
| chip.addEventListener('mouseleave', hideTooltip); | |
| tokenList.appendChild(chip); | |
| }); | |
| this.container.appendChild(tokenList); | |
| } | |
| } | |
| // 3. Multi-Head Self-Attention Matrix Visualizer (Transformer Explainer style) | |
| export class AttentionMatrixViz { | |
| constructor(containerId, svgArcsId) { | |
| this.container = document.getElementById(containerId); | |
| this.svgArcsContainer = document.getElementById(svgArcsId); | |
| this.activeHead = 0; | |
| this.tokens = []; | |
| this.matrix = []; | |
| } | |
| render(text, tokens = null) { | |
| if (!this.container) return; | |
| this.container.innerHTML = ''; | |
| if (!text) { | |
| this.container.innerHTML = '<div class="empty-viz">Awaiting input sentence...</div>'; | |
| return; | |
| } | |
| if (!tokens) { | |
| const rawWords = text.trim().split(/\s+/); | |
| tokens = ['[CLS]', ...rawWords, '[SEP]']; | |
| } | |
| this.tokens = tokens; | |
| const n = tokens.length; | |
| // Generate physically intuitive token-token self-attention score matrix | |
| this.matrix = Array.from({ length: n }, (_, i) => { | |
| const row = new Float32Array(n); | |
| let sum = 0; | |
| for (let j = 0; j < n; j++) { | |
| // Diagonal self-attention baseline + semantic proximity term | |
| let score = (i === j) ? 2.5 : Math.max(0.1, 1.5 - Math.abs(i - j) * 0.4); | |
| // Boost CLS token connections | |
| if (i === 0 || j === 0) score += 0.8; | |
| row[j] = Math.exp(score); | |
| sum += row[j]; | |
| } | |
| // Softmax normalization | |
| for (let j = 0; j < n; j++) row[j] /= sum; | |
| return row; | |
| }); | |
| // Controls bar for Attention Head selection | |
| const controls = document.createElement('div'); | |
| controls.className = 'attn-controls'; | |
| controls.innerHTML = ` | |
| <div class="attn-head-select"> | |
| <label>Attention Layer & Head:</label> | |
| <div class="head-pills"> | |
| <button class="head-pill active" data-head="avg">All-Head Avg</button> | |
| <button class="head-pill" data-head="1">Head 1 (Syntax)</button> | |
| <button class="head-pill" data-head="2">Head 2 (Semantics)</button> | |
| <button class="head-pill" data-head="3">Head 3 (Positional)</button> | |
| </div> | |
| </div> | |
| <span class="attn-hint">Hover matrix cells or tokens to view attention arcs</span> | |
| `; | |
| this.container.appendChild(controls); | |
| // Bind head pills | |
| controls.querySelectorAll('.head-pill').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| controls.querySelectorAll('.head-pill').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| this.renderMatrixGrid(gridContainer, tokens); | |
| }); | |
| }); | |
| const gridContainer = document.createElement('div'); | |
| gridContainer.className = 'attn-grid-wrapper'; | |
| this.container.appendChild(gridContainer); | |
| this.renderMatrixGrid(gridContainer, tokens); | |
| } | |
| renderMatrixGrid(wrapper, tokens) { | |
| wrapper.innerHTML = ''; | |
| const n = tokens.length; | |
| const table = document.createElement('div'); | |
| table.className = 'attn-matrix-table'; | |
| table.style.gridTemplateColumns = `max-content repeat(${n}, minmax(32px, 1fr))`; | |
| // Top Header Row (Target Tokens) | |
| const emptyCorner = document.createElement('div'); | |
| emptyCorner.className = 'attn-cell corner-cell'; | |
| emptyCorner.textContent = 'Q \\ K'; | |
| table.appendChild(emptyCorner); | |
| tokens.forEach((t, j) => { | |
| const th = document.createElement('div'); | |
| th.className = 'attn-cell col-header'; | |
| th.textContent = t.length > 7 ? t.slice(0, 5) + '..' : t; | |
| th.title = t; | |
| table.appendChild(th); | |
| }); | |
| // Matrix Rows | |
| tokens.forEach((rowToken, i) => { | |
| // Row Header (Query Token) | |
| const rh = document.createElement('div'); | |
| rh.className = 'attn-cell row-header'; | |
| rh.textContent = rowToken.length > 7 ? rowToken.slice(0, 5) + '..' : rowToken; | |
| rh.title = rowToken; | |
| table.appendChild(rh); | |
| tokens.forEach((colToken, j) => { | |
| const weight = this.matrix[i][j]; | |
| const cell = document.createElement('div'); | |
| cell.className = 'attn-cell weight-cell'; | |
| // Color intensity using vibrant Google Blue/Teal gradient | |
| const alpha = Math.min(1, Math.max(0.08, weight * 2.2)); | |
| cell.style.backgroundColor = `rgba(26, 115, 232, ${alpha})`; | |
| cell.style.color = alpha > 0.45 ? '#ffffff' : '#202124'; | |
| cell.textContent = weight.toFixed(2); | |
| // Cell hover highlights token pairs and draws interactive SVG arcs | |
| cell.addEventListener('mouseenter', (e) => { | |
| showTooltip(e, `Attention score (${rowToken} → ${colToken}): <strong>${(weight * 100).toFixed(1)}%</strong><br/>Softmax score: ${weight.toFixed(4)}`); | |
| this.highlightArcs(i, j, weight); | |
| }); | |
| cell.addEventListener('mouseleave', () => { | |
| hideTooltip(); | |
| this.clearArcs(); | |
| }); | |
| table.appendChild(cell); | |
| }); | |
| }); | |
| wrapper.appendChild(table); | |
| } | |
| highlightArcs(srcIdx, tgtIdx, weight) { | |
| if (!this.svgArcsContainer) return; | |
| this.clearArcs(); | |
| // SVG Arc drawing logic | |
| const arcSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| arcSvg.setAttribute('width', '100%'); | |
| arcSvg.setAttribute('height', '60'); | |
| arcSvg.style.position = 'absolute'; | |
| arcSvg.style.top = '0'; | |
| arcSvg.style.left = '0'; | |
| arcSvg.style.pointerEvents = 'none'; | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| const x1 = 50 + srcIdx * 60; | |
| const x2 = 50 + tgtIdx * 60; | |
| const mx = (x1 + x2) / 2; | |
| const my = 10; | |
| path.setAttribute('d', `M ${x1} 50 Q ${mx} ${my} ${x2} 50`); | |
| path.setAttribute('fill', 'none'); | |
| path.setAttribute('stroke', '#1a73e8'); | |
| path.setAttribute('stroke-width', Math.max(2, weight * 8)); | |
| path.setAttribute('stroke-linecap', 'round'); | |
| arcSvg.appendChild(path); | |
| this.svgArcsContainer.appendChild(arcSvg); | |
| } | |
| clearArcs() { | |
| if (this.svgArcsContainer) this.svgArcsContainer.innerHTML = ''; | |
| } | |
| } | |
| // 4. Mean Pooling & Vector Fingerprint Barcode Visualizer | |
| export class PoolingVectorViz { | |
| constructor(containerId) { | |
| this.container = document.getElementById(containerId); | |
| } | |
| render(embedding) { | |
| if (!this.container) return; | |
| this.container.innerHTML = ''; | |
| if (!embedding || embedding.length === 0) { | |
| this.container.innerHTML = '<div class="empty-viz">No vector embedding generated yet...</div>'; | |
| return; | |
| } | |
| const dim = embedding.length; | |
| const header = document.createElement('div'); | |
| header.className = 'viz-sub-header'; | |
| header.innerHTML = ` | |
| <span>Dense Sentence Embedding Barcode (${dim} Dimensions):</span> | |
| <span class="norm-badge">L2 Normalized (||v|| = 1.0)</span> | |
| `; | |
| this.container.appendChild(header); | |
| // Vector Barcode Color Map | |
| const barcodeWrapper = document.createElement('div'); | |
| barcodeWrapper.className = 'barcode-wrapper'; | |
| const canvas = document.createElement('canvas'); | |
| canvas.className = 'barcode-canvas'; | |
| canvas.width = Math.min(800, dim * 2); | |
| canvas.height = 36; | |
| const ctx = canvas.getContext('2d'); | |
| const sliceWidth = canvas.width / dim; | |
| embedding.forEach((val, i) => { | |
| // Warm red for positive values, cool blue/teal for negative values | |
| let color; | |
| if (val >= 0) { | |
| const intensity = Math.min(255, Math.floor(val * 1800)); | |
| color = `rgb(${255 - intensity}, ${intensity + 50}, ${120})`; // Emerald / Teal | |
| } else { | |
| const intensity = Math.min(255, Math.floor(Math.abs(val) * 1800)); | |
| color = `rgb(${intensity + 80}, ${100}, ${255 - intensity})`; // Royal Indigo / Blue | |
| } | |
| ctx.fillStyle = color; | |
| ctx.fillRect(i * sliceWidth, 0, sliceWidth + 0.5, canvas.height); | |
| }); | |
| barcodeWrapper.appendChild(canvas); | |
| // Canvas hover event for inspecting vector values | |
| canvas.addEventListener('mousemove', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mouseX = e.clientX - rect.left; | |
| const index = Math.floor((mouseX / rect.width) * dim); | |
| if (index >= 0 && index < dim) { | |
| const val = embedding[index]; | |
| showTooltip(e, `Dimension [${index} / ${dim}]: <strong>${val >= 0 ? '+' : ''}${val.toFixed(6)}</strong>`); | |
| } | |
| }); | |
| canvas.addEventListener('mouseleave', hideTooltip); | |
| this.container.appendChild(barcodeWrapper); | |
| } | |
| } | |
| // 5. Interactive 2D Embedding Space & k-NN Decision Boundary Canvas (GAN Lab style) | |
| export class EmbeddingSpace2DViz { | |
| constructor(canvasId, legendId) { | |
| this.canvas = document.getElementById(canvasId); | |
| this.legend = document.getElementById(legendId); | |
| this.ctx = this.canvas ? this.canvas.getContext('2d') : null; | |
| this.pca = new PCA2D(); | |
| this.dataset = []; // [{ text, label, embedding, x2d, y2d, color }] | |
| this.queryPoint = null; // { text, embedding, x2d, y2d } | |
| this.neighbors = []; | |
| this.onCanvasClickCallback = null; | |
| if (this.canvas) { | |
| this.setupCanvasListeners(); | |
| } | |
| } | |
| setClickCallback(fn) { | |
| this.onCanvasClickCallback = fn; | |
| } | |
| updateData(dataset, queryResult = null) { | |
| if (!this.canvas || !this.ctx) return; | |
| this.dataset = dataset || []; | |
| this.queryResult = queryResult; | |
| // Collect all vectors to fit PCA simultaneously | |
| const allVectors = this.dataset.map(item => item.embedding); | |
| if (queryResult && queryResult.inputEmbedding) { | |
| allVectors.push(queryResult.inputEmbedding); | |
| } | |
| if (allVectors.length === 0) { | |
| this.drawEmptyState(); | |
| return; | |
| } | |
| // Fit PCA to compute 2D coordinates | |
| const coords2d = this.pca.fitTransform(allVectors); | |
| // Assign 2D coordinates to dataset items | |
| this.dataset.forEach((item, i) => { | |
| item.x2d = coords2d[i].x; | |
| item.y2d = coords2d[i].y; | |
| item.color = this.getLabelColor(item.label); | |
| }); | |
| // Assign 2D coordinates to query point | |
| if (queryResult && queryResult.inputEmbedding) { | |
| const qCoord = coords2d[coords2d.length - 1]; | |
| this.queryPoint = { | |
| text: queryResult.text || "Test Input", | |
| predictedLabel: queryResult.predictedLabel, | |
| embedding: queryResult.inputEmbedding, | |
| x2d: qCoord.x, | |
| y2d: qCoord.y | |
| }; | |
| this.neighbors = queryResult.nearestNeighbors || []; | |
| } else { | |
| this.queryPoint = null; | |
| this.neighbors = []; | |
| } | |
| this.renderCanvas(); | |
| this.renderLegend(); | |
| } | |
| getLabelColor(label) { | |
| const lower = (label || '').toLowerCase(); | |
| if (lower === 'positive') return '#34a853'; // Google Green | |
| if (lower === 'negative') return '#ea4335'; // Google Red | |
| if (lower === 'neutral') return '#fbbc04'; // Google Yellow | |
| return '#1a73e8'; // Google Blue | |
| } | |
| renderCanvas() { | |
| const w = this.canvas.width; | |
| const h = this.canvas.height; | |
| const ctx = this.ctx; | |
| ctx.clearRect(0, 0, w, h); | |
| // 1. Draw background grid | |
| ctx.strokeStyle = '#e8eaed'; | |
| ctx.lineWidth = 1; | |
| const step = 40; | |
| for (let x = 0; x < w; x += step) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, h); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < h; y += step) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(w, y); | |
| ctx.stroke(); | |
| } | |
| // 2. Render k-NN Decision Region Soft Heatmap Overlay (GAN Lab style) | |
| if (this.dataset.length > 0) { | |
| this.renderDecisionBoundaries(w, h); | |
| } | |
| // 3. Draw Euclidean Distance Vectors from Query Point to Nearest Neighbors | |
| if (this.queryPoint && this.neighbors.length > 0) { | |
| const qCanvas = this.toCanvasCoords(this.queryPoint.x2d, this.queryPoint.y2d, w, h); | |
| this.neighbors.forEach((neighbor, rank) => { | |
| const matchedItem = this.dataset.find(d => d.text === neighbor.text); | |
| if (matchedItem) { | |
| const nCanvas = this.toCanvasCoords(matchedItem.x2d, matchedItem.y2d, w, h); | |
| // Draw connecting distance dashed vector line | |
| ctx.beginPath(); | |
| ctx.setLineDash([4, 4]); | |
| ctx.strokeStyle = '#1a73e8'; | |
| ctx.lineWidth = 2; | |
| ctx.moveTo(qCanvas.x, qCanvas.y); | |
| ctx.lineTo(nCanvas.x, nCanvas.y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Distance label tag badge at midpoint | |
| const mx = (qCanvas.x + nCanvas.x) / 2; | |
| const my = (qCanvas.y + nCanvas.y) / 2; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(mx - 22, my - 10, 44, 18); | |
| ctx.strokeStyle = '#aecbfa'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(mx - 22, my - 10, 44, 18); | |
| ctx.fillStyle = '#1a73e8'; | |
| ctx.font = '10px "Roboto Mono", monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(`d:${neighbor.distance.toFixed(2)}`, mx, my); | |
| } | |
| }); | |
| } | |
| // 4. Render Dataset Training Points | |
| this.dataset.forEach(item => { | |
| const pt = this.toCanvasCoords(item.x2d, item.y2d, w, h); | |
| // Point outer glow | |
| ctx.beginPath(); | |
| ctx.arc(pt.x, pt.y, 10, 0, Math.PI * 2); | |
| ctx.fillStyle = item.color + '33'; // 20% opacity | |
| ctx.fill(); | |
| // Point core circle | |
| ctx.beginPath(); | |
| ctx.arc(pt.x, pt.y, 6, 0, Math.PI * 2); | |
| ctx.fillStyle = item.color; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#ffffff'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| }); | |
| // 5. Render Query Test Point (Glowing Star / Pulse Node) | |
| if (this.queryPoint) { | |
| const qPt = this.toCanvasCoords(this.queryPoint.x2d, this.queryPoint.y2d, w, h); | |
| // Outer pulse circle | |
| ctx.beginPath(); | |
| ctx.arc(qPt.x, qPt.y, 16, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(26, 115, 232, 0.2)'; | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(qPt.x, qPt.y, 9, 0, Math.PI * 2); | |
| ctx.fillStyle = '#1a73e8'; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#ffffff'; | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| // 'Q' text mark inside query node | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 10px "Google Sans", sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('Q', qPt.x, qPt.y); | |
| } | |
| } | |
| renderDecisionBoundaries(w, h) { | |
| // Fast low-res canvas raster for smooth class decision background | |
| const res = 15; // Grid cell size in pixels | |
| const cols = Math.ceil(w / res); | |
| const rows = Math.ceil(h / res); | |
| for (let r = 0; r < rows; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| const cx = (c + 0.5) * res; | |
| const cy = (r + 0.5) * res; | |
| const norm = this.fromCanvasCoords(cx, cy, w, h); | |
| // Find 2D nearest neighbor in dataset | |
| let minDist = Infinity; | |
| let closestColor = '#ffffff'; | |
| this.dataset.forEach(item => { | |
| const dx = norm.x - item.x2d; | |
| const dy = norm.y - item.y2d; | |
| const dist = dx * dx + dy * dy; | |
| if (dist < minDist) { | |
| minDist = dist; | |
| closestColor = item.color; | |
| } | |
| }); | |
| this.ctx.fillStyle = closestColor + '12'; // Ultra soft 7% tint | |
| this.ctx.fillRect(c * res, r * res, res, res); | |
| } | |
| } | |
| } | |
| toCanvasCoords(x, y, w, h) { | |
| // Map [-1, 1] to [margin, w - margin] | |
| const margin = 40; | |
| return { | |
| x: ((x + 1) / 2) * (w - 2 * margin) + margin, | |
| y: ((1 - y) / 2) * (h - 2 * margin) + margin | |
| }; | |
| } | |
| fromCanvasCoords(cx, cy, w, h) { | |
| const margin = 40; | |
| return { | |
| x: ((cx - margin) / (w - 2 * margin)) * 2 - 1, | |
| y: (1 - (cy - margin) / (h - 2 * margin)) * 2 | |
| }; | |
| } | |
| setupCanvasListeners() { | |
| this.canvas.addEventListener('mousemove', (e) => { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const cx = e.clientX - rect.left; | |
| const cy = e.clientY - rect.top; | |
| const norm = this.fromCanvasCoords(cx, cy, this.canvas.width, this.canvas.height); | |
| // Check if hovering near a dataset point or query point | |
| let hoverItem = null; | |
| const w = this.canvas.width; | |
| const h = this.canvas.height; | |
| this.dataset.forEach(item => { | |
| const pt = this.toCanvasCoords(item.x2d, item.y2d, w, h); | |
| const dist = Math.hypot(pt.x - cx, pt.y - cy); | |
| if (dist < 12) hoverItem = item; | |
| }); | |
| if (!hoverItem && this.queryPoint) { | |
| const qPt = this.toCanvasCoords(this.queryPoint.x2d, this.queryPoint.y2d, w, h); | |
| if (Math.hypot(qPt.x - cx, qPt.y - cy) < 14) { | |
| hoverItem = { ...this.queryPoint, isQuery: true }; | |
| } | |
| } | |
| if (hoverItem) { | |
| this.canvas.style.cursor = 'pointer'; | |
| const labelText = hoverItem.isQuery ? `[Active Query] "${escapeHtml(hoverItem.text)}"` : `Label: <strong>${escapeHtml(hoverItem.label)}</strong><br/>"${escapeHtml(hoverItem.text)}"`; | |
| showTooltip(e, `${labelText}<br/>2D PCA Pos: (${hoverItem.x2d.toFixed(2)}, ${hoverItem.y2d.toFixed(2)})`); | |
| } else { | |
| this.canvas.style.cursor = 'crosshair'; | |
| hideTooltip(); | |
| } | |
| }); | |
| this.canvas.addEventListener('mouseleave', () => { | |
| this.canvas.style.cursor = 'default'; | |
| hideTooltip(); | |
| }); | |
| this.canvas.addEventListener('click', (e) => { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const cx = e.clientX - rect.left; | |
| const cy = e.clientY - rect.top; | |
| if (this.onCanvasClickCallback) { | |
| this.onCanvasClickCallback(cx, cy); | |
| } | |
| }); | |
| } | |
| renderLegend() { | |
| if (!this.legend) return; | |
| this.legend.innerHTML = ` | |
| <div class="legend-item"><span class="legend-dot" style="background:#34a853"></span> positive</div> | |
| <div class="legend-item"><span class="legend-dot" style="background:#ea4335"></span> negative</div> | |
| <div class="legend-item"><span class="legend-dot" style="background:#fbbc04"></span> neutral</div> | |
| <div class="legend-item"><span class="legend-dot star-dot" style="background:#1a73e8"></span> query (Q)</div> | |
| `; | |
| } | |
| drawEmptyState() { | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| this.ctx.fillStyle = '#5f6368'; | |
| this.ctx.font = '14px "Google Sans Text", sans-serif'; | |
| this.ctx.textAlign = 'center'; | |
| this.ctx.fillText('Add dataset examples to populate 2D PCA vector space...', this.canvas.width / 2, this.canvas.height / 2); | |
| } | |
| } | |
| // 6. Global Tooltip Helpers | |
| function showTooltip(evt, htmlContent) { | |
| let tooltip = document.getElementById('viz-global-tooltip'); | |
| if (!tooltip) { | |
| tooltip = document.createElement('div'); | |
| tooltip.id = 'viz-global-tooltip'; | |
| tooltip.className = 'viz-tooltip'; | |
| document.body.appendChild(tooltip); | |
| } | |
| tooltip.innerHTML = htmlContent; | |
| tooltip.style.display = 'block'; | |
| tooltip.style.left = `${evt.pageX + 12}px`; | |
| tooltip.style.top = `${evt.pageY + 12}px`; | |
| } | |
| function hideTooltip() { | |
| const tooltip = document.getElementById('viz-global-tooltip'); | |
| if (tooltip) tooltip.style.display = 'none'; | |
| } | |
| function escapeHtml(str) { | |
| return (str || '').replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| } | |