/** * Embedding Projector Component * * A TensorFlow Projector-inspired visualization tool for high-dimensional embeddings. * Supports multiple dimensionality reduction techniques: * - PCA (Principal Component Analysis) - fast, deterministic * - UMAP (Uniform Manifold Approximation and Projection) - preserves local structure * - t-SNE (t-distributed Stochastic Neighbor Embedding) - cluster visualization * * Features: * - 2D/3D interactive visualization * - Point search and filtering * - Nearest neighbor exploration * - Color coding by metadata fields * - Pan, zoom, rotate controls * - Export/import projections * * References: * - TensorFlow Embedding Projector: https://projector.tensorflow.org/ * - UMAP: https://umap-learn.readthedocs.io/ * - t-SNE: https://distill.pub/2016/misread-tsne/ */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import * as d3 from 'd3'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { UMAP } from 'umap-js'; import { useLanguage } from '@/contexts/LanguageContext'; import { PointDetailsPanel } from './PointDetailsPanel'; import { Tooltip } from '@/components/ui/Tooltip'; import { Play, Loader2, Settings, Search, X } from 'lucide-react'; import './EmbeddingProjector.css'; // Types export interface EmbeddingPoint { id: string | number; vector: number[]; payload: Record; } export interface ProjectedPoint { x: number; y: number; z?: number; originalIndex: number; } export type ProjectionMethod = 'pca' | 'umap' | 'tsne'; export type ViewMode = '2d' | '3d'; interface EmbeddingProjectorProps { points: EmbeddingPoint[]; onPointSelect?: (point: EmbeddingPoint | null) => void; colorByField?: string; height?: number; width?: number; /** Simple mode hides advanced controls for cleaner UI */ simpleMode?: boolean; /** Indices of points to highlight (e.g., RAG sources) */ highlightedIndices?: number[]; /** Callback when user selects a point to add to conversation context */ onContextSelect?: (point: EmbeddingPoint) => void; /** Show "Add to context" button on point selection */ showContextButton?: boolean; /** Title override for simple mode */ title?: string; /** Auto-run projection when points are loaded (no need to click Run button) */ autoRun?: boolean; } // Translation strings const TEXT = { title: { nl: 'Embedding Projector', en: 'Embedding Projector' }, description: { nl: 'Visualiseer hoog-dimensionale embeddings in 2D/3D', en: 'Visualize high-dimensional embeddings in 2D/3D', }, projectionMethod: { nl: 'Projectie methode', en: 'Projection method' }, viewMode: { nl: 'Weergave', en: 'View mode' }, colorBy: { nl: 'Kleur op', en: 'Color by' }, noField: { nl: 'Geen', en: 'None' }, neighbors: { nl: 'Buren', en: 'Neighbors' }, search: { nl: 'Zoeken...', en: 'Search...' }, computing: { nl: 'Berekenen...', en: 'Computing...' }, run: { nl: 'Start', en: 'Run' }, stop: { nl: 'Stop', en: 'Stop' }, reset: { nl: 'Reset', en: 'Reset' }, iteration: { nl: 'Iteratie', en: 'Iteration' }, perplexity: { nl: 'Perplexiteit', en: 'Perplexity' }, learningRate: { nl: 'Leersnelheid', en: 'Learning rate' }, umapNeighbors: { nl: 'Buren', en: 'Neighbors' }, minDist: { nl: 'Min afstand', en: 'Min distance' }, spread: { nl: 'Spreiding', en: 'Spread' }, pcaComponents: { nl: 'Componenten', en: 'Components' }, pointsLoaded: { nl: 'punten geladen', en: 'points loaded' }, dimensions: { nl: 'dimensies', en: 'dimensions' }, selectedPoint: { nl: 'Geselecteerd punt', en: 'Selected point' }, nearestNeighbors: { nl: 'Dichtstbijzijnde buren', en: 'Nearest neighbors' }, distance: { nl: 'Afstand', en: 'Distance' }, showLabels: { nl: 'Labels tonen', en: 'Show labels' }, sphereize: { nl: 'Sferiseren', en: 'Sphereize' }, variance: { nl: 'Variantie', en: 'Variance' }, addToContext: { nl: 'Toevoegen aan context', en: 'Add to context' }, sourcesHighlighted: { nl: 'bronnen gemarkeerd', en: 'sources highlighted' }, // Algorithm tooltips - user-friendly explanations pcaTooltip: { nl: 'PCA (Principal Component Analysis)\n\nSnelle lineaire methode die de belangrijkste variaties in de data vindt. Toont de globale structuur en maximaliseert de spreiding. Ideaal voor een eerste verkenning - resultaten zijn deterministisch en direct interpreteerbaar.', en: 'PCA (Principal Component Analysis)\n\nFast linear method that finds the most important variations in the data. Shows global structure and maximizes spread. Ideal for initial exploration - results are deterministic and directly interpretable.', }, umapTooltip: { nl: 'UMAP (Uniform Manifold Approximation)\n\nModerne niet-lineaire techniek die zowel lokale clusters als globale structuur behoudt. Sneller dan t-SNE en schaalt goed naar grote datasets. De beste keuze voor algemene visualisatie.', en: 'UMAP (Uniform Manifold Approximation)\n\nModern non-linear technique that preserves both local clusters and global structure. Faster than t-SNE and scales well to large datasets. The best choice for general-purpose visualization.', }, tsneTooltip: { nl: 't-SNE (t-Distributed Stochastic Neighbor Embedding)\n\nKrachtige niet-lineaire ML-techniek die verborgen clusters onthult. Behoudt lokale nabuurrelaties - punten die dichtbij zijn in hoge dimensies blijven dichtbij. Langzamer maar uitstekend voor het ontdekken van groepen.', en: 't-SNE (t-Distributed Stochastic Neighbor Embedding)\n\nPowerful non-linear ML technique that reveals hidden clusters. Preserves local neighbor relationships - points close in high dimensions stay close. Slower but excellent for discovering groupings.', }, }; // Color palette for categorical data const COLORS = [ '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#1d4ed8', ]; /** * Proper PCA implementation using power iteration for top eigenvectors */ function computePCA(vectors: number[][], nComponents: number = 2): { projected: number[][]; variance: number[]; explained: number[]; } { if (vectors.length === 0) return { projected: [], variance: [], explained: [] }; const n = vectors.length; const d = vectors[0].length; // Center the data const means = new Array(d).fill(0); for (const vec of vectors) { for (let i = 0; i < d; i++) { means[i] += vec[i] / n; } } const centered = vectors.map(vec => vec.map((v, i) => v - means[i])); // Compute covariance matrix (d x d can be large, so we use X^T X / n) // For efficiency with high-d data, we compute principal components via power iteration const components: number[][] = []; const eigenvalues: number[] = []; const workingData = centered.map(row => [...row]); for (let comp = 0; comp < Math.min(nComponents, d); comp++) { // Initialize random vector let v = new Array(d).fill(0).map(() => Math.random() - 0.5); let norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)); v = v.map(x => x / norm); // Power iteration (50 iterations should be enough for convergence) for (let iter = 0; iter < 50; iter++) { // Multiply by X^T X const Xv = workingData.map(row => row.reduce((s, x, i) => s + x * v[i], 0)); const XtXv = new Array(d).fill(0); for (let i = 0; i < n; i++) { for (let j = 0; j < d; j++) { XtXv[j] += workingData[i][j] * Xv[i]; } } // Normalize norm = Math.sqrt(XtXv.reduce((s, x) => s + x * x, 0)); if (norm > 1e-10) { v = XtXv.map(x => x / norm); } } // Compute eigenvalue const Xv = workingData.map(row => row.reduce((s, x, i) => s + x * v[i], 0)); const eigenvalue = Xv.reduce((s, x) => s + x * x, 0) / n; components.push(v); eigenvalues.push(eigenvalue); // Deflate: remove this component from data for (let i = 0; i < n; i++) { const proj = workingData[i].reduce((s, x, j) => s + x * v[j], 0); for (let j = 0; j < d; j++) { workingData[i][j] -= proj * v[j]; } } } // Project data onto principal components const projected = centered.map(vec => components.map(comp => vec.reduce((s, x, i) => s + x * comp[i], 0)) ); // Calculate explained variance ratio const totalVariance = eigenvalues.reduce((s, e) => s + e, 0) || 1; const explained = eigenvalues.map(e => (e / totalVariance) * 100); // Normalize to [-1, 1] range const mins = new Array(nComponents).fill(Infinity); const maxs = new Array(nComponents).fill(-Infinity); for (const point of projected) { for (let i = 0; i < point.length; i++) { mins[i] = Math.min(mins[i], point[i]); maxs[i] = Math.max(maxs[i], point[i]); } } const normalized = projected.map(point => point.map((v, i) => { const range = maxs[i] - mins[i]; return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; }) ); return { projected: normalized, variance: eigenvalues, explained }; } /** * Simple t-SNE implementation * Based on the Barnes-Hut approximation algorithm */ function computeTSNE( vectors: number[][], nComponents: number = 2, options: { perplexity?: number; learningRate?: number; iterations?: number; onProgress?: (iteration: number, error: number) => void; } = {} ): number[][] { const { perplexity = 30, learningRate = 200, iterations = 500, onProgress } = options; if (vectors.length === 0) return []; const n = vectors.length; // Compute pairwise distances const distances: number[][] = []; for (let i = 0; i < n; i++) { distances[i] = []; for (let j = 0; j < n; j++) { let d = 0; for (let k = 0; k < vectors[i].length; k++) { const diff = vectors[i][k] - vectors[j][k]; d += diff * diff; } distances[i][j] = d; } } // Compute Gaussian perplexities const P: number[][] = []; for (let i = 0; i < n; i++) { P[i] = new Array(n).fill(0); // Binary search for sigma let sigma = 1.0; let sigmaMin = 1e-10; let sigmaMax = 1e10; for (let iter = 0; iter < 50; iter++) { let sumP = 0; for (let j = 0; j < n; j++) { if (i !== j) { P[i][j] = Math.exp(-distances[i][j] / (2 * sigma * sigma)); sumP += P[i][j]; } } // Normalize for (let j = 0; j < n; j++) { P[i][j] /= sumP || 1; } // Compute entropy let entropy = 0; for (let j = 0; j < n; j++) { if (P[i][j] > 1e-10) { entropy -= P[i][j] * Math.log2(P[i][j]); } } const perpCurrent = Math.pow(2, entropy); if (Math.abs(perpCurrent - perplexity) < 1e-5) break; if (perpCurrent > perplexity) { sigmaMax = sigma; sigma = (sigma + sigmaMin) / 2; } else { sigmaMin = sigma; sigma = (sigma + sigmaMax) / 2; } } } // Symmetrize for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const pij = (P[i][j] + P[j][i]) / (2 * n); P[i][j] = pij; P[j][i] = pij; } } // Initialize embedding randomly const Y: number[][] = []; for (let i = 0; i < n; i++) { Y[i] = []; for (let d = 0; d < nComponents; d++) { Y[i][d] = (Math.random() - 0.5) * 0.0001; } } // Gradient descent const gains: number[][] = Y.map(row => row.map(() => 1.0)); const momentum: number[][] = Y.map(row => row.map(() => 0)); for (let iter = 0; iter < iterations; iter++) { // Compute Q distribution (Student-t with 1 DoF) const Q: number[][] = []; let sumQ = 0; for (let i = 0; i < n; i++) { Q[i] = []; for (let j = 0; j < n; j++) { if (i !== j) { let d = 0; for (let k = 0; k < nComponents; k++) { const diff = Y[i][k] - Y[j][k]; d += diff * diff; } Q[i][j] = 1 / (1 + d); sumQ += Q[i][j]; } else { Q[i][j] = 0; } } } // Normalize Q for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { Q[i][j] /= sumQ || 1; } } // Compute gradients const grad: number[][] = Y.map(row => row.map(() => 0)); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { if (i !== j) { const mult = 4 * (P[i][j] - Q[i][j]) * Q[i][j] * sumQ; for (let d = 0; d < nComponents; d++) { grad[i][d] += mult * (Y[i][d] - Y[j][d]); } } } } // Update with momentum const mom = iter < 250 ? 0.5 : 0.8; for (let i = 0; i < n; i++) { for (let d = 0; d < nComponents; d++) { const sign = grad[i][d] * momentum[i][d] >= 0; gains[i][d] = sign ? gains[i][d] * 0.8 : gains[i][d] + 0.2; gains[i][d] = Math.max(gains[i][d], 0.01); momentum[i][d] = mom * momentum[i][d] - learningRate * gains[i][d] * grad[i][d]; Y[i][d] += momentum[i][d]; } } // Center const means = new Array(nComponents).fill(0); for (let i = 0; i < n; i++) { for (let d = 0; d < nComponents; d++) { means[d] += Y[i][d] / n; } } for (let i = 0; i < n; i++) { for (let d = 0; d < nComponents; d++) { Y[i][d] -= means[d]; } } // Report progress if (onProgress && iter % 10 === 0) { let error = 0; for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { if (P[i][j] > 1e-10) { error += P[i][j] * Math.log(P[i][j] / (Q[i][j] + 1e-10)); } } } onProgress(iter, error); } } // Normalize to [-1, 1] const mins = new Array(nComponents).fill(Infinity); const maxs = new Array(nComponents).fill(-Infinity); for (const point of Y) { for (let i = 0; i < nComponents; i++) { mins[i] = Math.min(mins[i], point[i]); maxs[i] = Math.max(maxs[i], point[i]); } } return Y.map(point => point.map((v, i) => { const range = maxs[i] - mins[i]; return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; }) ); } /** * Compute UMAP projection using umap-js library */ async function computeUMAP( vectors: number[][], nComponents: number = 2, options: { nNeighbors?: number; minDist?: number; spread?: number; onProgress?: (epoch: number) => void; } = {} ): Promise { const { nNeighbors = 15, minDist = 0.1, spread = 1.0, // onProgress - available for future use } = options; if (vectors.length === 0) return []; const umap = new UMAP({ nComponents, nNeighbors: Math.min(nNeighbors, vectors.length - 1), minDist, spread, }); // Fit the data const embedding = umap.fit(vectors); // Normalize to [-1, 1] const mins = new Array(nComponents).fill(Infinity); const maxs = new Array(nComponents).fill(-Infinity); for (const point of embedding) { for (let i = 0; i < nComponents; i++) { mins[i] = Math.min(mins[i], point[i]); maxs[i] = Math.max(maxs[i], point[i]); } } return embedding.map(point => point.map((v, i) => { const range = maxs[i] - mins[i]; return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; }) ); } /** * Find k nearest neighbors in original space */ function findNearestNeighbors( targetIndex: number, vectors: number[][], k: number = 10, metric: 'euclidean' | 'cosine' = 'cosine' ): { index: number; distance: number }[] { const target = vectors[targetIndex]; const distances: { index: number; distance: number }[] = []; for (let i = 0; i < vectors.length; i++) { if (i === targetIndex) continue; let dist: number; if (metric === 'cosine') { // Cosine distance = 1 - cosine similarity let dotProduct = 0; let normA = 0; let normB = 0; for (let j = 0; j < target.length; j++) { dotProduct += target[j] * vectors[i][j]; normA += target[j] * target[j]; normB += vectors[i][j] * vectors[i][j]; } const similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB) || 1); dist = 1 - similarity; } else { // Euclidean distance dist = 0; for (let j = 0; j < target.length; j++) { const diff = target[j] - vectors[i][j]; dist += diff * diff; } dist = Math.sqrt(dist); } distances.push({ index: i, distance: dist }); } return distances.sort((a, b) => a.distance - b.distance).slice(0, k); } /** * Main Embedding Projector Component */ export function EmbeddingProjector({ points, onPointSelect, colorByField: initialColorByField, height = 600, // eslint-disable-next-line @typescript-eslint/no-unused-vars width: _width = undefined, simpleMode = false, highlightedIndices = [], onContextSelect, showContextButton = false, // eslint-disable-next-line @typescript-eslint/no-unused-vars title: _titleOverride = undefined, autoRun = false, }: EmbeddingProjectorProps) { const { language } = useLanguage(); const t = (key: keyof typeof TEXT) => TEXT[key][language]; const svgRef = useRef(null); const containerRef = useRef(null); const threeContainerRef = useRef(null); // Three.js refs const sceneRef = useRef(null); const cameraRef = useRef(null); const rendererRef = useRef(null); const controlsRef = useRef(null); const pointCloudRef = useRef(null); const animationFrameRef = useRef(null); const neighborLinesRef = useRef(null); const searchHaloRef = useRef(null); const selectedHaloRef = useRef(null); const neighborHalosRef = useRef(null); const highlightedHalosRef = useRef(null); const haloAnimationRef = useRef<{ time: number }>({ time: 0 }); // State const [projectionMethod, setProjectionMethod] = useState('pca'); const [viewMode, setViewMode] = useState('3d'); const [projectedPoints, setProjectedPoints] = useState([]); const [isComputing, setIsComputing] = useState(false); const [computeProgress, setComputeProgress] = useState<{ iteration: number; total: number } | null>(null); const [selectedIndex, setSelectedIndex] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const [nearestNeighbors, setNearestNeighbors] = useState<{ index: number; distance: number }[]>([]); const [searchQuery, setSearchQuery] = useState(''); const [colorByField, setColorByField] = useState(initialColorByField || ''); const [showLabels, setShowLabels] = useState(false); const [neighborCount, setNeighborCount] = useState(10); const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null); // Locked (pinned) panels - stores indices of points with locked panels const [lockedPanels, setLockedPanels] = useState>(new Set()); // UMAP parameters const [umapNeighbors, setUmapNeighbors] = useState(15); const [umapMinDist, setUmapMinDist] = useState(0.1); // t-SNE parameters const [tsnePerplexity, setTsnePerplexity] = useState(30); const [tsneLearningRate, setTsneLearningRate] = useState(200); // PCA variance explained const [pcaVariance, setPcaVariance] = useState([]); // UI state for compact toolbar const [showSettings, setShowSettings] = useState(false); // Color dropdown state preserved for future UI expansion const [, setShowColorDropdown] = useState(false); const settingsRef = useRef(null); const colorDropdownRef = useRef(null); // Close dropdowns when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (settingsRef.current && !settingsRef.current.contains(event.target as Node)) { setShowSettings(false); } if (colorDropdownRef.current && !colorDropdownRef.current.contains(event.target as Node)) { setShowColorDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Extract unique payload fields const payloadFields = useMemo(() => { const fields = new Set(); for (const point of points) { for (const key of Object.keys(point.payload)) { fields.add(key); } } return Array.from(fields).sort(); }, [points]); // Get unique categories for color legend const fieldCategories = useMemo(() => { if (!colorByField) return []; const values = new Set(); for (const point of points) { const value = point.payload[colorByField]; if (value !== undefined && value !== null) { values.add(String(value)); } } return Array.from(values).slice(0, 20); }, [points, colorByField]); // Helper function to recursively search through nested objects const searchInValue = useCallback((value: unknown, query: string): boolean => { if (value === null || value === undefined) return false; // Check string/number/boolean primitives if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value).toLowerCase().includes(query); } // Check arrays if (Array.isArray(value)) { return value.some(item => searchInValue(item, query)); } // Check objects (recursively) if (typeof value === 'object') { return Object.values(value).some(v => searchInValue(v, query)); } return false; }, []); // Filter points by search query const filteredIndices = useMemo(() => { if (!searchQuery.trim()) return null; const query = searchQuery.toLowerCase(); const matches: number[] = []; points.forEach((point, index) => { // Search in ID if (String(point.id).toLowerCase().includes(query)) { matches.push(index); return; } // Search in payload (recursively) if (searchInValue(point.payload, query)) { matches.push(index); } }); // Debug logging for search results console.log('[EmbeddingProjector Search] Searching for:', query); console.log('[EmbeddingProjector Search] Points array length:', points.length); console.log('[EmbeddingProjector Search] ProjectedPoints length:', projectedPoints.length); if (matches.length === 0 && points.length > 0) { console.log('[EmbeddingProjector Search] No matches found for:', query); console.log('[EmbeddingProjector Search] Sample payload structure:', points[0]?.payload); // Try to find what names exist const sampleNames = points.slice(0, 10).map(p => p.payload?.name).filter(Boolean); console.log('[EmbeddingProjector Search] Sample names in dataset:', sampleNames); } else if (matches.length > 0) { console.log(`[EmbeddingProjector Search] Found ${matches.length} matches for:`, query); // Log first few matched names const matchedNames = matches.slice(0, 5).map(idx => points[idx]?.payload?.name); console.log('[EmbeddingProjector Search] Matched institutions:', matchedNames); } else if (points.length === 0) { console.log('[EmbeddingProjector Search] WARNING: No points loaded yet! Run projection first.'); } return matches; }, [points, searchQuery, searchInValue]); // Get color for a point const getPointColor = useCallback((index: number) => { // Highlighted points (RAG sources) get a special color if (highlightedIndices.includes(index)) { return '#22c55e'; // Green for sources } if (!colorByField) return COLORS[0]; const value = String(points[index]?.payload[colorByField] ?? ''); const categoryIndex = fieldCategories.indexOf(value); return categoryIndex >= 0 ? COLORS[categoryIndex % COLORS.length] : '#94a3b8'; }, [colorByField, fieldCategories, points, highlightedIndices]); // Compute projection const runProjection = useCallback(async () => { if (points.length === 0) return; setIsComputing(true); setComputeProgress(null); const vectors = points.map(p => p.vector); const nComponents = viewMode === '3d' ? 3 : 2; try { let projected: number[][]; switch (projectionMethod) { case 'pca': { const result = computePCA(vectors, nComponents); projected = result.projected; setPcaVariance(result.explained); break; } case 'umap': { projected = await computeUMAP(vectors, nComponents, { nNeighbors: umapNeighbors, minDist: umapMinDist, onProgress: (epoch) => setComputeProgress({ iteration: epoch, total: 200 }), }); break; } case 'tsne': { projected = computeTSNE(vectors, nComponents, { perplexity: tsnePerplexity, learningRate: tsneLearningRate, iterations: 500, onProgress: (iter) => setComputeProgress({ iteration: iter, total: 500 }), }); break; } default: projected = []; } setProjectedPoints(projected.map((coords, i) => ({ x: coords[0], y: coords[1], z: coords[2], originalIndex: i, }))); } finally { setIsComputing(false); setComputeProgress(null); } }, [points, projectionMethod, viewMode, umapNeighbors, umapMinDist, tsnePerplexity, tsneLearningRate]); // Auto-run projection when enabled and points are loaded useEffect(() => { if (autoRun && points.length > 0 && projectedPoints.length === 0 && !isComputing) { runProjection(); } }, [autoRun, points.length, projectedPoints.length, isComputing, runProjection]); // Auto-reproject when switching between 2D and 3D view modes const previousViewMode = useRef(viewMode); useEffect(() => { // Only reproject if: // 1. viewMode actually changed // 2. We already have projected points (user has run projection before) // 3. We're not currently computing if (previousViewMode.current !== viewMode && projectedPoints.length > 0 && !isComputing) { previousViewMode.current = viewMode; runProjection(); } }, [viewMode, projectedPoints.length, isComputing, runProjection]); // Find nearest neighbors when point is selected useEffect(() => { if (selectedIndex !== null && points.length > 0) { const neighbors = findNearestNeighbors( selectedIndex, points.map(p => p.vector), neighborCount ); setNearestNeighbors(neighbors); onPointSelect?.(points[selectedIndex]); } else { setNearestNeighbors([]); onPointSelect?.(null); } }, [selectedIndex, points, neighborCount, onPointSelect]); // D3 visualization useEffect(() => { if (!svgRef.current || projectedPoints.length === 0) return; const svg = d3.select(svgRef.current); const containerWidth = containerRef.current?.clientWidth || 800; const containerHeight = height; // Clear previous content svg.selectAll('*').remove(); // Set dimensions svg .attr('width', containerWidth) .attr('height', containerHeight) .attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`); // Create main group for zoom/pan const g = svg.append('g'); // Add glow filter for search halos const defs = svg.append('defs'); const glowFilter = defs.append('filter') .attr('id', 'search-glow') .attr('x', '-100%') .attr('y', '-100%') .attr('width', '300%') .attr('height', '300%'); glowFilter.append('feGaussianBlur') .attr('stdDeviation', '4') .attr('result', 'coloredBlur'); const feMerge = glowFilter.append('feMerge'); feMerge.append('feMergeNode').attr('in', 'coloredBlur'); feMerge.append('feMergeNode').attr('in', 'SourceGraphic'); // Setup zoom const zoom = d3.zoom() .scaleExtent([0.1, 10]) .on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); // Scales const xScale = d3.scaleLinear() .domain([-1.2, 1.2]) .range([50, containerWidth - 50]); const yScale = d3.scaleLinear() .domain([-1.2, 1.2]) .range([containerHeight - 50, 50]); // Grid const gridGroup = g.append('g').attr('class', 'grid'); // Vertical grid lines for (let x = -1; x <= 1; x += 0.5) { gridGroup.append('line') .attr('x1', xScale(x)) .attr('y1', yScale(-1.2)) .attr('x2', xScale(x)) .attr('y2', yScale(1.2)) .attr('stroke', '#e2e8f0') .attr('stroke-width', 0.5); } // Horizontal grid lines for (let y = -1; y <= 1; y += 0.5) { gridGroup.append('line') .attr('x1', xScale(-1.2)) .attr('y1', yScale(y)) .attr('x2', xScale(1.2)) .attr('y2', yScale(y)) .attr('stroke', '#e2e8f0') .attr('stroke-width', 0.5); } // Axes gridGroup.append('line') .attr('x1', xScale(-1.2)) .attr('y1', yScale(0)) .attr('x2', xScale(1.2)) .attr('y2', yScale(0)) .attr('stroke', '#94a3b8') .attr('stroke-width', 1); gridGroup.append('line') .attr('x1', xScale(0)) .attr('y1', yScale(-1.2)) .attr('x2', xScale(0)) .attr('y2', yScale(1.2)) .attr('stroke', '#94a3b8') .attr('stroke-width', 1); // Points const pointsGroup = g.append('g').attr('class', 'points'); // Draw neighbor connections first (below points) if (selectedIndex !== null) { const selectedPoint = projectedPoints.find(p => p.originalIndex === selectedIndex); if (selectedPoint) { nearestNeighbors.forEach(neighbor => { const neighborPoint = projectedPoints.find(p => p.originalIndex === neighbor.index); if (neighborPoint) { pointsGroup.append('line') .attr('x1', xScale(selectedPoint.x)) .attr('y1', yScale(selectedPoint.y)) .attr('x2', xScale(neighborPoint.x)) .attr('y2', yScale(neighborPoint.y)) .attr('stroke', '#6366f1') .attr('stroke-width', 1) .attr('stroke-opacity', 0.3) .attr('stroke-dasharray', '4,2'); } }); } } // Draw ALL halos FIRST (below regular points) - order matters for layering // 1. Highlighted indices (RAG sources) - green halos (bottom layer) if (highlightedIndices && highlightedIndices.length > 0) { const highlightedPoints = projectedPoints.filter(d => highlightedIndices.includes(d.originalIndex)); highlightedPoints.forEach((point, i) => { const haloGroup = pointsGroup.append('g') .attr('class', `halo-group halo-highlighted`) .style('animation-delay', `${i * 0.15}s`); // Outer glow haloGroup.append('circle') .attr('cx', xScale(point.x)) .attr('cy', yScale(point.y)) .attr('r', 18) .attr('fill', 'none') .attr('stroke', '#22c55e') .attr('stroke-width', 3) .attr('stroke-opacity', 0.4) .style('filter', 'url(#search-glow)'); // Inner ring haloGroup.append('circle') .attr('cx', xScale(point.x)) .attr('cy', yScale(point.y)) .attr('r', 12) .attr('fill', 'none') .attr('stroke', '#22c55e') .attr('stroke-width', 2) .attr('stroke-opacity', 0.6); }); } // 2. Search results - amber halos console.log('[EmbeddingProjector Render] Checking search halos:', { filteredIndices: filteredIndices?.length ?? 'null', searchQuery: searchQuery, projectedPointsLength: projectedPoints.length, viewMode: viewMode }); if (filteredIndices && filteredIndices.length > 0 && searchQuery.trim()) { const matchedPoints = projectedPoints.filter(d => filteredIndices.includes(d.originalIndex)); console.log('[EmbeddingProjector Render] Drawing amber halos for', matchedPoints.length, 'points'); matchedPoints.forEach((point, i) => { const haloGroup = pointsGroup.append('g') .attr('class', `halo-group halo-search`) .style('animation-delay', `${i * 0.12}s`); // Outer glow haloGroup.append('circle') .attr('cx', xScale(point.x)) .attr('cy', yScale(point.y)) .attr('r', 16) .attr('fill', 'none') .attr('stroke', '#fbbf24') .attr('stroke-width', 3) .attr('stroke-opacity', 0.4) .style('filter', 'url(#search-glow)'); // Inner ring haloGroup.append('circle') .attr('cx', xScale(point.x)) .attr('cy', yScale(point.y)) .attr('r', 10) .attr('fill', 'none') .attr('stroke', '#fbbf24') .attr('stroke-width', 2) .attr('stroke-opacity', 0.7); }); } // 3. Nearest neighbors - purple halos if (nearestNeighbors.length > 0) { const neighborPoints = projectedPoints.filter(d => nearestNeighbors.some(n => n.index === d.originalIndex) ); neighborPoints.forEach((point, i) => { const haloGroup = pointsGroup.append('g') .attr('class', `halo-group halo-neighbor`) .style('animation-delay', `${i * 0.1}s`); // Outer glow haloGroup.append('circle') .attr('cx', xScale(point.x)) .attr('cy', yScale(point.y)) .attr('r', 14) .attr('fill', 'none') .attr('stroke', '#a855f7') .attr('stroke-width', 2.5) .attr('stroke-opacity', 0.5) .style('filter', 'url(#search-glow)'); // Inner ring haloGroup.append('circle') .attr('cx', xScale(point.x)) .attr('cy', yScale(point.y)) .attr('r', 9) .attr('fill', 'none') .attr('stroke', '#a855f7') .attr('stroke-width', 1.5) .attr('stroke-opacity', 0.7); }); } // 4. Selected point - cyan halo (top layer, most prominent) if (selectedIndex !== null) { const selectedPoint = projectedPoints.find(p => p.originalIndex === selectedIndex); if (selectedPoint) { const haloGroup = pointsGroup.append('g') .attr('class', 'halo-group halo-selected'); // Outer glow (largest) haloGroup.append('circle') .attr('cx', xScale(selectedPoint.x)) .attr('cy', yScale(selectedPoint.y)) .attr('r', 22) .attr('fill', 'none') .attr('stroke', '#06b6d4') .attr('stroke-width', 3) .attr('stroke-opacity', 0.4) .style('filter', 'url(#search-glow)'); // Middle ring haloGroup.append('circle') .attr('cx', xScale(selectedPoint.x)) .attr('cy', yScale(selectedPoint.y)) .attr('r', 15) .attr('fill', 'none') .attr('stroke', '#06b6d4') .attr('stroke-width', 2) .attr('stroke-opacity', 0.6); // Inner ring haloGroup.append('circle') .attr('cx', xScale(selectedPoint.x)) .attr('cy', yScale(selectedPoint.y)) .attr('r', 10) .attr('fill', 'none') .attr('stroke', '#06b6d4') .attr('stroke-width', 1.5) .attr('stroke-opacity', 0.8); } } // Draw points const circles = pointsGroup.selectAll('circle') .data(projectedPoints) .join('circle') .attr('cx', d => xScale(d.x)) .attr('cy', d => yScale(d.y)) .attr('r', d => { if (d.originalIndex === selectedIndex) return 8; if (d.originalIndex === hoveredIndex) return 6; if (nearestNeighbors.some(n => n.index === d.originalIndex)) return 5; if (filteredIndices && !filteredIndices.includes(d.originalIndex)) return 2; return 4; }) .attr('fill', d => getPointColor(d.originalIndex)) .attr('fill-opacity', d => { if (filteredIndices && !filteredIndices.includes(d.originalIndex)) return 0.1; if (d.originalIndex === selectedIndex) return 1; if (nearestNeighbors.some(n => n.index === d.originalIndex)) return 0.9; return 0.7; }) .attr('stroke', d => d.originalIndex === selectedIndex ? '#1e293b' : 'none') .attr('stroke-width', 2) .attr('cursor', 'pointer') .on('mouseenter', (_, d) => setHoveredIndex(d.originalIndex)) .on('mouseleave', () => setHoveredIndex(null)) .on('click', (event, d) => { // Capture click position for popup positioning setClickPosition({ x: event.clientX, y: event.clientY }); setSelectedIndex(prev => prev === d.originalIndex ? null : d.originalIndex); }); // Labels if (showLabels) { const labelField = colorByField || 'id'; pointsGroup.selectAll('text') .data(projectedPoints.filter((_, i) => i % Math.ceil(projectedPoints.length / 100) === 0)) .join('text') .attr('x', d => xScale(d.x) + 6) .attr('y', d => yScale(d.y) + 3) .attr('font-size', '10px') .attr('fill', '#475569') .text(d => { const point = points[d.originalIndex]; const value = labelField === 'id' ? point.id : point.payload[labelField]; const str = String(value ?? ''); return str.length > 15 ? str.slice(0, 12) + '...' : str; }); } // Tooltip const tooltip = d3.select(containerRef.current) .selectAll('.projector-tooltip') .data([null]) .join('div') .attr('class', 'projector-tooltip') .style('position', 'absolute') .style('pointer-events', 'none') .style('background', 'white') .style('border', '1px solid #e2e8f0') .style('border-radius', '4px') .style('padding', '8px') .style('font-size', '12px') .style('box-shadow', '0 2px 4px rgba(0,0,0,0.1)') .style('display', 'none') .style('z-index', '100'); circles .on('mouseenter', function(event, d) { const point = points[d.originalIndex]; tooltip .style('display', 'block') .style('left', `${event.offsetX + 10}px`) .style('top', `${event.offsetY + 10}px`) .html(` ID: ${point.id}
${colorByField ? `${colorByField}: ${point.payload[colorByField]}
` : ''} `); }) .on('mouseleave', () => { tooltip.style('display', 'none'); }); }, [projectedPoints, selectedIndex, hoveredIndex, nearestNeighbors, filteredIndices, showLabels, colorByField, getPointColor, points, height]); // Three.js 3D visualization - Scene initialization (only runs when viewMode or projectedPoints change) useEffect(() => { if (viewMode !== '3d' || !threeContainerRef.current || projectedPoints.length === 0) { // Cleanup if switching away from 3D if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } // Dispose of all Three.js resources if (pointCloudRef.current) { const pointCloud = pointCloudRef.current; const material = pointCloud.material as THREE.ShaderMaterial; if (material.uniforms?.pointTexture?.value) { material.uniforms.pointTexture.value.dispose(); } material.dispose(); pointCloud.geometry.dispose(); pointCloudRef.current = null; } if (sceneRef.current) { // Dispose all scene children sceneRef.current.traverse((object) => { if (object instanceof THREE.Mesh || object instanceof THREE.Points) { if (object.geometry) object.geometry.dispose(); if (object.material) { if (Array.isArray(object.material)) { object.material.forEach(m => m.dispose()); } else { object.material.dispose(); } } } }); sceneRef.current.clear(); sceneRef.current = null; } if (controlsRef.current) { controlsRef.current.dispose(); controlsRef.current = null; } if (rendererRef.current && threeContainerRef.current) { threeContainerRef.current.removeChild(rendererRef.current.domElement); rendererRef.current.dispose(); rendererRef.current.forceContextLoss(); rendererRef.current = null; } cameraRef.current = null; return; } const container = threeContainerRef.current; const containerWidth = container.clientWidth || 800; const containerHeight = container.clientHeight || 600; // Initialize scene const scene = new THREE.Scene(); scene.background = new THREE.Color(0xfafafa); sceneRef.current = scene; // Initialize camera const camera = new THREE.PerspectiveCamera( 60, containerWidth / containerHeight, 0.1, 1000 ); camera.position.set(2, 2, 2); camera.lookAt(0, 0, 0); cameraRef.current = camera; // Initialize renderer const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(containerWidth, containerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); rendererRef.current = renderer; // Add orbit controls for rotation/zoom/pan const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = true; controls.minDistance = 0.5; controls.maxDistance = 20; controlsRef.current = controls; // Add grid helper const gridHelper = new THREE.GridHelper(2, 10, 0xcccccc, 0xe0e0e0); scene.add(gridHelper); // Add axes helper const axesHelper = new THREE.AxesHelper(1.2); scene.add(axesHelper); // Create point cloud geometry const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(projectedPoints.length * 3); const colors = new Float32Array(projectedPoints.length * 3); const sizes = new Float32Array(projectedPoints.length); projectedPoints.forEach((point, i) => { positions[i * 3] = point.x; positions[i * 3 + 1] = point.y; positions[i * 3 + 2] = point.z ?? 0; // Use default color - actual colors will be set by the color update effect // This avoids having getPointColor as a dependency which would recreate // the scene (and reset camera) when selection/highlighting changes const defaultColor = new THREE.Color(COLORS[0]); colors[i * 3] = defaultColor.r; colors[i * 3 + 1] = defaultColor.g; colors[i * 3 + 2] = defaultColor.b; // Initial size (will be updated by selection effect) sizes[i] = 4; }); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); // Create texture (will be disposed in cleanup) const pointTexture = createCircleTexture(); // Create shader material for variable-sized points const material = new THREE.ShaderMaterial({ uniforms: { pointTexture: { value: pointTexture } }, vertexShader: ` attribute float size; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_PointSize = size * (20.0 / -mvPosition.z); gl_Position = projectionMatrix * mvPosition; } `, fragmentShader: ` uniform sampler2D pointTexture; varying vec3 vColor; void main() { gl_FragColor = vec4(vColor, 1.0); gl_FragColor = gl_FragColor * texture2D(pointTexture, gl_PointCoord); if (gl_FragColor.a < 0.3) discard; } `, vertexColors: true, transparent: true, }); const pointCloud = new THREE.Points(geometry, material); scene.add(pointCloud); pointCloudRef.current = pointCloud; // Add ambient light const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); // Add directional light const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(5, 10, 5); scene.add(directionalLight); // Add raycaster for point selection const raycaster = new THREE.Raycaster(); raycaster.params.Points = { threshold: 0.1 }; const mouse = new THREE.Vector2(); const onMouseClick = (event: MouseEvent) => { const rect = container.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / containerWidth) * 2 - 1; mouse.y = -((event.clientY - rect.top) / containerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(pointCloud); if (intersects.length > 0) { const index = intersects[0].index; if (index !== undefined) { const originalIndex = projectedPoints[index].originalIndex; // Capture click position for popup positioning setClickPosition({ x: event.clientX, y: event.clientY }); setSelectedIndex(prev => prev === originalIndex ? null : originalIndex); } } }; container.addEventListener('click', onMouseClick); // Animation loop const animate = () => { animationFrameRef.current = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); // Handle resize const handleResize = () => { if (!container || !renderer || !camera) return; const width = container.clientWidth; const height = container.clientHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); }; window.addEventListener('resize', handleResize); // Cleanup - comprehensive disposal of all Three.js resources return () => { window.removeEventListener('resize', handleResize); container.removeEventListener('click', onMouseClick); // Cancel animation frame first if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } // Dispose controls if (controls) { controls.dispose(); } // Dispose texture if (pointTexture) { pointTexture.dispose(); } // Dispose geometry and material if (geometry) geometry.dispose(); if (material) material.dispose(); // Clear scene (removes all objects) if (scene) { scene.traverse((object) => { if (object instanceof THREE.Mesh || object instanceof THREE.Points) { if (object.geometry) object.geometry.dispose(); if (object.material) { if (Array.isArray(object.material)) { object.material.forEach(m => m.dispose()); } else { object.material.dispose(); } } } }); scene.clear(); } // Dispose renderer and force context loss to free WebGL context if (renderer) { container.removeChild(renderer.domElement); renderer.dispose(); renderer.forceContextLoss(); } // Clear refs sceneRef.current = null; cameraRef.current = null; rendererRef.current = null; controlsRef.current = null; pointCloudRef.current = null; // Dispose all halos if (searchHaloRef.current) { searchHaloRef.current.geometry.dispose(); (searchHaloRef.current.material as THREE.Material).dispose(); searchHaloRef.current = null; } if (selectedHaloRef.current) { selectedHaloRef.current.geometry.dispose(); (selectedHaloRef.current.material as THREE.Material).dispose(); selectedHaloRef.current = null; } if (neighborHalosRef.current) { neighborHalosRef.current.geometry.dispose(); (neighborHalosRef.current.material as THREE.Material).dispose(); neighborHalosRef.current = null; } if (highlightedHalosRef.current) { highlightedHalosRef.current.geometry.dispose(); (highlightedHalosRef.current.material as THREE.Material).dispose(); highlightedHalosRef.current = null; } }; // Note: getPointColor is intentionally NOT a dependency here. // Colors are initialized with a default and updated by the separate color/size effect. // Including getPointColor would recreate the scene (reset camera) on selection changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewMode, projectedPoints]); // Update point sizes and colors when selection changes (without recreating the scene) useEffect(() => { if (viewMode !== '3d' || !pointCloudRef.current || projectedPoints.length === 0) { return; } const geometry = pointCloudRef.current.geometry; const sizes = geometry.getAttribute('size') as THREE.BufferAttribute; const colors = geometry.getAttribute('color') as THREE.BufferAttribute; if (!sizes || !colors) return; projectedPoints.forEach((point, i) => { // Update color const color = new THREE.Color(getPointColor(point.originalIndex)); colors.setXYZ(i, color.r, color.g, color.b); // Update size based on selection if (point.originalIndex === selectedIndex) { sizes.setX(i, 12); } else if (nearestNeighbors.some(n => n.index === point.originalIndex)) { sizes.setX(i, 8); } else if (filteredIndices && !filteredIndices.includes(point.originalIndex)) { sizes.setX(i, 2); } else { sizes.setX(i, 4); } }); sizes.needsUpdate = true; colors.needsUpdate = true; }, [viewMode, projectedPoints, selectedIndex, nearestNeighbors, filteredIndices, getPointColor]); // Update neighbor connection lines in 3D view when selection changes useEffect(() => { if (viewMode !== '3d' || !sceneRef.current) { return; } // Remove existing neighbor lines if (neighborLinesRef.current) { sceneRef.current.remove(neighborLinesRef.current); neighborLinesRef.current.geometry.dispose(); (neighborLinesRef.current.material as THREE.Material).dispose(); neighborLinesRef.current = null; } // If no selection or no neighbors, we're done if (selectedIndex === null || nearestNeighbors.length === 0 || projectedPoints.length === 0) { return; } // Find the selected point in projected space const selectedPoint = projectedPoints.find(p => p.originalIndex === selectedIndex); if (!selectedPoint) return; // Create line geometry for neighbor connections const linePositions: number[] = []; nearestNeighbors.forEach(neighbor => { const neighborPoint = projectedPoints.find(p => p.originalIndex === neighbor.index); if (neighborPoint) { // Add line segment from selected point to neighbor linePositions.push( selectedPoint.x, selectedPoint.y, selectedPoint.z ?? 0, neighborPoint.x, neighborPoint.y, neighborPoint.z ?? 0 ); } }); if (linePositions.length === 0) return; // Create buffer geometry const lineGeometry = new THREE.BufferGeometry(); lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(linePositions, 3)); // Create dashed line material const lineMaterial = new THREE.LineDashedMaterial({ color: 0x6366f1, // Indigo color matching 2D dashSize: 0.05, gapSize: 0.025, opacity: 0.5, transparent: true, }); // Create line segments const lines = new THREE.LineSegments(lineGeometry, lineMaterial); lines.computeLineDistances(); // Required for dashed lines to work // Add to scene sceneRef.current.add(lines); neighborLinesRef.current = lines; }, [viewMode, selectedIndex, nearestNeighbors, projectedPoints]); // Update search halos in 3D view useEffect(() => { if (viewMode !== '3d' || !sceneRef.current) return; // Remove existing halos if (searchHaloRef.current) { sceneRef.current.remove(searchHaloRef.current); searchHaloRef.current.geometry.dispose(); (searchHaloRef.current.material as THREE.Material).dispose(); searchHaloRef.current = null; } // Create new halos if search is active if (!filteredIndices || filteredIndices.length === 0 || !searchQuery.trim()) return; const matchedPoints = projectedPoints.filter(p => filteredIndices.includes(p.originalIndex)); if (matchedPoints.length === 0) return; const haloGeometry = new THREE.BufferGeometry(); const positions = new Float32Array(matchedPoints.length * 3); matchedPoints.forEach((point, i) => { positions[i * 3] = point.x; positions[i * 3 + 1] = point.y; positions[i * 3 + 2] = point.z ?? 0; }); haloGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const haloMaterial = new THREE.PointsMaterial({ size: 0.15, color: 0xfbbf24, // Amber transparent: true, opacity: 0.5, depthWrite: false, sizeAttenuation: true, }); const haloCloud = new THREE.Points(haloGeometry, haloMaterial); sceneRef.current.add(haloCloud); searchHaloRef.current = haloCloud; }, [viewMode, filteredIndices, searchQuery, projectedPoints]); // Update selected point halo in 3D view useEffect(() => { if (viewMode !== '3d' || !sceneRef.current) return; // Remove existing halo if (selectedHaloRef.current) { sceneRef.current.remove(selectedHaloRef.current); selectedHaloRef.current.geometry.dispose(); (selectedHaloRef.current.material as THREE.Material).dispose(); selectedHaloRef.current = null; } // Create new halo if point is selected if (selectedIndex === null) return; const selectedPoint = projectedPoints.find(p => p.originalIndex === selectedIndex); if (!selectedPoint) return; const haloGeometry = new THREE.BufferGeometry(); const positions = new Float32Array([ selectedPoint.x, selectedPoint.y, selectedPoint.z ?? 0 ]); haloGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const haloMaterial = new THREE.PointsMaterial({ size: 0.25, color: 0x06b6d4, // Cyan transparent: true, opacity: 0.7, depthWrite: false, sizeAttenuation: true, }); const haloCloud = new THREE.Points(haloGeometry, haloMaterial); sceneRef.current.add(haloCloud); selectedHaloRef.current = haloCloud; }, [viewMode, selectedIndex, projectedPoints]); // Update neighbor halos in 3D view useEffect(() => { if (viewMode !== '3d' || !sceneRef.current) return; // Remove existing halos if (neighborHalosRef.current) { sceneRef.current.remove(neighborHalosRef.current); neighborHalosRef.current.geometry.dispose(); (neighborHalosRef.current.material as THREE.Material).dispose(); neighborHalosRef.current = null; } // Create new halos if neighbors exist if (nearestNeighbors.length === 0) return; const neighborPoints = projectedPoints.filter(p => nearestNeighbors.some(n => n.index === p.originalIndex) ); if (neighborPoints.length === 0) return; const haloGeometry = new THREE.BufferGeometry(); const positions = new Float32Array(neighborPoints.length * 3); neighborPoints.forEach((point, i) => { positions[i * 3] = point.x; positions[i * 3 + 1] = point.y; positions[i * 3 + 2] = point.z ?? 0; }); haloGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const haloMaterial = new THREE.PointsMaterial({ size: 0.18, color: 0xa855f7, // Purple transparent: true, opacity: 0.6, depthWrite: false, sizeAttenuation: true, }); const haloCloud = new THREE.Points(haloGeometry, haloMaterial); sceneRef.current.add(haloCloud); neighborHalosRef.current = haloCloud; }, [viewMode, nearestNeighbors, projectedPoints]); // Update highlighted halos (RAG sources) in 3D view useEffect(() => { if (viewMode !== '3d' || !sceneRef.current) return; // Remove existing halos if (highlightedHalosRef.current) { sceneRef.current.remove(highlightedHalosRef.current); highlightedHalosRef.current.geometry.dispose(); (highlightedHalosRef.current.material as THREE.Material).dispose(); highlightedHalosRef.current = null; } // Create new halos if highlighted indices exist if (!highlightedIndices || highlightedIndices.length === 0) return; const highlightedPoints = projectedPoints.filter(p => highlightedIndices.includes(p.originalIndex)); if (highlightedPoints.length === 0) return; const haloGeometry = new THREE.BufferGeometry(); const positions = new Float32Array(highlightedPoints.length * 3); highlightedPoints.forEach((point, i) => { positions[i * 3] = point.x; positions[i * 3 + 1] = point.y; positions[i * 3 + 2] = point.z ?? 0; }); haloGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const haloMaterial = new THREE.PointsMaterial({ size: 0.2, color: 0x22c55e, // Green transparent: true, opacity: 0.6, depthWrite: false, sizeAttenuation: true, }); const haloCloud = new THREE.Points(haloGeometry, haloMaterial); sceneRef.current.add(haloCloud); highlightedHalosRef.current = haloCloud; }, [viewMode, highlightedIndices, projectedPoints]); // Animate 3D halos with pulsing effect useEffect(() => { if (viewMode !== '3d') return; let animationId: number; const animate = () => { haloAnimationRef.current.time += 0.02; const t = haloAnimationRef.current.time; // Pulse function: oscillates between 0.3 and 1.0 const pulse = (offset: number, speed: number) => 0.5 + 0.3 * Math.sin(t * speed + offset); // Size pulse: oscillates between 0.9 and 1.2 const sizePulse = (offset: number, speed: number) => 1.0 + 0.15 * Math.sin(t * speed + offset); // Animate selected halo (fastest, most prominent) if (selectedHaloRef.current) { const mat = selectedHaloRef.current.material as THREE.PointsMaterial; mat.opacity = 0.4 + 0.4 * pulse(0, 4); mat.size = 0.25 * sizePulse(0, 4); } // Animate neighbor halos if (neighborHalosRef.current) { const mat = neighborHalosRef.current.material as THREE.PointsMaterial; mat.opacity = 0.3 + 0.3 * pulse(1, 3); mat.size = 0.18 * sizePulse(1, 3); } // Animate search halos if (searchHaloRef.current) { const mat = searchHaloRef.current.material as THREE.PointsMaterial; mat.opacity = 0.3 + 0.25 * pulse(2, 2.5); mat.size = 0.15 * sizePulse(2, 2.5); } // Animate highlighted halos if (highlightedHalosRef.current) { const mat = highlightedHalosRef.current.material as THREE.PointsMaterial; mat.opacity = 0.35 + 0.3 * pulse(3, 3); mat.size = 0.2 * sizePulse(3, 3); } animationId = requestAnimationFrame(animate); }; animate(); return () => { if (animationId) { cancelAnimationFrame(animationId); } }; }, [viewMode]); // Helper function to create circle texture for points function createCircleTexture(): THREE.Texture { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d')!; const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); gradient.addColorStop(0.3, 'rgba(255, 255, 255, 1)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 64, 64); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } return (
{/* Compact Toolbar */} {!simpleMode && (
{/* Left: Method selector + View mode */}
{/* Center: Stats */}
{points.length.toLocaleString()} pts {projectionMethod === 'pca' && pcaVariance.length > 0 && ( {pcaVariance[0].toFixed(0)}% + {pcaVariance[1].toFixed(0)}% )}
{/* Right: Settings + Run */}
{/* Settings popover - contains all advanced options */}
{showSettings && (
Settings
setSearchQuery(e.target.value)} /> {searchQuery && ( )}
{/* Method-specific parameters */} {projectionMethod === 'umap' && ( <>
setUmapNeighbors(Number(e.target.value))} className="settings-slider" />
setUmapMinDist(Number(e.target.value))} className="settings-slider" />
)} {projectionMethod === 'tsne' && ( <>
setTsnePerplexity(Number(e.target.value))} className="settings-slider" />
setTsneLearningRate(Number(e.target.value))} className="settings-slider" />
)}
)}
{/* Run button */}
)} {/* Simple mode: just a run button */} {simpleMode && projectedPoints.length === 0 && points.length > 0 && (
)} {/* Main visualization area */}
{/* Canvas */}
{projectedPoints.length > 0 ? ( viewMode === '2d' ? ( ) : (
) ) : (

{points.length > 0 ? 'Click "Run" to compute projection' : 'Load vectors to visualize' }

)}
{/* Sidebar - legend only (point details moved to floating panel) */}
{/* Legend */} {colorByField && fieldCategories.length > 0 && (

{colorByField}

{fieldCategories.map((value, idx) => (
{value.length > 20 ? value.slice(0, 17) + '...' : value}
))}
)}
{/* Locked Point Details Panels */} {Array.from(lockedPanels).map((lockedIndex) => ( points[lockedIndex] && ( { setLockedPanels(prev => { const next = new Set(prev); next.delete(lockedIndex); return next; }); }} onNeighborClick={(index) => { setSelectedIndex(index); setClickPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); }} language={language as 'nl' | 'en'} onAddToContext={onContextSelect ? (point) => onContextSelect(point) : undefined} showContextButton={showContextButton} isLocked={true} onLockToggle={() => { setLockedPanels(prev => { const next = new Set(prev); next.delete(lockedIndex); return next; }); }} /> ) ))} {/* Active (unlocked) Point Details Panel */} {selectedIndex !== null && points[selectedIndex] && !lockedPanels.has(selectedIndex) && ( setSelectedIndex(null)} onNeighborClick={(index) => { setSelectedIndex(index); setClickPosition(prev => prev || { x: window.innerWidth / 2, y: window.innerHeight / 2 }); }} clickPosition={clickPosition || undefined} language={language as 'nl' | 'en'} onAddToContext={onContextSelect ? (point) => onContextSelect(point) : undefined} showContextButton={showContextButton} isLocked={false} onLockToggle={() => { setLockedPanels(prev => new Set(prev).add(selectedIndex)); setSelectedIndex(null); }} /> )}
); } export default EmbeddingProjector;