Download static/index.html from wop/visualize: direct link, hf CLI and curl.
- Browser
- Download file 34.3 kB
-
https://huggingface.co/spaces/wop/visualize/resolve/main/static/index.html
- Command line
-
hf download hf://spaces/wop/visualize/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/wop/visualize/resolve/main/static/index.html
34.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Human Essence - Emotional Flow Visualizer</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg-primary: #08080d; | |
| --bg-secondary: #0e0e16; | |
| --bg-tertiary: #16161f; | |
| --bg-card: #111119; | |
| --text-primary: #e0e0e8; | |
| --text-secondary: #6b6b80; | |
| --text-muted: #44445a; | |
| --accent: #6366f1; | |
| --border: #1e1e2e; | |
| --border-subtle: #181828; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| background: var(--bg-primary); | |
| color: var(--text-primary); | |
| min-height: 100vh; | |
| } | |
| .container { max-width: 1400px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } | |
| .header { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 12px 18px; background: var(--bg-secondary); | |
| border-radius: 12px; border: 1px solid var(--border); flex-shrink: 0; | |
| } | |
| .header-left { display: flex; align-items: center; gap: 10px; } | |
| .header-icon { | |
| width: 32px; height: 32px; border-radius: 8px; | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| display: flex; align-items: center; justify-content: center; font-size: 0.9rem; | |
| } | |
| .header h1 { | |
| font-size: 1.2rem; font-weight: 600; | |
| background: linear-gradient(135deg, #c7c8ff, #8b5cf6); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| letter-spacing: -0.02em; | |
| } | |
| .header-controls { display: flex; gap: 8px; } | |
| .btn { | |
| padding: 7px 14px; border: none; border-radius: 7px; cursor: pointer; | |
| font-size: 0.8rem; font-weight: 500; transition: all 0.2s ease; | |
| font-family: inherit; display: flex; align-items: center; gap: 5px; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #6366f1, #7c3aed); color: white; | |
| } | |
| .btn-primary:hover { background: linear-gradient(135deg, #5558e3, #6d28d9); transform: translateY(-1px); } | |
| .btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); } | |
| .btn-secondary:hover { background: #1e1e2e; } | |
| .btn-danger { background: rgba(127,29,29,0.5); color: #fca5a5; border: 1px solid rgba(153,27,27,0.5); } | |
| .btn-danger:hover { background: rgba(153,27,27,0.6); } | |
| .stats-grid { | |
| display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; | |
| } | |
| .stat-card { | |
| background: var(--bg-card); border: 1px solid var(--border); | |
| border-radius: 10px; padding: 16px; text-align: center; | |
| transition: border-color 0.2s; | |
| } | |
| .stat-card:hover { border-color: rgba(99,102,241,0.2); } | |
| .stat-value { font-size: 2rem; font-weight: 700; color: #c7c8ff; } | |
| .stat-label { font-size: 0.78rem; color: var(--text-secondary); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; } | |
| .controls-bar { | |
| background: var(--bg-secondary); border: 1px solid var(--border); | |
| border-radius: 12px; padding: 14px 18px; flex-shrink: 0; | |
| } | |
| .controls-row { | |
| display: flex; gap: 12px; align-items: center; flex-wrap: wrap; | |
| } | |
| .controls-row input, .controls-row select { | |
| padding: 7px 12px; background: var(--bg-tertiary); | |
| border: 1px solid var(--border); border-radius: 7px; | |
| color: var(--text-primary); font-size: 0.82rem; font-family: inherit; | |
| transition: border-color 0.2s; | |
| } | |
| .controls-row input:focus, .controls-row select:focus { outline: none; border-color: rgba(99,102,241,0.35); } | |
| .controls-row input { width: 90px; } | |
| .controls-row select { min-width: 180px; } | |
| .page-info { font-size: 0.82rem; color: var(--text-secondary); min-width: 120px; text-align: center; } | |
| .main-split { | |
| display: grid; grid-template-columns: 340px 1fr; gap: 12px; flex: 1; min-height: 0; | |
| } | |
| .entries-panel { | |
| background: var(--bg-secondary); border: 1px solid var(--border); | |
| border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; | |
| } | |
| .entries-panel-header { | |
| padding: 12px 16px; font-size: 0.82rem; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); | |
| border-bottom: 1px solid var(--border); flex-shrink: 0; | |
| } | |
| .entries-list { flex: 1; overflow-y: auto; padding: 8px; } | |
| .entries-list::-webkit-scrollbar { width: 4px; } | |
| .entries-list::-webkit-scrollbar-track { background: transparent; } | |
| .entries-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } | |
| .entry-item { | |
| padding: 12px 14px; margin-bottom: 6px; background: var(--bg-card); | |
| border-radius: 8px; cursor: pointer; transition: all 0.2s; | |
| border: 1px solid var(--border-subtle); border-left: 3px solid transparent; | |
| } | |
| .entry-item:hover { background: #14141f; border-left-color: var(--text-muted); } | |
| .entry-item.active { border-left-color: var(--accent); background: rgba(99,102,241,0.06); } | |
| .entry-preview { font-size: 0.82rem; line-height: 1.5; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } | |
| .entry-meta { display: flex; gap: 8px; margin-top: 8px; } | |
| .entry-tag { | |
| font-size: 0.68rem; padding: 2px 7px; border-radius: 6px; | |
| background: rgba(255,255,255,0.04); color: var(--text-muted); | |
| font-weight: 500; | |
| } | |
| .entry-tag.emotions { color: #a78bfa; background: rgba(99,102,241,0.1); } | |
| .empty-state { | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| height: 100%; color: var(--text-muted); font-size: 0.88rem; text-align: center; padding: 40px 20px; | |
| } | |
| .empty-state-icon { font-size: 2rem; margin-bottom: 12px; opacity: 0.4; } | |
| .viz-panel { | |
| background: var(--bg-secondary); border: 1px solid var(--border); | |
| border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; | |
| } | |
| .viz-panel-header { | |
| padding: 12px 16px; font-size: 0.82rem; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); | |
| border-bottom: 1px solid var(--border); flex-shrink: 0; | |
| display: flex; justify-content: space-between; align-items: center; | |
| } | |
| .viz-content { flex: 1; padding: 20px; overflow-y: auto; } | |
| .viz-content::-webkit-scrollbar { width: 4px; } | |
| .viz-content::-webkit-scrollbar-track { background: transparent; } | |
| .viz-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } | |
| .text-display { | |
| font-size: 1rem; line-height: 1.8; padding: 20px; | |
| background: var(--bg-card); border-radius: 10px; border: 1px solid var(--border-subtle); | |
| margin-bottom: 20px; | |
| } | |
| .text-display .word { | |
| display: inline; padding: 1px 2px; margin: 0 -1px; | |
| border-radius: 3px; transition: background-color 0.2s ease; | |
| cursor: default; | |
| } | |
| .text-display .word.has-emotion { | |
| border-bottom: 1.5px solid currentColor; | |
| } | |
| .text-display .word:hover { filter: brightness(1.15); } | |
| .emotion-charts { display: flex; flex-direction: column; gap: 24px; } | |
| .emotion-chart-block { } | |
| .emotion-chart-header { | |
| display: flex; align-items: center; gap: 8px; margin-bottom: 8px; | |
| font-size: 0.85rem; font-weight: 600; | |
| } | |
| .emotion-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } | |
| .chart-container { | |
| position: relative; height: 200px; border-radius: 8px; | |
| background: var(--bg-tertiary); border: 1px solid var(--border-subtle); | |
| overflow: hidden; | |
| } | |
| .chart-container canvas { display: block; width: 100%; height: 100%; } | |
| .legend-bar { | |
| display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; padding-top: 12px; | |
| border-top: 1px solid var(--border-subtle); | |
| } | |
| .legend-item { | |
| display: flex; align-items: center; gap: 6px; | |
| font-size: 0.78rem; color: var(--text-secondary); font-weight: 500; | |
| } | |
| .legend-color { width: 12px; height: 12px; border-radius: 4px; } | |
| .loading { text-align: center; padding: 40px; color: var(--text-muted); font-size: 0.9rem; } | |
| .loading .spinner { | |
| display: inline-block; width: 24px; height: 24px; border: 2px solid var(--border); | |
| border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; | |
| margin-bottom: 12px; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| .fade-in { animation: fadeIn 0.35s ease-out; } | |
| .error-state { | |
| background: rgba(127,29,29,0.3); color: #fca5a5; | |
| padding: 15px 20px; border-radius: 8px; margin: 10px 0; | |
| border: 1px solid rgba(153,27,27,0.4); font-size: 0.85rem; | |
| } | |
| .tooltip { | |
| display: none; position: fixed; | |
| background: rgba(14,14,22,0.95); color: #e8e8f0; | |
| padding: 6px 11px; border-radius: 7px; font-size: 0.73rem; | |
| pointer-events: none; z-index: 100; border: 1px solid #2a2a3a; | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.5); | |
| font-family: 'Inter', sans-serif; max-width: 260px; line-height: 1.4; | |
| } | |
| @media (max-width: 900px) { | |
| .main-split { grid-template-columns: 1fr; } | |
| .stats-grid { grid-template-columns: 1fr; } | |
| .header { flex-direction: column; gap: 10px; } | |
| .header-controls { width: 100%; justify-content: center; flex-wrap: wrap; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <div class="header-left"> | |
| <div class="header-icon">✦</div> | |
| <h1>Human Essence · Emotional Flow Explorer</h1> | |
| </div> | |
| <div class="header-controls"> | |
| <button class="btn btn-secondary" onclick="refreshDataset()">⟳ Refresh</button> | |
| <button class="btn btn-primary" onclick="loadEntries()">Load Entries</button> | |
| </div> | |
| </div> | |
| <div class="stats-grid" id="statsGrid"> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="totalEntries">—</div> | |
| <div class="stat-label">Total Entries</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="withText">—</div> | |
| <div class="stat-label">With Text</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value" id="withEmotions">—</div> | |
| <div class="stat-label">With Emotions</div> | |
| </div> | |
| </div> | |
| <div class="controls-bar"> | |
| <div class="controls-row"> | |
| <select id="emotionFilter" onchange="filterByEmotion()"> | |
| <option value="">All Emotions</option> | |
| </select> | |
| <input type="number" id="limitInput" value="50" min="1" max="500" placeholder="Limit"> | |
| <button class="btn btn-secondary" onclick="previousPage()" id="prevBtn">← Prev</button> | |
| <span class="page-info" id="pageInfo">Page 1</span> | |
| <button class="btn btn-secondary" onclick="nextPage()" id="nextBtn">Next →</button> | |
| </div> | |
| </div> | |
| <div class="main-split"> | |
| <div class="entries-panel"> | |
| <div class="entries-panel-header">Entries</div> | |
| <div class="entries-list" id="entriesList"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">⊞</div> | |
| <div>Load the dataset to explore entries</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="viz-panel"> | |
| <div class="viz-panel-header"> | |
| <span>Emotional Flow Visualization</span> | |
| <span id="entryIndexDisplay" style="font-size:0.75rem;color:var(--text-muted);font-weight:400;"></span> | |
| </div> | |
| <div class="viz-content" id="visualization"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">◈</div> | |
| <div>Select an entry to visualize its emotional flow</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tooltip" id="tooltip"></div> | |
| <script> | |
| let currentOffset = 0; | |
| let currentLimit = 50; | |
| let totalEntries = 0; | |
| let selectedEntryIndex = null; | |
| let entries = []; | |
| let emotionColors = {}; | |
| let allStats = null; | |
| const palette = [ | |
| '#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316', | |
| '#eab308','#22c55e','#06b6d4','#3b82f6','#a855f7', | |
| '#14b8a6','#f472b6','#fb923c','#a3e635','#818cf8', | |
| '#c084fc','#f9a8d4','#fdba74','#bef264','#67e8f9' | |
| ]; | |
| function getColorForEmotion(emotion) { | |
| if (!emotionColors[emotion]) { | |
| const idx = Object.keys(emotionColors).length % palette.length; | |
| emotionColors[emotion] = palette[idx]; | |
| } | |
| return emotionColors[emotion]; | |
| } | |
| function hexToRgb(hex) { | |
| const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| return r ? { r: parseInt(r[1],16), g: parseInt(r[2],16), b: parseInt(r[3],16) } : { r:0,g:0,b:0 }; | |
| } | |
| const tooltip = document.getElementById('tooltip'); | |
| let tooltipTimeout = null; | |
| function showTooltip(html, x, y) { | |
| tooltip.innerHTML = html; | |
| tooltip.style.display = 'block'; | |
| const r = tooltip.getBoundingClientRect(); | |
| let left = x + 14, top = y - 10; | |
| if (left + r.width > window.innerWidth - 10) left = x - r.width - 14; | |
| if (top + r.height > window.innerHeight - 10) top = y - r.height - 10; | |
| if (top < 5) top = 5; | |
| tooltip.style.left = left + 'px'; | |
| tooltip.style.top = top + 'px'; | |
| clearTimeout(tooltipTimeout); | |
| } | |
| function hideTooltip() { | |
| tooltipTimeout = setTimeout(() => { tooltip.style.display = 'none'; }, 100); | |
| } | |
| // --- API --- | |
| async function refreshDataset() { | |
| const btn = event?.target || document.querySelector('.btn-secondary'); | |
| btn.textContent = '⟳ Downloading…'; | |
| btn.disabled = true; | |
| try { | |
| const res = await fetch('/api/refresh', { method: 'POST' }); | |
| if (!res.ok) throw new Error(await res.text()); | |
| await loadStats(); | |
| await loadEntries(); | |
| } catch (e) { | |
| console.error('Refresh error:', e); | |
| const container = document.getElementById('entriesList'); | |
| container.innerHTML = '<div class="error-state">Refresh failed: ' + escapeHtml(e.message) + '</div>'; | |
| } finally { | |
| btn.textContent = '⟳ Refresh'; | |
| btn.disabled = false; | |
| } | |
| } | |
| async function loadStats() { | |
| try { | |
| const res = await fetch('/api/stats'); | |
| const stats = await res.json(); | |
| allStats = stats; | |
| document.getElementById('totalEntries').textContent = stats.total_entries.toLocaleString(); | |
| document.getElementById('withText').textContent = stats.entries_with_text.toLocaleString(); | |
| document.getElementById('withEmotions').textContent = stats.entries_with_emotions.toLocaleString(); | |
| const sel = document.getElementById('emotionFilter'); | |
| sel.innerHTML = '<option value="">All Emotions</option>'; | |
| for (const [emotion, count] of Object.entries(stats.emotion_distribution)) { | |
| const opt = document.createElement('option'); | |
| opt.value = emotion; | |
| opt.textContent = `${emotion} (${count})`; | |
| sel.appendChild(opt); | |
| } | |
| } catch (e) { | |
| console.error('loadStats error:', e); | |
| } | |
| } | |
| async function loadEntries() { | |
| const limit = parseInt(document.getElementById('limitInput').value) || 50; | |
| currentLimit = limit; | |
| const container = document.getElementById('entriesList'); | |
| container.innerHTML = '<div class="loading"><div class="spinner"></div>Loading entries…</div>'; | |
| try { | |
| const res = await fetch(`/api/dataset?limit=${limit}&offset=${currentOffset}`); | |
| const data = await res.json(); | |
| entries = data.entries; | |
| totalEntries = data.total; | |
| displayEntries(entries); | |
| updatePagination(); | |
| } catch (e) { | |
| console.error('loadEntries error:', e); | |
| container.innerHTML = '<div class="error-state">Failed to load entries. Try again.</div>'; | |
| } | |
| } | |
| function displayEntries(items) { | |
| const container = document.getElementById('entriesList'); | |
| if (!items || items.length === 0) { | |
| container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">∅</div><div>No entries found</div></div>'; | |
| return; | |
| } | |
| container.innerHTML = items.map((entry, idx) => { | |
| const globalIdx = currentOffset + idx; | |
| const preview = entry.text ? entry.text.substring(0, 120) : '(empty)'; | |
| const hasEmotions = entry.emotional_flow && entry.emotional_flow.length > 0; | |
| const wordCount = entry.text ? entry.text.split(/\s+/).length : 0; | |
| return ` | |
| <div class="entry-item ${selectedEntryIndex === globalIdx ? 'active' : ''}" | |
| onclick="selectEntry(${globalIdx})"> | |
| <div class="entry-preview">${escapeHtml(preview)}${entry.text && entry.text.length > 120 ? '…' : ''}</div> | |
| <div class="entry-meta"> | |
| <span class="entry-tag">${wordCount} words</span> | |
| ${hasEmotions ? `<span class="entry-tag emotions">${entry.emotional_flow.length} emotions</span>` : ''} | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function escapeHtml(str) { | |
| const d = document.createElement('div'); | |
| d.textContent = str; | |
| return d.innerHTML; | |
| } | |
| function selectEntry(index) { | |
| selectedEntryIndex = index; | |
| const entry = entries[index - currentOffset]; | |
| if (!entry) return; | |
| document.querySelectorAll('.entry-item').forEach((item, idx) => { | |
| item.classList.toggle('active', currentOffset + idx === index); | |
| }); | |
| document.getElementById('entryIndexDisplay').textContent = `#${index + 1}`; | |
| visualizeEntry(entry); | |
| } | |
| // --- Visualization --- | |
| function visualizeEntry(entry) { | |
| const container = document.getElementById('visualization'); | |
| if (!entry.text) { | |
| container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">—</div><div>This entry has no text</div></div>'; | |
| return; | |
| } | |
| const words = entry.text.split(/\s+/); | |
| const emotionData = {}; | |
| if (entry.emotional_flow && entry.emotional_flow.length > 0) { | |
| const maxWordIdx = words.length - 1; | |
| entry.emotional_flow.forEach(flow => { | |
| const color = getColorForEmotion(flow.label); | |
| const sorted = [...flow.curves].sort((a, b) => a.start_word - b.start_word); | |
| if (sorted.length === 0) return; | |
| // Bezier interpolation between consecutive curves | |
| for (let i = 0; i < sorted.length; i++) { | |
| const curve = sorted[i]; | |
| const end = Math.min(curve.end_word, maxWordIdx); | |
| if (i < sorted.length - 1) { | |
| const next = sorted[i + 1]; | |
| const interpEnd = Math.min(next.start_word, end); | |
| for (let w = curve.start_word; w <= interpEnd; w++) { | |
| const t = (next.start_word === curve.start_word) ? 0 | |
| : (w - curve.start_word) / (next.start_word - curve.start_word); | |
| const u = 1 - t; | |
| const intensity = u*u*u*curve.peak_intensity | |
| + 3*u*u*t*curve.peak_intensity | |
| + 3*u*t*t*next.peak_intensity | |
| + t*t*t*next.peak_intensity; | |
| if (!emotionData[w]) emotionData[w] = []; | |
| emotionData[w].push({ label: flow.label, intensity, color }); | |
| } | |
| // Flat tail beyond next start_word until this curve's end_word | |
| for (let w = Math.max(interpEnd + 1, curve.start_word); w <= end; w++) { | |
| if (!emotionData[w]) emotionData[w] = []; | |
| emotionData[w].push({ label: flow.label, intensity: curve.peak_intensity, color }); | |
| } | |
| } else { | |
| for (let w = curve.start_word; w <= end; w++) { | |
| if (!emotionData[w]) emotionData[w] = []; | |
| emotionData[w].push({ label: flow.label, intensity: curve.peak_intensity, color }); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| let wordHTML = words.map((word, idx) => { | |
| const emotions = emotionData[idx]; | |
| let style = ''; | |
| let cls = 'word'; | |
| if (emotions && emotions.length > 0) { | |
| let r = 0, g = 0, b = 0, total = 0; | |
| emotions.forEach(e => { | |
| const rgb = hexToRgb(e.color); | |
| r += rgb.r * e.intensity; g += rgb.g * e.intensity; b += rgb.b * e.intensity; total += e.intensity; | |
| }); | |
| if (total > 0) { | |
| r = Math.round(r / total); g = Math.round(g / total); b = Math.round(b / total); | |
| const maxI = Math.max(...emotions.map(e => e.intensity)); | |
| const bgA = Math.max(0.06, maxI * 0.25); | |
| style += `background:rgba(${r},${g},${b},${bgA});`; | |
| const blend = Math.min(0.45, maxI * 0.4); | |
| const tr = Math.round(224 * (1 - blend) + r * blend); | |
| const tg = Math.round(224 * (1 - blend) + g * blend); | |
| const tb = Math.round(232 * (1 - blend) + b * blend); | |
| style += `color:rgb(${tr},${tg},${tb});`; | |
| style += `border-bottom-color:rgba(${r},${g},${b},${Math.min(0.5, maxI * 0.4)});`; | |
| cls += ' has-emotion'; | |
| } | |
| } | |
| const escaped = escapeHtml(word); | |
| return `<span class="${cls}" style="${style}" data-idx="${idx}">${escaped}</span>`; | |
| }).join(' '); | |
| // Charts | |
| let chartsHTML = ''; | |
| if (entry.emotional_flow && entry.emotional_flow.length > 0) { | |
| chartsHTML = '<div class="emotion-charts">'; | |
| entry.emotional_flow.forEach((flow, fi) => { | |
| const c = getColorForEmotion(flow.label); | |
| chartsHTML += ` | |
| <div class="emotion-chart-block"> | |
| <div class="emotion-chart-header"> | |
| <span class="emotion-dot" style="background:${c}"></span> | |
| ${flow.label} | |
| </div> | |
| <div class="chart-container" id="chartWrap-${fi}"> | |
| <canvas id="chart-${fi}"></canvas> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| chartsHTML += '</div>'; | |
| chartsHTML += '<div class="legend-bar">'; | |
| entry.emotional_flow.forEach(flow => { | |
| const c = getColorForEmotion(flow.label); | |
| chartsHTML += `<div class="legend-item"><span class="legend-color" style="background:${c}"></span>${flow.label}</div>`; | |
| }); | |
| chartsHTML += '</div>'; | |
| } | |
| container.innerHTML = ` | |
| <div class="fade-in"> | |
| <div class="text-display">${wordHTML}</div> | |
| ${chartsHTML || '<div style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:20px;">No emotion data for this entry</div>'} | |
| </div> | |
| `; | |
| // Word hover tooltips | |
| container.querySelectorAll('.word.has-emotion').forEach(el => { | |
| const idx = parseInt(el.dataset.idx); | |
| const emotions = emotionData[idx]; | |
| if (!emotions) return; | |
| el.addEventListener('mouseenter', e => { | |
| const html = emotions.map(em => | |
| `<div style="display:flex;align-items:center;gap:6px;margin:1px 0"> | |
| <span style="width:8px;height:8px;border-radius:50%;background:${em.color};display:inline-block"></span> | |
| <span style="font-weight:500">${em.label}</span> | |
| <span style="color:var(--text-muted)">${(em.intensity * 100).toFixed(0)}%</span> | |
| </div>` | |
| ).join(''); | |
| showTooltip(html, e.clientX, e.clientY); | |
| }); | |
| el.addEventListener('mousemove', e => { | |
| showTooltip(tooltip.innerHTML, e.clientX, e.clientY); | |
| }); | |
| el.addEventListener('mouseleave', hideTooltip); | |
| }); | |
| // Draw charts | |
| if (entry.emotional_flow && entry.emotional_flow.length > 0) { | |
| requestAnimationFrame(() => { | |
| entry.emotional_flow.forEach((flow, fi) => { | |
| drawChart(`chart-${fi}`, flow); | |
| }); | |
| }); | |
| } | |
| } | |
| function drawChart(canvasId, flow) { | |
| const canvas = document.getElementById(canvasId); | |
| if (!canvas) return; | |
| const wrap = canvas.parentElement; | |
| const rect = wrap.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.scale(dpr, dpr); | |
| const W = rect.width; | |
| const H = rect.height; | |
| const pad = { top: 20, right: 16, bottom: 24, left: 36 }; | |
| const cw = W - pad.left - pad.right; | |
| const ch = H - pad.top - pad.bottom; | |
| const color = getColorForEmotion(flow.label); | |
| const curves = flow.curves; | |
| if (curves.length === 0) return; | |
| const maxWord = Math.max(...curves.map(c => c.end_word), 1); | |
| ctx.clearRect(0, 0, W, H); | |
| // Grid | |
| ctx.strokeStyle = '#1a1a2a'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 4; i++) { | |
| const y = pad.top + (ch * i / 4); | |
| ctx.beginPath(); | |
| ctx.moveTo(pad.left, y); | |
| ctx.lineTo(W - pad.right, y); | |
| ctx.stroke(); | |
| ctx.fillStyle = '#3a3a52'; | |
| ctx.font = '9px Inter, sans-serif'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText((1 - i / 4).toFixed(1), pad.left - 6, y + 3); | |
| } | |
| // X-axis labels (every 5th word) | |
| ctx.fillStyle = '#3a3a52'; | |
| ctx.font = '9px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| const step = Math.max(1, Math.floor(maxWord / 15)); | |
| for (let i = 0; i <= maxWord; i += step) { | |
| const x = pad.left + (i / maxWord) * cw; | |
| ctx.fillText(i, x, H - 6); | |
| } | |
| // Area fill | |
| ctx.beginPath(); | |
| const firstX = pad.left + (curves[0].start_word / maxWord) * cw; | |
| const firstY = pad.top + ch - (curves[0].peak_intensity * ch); | |
| ctx.moveTo(firstX, pad.top + ch); | |
| ctx.lineTo(firstX, firstY); | |
| for (let i = 1; i < curves.length; i++) { | |
| const x = pad.left + (curves[i].start_word / maxWord) * cw; | |
| const y = pad.top + ch - (curves[i].peak_intensity * ch); | |
| const px = pad.left + (curves[i - 1].start_word / maxWord) * cw; | |
| const py = pad.top + ch - (curves[i - 1].peak_intensity * ch); | |
| ctx.bezierCurveTo((px + x) / 2, py, (px + x) / 2, y, x, y); | |
| } | |
| const lastX = pad.left + (curves[curves.length - 1].start_word / maxWord) * cw; | |
| ctx.lineTo(lastX, pad.top + ch); | |
| ctx.closePath(); | |
| const rgb = hexToRgb(color); | |
| const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch); | |
| grad.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.15)`); | |
| grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| // Curve line | |
| ctx.beginPath(); | |
| curves.forEach((curve, idx) => { | |
| const x = pad.left + (curve.start_word / maxWord) * cw; | |
| const y = pad.top + ch - (curve.peak_intensity * ch); | |
| if (idx === 0) ctx.moveTo(x, y); | |
| else { | |
| const px = pad.left + (curves[idx - 1].start_word / maxWord) * cw; | |
| const py = pad.top + ch - (curves[idx - 1].peak_intensity * ch); | |
| ctx.bezierCurveTo((px + x) / 2, py, (px + x) / 2, y, x, y); | |
| } | |
| }); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Points | |
| curves.forEach(curve => { | |
| const x = pad.left + (curve.start_word / maxWord) * cw; | |
| const y = pad.top + ch - (curve.peak_intensity * ch); | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 4, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 2, 0, Math.PI * 2); | |
| ctx.fillStyle = '#fff'; | |
| ctx.fill(); | |
| }); | |
| } | |
| // --- Pagination & Filter --- | |
| function updatePagination() { | |
| const page = Math.floor(currentOffset / currentLimit) + 1; | |
| const total = Math.ceil(totalEntries / currentLimit); | |
| document.getElementById('pageInfo').textContent = `Page ${page} of ${total || 1}`; | |
| document.getElementById('prevBtn').disabled = currentOffset === 0; | |
| document.getElementById('nextBtn').disabled = currentOffset + currentLimit >= totalEntries; | |
| } | |
| function previousPage() { | |
| if (currentOffset > 0) { | |
| currentOffset -= currentLimit; | |
| loadEntries(); | |
| } | |
| } | |
| function nextPage() { | |
| if (currentOffset + currentLimit < totalEntries) { | |
| currentOffset += currentLimit; | |
| loadEntries(); | |
| } | |
| } | |
| function filterByEmotion() { | |
| const filter = document.getElementById('emotionFilter').value; | |
| currentOffset = 0; | |
| if (!filter) { loadEntries(); return; } | |
| const filtered = entries.filter(e => | |
| e.emotional_flow && e.emotional_flow.some(f => f.label === filter) | |
| ); | |
| displayEntries(filtered); | |
| document.getElementById('pageInfo').textContent = 'Filtered'; | |
| } | |
| // --- Init --- | |
| window.addEventListener('load', () => { | |
| loadStats(); | |
| }); | |
| window.addEventListener('resize', () => { | |
| if (selectedEntryIndex !== null) { | |
| const entry = entries[selectedEntryIndex - currentOffset]; | |
| if (entry) visualizeEntry(entry); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |