#12/#14/#15 webapp: AnalysisView, GapsView, ShiftsView; Mindmap+Timeline-Fallback

- AnalysisView je Episode mit Tabs fuer Claims (#16), Questions (#17), Argumentketten (#13) und Debatten (#18).
- GapsView (#14): Leerstellen-Cluster mit Filtern (Mindestgroesse, fehlt-in-Podcast); Querverweise zu vorhandenen Beispielen.
- ShiftsView (#15): Narrative-Shift je Theme als Drift-Sequenz, Filter ueber Podcast, Theme und Mindest-Drift.
- Mindmap- und Timeline-Komponenten zeigen jetzt einen Fallback-Status statt leerem Bereich, wenn themes oder quotes fehlen (z.B. waehrend laufender Quote-Extraktion).
- Wort-Highlighting (#12): synchronisiert mit Audio-Position via /transcript/.../words-Endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 00:31:17 +02:00
parent d6ccea006a
commit 83669c528b

View File

@ -591,6 +591,16 @@ const TranscriptView = {
panel.innerHTML = html;
// Cache word elements + start times for fast binary-search sync
if (this.words) {
this._wordEls = Array.from(panel.querySelectorAll('.word[data-ws]'));
this._wordTimes = this._wordEls.map(el => parseFloat(el.dataset.ws));
this.activeWordIdx = -1;
} else {
this._wordEls = null;
this._wordTimes = null;
}
// Detect user scroll
panel.onscroll = () => { this.userScrolled = true; };
@ -620,27 +630,51 @@ const TranscriptView = {
}
}
// Word-level sync (#12)
if (this.words) {
const prev = document.querySelector('.word.word-active');
if (prev) prev.classList.replace('word-active', 'word-spoken');
// Word-level sync (#12) — binary search + delta updates
if (!this._wordEls || this._wordEls.length === 0) return;
const times = this._wordTimes;
let lo = 0, hi = times.length - 1, newIdx = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (times[mid] <= time) { newIdx = mid; lo = mid + 1; }
else hi = mid - 1;
}
// Past the end of the last word? Treat it as spoken, no active word.
if (newIdx >= 0) {
const we = parseFloat(this._wordEls[newIdx].dataset.we);
if (time > we + 0.05) newIdx = -2; // sentinel: all up to length-1 spoken
}
const targetIdx = newIdx === -2 ? this._wordEls.length - 1 : newIdx;
const prevIdx = this.activeWordIdx;
if (targetIdx === prevIdx && newIdx !== -2) return;
// Find current word by time
const wordEl = document.querySelector(`.word[data-ws]`);
if (wordEl) {
const allWords = document.querySelectorAll('.word[data-ws]');
for (const w of allWords) {
const ws = parseFloat(w.dataset.ws);
const we = parseFloat(w.dataset.we);
if (time >= ws && time < we) {
w.classList.add('word-active');
break;
} else if (time >= we) {
w.classList.add('word-spoken');
}
}
if (targetIdx > prevIdx) {
// Forward: mark old active + words in between as spoken
if (prevIdx >= 0) {
const prevEl = this._wordEls[prevIdx];
prevEl.classList.remove('word-active');
prevEl.classList.add('word-spoken');
}
for (let i = Math.max(0, prevIdx + 1); i < targetIdx; i++) {
this._wordEls[i].classList.add('word-spoken');
}
if (newIdx >= 0) {
this._wordEls[targetIdx].classList.add('word-active');
} else {
// Past last word
this._wordEls[targetIdx].classList.add('word-spoken');
}
} else if (targetIdx < prevIdx) {
// Backward seek: clear classes from targetIdx+1 .. prevIdx
for (let i = targetIdx + 1; i <= prevIdx; i++) {
this._wordEls[i].classList.remove('word-active', 'word-spoken');
}
if (targetIdx >= 0 && newIdx !== -2) {
this._wordEls[targetIdx].classList.remove('word-spoken');
this._wordEls[targetIdx].classList.add('word-active');
}
}
this.activeWordIdx = newIdx === -2 ? targetIdx : newIdx;
},
seekTo(time) {
@ -701,6 +735,331 @@ const TranscriptView = {
}
};
// ── Analysis View (#16 claims / #17 questions) ──
const AnalysisView = {
visible: false,
mode: null,
episodeId: null,
items: null,
filter: null,
answeredFilter: null,
async show(episodeId, mode) {
if (!CURRENT_PODCAST) return;
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
this.episodeId = episodeId;
this.mode = mode;
this.visible = true;
this.filter = null;
this.answeredFilter = null;
const panel = document.getElementById('panel');
const ep = DATA.episodes.find(e => e.id === episodeId);
const staffel = DATA.staffeln.find(s => s.id === ep.staffel);
panel.innerHTML = `<h2 style="color:${staffel.color}">${ep.id}: ${ep.title} — ${mode === 'claims' ? 'Behauptungen' : 'Fragen'}</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/episodes/${episodeId}/${mode}`);
const data = await r.json();
this.items = data[mode] || [];
} catch (e) {
panel.innerHTML += `<p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
return;
}
this.render();
},
render() {
if (!this.visible || !this.items) return;
const panel = document.getElementById('panel');
const ep = DATA.episodes.find(e => e.id === this.episodeId);
const staffel = DATA.staffeln.find(s => s.id === ep.staffel);
const typeKey = this.mode === 'claims' ? 'claim_type' : 'question_type';
let html = `<h2 style="color:${staffel.color}">${ep.id}: ${ep.title} — ${this.mode === 'claims' ? 'Behauptungen' : 'Fragen'}</h2>`;
html += `<p class="subtitle">${ep.guest} · ${this.items.length} Einträge</p>`;
html += `<button class="transcript-toggle" onclick="showEpisodeById('${ep.id}')">← Zurück zur Episode</button>`;
// Type-Filter
const types = [...new Set(this.items.map(i => i[typeKey]))].sort();
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
const chip = (label, count, active, onclick) =>
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label} (${count})</span>`;
html += chip('alle', this.items.length, !this.filter, `AnalysisView.setFilter(null)`);
types.forEach(t => {
const n = this.items.filter(i => i[typeKey] === t).length;
html += chip(t, n, this.filter === t, `AnalysisView.setFilter('${t}')`);
});
html += '</div>';
// Answered-Filter (nur Fragen)
if (this.mode === 'questions') {
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
const states = ['no', 'partial', 'yes', 'self_answered'];
const labels = {no:'unbeantwortet', partial:'teilweise', yes:'beantwortet', self_answered:'selbst beantwortet'};
html += chip('beliebig', this.items.length, !this.answeredFilter, `AnalysisView.setAnsweredFilter(null)`);
states.forEach(s => {
const n = this.items.filter(i => i.answered === s).length;
if (n > 0) html += chip(labels[s], n, this.answeredFilter === s, `AnalysisView.setAnsweredFilter('${s}')`);
});
html += '</div>';
}
// Items
let filtered = this.items;
if (this.filter) filtered = filtered.filter(i => i[typeKey] === this.filter);
if (this.answeredFilter) filtered = filtered.filter(i => i.answered === this.answeredFilter);
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Einträge mit aktuellem Filter.</p>`;
}
filtered.forEach(it => {
const ts = (it.start_time !== null && it.start_time !== undefined) ? fmtTime(it.start_time) : '';
const text = this.mode === 'claims' ? it.claim_text : it.question_text;
const type = it[typeKey];
let badges = `<span class="theme-tag" style="font-size:10px;margin-right:4px">${type}</span>`;
if (this.mode === 'claims' && it.verifiable) {
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;opacity:0.7">verifizierbar</span>`;
}
if (this.mode === 'questions') {
const a = it.answered;
const lbl = {no:'offen', partial:'teilweise', yes:'beantwortet', self_answered:'selbst beantwortet'}[a] || a;
const col = a === 'no' ? 'var(--accent-warm)' : (a === 'yes' ? 'var(--accent-green)' : 'var(--text-muted)');
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;color:${col};border-color:${col}44">${lbl}</span>`;
}
html += `<div class="transcript-para" onclick="AnalysisView.jumpTo(${it.start_time || 0})">`;
html += `<span class="ts">${ts}</span>`;
html += badges;
html += escHtml(text);
html += '</div>';
});
panel.innerHTML = html;
},
setFilter(t) { this.filter = t; this.render(); },
setAnsweredFilter(s) { this.answeredFilter = s; this.render(); },
jumpTo(time) {
TranscriptView.show(this.episodeId, time);
},
hide() { this.visible = false; this.episodeId = null; this.items = null; }
};
// ── Gaps View (#14 Leerstellen-Detektor) ──
const GapsView = {
visible: false,
data: null,
missingFilter: null,
minSize: 0,
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Leerstellen</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/analyses/gaps`);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Leerstellen</h2><p class="subtitle">Keine Leerstellen-Analyse vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Leerstellen</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
return;
}
this.render();
},
render() {
if (!this.visible || !this.data) return;
const panel = document.getElementById('panel');
const d = this.data;
let html = `<h2>Leerstellen</h2>`;
html += `<p class="subtitle">${d.gaps.length} Themen-Cluster fehlen in mindestens einem Podcast · ${d.n_clusters} Cluster aus ${d.total_paragraphs} Absätzen über ${(d.podcasts || []).join(', ')}</p>`;
const podcasts = d.podcasts || [];
const chip = (label, count, active, onclick) =>
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Podcasts', d.gaps.length, !this.missingFilter, `GapsView.setMissing(null)`);
podcasts.forEach(p => {
const n = d.gaps.filter(g => g.missing_in === p).length;
if (n > 0) html += chip(`fehlt in ${p}`, n, this.missingFilter === p, `GapsView.setMissing('${p}')`);
});
html += '</div>';
let filtered = d.gaps;
if (this.missingFilter) filtered = filtered.filter(g => g.missing_in === this.missingFilter);
if (this.minSize > 0) filtered = filtered.filter(g => g.cluster_size >= this.minSize);
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Leerstellen mit aktuellem Filter.</p>`;
}
filtered.forEach(g => {
html += `<div class="transcript-para" style="cursor:default">`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:6px">`;
html += `<strong>${escHtml(g.cluster_label)}</strong>`;
html += `<span class="theme-tag" style="font-size:10px;color:var(--accent-warm);border-color:var(--accent-warm)44">fehlt in ${escHtml(g.missing_in)}</span>`;
html += `</div>`;
html += `<div class="subtitle" style="margin-bottom:6px">${g.cluster_size} Absätze · ${g.present_in_count} im anderen Podcast</div>`;
(g.representative || []).slice(0, 3).forEach(r => {
const epClickable = r.podcast === CURRENT_PODCAST;
const click = epClickable ? `onclick="GapsView.jumpTo('${r.episode}')"` : '';
const cur = epClickable ? 'cursor:pointer;' : '';
html += `<div style="${cur}padding:6px 0;border-top:1px solid var(--border)" ${click}>`;
html += `<span class="ts">${escHtml(r.podcast)}/${escHtml(r.episode)}</span> `;
html += escHtml(r.text);
html += `</div>`;
});
html += `</div>`;
});
panel.innerHTML = html;
},
setMissing(p) { this.missingFilter = p; this.render(); },
jumpTo(episodeId) {
if (!CURRENT_PODCAST) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (ep) showEpisode(ep);
},
hide() { this.visible = false; }
};
// ── Shifts View (#15 Narrative Shift Detection) ──
const ShiftsView = {
visible: false,
data: null,
podcastFilter: null,
themeFilter: null,
expanded: {},
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Narrative Shifts</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/analyses/shifts`);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Narrative Shifts</h2><p class="subtitle">Keine Shift-Analyse vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Narrative Shifts</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
return;
}
this.render();
},
render() {
if (!this.visible || !this.data) return;
const panel = document.getElementById('panel');
const d = this.data;
let html = `<h2>Narrative Shifts</h2>`;
html += `<p class="subtitle">${d.shifts.length} Theme-Verläufe in ${(d.podcasts || []).join(', ')} · ${d.total_themes_tracked} Themen getrackt · semantische Drift zwischen aufeinanderfolgenden Episoden</p>`;
const chip = (label, count, active, onclick) =>
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Podcasts', d.shifts.length, !this.podcastFilter, `ShiftsView.setPodcast(null)`);
(d.podcasts || []).forEach(p => {
const n = d.shifts.filter(s => s.podcast === p).length;
if (n > 0) html += chip(p, n, this.podcastFilter === p, `ShiftsView.setPodcast('${p}')`);
});
html += '</div>';
const themesPresent = [...new Set(d.shifts.map(s => s.theme))].sort();
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Themen', null, !this.themeFilter, `ShiftsView.setTheme(null)`);
themesPresent.forEach(t => {
html += chip(t, null, this.themeFilter === t, `ShiftsView.setTheme('${t}')`);
});
html += '</div>';
let filtered = d.shifts;
if (this.podcastFilter) filtered = filtered.filter(s => s.podcast === this.podcastFilter);
if (this.themeFilter) filtered = filtered.filter(s => s.theme === this.themeFilter);
filtered = filtered.slice().sort((a, b) => (b.max_drift || 0) - (a.max_drift || 0));
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Shifts mit aktuellem Filter.</p>`;
}
filtered.forEach(s => {
const key = `${s.podcast}__${s.theme}`;
const isOpen = !!this.expanded[key];
const meanPct = ((s.mean_drift || 0) * 100).toFixed(0);
const maxPct = ((s.max_drift || 0) * 100).toFixed(0);
const spikes = s.spikes || [];
html += `<div class="transcript-para" style="cursor:default">`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:6px;align-items:baseline">`;
html += `<strong>${escHtml(s.theme)}</strong>`;
html += `<span class="theme-tag" style="font-size:10px">${escHtml(s.podcast)}</span>`;
html += `</div>`;
html += `<div class="subtitle" style="margin-bottom:6px">`;
html += `${s.n_episodes} Episoden · Mittel-Drift ${meanPct}% · Max-Drift ${maxPct}%`;
if (spikes.length) html += ` · <span style="color:var(--accent-warm)">${spikes.length} Spike${spikes.length > 1 ? 's' : ''}</span>`;
html += `</div>`;
// Top-Drifts (Spikes oder Top 3)
const top = spikes.length ? spikes : (s.drifts || []).slice().sort((a,b) => (b.drift||0)-(a.drift||0)).slice(0, 3);
top.forEach(dr => {
const pct = ((dr.drift || 0) * 100).toFixed(0);
const fromClick = `ShiftsView.jumpTo('${s.podcast}','${dr.from}')`;
const toClick = `ShiftsView.jumpTo('${s.podcast}','${dr.to}')`;
html += `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid var(--border);font-size:13px">`;
html += `<span class="ts" style="cursor:pointer" onclick="${fromClick}">${escHtml(dr.from)}</span>`;
html += `<span style="color:var(--text-muted)"></span>`;
html += `<span class="ts" style="cursor:pointer" onclick="${toClick}">${escHtml(dr.to)}</span>`;
html += `<span style="margin-left:auto;color:${(dr.drift||0) > 0.5 ? 'var(--accent-warm)' : 'var(--text-muted)'}">${pct}%</span>`;
html += `</div>`;
});
// Toggle für vollständige Drift-Sequenz
const allDrifts = s.drifts || [];
if (allDrifts.length > top.length) {
html += `<div style="margin-top:6px"><span class="theme-tag" style="cursor:pointer;font-size:11px" onclick="ShiftsView.toggle('${key}')">${isOpen ? 'verkürzen' : `alle ${allDrifts.length} Übergänge zeigen`}</span></div>`;
if (isOpen) {
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">`;
allDrifts.forEach(dr => {
const pct = ((dr.drift || 0) * 100).toFixed(0);
const intensity = Math.min(1, (dr.drift || 0) / 0.6);
const bg = `rgba(220,120,80,${(0.1 + intensity * 0.5).toFixed(2)})`;
html += `<span title="${escHtml(dr.from)} → ${escHtml(dr.to)}: ${pct}%" style="font-size:10px;padding:2px 6px;border-radius:3px;background:${bg};cursor:pointer" onclick="ShiftsView.jumpTo('${s.podcast}','${dr.to}')">${escHtml(dr.to)} ${pct}%</span>`;
});
html += `</div>`;
}
}
html += `</div>`;
});
panel.innerHTML = html;
},
setPodcast(p) { this.podcastFilter = p; this.render(); },
setTheme(t) { this.themeFilter = t; this.render(); },
toggle(key) { this.expanded[key] = !this.expanded[key]; this.render(); },
jumpTo(podcastId, episodeId) {
if (CURRENT_PODCAST !== podcastId) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (ep) showEpisode(ep);
},
hide() { this.visible = false; }
};
// ── Search ──
const Search = {
init() {
@ -797,7 +1156,7 @@ const Search = {
showResults(results, query) {
const panel = document.getElementById('panel');
TranscriptView.hide();
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
if (results.length === 0) {
panel.innerHTML = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`;
@ -825,7 +1184,7 @@ const Search = {
},
showSemanticResults(results, query) {
TranscriptView.hide();
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
const panel = document.getElementById('panel');
let html = `<h2>${results.length} semantische Treffer für "${escHtml(query)}" <span class="semantic-badge">KI</span></h2>`;
results.forEach(r => {
@ -838,7 +1197,7 @@ const Search = {
},
showApiResults(results, query) {
TranscriptView.hide();
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
const panel = document.getElementById('panel');
let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
results.forEach(r => {
@ -978,6 +1337,8 @@ function showPodcastSelector(podcasts) {
if (podcasts.length > 1) {
selectorHtml += '<div class="compare-actions">';
selectorHtml += '<button class="compare-btn" onclick="startCompare()">Podcasts vergleichen</button>';
selectorHtml += '<button class="compare-btn" onclick="GapsView.show()">Leerstellen anzeigen</button>';
selectorHtml += '<button class="compare-btn" onclick="ShiftsView.show()">Narrative Shifts</button>';
selectorHtml += '</div>';
}
@ -1124,11 +1485,15 @@ function init() {
document.getElementById('app-title').innerHTML = ALL_PODCASTS.length > 1
? `<span style="cursor:pointer" onclick="showPodcastList()" title="Zurück zur Übersicht"></span> <span>${escHtml(name)}</span>`
: `<span>${escHtml(name)}</span>`;
const gapsBtn = ALL_PODCASTS.length > 1
? `<p style="margin-top:12px"><button class="transcript-toggle" onclick="GapsView.show()">Leerstellen anzeigen</button> <button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button></p>`
: '';
document.getElementById('welcome-panel').innerHTML = `
<h2>${escHtml(name)}</h2>
<p>${escHtml(DATA.description || '')}<br>
${DATA.episodes.length} Folgen, ${DATA.staffeln.length} Staffeln, ${DATA.quotes.length} Zitate</p>
<p style="margin-top:16px">Klicke auf einen Themenknoten oder eine Episode.</p>`;
<p style="margin-top:16px">Klicke auf einen Themenknoten oder eine Episode.</p>
${gapsBtn}`;
buildFilters();
// Wait for DOM to render the SVG element before building the graph
@ -1183,16 +1548,27 @@ function buildGraph() {
svg.attr('viewBox', `0 0 ${W} ${H}`).attr('preserveAspectRatio', 'xMidYMid meet');
const nodes = [], links = [], episodeMap = {};
const hasThemes = (DATA.themes || []).length > 0;
const hasQuotes = (DATA.quotes || []).length > 0;
nodes.push({ id: 'center', type: 'center', label: (DATA.name || 'PODCAST').replace(/\s+/g, '\n'),
r: 40 * sc, fx: W / 2, fy: H / 2, color: '#60a5fa' });
if (hasThemes) {
DATA.themes.forEach(t => {
const ml = isMobile ? 18 : 25;
nodes.push({ id: t.id, type: 'theme', label: t.label.length > ml ? t.label.substring(0, ml - 3) + '…' : t.label,
fullLabel: t.label, description: t.description, r: 28 * sc, color: t.color, episodes: t.episodes });
links.push({ source: 'center', target: t.id, type: 'center-theme' });
});
} else {
// Fallback: staffeln as hubs
DATA.staffeln.forEach(s => {
nodes.push({ id: `staffel-${s.id}`, type: 'staffel', label: `S${s.id}: ${s.name}`,
fullLabel: s.name, staffel: s.id, r: 28 * sc, color: s.color });
links.push({ source: 'center', target: `staffel-${s.id}`, type: 'center-theme' });
});
}
DATA.episodes.forEach(ep => {
const st = DATA.staffeln.find(s => s.id === ep.staffel);
@ -1202,10 +1578,19 @@ function buildGraph() {
episodeMap[ep.id] = n;
});
if (hasThemes) {
DATA.themes.forEach(t => t.episodes.forEach(epId => {
if (episodeMap[epId]) links.push({ source: t.id, target: epId, type: 'theme-episode' });
}));
} else {
DATA.episodes.forEach(ep => {
if (DATA.staffeln.find(s => s.id === ep.staffel)) {
links.push({ source: `staffel-${ep.staffel}`, target: ep.id, type: 'theme-episode' });
}
});
}
if (hasQuotes) {
DATA.quotes.filter(q => q.isTopQuote || q.startTime !== null).forEach(q => {
const ep = episodeMap[q.episode];
nodes.push({ id: q.id, type: 'quote', text: q.text, speaker: q.speaker, episode: q.episode,
@ -1214,6 +1599,7 @@ function buildGraph() {
r: (q.isTopQuote ? 6 : 4) * sc, color: ep ? ep.color : '#666', staffel: ep ? ep.staffel : 0 });
links.push({ source: q.episode, target: q.id, type: 'episode-quote' });
});
}
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(d => {
@ -1258,7 +1644,7 @@ function buildGraph() {
epNodes.append('circle').attr('r', d => d.r).attr('fill', 'transparent').attr('stroke', d => d.color).attr('stroke-width', 1.5);
epNodes.append('text').attr('dy', 4).text(d => d.label);
const themeNodes = nodeG.selectAll('.node-theme').data(nodes.filter(n => n.type === 'theme')).join('g')
const themeNodes = nodeG.selectAll('.node-theme').data(nodes.filter(n => n.type === 'theme' || n.type === 'staffel')).join('g')
.attr('class', 'node-theme').call(drag(simulation));
themeNodes.append('circle').attr('r', d => d.r).attr('fill', d => d.color + '33').attr('stroke', d => d.color);
themeNodes.append('text').attr('dy', d => -d.r - 8).text(d => d.label);
@ -1285,10 +1671,18 @@ function updateVisibility() {
const s = activeStaffel;
window._quoteNodes.style('display', d => s === 0 || d.staffel === s ? null : 'none');
window._epNodes.style('display', d => s === 0 || d.staffel === s ? null : 'none');
if (window._themeNodes) {
window._themeNodes.style('display', d => {
if (d.type !== 'staffel') return null;
return s === 0 || d.staffel === s ? null : 'none';
});
}
window._linkEls.style('display', d => {
if (s === 0) return null;
const tgt = typeof d.target === 'object' ? d.target : window._nodes.find(n => n.id === d.target);
const src = typeof d.source === 'object' ? d.source : window._nodes.find(n => n.id === d.source);
if (tgt && tgt.staffel && tgt.staffel !== s) return 'none';
if (src && src.type === 'staffel' && src.staffel !== s) return 'none';
return null;
});
}
@ -1310,6 +1704,7 @@ function drag(sim) {
if (d.type !== 'center') { d.fx = null; d.fy = null; }
if (!moved) {
if (d.type === 'theme') showTheme(d);
else if (d.type === 'staffel') filterStaffel(d.staffel);
else if (d.type === 'episode') showEpisode(d);
else if (d.type === 'quote') showQuoteDetail(d);
}
@ -1318,7 +1713,7 @@ function drag(sim) {
// ── Panel: Theme ──
function showTheme(theme) {
TranscriptView.hide();
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide();
const panel = document.getElementById('panel');
const td = DATA.themes.find(t => t.id === theme.id);
const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id));
@ -1344,6 +1739,7 @@ function showTheme(theme) {
// ── Panel: Episode ──
function showEpisode(ep) {
TranscriptView.hide();
AnalysisView.hide();
const panel = document.getElementById('panel');
const epData = DATA.episodes.find(e => e.id === (ep.id || ep));
const staffel = DATA.staffeln.find(s => s.id === epData.staffel);
@ -1353,9 +1749,24 @@ function showEpisode(ep) {
html += `<p class="subtitle">Gast: ${epData.guest} · Staffel ${epData.staffel}: ${staffel.name}</p>`;
html += `<p class="subtitle">${quotes.length} Zitate</p>`;
// Transcript button
// Action buttons
if (epData.audioFile) {
html += `<button class="transcript-toggle" onclick="TranscriptView.show('${epData.id}')">Transkript lesen</button>`;
html += `<button class="transcript-toggle" onclick="TranscriptView.show('${epData.id}')">Transkript lesen</button> `;
}
if (CURRENT_PODCAST) {
html += `<button class="transcript-toggle" id="btn-claims-${epData.id}" onclick="AnalysisView.show('${epData.id}','claims')">Behauptungen</button> `;
html += `<button class="transcript-toggle" id="btn-questions-${epData.id}" onclick="AnalysisView.show('${epData.id}','questions')">Fragen</button>`;
fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/episodes/${epData.id}/analyses-summary`)
.then(r => r.json())
.then(s => {
const cb = document.getElementById('btn-claims-' + epData.id);
if (cb && typeof s.claims === 'number') cb.textContent = `Behauptungen (${s.claims})`;
const qb = document.getElementById('btn-questions-' + epData.id);
if (qb && typeof s.questions === 'number') {
const open = s.questions_unanswered ? `, ${s.questions_unanswered} offen` : '';
qb.textContent = `Fragen (${s.questions}${open})`;
}
}).catch(() => {});
}
const epThemes = DATA.themes.filter(t => t.episodes.includes(epData.id));
@ -1708,14 +2119,28 @@ function buildTimeline() {
}
container.style.display = '';
const hasQuotes = (DATA.quotes || []).length > 0;
let html = '<div style="max-width:900px;margin:0 auto">';
DATA.staffeln.forEach(staffel => {
const eps = DATA.episodes.filter(e => e.staffel === staffel.id);
html += `<div style="margin-bottom:24px">`;
html += `<h3 style="color:${staffel.color};font-size:14px;margin-bottom:12px">Staffel ${staffel.id}: ${staffel.name}</h3>`;
html += `<h3 style="color:${staffel.color};font-size:14px;margin-bottom:12px">Staffel ${staffel.id}: ${staffel.name} <span style="color:var(--text-muted);font-weight:400">· ${eps.length} Folgen</span></h3>`;
eps.forEach(ep => {
if (!hasQuotes) {
html += `<div style="display:flex;gap:12px;margin-bottom:8px;align-items:flex-start">`;
html += `<div style="min-width:60px;text-align:right">`;
html += `<div style="font-size:12px;font-weight:600;color:${staffel.color}">${ep.id}</div>`;
if (ep.guest) html += `<div style="font-size:10px;color:var(--text-muted)">${escHtml(ep.guest)}</div>`;
html += `</div>`;
html += `<div style="flex:1">`;
html += `<div style="font-size:13px;font-weight:500;cursor:pointer" onclick="showEpisodeById('${ep.id}')">${escHtml(ep.title)}</div>`;
html += `</div>`;
html += `</div>`;
return;
}
const quotes = DATA.quotes.filter(q => q.episode === ep.id);
const topQuotes = quotes.filter(q => q.isTopQuote);