| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>3D Word Embedding Visualization</title> |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap'); |
| |
| body, html { |
| margin: 0; |
| padding: 0; |
| overflow: hidden; |
| font-family: 'Inter', sans-serif; |
| background: radial-gradient(ellipse at center, #1a1a2e 0%, #16213e 50%, #0f3460 100%); |
| } |
| |
| #info { |
| position: absolute; |
| top: 20px; |
| left: 20px; |
| background: rgba(0, 0, 0, 0.8); |
| backdrop-filter: blur(10px); |
| color: #fff; |
| padding: 16px 20px; |
| border-radius: 12px; |
| font-size: 14px; |
| border: 1px solid rgba(255, 255, 255, 0.1); |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); |
| } |
| |
| #info p { |
| margin: 0 0 8px 0; |
| font-weight: 400; |
| } |
| |
| #info p:last-child { |
| margin-bottom: 0; |
| } |
| |
| #countDisplay { |
| color: #64ffda; |
| font-weight: 600; |
| } |
| |
| #wordInfo { |
| position: absolute; |
| bottom: 20px; |
| left: 20px; |
| background: rgba(0, 0, 0, 0.9); |
| backdrop-filter: blur(15px); |
| color: #fff; |
| padding: 20px 24px; |
| border-radius: 12px; |
| display: none; |
| font-size: 14px; |
| border: 1px solid rgba(100, 255, 218, 0.3); |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); |
| min-width: 200px; |
| } |
| |
| #wordInfo strong { |
| color: #64ffda; |
| font-size: 18px; |
| font-weight: 600; |
| display: block; |
| margin-bottom: 12px; |
| } |
| |
| #wordInfo .coord { |
| margin: 4px 0; |
| font-family: 'Courier New', monospace; |
| color: #b0bec5; |
| } |
| |
| canvas { |
| display: block; |
| cursor: grab; |
| } |
| |
| canvas:active { |
| cursor: grabbing; |
| } |
| |
| #loading { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| color: #64ffda; |
| font-size: 18px; |
| font-weight: 500; |
| } |
| |
| .pulse { |
| animation: pulse 2s infinite; |
| } |
| |
| @keyframes pulse { |
| 0% { opacity: 0.6; } |
| 50% { opacity: 1; } |
| 100% { opacity: 0.6; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loading" class="pulse">Loading word embeddings...</div> |
| <div id="info" style="display: none;"> |
| <p>Visualizing <span id="countDisplay"></span> most frequent words</p> |
| <p><strong>Controls:</strong> Rotate: drag • Zoom: scroll • Pan: right-click + drag</p> |
| <p>Click any word sphere to inspect details</p> |
| </div> |
| <div id="wordInfo"></div> |
| |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.module.min.js", |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" |
| } |
| } |
| </script> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| |
| const MAX_WORDS = 4000; |
| |
| let scene, camera, renderer, controls; |
| let raycaster = new THREE.Raycaster(); |
| let mouse = new THREE.Vector2(); |
| let spheres = []; |
| let selectedSphere = null; |
| let originalMaterials = new Map(); |
| let hoveredSphere = null; |
| |
| init(); |
| animate(); |
| |
| function init() { |
| document.getElementById('countDisplay').textContent = MAX_WORDS.toLocaleString(); |
| |
| |
| scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x0a0a0a); |
| scene.fog = new THREE.Fog(0x0a0a0a, 50, 200); |
| |
| |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000); |
| camera.position.set(15, 10, 25); |
| |
| |
| renderer = new THREE.WebGLRenderer({ |
| antialias: true, |
| alpha: true, |
| powerPreference: "high-performance" |
| }); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 1.2; |
| document.body.appendChild(renderer.domElement); |
| |
| |
| controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.05; |
| controls.screenSpacePanning = false; |
| controls.minDistance = 5; |
| controls.maxDistance = 100; |
| controls.maxPolarAngle = Math.PI; |
| |
| |
| window.addEventListener('resize', onResize); |
| window.addEventListener('pointermove', onPointerMove, false); |
| window.addEventListener('pointerdown', onClick, false); |
| |
| |
| fetch('word_vectors_3d.json') |
| .then(r => { |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); |
| return r.json(); |
| }) |
| .then(data => { |
| const subset = data.slice(0, MAX_WORDS); |
| createEnhancedSpheres(subset); |
| document.getElementById('loading').style.display = 'none'; |
| document.getElementById('info').style.display = 'block'; |
| }) |
| .catch(e => { |
| console.error(e); |
| document.getElementById('loading').textContent = 'Error loading data. Make sure word_vectors_3d.json exists.'; |
| }); |
| } |
| |
| function createEnhancedSpheres(data) { |
| |
| let mins = { x: Infinity, y: Infinity, z: Infinity }; |
| let maxs = { x: -Infinity, y: -Infinity, z: -Infinity }; |
| |
| data.forEach(p => { |
| mins.x = Math.min(mins.x, p.x); |
| mins.y = Math.min(mins.y, p.y); |
| mins.z = Math.min(mins.z, p.z); |
| maxs.x = Math.max(maxs.x, p.x); |
| maxs.y = Math.max(maxs.y, p.y); |
| maxs.z = Math.max(maxs.z, p.z); |
| }); |
| |
| |
| const scale = 40; |
| |
| |
| const radius = 0.15; |
| const geometry = new THREE.SphereGeometry(radius, 12, 8); |
| |
| |
| createStarField(); |
| |
| let group = new THREE.Group(); |
| |
| data.forEach((p, index) => { |
| |
| const x = ((p.x - mins.x) / (maxs.x - mins.x) - 0.5) * scale; |
| const y = ((p.y - mins.y) / (maxs.y - mins.y) - 0.5) * scale; |
| const z = ((p.z - mins.z) / (maxs.z - mins.z) - 0.5) * scale; |
| |
| |
| const hue = (p.x - mins.x) / (maxs.x - mins.x); |
| const saturation = 0.7 + 0.3 * ((p.y - mins.y) / (maxs.y - mins.y)); |
| const lightness = 0.4 + 0.4 * ((p.z - mins.z) / (maxs.z - mins.z)); |
| |
| const color = new THREE.Color().setHSL(hue * 0.8 + 0.1, saturation, lightness); |
| |
| |
| const material = new THREE.MeshPhysicalMaterial({ |
| color: color, |
| emissive: color.clone().multiplyScalar(0.1), |
| metalness: 0.1, |
| roughness: 0.4, |
| clearcoat: 0.3, |
| clearcoatRoughness: 0.2, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| |
| const mesh = new THREE.Mesh(geometry, material); |
| mesh.position.set(x, y, z); |
| mesh.userData = { ...p, index }; |
| mesh.castShadow = true; |
| mesh.receiveShadow = true; |
| |
| |
| originalMaterials.set(mesh, material.clone()); |
| |
| spheres.push(mesh); |
| group.add(mesh); |
| }); |
| |
| scene.add(group); |
| setupLighting(); |
| } |
| |
| function createStarField() { |
| const starsGeometry = new THREE.BufferGeometry(); |
| const starsMaterial = new THREE.PointsMaterial({ |
| color: 0x888888, |
| size: 0.5, |
| transparent: true, |
| opacity: 0.3 |
| }); |
| |
| const starsVertices = []; |
| for (let i = 0; i < 1000; i++) { |
| const x = (Math.random() - 0.5) * 200; |
| const y = (Math.random() - 0.5) * 200; |
| const z = (Math.random() - 0.5) * 200; |
| starsVertices.push(x, y, z); |
| } |
| |
| starsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starsVertices, 3)); |
| const stars = new THREE.Points(starsGeometry, starsMaterial); |
| scene.add(stars); |
| } |
| |
| function setupLighting() { |
| |
| const ambientLight = new THREE.AmbientLight(0x404040, 0.4); |
| scene.add(ambientLight); |
| |
| |
| const mainLight = new THREE.DirectionalLight(0xffffff, 0.8); |
| mainLight.position.set(20, 20, 20); |
| mainLight.castShadow = true; |
| mainLight.shadow.mapSize.width = 2048; |
| mainLight.shadow.mapSize.height = 2048; |
| scene.add(mainLight); |
| |
| |
| const fillLight = new THREE.DirectionalLight(0x64ffda, 0.3); |
| fillLight.position.set(-20, -20, -20); |
| scene.add(fillLight); |
| |
| |
| const rimLight = new THREE.DirectionalLight(0xff6b6b, 0.2); |
| rimLight.position.set(0, 20, -20); |
| scene.add(rimLight); |
| } |
| |
| function onPointerMove(event) { |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; |
| |
| raycaster.setFromCamera(mouse, camera); |
| const intersects = raycaster.intersectObjects(spheres); |
| |
| if (intersects.length > 0) { |
| renderer.domElement.style.cursor = 'pointer'; |
| } else { |
| renderer.domElement.style.cursor = 'grab'; |
| } |
| } |
| |
| function onClick(event) { |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; |
| |
| raycaster.setFromCamera(mouse, camera); |
| const intersects = raycaster.intersectObjects(spheres); |
| |
| |
| if (selectedSphere) { |
| resetSphereAppearance(selectedSphere); |
| } |
| |
| const wordInfo = document.getElementById('wordInfo'); |
| |
| if (intersects.length > 0) { |
| selectedSphere = intersects[0].object; |
| const data = selectedSphere.userData; |
| |
| |
| highlightSphere(selectedSphere, 'selected'); |
| |
| |
| wordInfo.innerHTML = ` |
| <strong>${data.word}</strong> |
| <div class="coord">x: ${data.x.toFixed(3)}</div> |
| <div class="coord">y: ${data.y.toFixed(3)}</div> |
| <div class="coord">z: ${data.z.toFixed(3)}</div> |
| <div style="margin-top: 8px; color: #90a4ae; font-size: 12px;"> |
| Index: ${data.index + 1} / ${MAX_WORDS.toLocaleString()} |
| </div> |
| `; |
| wordInfo.style.display = 'block'; |
| } else { |
| selectedSphere = null; |
| wordInfo.style.display = 'none'; |
| } |
| } |
| |
| function highlightSphere(sphere, type) { |
| const material = sphere.material; |
| |
| if (type === 'selected') { |
| material.emissive.setHex(0x64ffda); |
| material.emissiveIntensity = 0.5; |
| sphere.scale.setScalar(1.5); |
| |
| |
| const originalScale = sphere.scale.clone(); |
| function pulse() { |
| if (sphere === selectedSphere) { |
| sphere.scale.multiplyScalar(1.02); |
| if (sphere.scale.x > originalScale.x * 1.1) { |
| sphere.scale.copy(originalScale); |
| } |
| requestAnimationFrame(pulse); |
| } |
| } |
| pulse(); |
| } |
| } |
| |
| function resetSphereAppearance(sphere) { |
| const originalMaterial = originalMaterials.get(sphere); |
| if (originalMaterial) { |
| sphere.material.emissive.copy(originalMaterial.emissive); |
| sphere.material.emissiveIntensity = originalMaterial.emissiveIntensity || 0.1; |
| } |
| sphere.scale.setScalar(1); |
| } |
| |
| function animateCamera(targetPosition, lookAtPosition) { |
| |
| } |
| |
| function onResize() { |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| |
| |
| if (scene.children.length > 0) { |
| const stars = scene.children.find(child => child.type === 'Points'); |
| if (stars) { |
| stars.rotation.y += 0.0005; |
| } |
| } |
| |
| controls.update(); |
| renderer.render(scene, camera); |
| } |
| </script> |
| </body> |
| </html> |