| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Embeddings Visualizer</title> |
| <style> |
| body { |
| margin: 0; |
| overflow: hidden; |
| font-family: Arial, sans-serif; |
| } |
| canvas { |
| display: block; |
| } |
| #info { |
| position: absolute; |
| top: 10px; |
| left: 10px; |
| background-color: rgba(0, 0, 0, 0.7); |
| color: white; |
| padding: 10px; |
| border-radius: 5px; |
| display: none; |
| max-width: 300px; |
| max-height: 200px; |
| overflow: auto; |
| } |
| #loading { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| background-color: rgba(0, 0, 0, 0.7); |
| color: white; |
| padding: 20px; |
| border-radius: 5px; |
| font-size: 18px; |
| } |
| #legend { |
| position: absolute; |
| top: 10px; |
| right: 10px; |
| background-color: rgba(0, 0, 0, 0.7); |
| color: white; |
| padding: 10px; |
| border-radius: 5px; |
| max-width: 200px; |
| } |
| .color-box { |
| display: inline-block; |
| width: 15px; |
| height: 15px; |
| margin-right: 8px; |
| border-radius: 3px; |
| } |
| .legend-item { |
| margin: 5px 0; |
| display: flex; |
| align-items: center; |
| } |
| #controls { |
| position: absolute; |
| bottom: 10px; |
| left: 10px; |
| background-color: rgba(0, 0, 0, 0.7); |
| color: white; |
| padding: 10px; |
| border-radius: 5px; |
| } |
| select, button { |
| margin: 5px 0; |
| padding: 5px; |
| border-radius: 3px; |
| border: none; |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loading">Loading embeddings, please wait...</div> |
| <div id="info"></div> |
| <div id="legend"></div> |
| <div id="controls"> |
| <div> |
| <label for="category-filter">Filter by category:</label> |
| <select id="category-filter"> |
| <option value="all">All Categories</option> |
| </select> |
| </div> |
| <div> |
| <label for="point-size">Point Size:</label> |
| <input type="range" id="point-size" min="0.05" max="0.3" step="0.01" value="0.1"> |
| </div> |
| <button id="reset-view">Reset View</button> |
| </div> |
| |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script> |
| |
| <script> |
| |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x111111); |
| |
| |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); |
| camera.position.z = 5; |
| |
| |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| document.body.appendChild(renderer.domElement); |
| |
| |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.05; |
| |
| |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); |
| scene.add(ambientLight); |
| |
| |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); |
| directionalLight.position.set(1, 1, 1); |
| scene.add(directionalLight); |
| |
| |
| const raycaster = new THREE.Raycaster(); |
| const mouse = new THREE.Vector2(); |
| let hoveredPoint = null; |
| let points = []; |
| let pointsData = []; |
| let pointCloud = null; |
| let pointSize = 0.1; |
| let categoryColors = {}; |
| let categories = []; |
| let originalPositions = null; |
| let visibleCategories = new Set(); |
| |
| |
| function generateColorMap(categories) { |
| const colorMap = {}; |
| const hueStep = 1 / categories.length; |
| |
| categories.forEach((category, index) => { |
| const color = new THREE.Color(); |
| color.setHSL(hueStep * index, 0.7, 0.5); |
| colorMap[category] = color; |
| }); |
| |
| return colorMap; |
| } |
| |
| |
| function createLegend(colorMap) { |
| const legendEl = document.getElementById('legend'); |
| legendEl.innerHTML = '<h3>Categories</h3>'; |
| |
| Object.entries(colorMap).forEach(([category, color]) => { |
| const item = document.createElement('div'); |
| item.className = 'legend-item'; |
| |
| const colorBox = document.createElement('span'); |
| colorBox.className = 'color-box'; |
| colorBox.style.backgroundColor = `#${color.getHexString()}`; |
| |
| const label = document.createElement('span'); |
| label.textContent = category; |
| |
| item.appendChild(colorBox); |
| item.appendChild(label); |
| legendEl.appendChild(item); |
| }); |
| } |
| |
| |
| function updateCategoryFilter(categories) { |
| const filterEl = document.getElementById('category-filter'); |
| |
| |
| while (filterEl.options.length > 1) { |
| filterEl.remove(1); |
| } |
| |
| |
| categories.forEach(category => { |
| const option = document.createElement('option'); |
| option.value = category; |
| option.textContent = category; |
| filterEl.appendChild(option); |
| }); |
| } |
| |
| |
| function filterByCategory(category) { |
| if (!pointCloud || !originalPositions) return; |
| |
| const positions = pointCloud.geometry.attributes.position.array; |
| const colors = pointCloud.geometry.attributes.color.array; |
| const visible = new Float32Array(positions.length); |
| |
| if (category === 'all') { |
| |
| for (let i = 0; i < positions.length; i++) { |
| positions[i] = originalPositions[i]; |
| } |
| visibleCategories = new Set(categories); |
| } else { |
| |
| visibleCategories = new Set([category]); |
| |
| for (let i = 0; i < pointsData.length; i++) { |
| const idx = i * 3; |
| |
| if (pointsData[i].category === category) { |
| positions[idx] = originalPositions[idx]; |
| positions[idx + 1] = originalPositions[idx + 1]; |
| positions[idx + 2] = originalPositions[idx + 2]; |
| } else { |
| |
| positions[idx] = 10000; |
| positions[idx + 1] = 10000; |
| positions[idx + 2] = 10000; |
| } |
| } |
| } |
| |
| pointCloud.geometry.attributes.position.needsUpdate = true; |
| } |
| |
| |
| function resetView() { |
| if (!pointCloud) return; |
| |
| |
| const visiblePoints = pointsData.filter(p => visibleCategories.has(p.category)); |
| if (visiblePoints.length === 0) return; |
| |
| const center = new THREE.Vector3(0, 0, 0); |
| const boundingSphere = new THREE.Sphere(center, 5); |
| const distance = boundingSphere.radius / Math.sin(camera.fov * Math.PI / 360); |
| |
| camera.position.set(0, 0, distance * 1.2); |
| camera.lookAt(center); |
| |
| controls.target.copy(center); |
| controls.update(); |
| } |
| |
| |
| Promise.all([ |
| fetch('/static/embeddings.json').then(res => res.json()), |
| fetch('/static/categories.json').then(res => res.json()).catch(() => ({ categories: [] })) |
| ]).then(([data, categoryData]) => { |
| |
| document.getElementById('loading').style.display = 'none'; |
| |
| |
| pointsData = data; |
| |
| |
| if (categoryData.categories && categoryData.categories.length > 0) { |
| categories = categoryData.categories; |
| } else { |
| const categorySet = new Set(); |
| data.forEach(point => { |
| if (point.category) categorySet.add(point.category); |
| }); |
| categories = Array.from(categorySet); |
| } |
| |
| |
| categoryColors = generateColorMap(categories); |
| createLegend(categoryColors); |
| updateCategoryFilter(categories); |
| visibleCategories = new Set(categories); |
| |
| |
| let xValues = data.map(p => p.x); |
| let yValues = data.map(p => p.y); |
| let zValues = data.map(p => p.z); |
| |
| let xMin = Math.min(...xValues), xMax = Math.max(...xValues); |
| let yMin = Math.min(...yValues), yMax = Math.max(...yValues); |
| let zMin = Math.min(...zValues), zMax = Math.max(...zValues); |
| |
| |
| const geometry = new THREE.BufferGeometry(); |
| const positions = new Float32Array(data.length * 3); |
| const colors = new Float32Array(data.length * 3); |
| |
| for (let i = 0; i < data.length; i++) { |
| |
| const x = ((data[i].x - xMin) / (xMax - xMin) * 10) - 5; |
| const y = ((data[i].y - yMin) / (yMax - yMin) * 10) - 5; |
| const z = ((data[i].z - zMin) / (zMax - zMin) * 10) - 5; |
| |
| positions[i * 3] = x; |
| positions[i * 3 + 1] = y; |
| positions[i * 3 + 2] = z; |
| |
| |
| let color; |
| if (data[i].category && categoryColors[data[i].category]) { |
| color = categoryColors[data[i].category]; |
| } else { |
| |
| color = new THREE.Color(); |
| color.setHSL(Math.random(), 0.7, 0.5); |
| } |
| |
| colors[i * 3] = color.r; |
| colors[i * 3 + 1] = color.g; |
| colors[i * 3 + 2] = color.b; |
| } |
| |
| |
| originalPositions = positions.slice(); |
| |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); |
| |
| |
| const material = new THREE.PointsMaterial({ |
| size: pointSize, |
| vertexColors: true, |
| sizeAttenuation: true |
| }); |
| |
| |
| pointCloud = new THREE.Points(geometry, material); |
| scene.add(pointCloud); |
| |
| |
| for (let i = 0; i < data.length; i++) { |
| points.push({ |
| position: new THREE.Vector3( |
| positions[i * 3], |
| positions[i * 3 + 1], |
| positions[i * 3 + 2] |
| ), |
| index: i |
| }); |
| } |
| |
| |
| resetView(); |
| |
| |
| document.getElementById('point-size').addEventListener('input', (e) => { |
| pointSize = parseFloat(e.target.value); |
| if (pointCloud) { |
| pointCloud.material.size = pointSize; |
| } |
| }); |
| |
| document.getElementById('category-filter').addEventListener('change', (e) => { |
| filterByCategory(e.target.value); |
| }); |
| |
| document.getElementById('reset-view').addEventListener('click', resetView); |
| }) |
| .catch(error => { |
| console.error('Error loading embeddings:', error); |
| document.getElementById('loading').textContent = 'Error loading embeddings. Check console for details.'; |
| }); |
| |
| |
| function onMouseMove(event) { |
| |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; |
| mouse.y = - (event.clientY / window.innerHeight) * 2 + 1; |
| } |
| |
| |
| function onWindowResize() { |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| } |
| |
| |
| function checkIntersection() { |
| if (!pointCloud) return; |
| |
| raycaster.setFromCamera(mouse, camera); |
| |
| let intersects = []; |
| for (let i = 0; i < points.length; i++) { |
| const point = points[i]; |
| const pointData = pointsData[i]; |
| |
| |
| if (!visibleCategories.has(pointData.category)) continue; |
| |
| const distance = raycaster.ray.distanceToPoint(point.position); |
| |
| |
| if (distance < 0.1) { |
| intersects.push({ distance, index: point.index }); |
| } |
| } |
| |
| |
| intersects.sort((a, b) => a.distance - b.distance); |
| |
| |
| if (hoveredPoint !== null) { |
| document.getElementById('info').style.display = 'none'; |
| hoveredPoint = null; |
| } |
| |
| |
| if (intersects.length > 0) { |
| const index = intersects[0].index; |
| const data = pointsData[index]; |
| |
| hoveredPoint = index; |
| |
| const infoElement = document.getElementById('info'); |
| infoElement.innerHTML = ` |
| <h3>${data.title}</h3> |
| <p><strong>Category:</strong> ${data.category || 'Uncategorized'}</p> |
| <p>${data.text}</p> |
| `; |
| infoElement.style.display = 'block'; |
| } |
| } |
| |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| |
| controls.update(); |
| checkIntersection(); |
| |
| renderer.render(scene, camera); |
| } |
| |
| |
| window.addEventListener('mousemove', onMouseMove, false); |
| window.addEventListener('resize', onWindowResize, false); |
| |
| |
| animate(); |
| </script> |
| </body> |
| </html> |