| <div class="d3-latent-space"></div> |
| <style> |
| .d3-latent-space { |
| width: 100%; |
| margin: 10px 0; |
| aspect-ratio: 3/1; |
| min-height: 260px; |
| overflow: hidden; |
| background: transparent; |
| border-radius: 12px; |
| border: 1px solid var(--border-color); |
| } |
| |
| .d3-latent-space canvas { |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| } |
| |
| .d3-latent-space .tp-dfwv { |
| top: 16px; |
| right: 16px; |
| z-index: 10; |
| } |
| |
| .d3-latent-space .d3-tooltip { |
| position: absolute; |
| background: color-mix(in srgb, var(--surface-bg) 95%, transparent); |
| backdrop-filter: blur(16px) saturate(1.2); |
| border: 1px solid var(--border-color); |
| border-radius: 12px; |
| padding: 14px 18px; |
| box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.12); |
| pointer-events: none; |
| opacity: 0; |
| transform: translate(-50%, -120%); |
| transition: opacity 0.15s ease; |
| z-index: 10; |
| max-width: 400px; |
| } |
| |
| .d3-latent-space .tooltip-category { |
| font-size: 10px; |
| font-weight: 800; |
| text-transform: uppercase; |
| letter-spacing: 1px; |
| margin-bottom: 8px; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| .d3-latent-space .tooltip-badge { |
| display: inline-block; |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| box-shadow: 0 0 8px currentColor; |
| } |
| |
| .d3-latent-space .tooltip-question { |
| font-size: 12px; |
| font-weight: 600; |
| color: var(--text-color); |
| margin-bottom: 6px; |
| line-height: 1.4; |
| } |
| |
| .d3-latent-space .tooltip-answer { |
| font-size: 11px; |
| color: var(--muted-color); |
| line-height: 1.4; |
| border-top: 1px solid var(--border-color); |
| padding-top: 8px; |
| margin-top: 6px; |
| } |
| </style> |
| <script> |
| (() => { |
| const ensureD3 = (cb) => { |
| if (window.d3 && typeof window.d3.csvParse === 'function') return cb(); |
| let s = document.getElementById('d3-cdn-script'); |
| if (!s) { |
| s = document.createElement('script'); |
| s.id = 'd3-cdn-script'; |
| s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; |
| document.head.appendChild(s); |
| } |
| const onReady = () => { if (window.d3 && typeof window.d3.csvParse === 'function') cb(); }; |
| s.addEventListener('load', onReady, { once: true }); |
| if (window.d3) onReady(); |
| }; |
| |
| const ensureAnime = (cb) => { |
| if (window.anime) return cb(); |
| let s = document.getElementById('anime-cdn-script'); |
| if (!s) { |
| s = document.createElement('script'); |
| s.id = 'anime-cdn-script'; |
| s.src = 'https://cdn.jsdelivr.net/npm/animejs@3.2.1/lib/anime.min.js'; |
| document.head.appendChild(s); |
| } |
| const onReady = () => { if (window.anime) cb(); }; |
| s.addEventListener('load', onReady, { once: true }); |
| if (window.anime) onReady(); |
| }; |
| |
| const ensureTweakpane = (cb) => { |
| if (window.Tweakpane) return cb(); |
| let s = document.getElementById('tweakpane-cdn-script'); |
| if (!s) { |
| s = document.createElement('script'); |
| s.id = 'tweakpane-cdn-script'; |
| s.src = 'https://cdn.jsdelivr.net/npm/tweakpane@3.1.10/dist/tweakpane.min.js'; |
| document.head.appendChild(s); |
| } |
| const onReady = () => { if (window.Tweakpane) cb(); }; |
| s.addEventListener('load', onReady, { once: true }); |
| if (window.Tweakpane) onReady(); |
| }; |
| |
| const bootstrap = () => { |
| const scriptEl = document.currentScript; |
| let container = scriptEl ? scriptEl.previousElementSibling : null; |
| if (!(container && container.classList && container.classList.contains('d3-latent-space'))) { |
| const candidates = Array.from(document.querySelectorAll('.d3-latent-space')) |
| .filter((el) => !(el.dataset && el.dataset.mounted === 'true')); |
| container = candidates[candidates.length - 1] || null; |
| } |
| if (!container) return; |
| if (container.dataset) { |
| if (container.dataset.mounted === 'true') return; |
| container.dataset.mounted = 'true'; |
| } |
| |
| |
| const canvas = document.createElement('canvas'); |
| container.appendChild(canvas); |
| const ctx = canvas.getContext('2d'); |
| |
| |
| const tooltip = document.createElement('div'); |
| tooltip.className = 'd3-tooltip'; |
| container.appendChild(tooltip); |
| |
| let width = container.clientWidth || 800; |
| let height = Math.max(260, Math.round(width / 3)); |
| let points = []; |
| let categories = new Map(); |
| let selectedCategory = null; |
| let animationFrame; |
| let time = 0; |
| |
| |
| const params = { |
| baseSize: 2.5 |
| }; |
| |
| const resizeCanvas = () => { |
| width = container.clientWidth || 800; |
| height = Math.max(260, Math.round(width / 3)); |
| canvas.width = width * window.devicePixelRatio || 1; |
| canvas.height = height * window.devicePixelRatio || 1; |
| canvas.style.width = width + 'px'; |
| canvas.style.height = height + 'px'; |
| ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); |
| }; |
| |
| const getColors = () => { |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; |
| const colors = window.ColorPalettes |
| ? window.ColorPalettes.getColors('categorical', 10) |
| : ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#B983FF', '#FF85A2', '#5DADE2', '#52BE80']; |
| return { isDark, colors }; |
| }; |
| |
| class Point { |
| constructor(data, color, index) { |
| this.originalX = parseFloat(data.x); |
| this.originalY = parseFloat(data.y); |
| this.category = data.primary_category; |
| this.title = data.title; |
| this.authors = data.authors; |
| this.year = data.year; |
| this.abstract = data.abstract; |
| this.color = color; |
| this.index = index; |
| |
| |
| this.x = this.originalX; |
| this.y = this.originalY; |
| this.displayX = 0; |
| this.displayY = 0; |
| this.opacity = 0; |
| this.sizeVariation = 0.5 + Math.random() * 1; |
| this.size = params.baseSize * this.sizeVariation; |
| this.baseSize = this.size; |
| this.glowIntensity = 0; |
| this.phase = Math.random() * Math.PI * 2; |
| this.speed = 0.3 + Math.random() * 0.4; |
| } |
| |
| updateSize() { |
| this.baseSize = params.baseSize * this.sizeVariation; |
| if (!this.isHighlighted) { |
| this.size = this.baseSize; |
| } |
| } |
| |
| update(time, selectedCat) { |
| |
| this.x = this.originalX; |
| this.y = this.originalY; |
| |
| |
| if (selectedCat === null) { |
| this.glowIntensity = 0.8; |
| this.isHighlighted = false; |
| this.size = this.baseSize; |
| } else if (this.category === selectedCat) { |
| this.glowIntensity = 1; |
| this.isHighlighted = true; |
| this.size = this.baseSize * 1.4; |
| } else { |
| this.glowIntensity = 0.1; |
| this.isHighlighted = true; |
| this.size = this.baseSize * 0.6; |
| } |
| } |
| |
| draw(ctx, scaleX, scaleY, offsetX, offsetY) { |
| this.displayX = this.x * scaleX + offsetX; |
| this.displayY = this.y * scaleY + offsetY; |
| |
| const alpha = this.opacity * this.glowIntensity; |
| |
| |
| if (this.glowIntensity > 0.3) { |
| const gradient = ctx.createRadialGradient( |
| this.displayX, this.displayY, 0, |
| this.displayX, this.displayY, this.size * 4 |
| ); |
| gradient.addColorStop(0, this.color + Math.floor(alpha * 60).toString(16).padStart(2, '0')); |
| gradient.addColorStop(0.5, this.color + Math.floor(alpha * 30).toString(16).padStart(2, '0')); |
| gradient.addColorStop(1, this.color + '00'); |
| |
| ctx.fillStyle = gradient; |
| ctx.beginPath(); |
| ctx.arc(this.displayX, this.displayY, this.size * 4, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| |
| |
| ctx.fillStyle = this.color + Math.floor(alpha * 255).toString(16).padStart(2, '0'); |
| ctx.beginPath(); |
| ctx.arc(this.displayX, this.displayY, this.size, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| |
| isNear(mx, my, threshold = 20) { |
| const dx = this.displayX - mx; |
| const dy = this.displayY - my; |
| return Math.sqrt(dx * dx + dy * dy) < threshold; |
| } |
| } |
| |
| const loadData = async () => { |
| try { |
| console.log('Loading research papers data...'); |
| const response = await fetch('/data/data.json', { cache: 'no-cache' }); |
| if (!response.ok) { |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
| } |
| |
| console.log('Parsing JSON...'); |
| const rawData = await response.json(); |
| console.log(`Loaded ${rawData.length} papers`); |
| |
| |
| const sampledData = rawData.length > 3000 |
| ? rawData.filter((_, i) => i % Math.ceil(rawData.length / 3000) === 0) |
| : rawData; |
| |
| |
| sampledData.forEach(paper => { |
| const cat = paper.primary_category || 'Unknown'; |
| if (!categories.has(cat)) { |
| categories.set(cat, []); |
| } |
| categories.get(cat).push(paper); |
| }); |
| |
| |
| const { colors } = getColors(); |
| const categoryList = Array.from(categories.keys()); |
| |
| categoryList.forEach((cat, i) => { |
| const color = colors[i % colors.length]; |
| categories.get(cat).forEach((data, j) => { |
| points.push(new Point(data, color, i * 100 + j)); |
| }); |
| }); |
| |
| |
| points.forEach((point, i) => { |
| anime({ |
| targets: point, |
| opacity: [0, 1], |
| duration: 1500, |
| delay: i * 2, |
| easing: 'easeOutQuad' |
| }); |
| }); |
| |
| |
| let pane; |
| try { |
| if (window.Tweakpane && window.Tweakpane.Pane) { |
| pane = new window.Tweakpane.Pane({ |
| container: container, |
| title: 'Controls' |
| }); |
| } else if (window.Tweakpane) { |
| pane = new window.Tweakpane({ |
| container: container, |
| title: 'Controls' |
| }); |
| } |
| |
| if (pane) { |
| const input = pane.addInput ? pane.addInput(params, 'baseSize', { |
| label: 'Point Size', |
| min: 0.5, |
| max: 8, |
| step: 0.1 |
| }) : pane.addBinding ? pane.addBinding(params, 'baseSize', { |
| label: 'Point Size', |
| min: 0.5, |
| max: 8, |
| step: 0.1 |
| }) : null; |
| |
| if (input) { |
| input.on('change', () => { |
| points.forEach(p => p.updateSize()); |
| }); |
| } |
| } |
| } catch (err) { |
| console.warn('Tweakpane initialization failed:', err); |
| |
| const controls = document.createElement('div'); |
| controls.style.cssText = 'position:absolute;top:16px;right:16px;background:var(--surface-bg);border:1px solid var(--border-color);border-radius:8px;padding:12px;z-index:10;'; |
| controls.innerHTML = ` |
| <label style="font-size:11px;font-weight:700;color:var(--text-color);display:block;margin-bottom:6px;">Point Size</label> |
| <input type="range" min="0.5" max="8" step="0.1" value="2.5" style="width:120px;"> |
| <span style="font-size:11px;color:var(--muted-color);margin-left:8px;">2.5</span> |
| `; |
| const slider = controls.querySelector('input'); |
| const label = controls.querySelector('span'); |
| slider.addEventListener('input', (e) => { |
| params.baseSize = parseFloat(e.target.value); |
| label.textContent = params.baseSize.toFixed(1); |
| points.forEach(p => p.updateSize()); |
| }); |
| container.appendChild(controls); |
| } |
| |
| console.log(`Created ${points.length} points from ${categoryList.length} categories`); |
| render(); |
| |
| } catch (error) { |
| console.error('Error loading data:', error); |
| const errorMsg = error.message || error.toString(); |
| container.innerHTML = `<pre style="color:red;padding:20px;margin:0;font-size:12px;">Error: ${errorMsg}<br><br>Trying to load: /data/data.json<br>Check console for details.</pre>`; |
| } |
| }; |
| |
| const render = () => { |
| time += 16; |
| ctx.clearRect(0, 0, width, height); |
| |
| if (points.length === 0) { |
| animationFrame = requestAnimationFrame(render); |
| return; |
| } |
| |
| |
| const xValues = points.map(p => p.x); |
| const yValues = points.map(p => p.y); |
| const minX = Math.min(...xValues); |
| const maxX = Math.max(...xValues); |
| const minY = Math.min(...yValues); |
| const maxY = Math.max(...yValues); |
| |
| const padding = 40; |
| const scaleX = (width - padding * 2) / (maxX - minX); |
| const scaleY = (height - padding * 2) / (maxY - minY); |
| |
| const offsetX = padding - minX * scaleX; |
| const offsetY = padding - minY * scaleY; |
| |
| |
| points.forEach(point => { |
| point.update(time, selectedCategory); |
| point.draw(ctx, scaleX, scaleY, offsetX, offsetY); |
| }); |
| |
| animationFrame = requestAnimationFrame(render); |
| }; |
| |
| |
| let hoveredPoint = null; |
| canvas.addEventListener('mousemove', (e) => { |
| const rect = canvas.getBoundingClientRect(); |
| const mx = e.clientX - rect.left; |
| const my = e.clientY - rect.top; |
| |
| const closest = points.find(p => p.isNear(mx, my)); |
| |
| if (closest && closest !== hoveredPoint) { |
| hoveredPoint = closest; |
| const authorsStr = Array.isArray(closest.authors) |
| ? (closest.authors.length > 3 |
| ? `${closest.authors.slice(0, 3).join(', ')} et al.` |
| : closest.authors.join(', ')) |
| : closest.authors || 'Unknown'; |
| |
| tooltip.innerHTML = ` |
| <div class="tooltip-category"> |
| <span class="tooltip-badge" style="background: ${closest.color}; color: ${closest.color}"></span> |
| ${closest.category} · ${closest.year} |
| </div> |
| <div class="tooltip-question">${closest.title.substring(0, 120)}${closest.title.length > 120 ? '...' : ''}</div> |
| <div class="tooltip-answer">${authorsStr}<br>${closest.abstract.substring(0, 180)}${closest.abstract.length > 180 ? '...' : ''}</div> |
| `; |
| tooltip.style.left = mx + 'px'; |
| tooltip.style.top = my + 'px'; |
| tooltip.style.opacity = '1'; |
| canvas.style.cursor = 'pointer'; |
| } else if (!closest) { |
| hoveredPoint = null; |
| tooltip.style.opacity = '0'; |
| canvas.style.cursor = 'crosshair'; |
| } |
| }); |
| |
| canvas.addEventListener('mouseleave', () => { |
| hoveredPoint = null; |
| tooltip.style.opacity = '0'; |
| canvas.style.cursor = 'crosshair'; |
| }); |
| |
| |
| resizeCanvas(); |
| if (window.ResizeObserver) { |
| const ro = new ResizeObserver(() => resizeCanvas()); |
| ro.observe(container); |
| } else { |
| window.addEventListener('resize', resizeCanvas); |
| } |
| |
| |
| const observer = new MutationObserver(() => { |
| const { colors } = getColors(); |
| const categoryList = Array.from(categories.keys()); |
| points.forEach(point => { |
| const catIndex = categoryList.indexOf(point.category); |
| if (catIndex >= 0) { |
| point.color = colors[catIndex % colors.length]; |
| } |
| }); |
| }); |
| observer.observe(document.documentElement, { |
| attributes: true, |
| attributeFilter: ['data-theme'] |
| }); |
| |
| loadData(); |
| }; |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', () => ensureD3(() => ensureAnime(() => ensureTweakpane(bootstrap))), { once: true }); |
| } else { |
| ensureD3(() => ensureAnime(() => ensureTweakpane(bootstrap))); |
| } |
| })(); |
| </script> |