word2vec-galaxy / static /index.html
Gayanukaa's picture
update docs and styles
99ede6a
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Word2Vec Galaxy ✨</title>
<style>
:root {
--accent: #5b8af5;
--accent-glow: rgba(91, 138, 245, 0.35);
--bg: #000008;
--panel-bg: rgba(6, 10, 26, 0.92);
--border: rgba(80, 120, 220, 0.22);
--text: #c8d4f0;
--text-dim: #6a7a9e;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
overflow: hidden;
height: 100vh;
width: 100vw;
}
/* ── Three.js containers ─────────────────────────────── */
#scene {
position: fixed;
inset: 0;
}
#scene canvas {
display: block;
}
/* CSS2DRenderer overlay inserted by Three.js */
#label-layer {
position: fixed;
inset: 0;
pointer-events: none;
}
/* ── Word labels ─────────────────────────────────────── */
.word-label {
color: #ccdaff;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
text-shadow:
0 0 6px #000,
0 0 14px #000a2a;
white-space: nowrap;
padding: 2px 5px;
border-radius: 4px;
background: rgba(0, 0, 15, 0.55);
pointer-events: none;
transform: translateX(-50%);
margin-top: 6px;
}
.word-label.lbl-target {
color: #ffd700;
font-size: 14px;
font-weight: 700;
margin-top: 10px;
}
.word-label.lbl-result {
color: #7effaa;
font-size: 13px;
font-weight: 700;
}
.word-label.lbl-base {
color: #64b5f6;
font-size: 12px;
}
.word-label.lbl-sub {
color: #ef9a9a;
font-size: 12px;
}
.word-label.lbl-add {
color: #a5d6a7;
font-size: 12px;
}
/* ── Side panel ──────────────────────────────────────── */
#panel {
position: fixed;
top: 20px;
left: 20px;
width: 292px;
background: var(--panel-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 16px;
z-index: 100;
box-shadow:
0 16px 48px rgba(0, 0, 24, 0.7),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.panel-head {
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border);
}
.panel-title {
font-size: 17px;
font-weight: 700;
color: #e6ecff;
letter-spacing: 0.4px;
}
.panel-sub {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
/* tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 10px 6px;
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-dim);
cursor: pointer;
transition: all 0.18s;
user-select: none;
}
.tab.active {
color: var(--accent);
background: rgba(91, 138, 245, 0.1);
}
.tab:hover:not(.active) {
color: var(--text);
background: rgba(255, 255, 255, 0.04);
}
.tab-body {
padding: 18px 22px 14px;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
/* form */
.field {
margin-bottom: 13px;
}
.field label {
display: block;
font-size: 10px;
color: var(--text-dim);
margin-bottom: 5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.7px;
}
.field input[type="text"] {
width: 100%;
padding: 8px 11px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 8px;
color: #dde6ff;
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.18s,
box-shadow 0.18s;
}
.field input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.row {
display: flex;
gap: 10px;
align-items: center;
}
.field input[type="range"] {
flex: 1;
accent-color: var(--accent);
cursor: pointer;
}
.range-val {
font-size: 12px;
color: var(--accent);
font-weight: 700;
min-width: 22px;
text-align: right;
}
.btn {
width: 100%;
padding: 9px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
letter-spacing: 0.2px;
transition:
background 0.18s,
box-shadow 0.18s,
transform 0.1s;
}
.btn:hover {
background: #7aaaf8;
box-shadow: 0 4px 18px rgba(91, 138, 245, 0.45);
}
.btn:active {
transform: scale(0.98);
}
.btn:disabled {
background: #242840;
color: #445;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
/* analogy result line */
#analogy-eq {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 11px;
min-height: 18px;
line-height: 1.5;
}
/* status bar */
#statusbar {
padding: 9px 22px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 7px;
min-height: 34px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: #555;
transition: background 0.3s;
}
.dot.loading {
background: #f5a623;
animation: pulse 1s ease-in-out infinite;
}
.dot.ready {
background: #4caf50;
}
.dot.error {
background: #f44336;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
/* ── Loading overlay ───────────────────────────────────── */
#loading {
position: fixed;
inset: 0;
background: rgba(0, 0, 14, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 200;
backdrop-filter: blur(6px);
transition: opacity 0.5s;
}
#loading.hidden {
opacity: 0;
pointer-events: none;
}
.load-title {
font-size: 28px;
font-weight: 700;
color: #e6ecff;
margin-bottom: 8px;
}
.load-sub {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 36px;
}
.spinner {
width: 44px;
height: 44px;
border: 3px solid rgba(91, 138, 245, 0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.load-note {
margin-top: 14px;
font-size: 11px;
color: var(--text-dim);
}
/* ── Hover tooltip ───────────────────────────────────── */
#tip {
position: fixed;
background: rgba(8, 12, 30, 0.96);
border: 1px solid var(--border);
border-radius: 9px;
padding: 8px 12px;
font-size: 12px;
pointer-events: none;
z-index: 60;
opacity: 0;
transition: opacity 0.12s;
}
#tip.show {
opacity: 1;
}
.tip-word {
color: #e0e8ff;
font-weight: 700;
font-size: 13px;
}
.tip-sim {
color: var(--text-dim);
margin-top: 2px;
}
/* ── Legend ─────────────────────────────────────────── */
#legend {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 16px;
font-size: 11px;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
#legend.show {
opacity: 1;
}
.leg-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-dim);
margin-bottom: 8px;
}
.leg-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
color: var(--text-dim);
}
.leg-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Scene loading spinner ────────────────────────────── */
#scene-spinner {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 55;
opacity: 0;
transition: opacity 0.2s;
}
#scene-spinner.show {
opacity: 1;
}
#scene-spinner .ring {
width: 52px;
height: 52px;
border: 3px solid rgba(91, 138, 245, 0.18);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
#scene-spinner .ring-label {
margin-top: 14px;
font-size: 12px;
color: var(--text-dim);
letter-spacing: 0.4px;
}
/* ── Bottom hints ────────────────────────────────────── */
#hint-wrap {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 50;
pointer-events: none;
}
#hint-wrap .hint-pill {
background: rgba(8, 12, 30, 0.75);
border: 1px solid var(--border);
border-radius: 20px;
padding: 5px 16px;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
#hint-wrap .hint-pill a {
color: var(--accent);
text-decoration: none;
pointer-events: auto;
}
#hint-wrap .hint-pill a:hover {
text-decoration: underline;
}
#hint-credit {
font-size: 10px;
}
/* ── Info box ──────────────────────────────────────────── */
#info-box {
position: fixed;
top: calc(20px + var(--panel-h, 420px) + 12px);
left: 20px;
width: 292px;
z-index: 100;
}
#info-toggle {
width: 100%;
padding: 9px 16px;
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
user-select: none;
transition: background 0.18s;
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.3px;
}
#info-toggle:hover {
color: var(--text);
background: rgba(12, 18, 40, 0.94);
}
#info-toggle .info-icon {
font-size: 15px;
line-height: 1;
}
#info-panel {
margin-top: 6px;
background: var(--panel-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0;
overflow: hidden;
max-height: 0;
opacity: 0;
transition:
max-height 0.35s ease,
opacity 0.25s ease,
padding 0.35s ease;
box-shadow: 0 12px 36px rgba(0, 0, 24, 0.6);
}
#info-panel.open {
max-height: 450px;
opacity: 1;
padding: 18px 20px 16px;
}
#info-panel .info-section-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.7px;
color: var(--accent);
margin-bottom: 8px;
font-weight: 700;
}
#info-content {
font-size: 12px;
line-height: 1.65;
color: var(--text);
margin-bottom: 14px;
}
#info-content p {
margin: 0 0 7px;
}
#info-content strong {
color: #e0e8ff;
}
.info-divider {
border: none;
border-top: 1px solid var(--border);
margin: 12px 0;
}
.info-author {
font-size: 11px;
color: var(--text-dim);
line-height: 1.6;
}
.info-author a {
color: var(--accent);
text-decoration: none;
}
.info-author a:hover {
text-decoration: underline;
}
.about-links {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.about-links a {
color: var(--accent);
text-decoration: none;
}
.about-links a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<!-- Three.js render targets -->
<div id="scene"></div>
<div id="label-layer"></div>
<!-- ── Control Panel ─────────────────────────────────────────────────────── -->
<div id="panel">
<div class="panel-head">
<div class="panel-title">✨ Word2Vec Galaxy</div>
<div class="panel-sub">Explore word embeddings in 3D space</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="similar">Similar Words</div>
<div class="tab" data-tab="analogy">Analogy</div>
</div>
<div class="tab-body">
<!-- Similar Words -->
<div class="tab-pane active" id="tab-similar">
<div class="field">
<label>Word</label>
<input
type="text"
id="word-input"
value="galaxy"
placeholder="e.g. king, ocean, robot…"
/>
</div>
<div class="field">
<label>Nearest neighbours</label>
<div class="row">
<input type="range" id="num-slider" min="5" max="50" value="25" />
<span class="range-val" id="num-val">25</span>
</div>
</div>
<button class="btn" id="btn-similar">Visualize Galaxy</button>
</div>
<!-- Analogy -->
<div class="tab-pane" id="tab-analogy">
<div class="field">
<label>Base word</label>
<input
type="text"
id="a-word3"
value="king"
placeholder="e.g. king"
/>
</div>
<div class="field">
<label>Subtract</label>
<input
type="text"
id="a-word1"
value="man"
placeholder="e.g. man"
/>
</div>
<div class="field">
<label>Add</label>
<input
type="text"
id="a-word2"
value="woman"
placeholder="e.g. woman"
/>
</div>
<div id="analogy-eq"></div>
<button class="btn" id="btn-analogy">Calculate Analogy</button>
</div>
</div>
<div id="statusbar">
<div class="dot loading" id="dot"></div>
<span id="status-txt">Connecting…</span>
</div>
</div>
<!-- ── Info box ──────────────────────────────────────────────────────────── -->
<div id="info-box">
<button id="info-toggle">
<span class="info-icon">ℹ️</span>
<span>About this</span>
</button>
<div id="info-panel">
<div class="info-section-title" id="info-feature-title">
SIMILAR WORDS
</div>
<div id="info-content">
<!-- filled by JS -->
</div>
<hr class="info-divider" />
<div class="info-author">
<div class="about-links">
<a
href="https://gayanukaa.com/projects/word2vec-galaxy"
target="_blank"
>Project Page</a
>
<a
href="https://gayanukaa.com/blog/2025-07-18-word2vec-galaxy"
target="_blank"
>Understanding SPICE - Blog Post</a
>
<a
href="https://github.com/Gayanukaa/Word2Vec-Galaxy"
target="_blank"
>Source Code</a
>
</div>
</div>
</div>
</div>
<!-- ── Loading overlay ──────────────────────────────────────────────────── -->
<div id="loading">
<div class="load-title">Word2Vec Galaxy</div>
<div class="load-sub">Preparing the universe…</div>
<div class="spinner"></div>
<div class="load-note">
Loading the 1.5 GB Word2Vec model on first run — hang tight!
</div>
</div>
<!-- ── Tooltip ──────────────────────────────────────────────────────────── -->
<div id="tip">
<div class="tip-word" id="tip-word"></div>
<div class="tip-sim" id="tip-sim"></div>
</div>
<!-- ── Legend ───────────────────────────────────────────────────────────── -->
<div id="legend">
<div class="leg-title">Similarity to target</div>
<div class="leg-row">
<div class="leg-dot" style="background: #ffd600"></div>
High
</div>
<div class="leg-row">
<div class="leg-dot" style="background: #26c6da"></div>
Medium
</div>
<div class="leg-row">
<div class="leg-dot" style="background: #7986cb"></div>
Low
</div>
<div class="leg-row">
<div
class="leg-dot"
style="background: #ffd700; box-shadow: 0 0 4px #ffd700"
></div>
Target word
</div>
</div>
<!-- ── Scene loading spinner ─────────────────────────────────────────── -->
<div id="scene-spinner">
<div class="ring"></div>
<div class="ring-label" id="spinner-label">Computing…</div>
</div>
<div id="hint-wrap">
<div class="hint-pill">
Drag to rotate &nbsp;·&nbsp; Scroll to zoom &nbsp;·&nbsp; Hover to
inspect
</div>
<div class="hint-pill" id="hint-credit">
Developed by
<a href="https://gayanukaa.com" target="_blank" rel="noopener"
>Gayanukaa</a
>
· Powered by Three.js &amp; Gensim
</div>
</div>
<!-- ── Three.js (ES module) ─────────────────────────────────────────────── -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.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";
import {
CSS2DRenderer,
CSS2DObject,
} from "three/addons/renderers/CSS2DRenderer.js";
// ══════════════════════════════════════════════════════════════════════════
// SCENE SETUP
// ══════════════════════════════════════════════════════════════════════════
const W = window.innerWidth,
H = window.innerHeight;
const sceneEl = document.getElementById("scene");
const labelEl = document.getElementById("label-layer");
// WebGL renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(W, H);
renderer.setClearColor(0x000008, 1);
sceneEl.appendChild(renderer.domElement);
// CSS2D renderer (word labels)
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(W, H);
Object.assign(labelRenderer.domElement.style, {
position: "absolute",
top: "0",
left: "0",
pointerEvents: "none",
});
labelEl.appendChild(labelRenderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000008, 0.03);
const camera = new THREE.PerspectiveCamera(58, W / H, 0.1, 600);
camera.position.set(0, 0, 32);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 4;
controls.maxDistance = 150;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.25;
// ── Lights ────────────────────────────────────────────────────────────────
scene.add(new THREE.AmbientLight(0x141428, 3));
const lA = new THREE.PointLight(0x4466ff, 4, 90);
lA.position.set(20, 20, 20);
scene.add(lA);
const lB = new THREE.PointLight(0xff5533, 2.5, 70);
lB.position.set(-20, -15, -25);
scene.add(lB);
const lC = new THREE.PointLight(0x00eeff, 2, 60);
lC.position.set(0, 30, 0);
scene.add(lC);
// ── Star field (circular, twinkling) ──────────────────────────────────
let starMaterial;
(function buildStars() {
// Soft circular sprite via canvas
const sz = 64;
const c = document.createElement("canvas");
c.width = c.height = sz;
const ctx = c.getContext("2d");
const g = ctx.createRadialGradient(
sz / 2,
sz / 2,
0,
sz / 2,
sz / 2,
sz / 2,
);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(0.15, "rgba(200,220,255,0.9)");
g.addColorStop(0.5, "rgba(140,170,255,0.25)");
g.addColorStop(1, "rgba(0,0,40,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, sz, sz);
const starTex = new THREE.CanvasTexture(c);
const N = 120000;
const pos = new Float32Array(N * 3);
const sizes = new Float32Array(N);
const phases = new Float32Array(N);
const speeds = new Float32Array(N);
const baselines = new Float32Array(N);
for (let i = 0; i < N; i++) {
pos[i * 3] = (Math.random() - 0.5) * 600;
pos[i * 3 + 1] = (Math.random() - 0.5) * 600;
pos[i * 3 + 2] = (Math.random() - 0.5) * 600;
sizes[i] = 0.2 + Math.random() * 0.7;
baselines[i] = 0.4 + Math.random() * 0.6;
phases[i] = Math.random() * Math.PI * 2;
speeds[i] = 0.5 + Math.random() * 1.5;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geo.setAttribute("aSize", new THREE.BufferAttribute(sizes, 1));
geo.setAttribute("aPhase", new THREE.BufferAttribute(phases, 1));
geo.setAttribute("aSpeed", new THREE.BufferAttribute(speeds, 1));
geo.setAttribute("aBaseline", new THREE.BufferAttribute(baselines, 1));
// Custom shader for per-star glow animation
starMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
uTexture: { value: starTex },
uSize: { value: 0.6 },
},
vertexShader: `
uniform float uTime;
uniform float uSize;
attribute float aSize;
attribute float aPhase;
attribute float aSpeed;
attribute float aBaseline;
varying float vOpacity;
void main() {
// Each star glows independently based on phase and speed
float glow = sin(uTime * aSpeed + aPhase);
vOpacity = aBaseline + glow * 0.35;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * uSize * (1500.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform sampler2D uTexture;
varying float vOpacity;
void main() {
vec4 texColor = texture2D(uTexture, gl_PointCoord);
gl_FragColor = vec4(texColor.rgb, texColor.a * vOpacity);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
scene.add(new THREE.Points(geo, starMaterial));
})();
// ══════════════════════════════════════════════════════════════════════════
// COLOR HELPERS
// ══════════════════════════════════════════════════════════════════════════
const C_LOW = new THREE.Color(0x7986cb);
const C_MID = new THREE.Color(0x26c6da);
const C_HIGH = new THREE.Color(0xffd600);
const C_GOLD = new THREE.Color(0xffd700);
function simColor(t) {
// t should be 0..1 (normalized). Remap to a brighter 3-stop gradient.
const clamped = Math.max(0, Math.min(1, t));
if (clamped < 0.5) return C_LOW.clone().lerp(C_MID, clamped * 2);
return C_MID.clone().lerp(C_HIGH, (clamped - 0.5) * 2);
}
/** Normalize an array of similarities to 0..1 using min-max scaling */
function normalizeSimilarities(sims) {
let min = Infinity,
max = -Infinity;
for (const s of sims) {
if (s >= 1.0) continue; // skip target word (sim=1.0)
if (s < min) min = s;
if (s > max) max = s;
}
const range = max - min || 1;
return sims.map((s) => (s >= 1.0 ? 1.0 : (s - min) / range));
}
// ══════════════════════════════════════════════════════════════════════════
// VISUALIZATION STATE
// ══════════════════════════════════════════════════════════════════════════
let wordGroup = null; // Three.Group holding current scene
const meshList = []; // meshes for raycasting {mesh, word, sim}
const sceneSpinner = document.getElementById("scene-spinner");
const spinnerLabel = document.getElementById("spinner-label");
function showSceneLoading(msg = "Computing…") {
spinnerLabel.textContent = msg;
sceneSpinner.classList.add("show");
}
function hideSceneLoading() {
sceneSpinner.classList.remove("show");
}
function clearScene() {
if (hoveredMesh) {
hoveredMesh = null;
}
tip.classList.remove("show");
if (wordGroup) {
// Collect all objects first, then clean up
const toDispose = [];
wordGroup.traverse((o) => toDispose.push(o));
for (const o of toDispose) {
// Remove CSS2D label DOM elements explicitly
if (o.isCSS2DObject && o.element && o.element.parentNode) {
o.element.parentNode.removeChild(o.element);
}
// Dispose materials (meshes and line segments)
if (o.material) {
if (Array.isArray(o.material))
o.material.forEach((m) => m.dispose());
else o.material.dispose();
}
// Dispose non-shared geometries (lines etc, but NOT the shared sphereGeo)
if (o.geometry && o.geometry !== sphereGeo) {
o.geometry.dispose();
}
}
scene.remove(wordGroup);
wordGroup = null;
}
meshList.length = 0;
}
function makeLabel(text, cls) {
const d = document.createElement("div");
d.className = "word-label " + cls;
d.textContent = text;
return new CSS2DObject(d);
}
// shared geometry (disposed lazily on clearScene rebuild)
const sphereGeo = new THREE.SphereGeometry(1, 18, 14);
function addSphere(
group,
{ pos, radius, color, word, sim, labelClass, labelText },
) {
const mat = new THREE.MeshPhongMaterial({
color,
emissive: color.clone().multiplyScalar(0.45),
shininess: 90,
transparent: true,
opacity: labelClass === "lbl-target" ? 1.0 : 0.88,
});
const mesh = new THREE.Mesh(sphereGeo, mat);
mesh.scale.setScalar(radius);
mesh.position.set(...pos);
mesh.userData = { word, sim, baseScale: radius };
const lbl = makeLabel(labelText || word, labelClass);
lbl.position.set(0, radius * 2.0, 0);
mesh.add(lbl);
group.add(mesh);
meshList.push(mesh);
return mesh;
}
// ── Similar-words visualization ───────────────────────────────────────────
function showSimilar(data) {
clearScene();
const G = new THREE.Group();
const SCALE = 13;
const { target, words, vectors, similarities } = data;
// Connection lines from target
const ti = words.indexOf(target);
if (ti !== -1) {
const [tx, ty, tz] = [
vectors[ti][0] * SCALE,
vectors[ti][1] * SCALE,
vectors[ti][2] * SCALE,
];
const pts = [];
words.forEach((_, i) => {
if (i === ti) return;
pts.push(
tx,
ty,
tz,
vectors[i][0] * SCALE,
vectors[i][1] * SCALE,
vectors[i][2] * SCALE,
);
});
if (pts.length) {
const lg = new THREE.BufferGeometry();
lg.setAttribute(
"position",
new THREE.Float32BufferAttribute(pts, 3),
);
G.add(
new THREE.LineSegments(
lg,
new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.8,
}),
),
);
}
}
const normed = normalizeSimilarities(similarities);
words.forEach((word, i) => {
const isTarget = word === target;
const sim = similarities[i];
const color = isTarget ? C_GOLD.clone() : simColor(normed[i]);
addSphere(G, {
pos: [
vectors[i][0] * SCALE,
vectors[i][1] * SCALE,
vectors[i][2] * SCALE,
],
radius: isTarget ? 0.58 : 0.28,
color,
word,
sim,
labelClass: isTarget ? "lbl-target" : "",
});
});
scene.add(G);
wordGroup = G;
// Find target word position for zoom
const ti2 = words.indexOf(target);
const focusPos =
ti2 !== -1
? new THREE.Vector3(
vectors[ti2][0] * SCALE,
vectors[ti2][1] * SCALE,
vectors[ti2][2] * SCALE,
)
: new THREE.Vector3();
flyThenZoom(vectors, SCALE, focusPos, 6);
controls.autoRotate = false;
document.getElementById("legend").classList.add("show");
}
// ── Analogy visualization ─────────────────────────────────────────────────
function showAnalogy(data) {
clearScene();
const G = new THREE.Group();
const SCALE = 11;
const { word1, word2, word3, result, words, vectors } = data;
const META = {
[word3]: {
color: new THREE.Color(0x2196f3),
cls: "lbl-base",
lbl: `${word3} (base)`,
},
[word1]: {
color: new THREE.Color(0xef5350),
cls: "lbl-sub",
lbl: `${word1} (−)`,
},
[word2]: {
color: new THREE.Color(0x66bb6a),
cls: "lbl-add",
lbl: `${word2} (+)`,
},
[result]: {
color: new THREE.Color(0xffd700),
cls: "lbl-result",
lbl: `${result} ✨`,
},
};
// Draw edges and 3D arrows for vector arithmetic
const idx = Object.fromEntries(words.map((w, i) => [w, i]));
const edges = [
[word3, word2, 0x66bb6a],
[word3, word1, 0xef5350],
[word2, result, 0xffd700],
];
const pts = [];
edges.forEach(([a, b]) => {
if (idx[a] == null || idx[b] == null) return;
pts.push(
vectors[idx[a]][0] * SCALE,
vectors[idx[a]][1] * SCALE,
vectors[idx[a]][2] * SCALE,
vectors[idx[b]][0] * SCALE,
vectors[idx[b]][1] * SCALE,
vectors[idx[b]][2] * SCALE,
);
});
if (pts.length) {
const lg = new THREE.BufferGeometry();
lg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
G.add(
new THREE.LineSegments(
lg,
new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.8,
}),
),
);
}
// Add 3D arrows to show vector directions
edges.forEach(([a, b, color]) => {
if (idx[a] == null || idx[b] == null) return;
const from = new THREE.Vector3(
vectors[idx[a]][0] * SCALE,
vectors[idx[a]][1] * SCALE,
vectors[idx[a]][2] * SCALE,
);
const to = new THREE.Vector3(
vectors[idx[b]][0] * SCALE,
vectors[idx[b]][1] * SCALE,
vectors[idx[b]][2] * SCALE,
);
const direction = to.clone().sub(from);
const length = direction.length();
direction.normalize();
// ArrowHelper(direction, origin, length, color, headLength, headWidth)
const arrow = new THREE.ArrowHelper(
direction,
from,
length,
color,
length * 0.03, // head length (reduced)
length * 0.01, // head width (reduced)
);
// Make arrow semi-transparent and thinner
arrow.line.material.transparent = true;
arrow.line.material.opacity = 0.6;
arrow.line.material.linewidth = 1; // thinner shaft
arrow.cone.material.transparent = true;
arrow.cone.material.opacity = 0.75;
G.add(arrow);
});
words.forEach((word, i) => {
const m = META[word] || {
color: new THREE.Color(0x888888),
cls: "",
lbl: word,
};
addSphere(G, {
pos: [
vectors[i][0] * SCALE,
vectors[i][1] * SCALE,
vectors[i][2] * SCALE,
],
radius: word === result ? 0.6 : 0.44,
color: m.color,
word,
sim: null,
labelClass: m.cls,
labelText: m.lbl,
});
});
scene.add(G);
wordGroup = G;
// Compute centroid for zoom target
const cx =
(vectors.reduce((s, v) => s + v[0], 0) / vectors.length) * SCALE;
const cy =
(vectors.reduce((s, v) => s + v[1], 0) / vectors.length) * SCALE;
const cz =
(vectors.reduce((s, v) => s + v[2], 0) / vectors.length) * SCALE;
flyThenZoom(vectors, SCALE, new THREE.Vector3(cx, cy, cz), 8);
controls.autoRotate = false;
document.getElementById("legend").classList.remove("show");
}
// ── Camera animation ──────────────────────────────────────────────────────
let flyAnimId = 0; // incremented to cancel stale animations
/**
* Phase 1 (instant): pull back to show the full galaxy.
* Phase 2 (after ~1 s): smoothly zoom toward `focusPoint`
* and orbit-target it, stopping `closeDist` units away.
*/
function flyThenZoom(vectors, scale, focusPoint, closeDist) {
const id = ++flyAnimId;
// Compute overview distance
let maxR = 0;
for (const v of vectors) {
const r = Math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2) * scale;
if (r > maxR) maxR = r;
}
const overviewDist = Math.max(maxR * 2.4, 18);
// Phase 1 — snap to overview
const dir = camera.position.clone().normalize();
camera.position.copy(dir.multiplyScalar(overviewDist));
controls.target.set(0, 0, 0);
// Phase 2 — after 1 s, zoom into focusPoint
setTimeout(() => {
if (flyAnimId !== id) return; // cancelled by a new request
const from = camera.position.clone();
const offset = from
.clone()
.sub(controls.target)
.normalize()
.multiplyScalar(closeDist);
const to = focusPoint.clone().add(offset);
const fromTgt = controls.target.clone();
const t0 = performance.now();
const DUR = 1400; // ms
(function step() {
if (flyAnimId !== id) return;
const p = Math.min((performance.now() - t0) / DUR, 1);
const e = 1 - (1 - p) ** 3; // cubic ease-out
camera.position.lerpVectors(from, to, e);
controls.target.lerpVectors(fromTgt, focusPoint, e);
if (p < 1) requestAnimationFrame(step);
})();
}, 1000);
}
// ══════════════════════════════════════════════════════════════════════════
// RAYCASTER / HOVER
// ══════════════════════════════════════════════════════════════════════════
const ray = new THREE.Raycaster();
const mouse = new THREE.Vector2(-9999, -9999);
const tip = document.getElementById("tip");
const tipWord = document.getElementById("tip-word");
const tipSim = document.getElementById("tip-sim");
let hoveredMesh = null;
document.addEventListener("mousemove", (e) => {
mouse.x = (e.clientX / innerWidth) * 2 - 1;
mouse.y = (e.clientY / innerHeight) * -2 + 1;
tip.style.left = e.clientX + 14 + "px";
tip.style.top = e.clientY - 10 + "px";
});
function pollHover() {
if (!meshList.length) return;
ray.setFromCamera(mouse, camera);
const hits = ray.intersectObjects(meshList);
if (hits.length) {
const m = hits[0].object;
if (m !== hoveredMesh) {
if (hoveredMesh)
hoveredMesh.scale.setScalar(hoveredMesh.userData.baseScale);
hoveredMesh = m;
m.scale.setScalar(m.userData.baseScale * 1.45);
}
tipWord.textContent = m.userData.word;
tipSim.textContent =
m.userData.sim != null
? `similarity: ${m.userData.sim.toFixed(4)}`
: "";
tip.classList.add("show");
} else {
if (hoveredMesh) {
hoveredMesh.scale.setScalar(hoveredMesh.userData.baseScale);
hoveredMesh = null;
}
tip.classList.remove("show");
}
}
// ══════════════════════════════════════════════════════════════════════════
// RENDER LOOP
// ══════════════════════════════════════════════════════════════════════════
const clock = new THREE.Clock();
(function loop() {
requestAnimationFrame(loop);
const t = clock.getElapsedTime();
// Animate lights
lA.position.x = Math.sin(t * 0.28) * 22;
lA.position.y = Math.cos(t * 0.34) * 18;
lB.position.x = Math.cos(t * 0.22) * 22;
lB.position.z = Math.sin(t * 0.31) * 22;
// Update star shader time for per-star glow animation
if (starMaterial && starMaterial.uniforms) {
starMaterial.uniforms.uTime.value = t;
}
controls.update();
pollHover();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
})();
// ── Resize ────────────────────────────────────────────────────────────────
window.addEventListener("resize", () => {
const W = innerWidth,
H = innerHeight;
camera.aspect = W / H;
camera.updateProjectionMatrix();
renderer.setSize(W, H);
labelRenderer.setSize(W, H);
});
// ══════════════════════════════════════════════════════════════════════════
// UI LOGIC
// ══════════════════════════════════════════════════════════════════════════
const loadingEl = document.getElementById("loading");
const dotEl = document.getElementById("dot");
const statusTxt = document.getElementById("status-txt");
const btnSimilar = document.getElementById("btn-similar");
const btnAnalogy = document.getElementById("btn-analogy");
function setStatus(state, msg) {
dotEl.className = "dot " + state;
statusTxt.textContent = msg;
}
function setBtnsDisabled(disabled) {
btnSimilar.disabled = disabled;
btnAnalogy.disabled = disabled;
}
setBtnsDisabled(true);
// ── Health poll ───────────────────────────────────────────────────────────
let alreadyReady = false;
async function pollHealth() {
try {
const r = await fetch("/api/health");
const d = await r.json();
if (d.model_loaded) {
loadingEl.classList.add("hidden");
setStatus("ready", "Model ready");
setBtnsDisabled(false);
if (!alreadyReady) {
alreadyReady = true;
setTimeout(() => visualizeSimilar("galaxy", 25), 400);
}
return;
}
} catch (_) {
/* server still starting */
}
setTimeout(pollHealth, 2200);
}
pollHealth();
// ── Tab switching ─────────────────────────────────────────────────────────
document.querySelectorAll(".tab").forEach((t) =>
t.addEventListener("click", () => {
document
.querySelectorAll(".tab")
.forEach((x) => x.classList.remove("active"));
document
.querySelectorAll(".tab-pane")
.forEach((x) => x.classList.remove("active"));
t.classList.add("active");
document
.getElementById("tab-" + t.dataset.tab)
.classList.add("active");
// Keep info content in sync with active tab
updateInfoContent();
}),
);
// ── Info box ───────────────────────────────────────────────────────────────
const infoBox = document.getElementById("info-box");
const infoToggle = document.getElementById("info-toggle");
const infoPanel = document.getElementById("info-panel");
const infoTitle = document.getElementById("info-feature-title");
const infoContent = document.getElementById("info-content");
let infoTimer = null;
// Position info box dynamically below the panel
function positionInfoBox() {
const panelEl = document.getElementById("panel");
const rect = panelEl.getBoundingClientRect();
infoBox.style.top = rect.bottom + 12 + "px";
}
positionInfoBox();
new ResizeObserver(positionInfoBox).observe(
document.getElementById("panel"),
);
const INFO_SIMILAR = `
<p><strong>Similar Words</strong> finds the nearest neighbours of any word in a 300-dimensional Word2Vec space trained on Google News.</p>
<p>Words are projected to 3D via PCA and rendered as an interactive galaxy. Colour encodes cosine similarity - gold is closest, blue is farthest.</p>
<p>Hover any star to see the word and its similarity score. Drag to orbit, scroll to zoom.</p>
`;
const INFO_ANALOGY = `
<p><strong>Vector Arithmetic</strong> computes analogy relationships between words.</p>
<p>Enter a base word, a word to subtract, and a word to add; the engine solves <em>base - subtract + add = ?</em></p>
<p>The 4 words are rendered with connecting edges in 3D space.</p>
`;
function getActiveTab() {
const active = document.querySelector(".tab.active");
return active ? active.dataset.tab : "similar";
}
function updateInfoContent() {
if (getActiveTab() === "analogy") {
infoTitle.textContent = "VECTOR ARITHMETIC";
infoContent.innerHTML = INFO_ANALOGY;
} else {
infoTitle.textContent = "SIMILAR WORDS";
infoContent.innerHTML = INFO_SIMILAR;
}
}
updateInfoContent();
function startAutoCollapse() {
clearTimeout(infoTimer);
infoTimer = setTimeout(() => {
infoPanel.classList.remove("open");
}, 10000);
}
infoToggle.addEventListener("click", () => {
const opening = !infoPanel.classList.contains("open");
updateInfoContent();
infoPanel.classList.toggle("open");
if (opening) startAutoCollapse();
else clearTimeout(infoTimer);
});
// Pause auto-collapse while hovering
infoPanel.addEventListener("mouseenter", () => clearTimeout(infoTimer));
infoPanel.addEventListener("mouseleave", () => {
if (infoPanel.classList.contains("open")) startAutoCollapse();
});
// Range slider
const slider = document.getElementById("num-slider");
const sliderLbl = document.getElementById("num-val");
slider.addEventListener(
"input",
() => (sliderLbl.textContent = slider.value),
);
// ── Similar words ─────────────────────────────────────────────────────────
async function visualizeSimilar(word, n) {
if (!word) return;
// Clear immediately so old viz doesn't linger during the fetch
clearScene();
setBtnsDisabled(true);
showSceneLoading(`Finding "${word}"…`);
setStatus("loading", `Finding neighbours for "${word}"…`);
try {
const res = await fetch(
`/api/similar?word=${encodeURIComponent(word)}&n=${n}`,
);
if (!res.ok) {
const e = await res.json();
setStatus("error", e.detail || "Error");
return;
}
const data = await res.json();
showSimilar(data);
setStatus(
"ready",
`${data.words.length} words near "${data.target}"`,
);
} catch (_) {
setStatus("error", "Network error");
} finally {
hideSceneLoading();
setBtnsDisabled(false);
}
}
btnSimilar.addEventListener("click", () => {
visualizeSimilar(
document.getElementById("word-input").value.trim(),
parseInt(slider.value),
);
});
document.getElementById("word-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") btnSimilar.click();
});
// ── Analogy ───────────────────────────────────────────────────────────────
btnAnalogy.addEventListener("click", async () => {
const word1 = document.getElementById("a-word1").value.trim();
const word2 = document.getElementById("a-word2").value.trim();
const word3 = document.getElementById("a-word3").value.trim();
if (!word1 || !word2 || !word3) return;
// Clear immediately so old viz doesn't linger during the fetch
clearScene();
setBtnsDisabled(true);
document.getElementById("analogy-eq").innerHTML = "";
showSceneLoading(`${word3}${word1} + ${word2}…`);
setStatus("loading", "Calculating analogy…");
try {
const res = await fetch("/api/analogy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ word1, word2, word3 }),
});
if (!res.ok) {
const e = await res.json();
setStatus("error", e.detail || "Error");
return;
}
const data = await res.json();
document.getElementById("analogy-eq").innerHTML =
`<span style="color:#c8d4f0">${data.word3}${data.word1} + ${data.word2} = </span>` +
`<span style="color:#ffd700;font-weight:700">${data.result}</span>`;
showAnalogy(data);
setStatus(
"ready",
`${data.word3}${data.word1} + ${data.word2} = ${data.result}`,
);
} catch (_) {
setStatus("error", "Network error");
} finally {
hideSceneLoading();
setBtnsDisabled(false);
}
});
</script>
</body>
</html>