visualize / static /index.html
wop's picture
Words now interpolate along bezier curve between points instead of only at exact positions
272e426
Raw
History Blame Contribute Delete
34.3 kB
<!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);
}
// --- 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>