| <!DOCTYPE html> |
| <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); |
| } |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| 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; |
| |
| |
| 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 }); |
| } |
| |
| |
| 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(' '); |
| |
| |
| 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> |
| `; |
| |
| |
| 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); |
| }); |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| |
| |
| 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(); |
| |
| |
| 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(); |
| }); |
| } |
| |
| |
| |
| 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'; |
| } |
| |
| |
| |
| window.addEventListener('load', () => { |
| loadStats(); |
| }); |
| |
| window.addEventListener('resize', () => { |
| if (selectedEntryIndex !== null) { |
| const entry = entries[selectedEntryIndex - currentOffset]; |
| if (entry) visualizeEntry(entry); |
| } |
| }); |
| </script> |
| </body> |
| </html> |
|
|