|
<!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/[email protected]/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> |