Spaces:
Running
Running
| <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 · Scroll to zoom · 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 & 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> | |