From 83669c528bc7b6048b98740aafffad35d83b98ab Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Tue, 28 Apr 2026 00:31:17 +0200 Subject: [PATCH] #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) --- webapp/index.html | 513 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 469 insertions(+), 44 deletions(-) diff --git a/webapp/index.html b/webapp/index.html index 2e69712..de6c40f 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -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 = `

${ep.id}: ${ep.title} — ${mode === 'claims' ? 'Behauptungen' : 'Fragen'}

Lädt …

`; + + 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 += `

Fehler: ${escHtml(e.message)}

`; + 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 = `

${ep.id}: ${ep.title} — ${this.mode === 'claims' ? 'Behauptungen' : 'Fragen'}

`; + html += `

${ep.guest} · ${this.items.length} Einträge

`; + html += ``; + + // Type-Filter + const types = [...new Set(this.items.map(i => i[typeKey]))].sort(); + html += `
`; + const chip = (label, count, active, onclick) => + `${label} (${count})`; + 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 += '
'; + + // Answered-Filter (nur Fragen) + if (this.mode === 'questions') { + html += `
`; + 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 += '
'; + } + + // 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 += `

Keine Einträge mit aktuellem Filter.

`; + } + + 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 = `${type}`; + if (this.mode === 'claims' && it.verifiable) { + badges += `verifizierbar`; + } + 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 += `${lbl}`; + } + html += `
`; + html += `${ts}`; + html += badges; + html += escHtml(text); + html += '
'; + }); + 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 = `

Leerstellen

Lädt …

`; + try { + const r = await fetch(`${API_BASE}/api/analyses/gaps`); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `

Leerstellen

Keine Leerstellen-Analyse vorhanden.

`; + return; + } + this.data = data; + } catch (e) { + panel.innerHTML = `

Leerstellen

Fehler: ${escHtml(e.message)}

`; + return; + } + this.render(); + }, + + render() { + if (!this.visible || !this.data) return; + const panel = document.getElementById('panel'); + const d = this.data; + let html = `

Leerstellen

`; + html += `

${d.gaps.length} Themen-Cluster fehlen in mindestens einem Podcast · ${d.n_clusters} Cluster aus ${d.total_paragraphs} Absätzen über ${(d.podcasts || []).join(', ')}

`; + + const podcasts = d.podcasts || []; + const chip = (label, count, active, onclick) => + `${label}${count !== null ? ` (${count})` : ''}`; + + html += `
`; + 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 += '
'; + + 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 += `

Keine Leerstellen mit aktuellem Filter.

`; + } + + filtered.forEach(g => { + html += `
`; + html += `
`; + html += `${escHtml(g.cluster_label)}`; + html += `fehlt in ${escHtml(g.missing_in)}`; + html += `
`; + html += `
${g.cluster_size} Absätze · ${g.present_in_count} im anderen Podcast
`; + (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 += `
`; + html += `${escHtml(r.podcast)}/${escHtml(r.episode)} `; + html += escHtml(r.text); + html += `
`; + }); + html += `
`; + }); + 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 = `

Narrative Shifts

Lädt …

`; + try { + const r = await fetch(`${API_BASE}/api/analyses/shifts`); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `

Narrative Shifts

Keine Shift-Analyse vorhanden.

`; + return; + } + this.data = data; + } catch (e) { + panel.innerHTML = `

Narrative Shifts

Fehler: ${escHtml(e.message)}

`; + return; + } + this.render(); + }, + + render() { + if (!this.visible || !this.data) return; + const panel = document.getElementById('panel'); + const d = this.data; + let html = `

Narrative Shifts

`; + html += `

${d.shifts.length} Theme-Verläufe in ${(d.podcasts || []).join(', ')} · ${d.total_themes_tracked} Themen getrackt · semantische Drift zwischen aufeinanderfolgenden Episoden

`; + + const chip = (label, count, active, onclick) => + `${label}${count !== null ? ` (${count})` : ''}`; + + html += `
`; + 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 += '
'; + + const themesPresent = [...new Set(d.shifts.map(s => s.theme))].sort(); + html += `
`; + html += chip('alle Themen', null, !this.themeFilter, `ShiftsView.setTheme(null)`); + themesPresent.forEach(t => { + html += chip(t, null, this.themeFilter === t, `ShiftsView.setTheme('${t}')`); + }); + html += '
'; + + 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 += `

Keine Shifts mit aktuellem Filter.

`; + } + + 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 += `
`; + html += `
`; + html += `${escHtml(s.theme)}`; + html += `${escHtml(s.podcast)}`; + html += `
`; + html += `
`; + html += `${s.n_episodes} Episoden · Mittel-Drift ${meanPct}% · Max-Drift ${maxPct}%`; + if (spikes.length) html += ` · ${spikes.length} Spike${spikes.length > 1 ? 's' : ''}`; + html += `
`; + + // 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 += `
`; + html += `${escHtml(dr.from)}`; + html += ``; + html += `${escHtml(dr.to)}`; + html += `${pct}%`; + html += `
`; + }); + + // Toggle für vollständige Drift-Sequenz + const allDrifts = s.drifts || []; + if (allDrifts.length > top.length) { + html += `
${isOpen ? 'verkürzen' : `alle ${allDrifts.length} Übergänge zeigen`}
`; + if (isOpen) { + html += `
`; + 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 += `${escHtml(dr.to)} ${pct}%`; + }); + html += `
`; + } + } + html += `
`; + }); + 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 = `

Keine Treffer für "${escHtml(query)}"

`; @@ -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 = `

${results.length} semantische Treffer für "${escHtml(query)}" KI

`; 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 = `

${results.length} Treffer für "${escHtml(query)}"

`; results.forEach(r => { @@ -978,6 +1337,8 @@ function showPodcastSelector(podcasts) { if (podcasts.length > 1) { selectorHtml += '
'; selectorHtml += ''; + selectorHtml += ''; + selectorHtml += ''; selectorHtml += '
'; } @@ -1124,11 +1485,15 @@ function init() { document.getElementById('app-title').innerHTML = ALL_PODCASTS.length > 1 ? ` ${escHtml(name)}` : `${escHtml(name)}`; + const gapsBtn = ALL_PODCASTS.length > 1 + ? `

` + : ''; document.getElementById('welcome-panel').innerHTML = `

${escHtml(name)}

${escHtml(DATA.description || '')}
${DATA.episodes.length} Folgen, ${DATA.staffeln.length} Staffeln, ${DATA.quotes.length} Zitate

-

Klicke auf einen Themenknoten oder eine Episode.

`; +

Klicke auf einen Themenknoten oder eine Episode.

+ ${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' }); - 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' }); - }); + 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,18 +1578,28 @@ function buildGraph() { episodeMap[ep.id] = n; }); - DATA.themes.forEach(t => t.episodes.forEach(epId => { - if (episodeMap[epId]) links.push({ source: t.id, target: epId, type: 'theme-episode' }); - })); + 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' }); + } + }); + } - 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, - themes: q.themes, startTime: q.startTime, endTime: q.endTime, audioFile: q.audioFile, - isTopQuote: q.isTopQuote, verbatim: q.verbatim, - 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' }); - }); + 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, + themes: q.themes, startTime: q.startTime, endTime: q.endTime, audioFile: q.audioFile, + isTopQuote: q.isTopQuote, verbatim: q.verbatim, + 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 += `

Gast: ${epData.guest} · Staffel ${epData.staffel}: ${staffel.name}

`; html += `

${quotes.length} Zitate

`; - // Transcript button + // Action buttons if (epData.audioFile) { - html += ``; + html += ` `; + } + if (CURRENT_PODCAST) { + html += ` `; + html += ``; + 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 = '
'; DATA.staffeln.forEach(staffel => { const eps = DATA.episodes.filter(e => e.staffel === staffel.id); html += `
`; - html += `

Staffel ${staffel.id}: ${staffel.name}

`; + html += `

Staffel ${staffel.id}: ${staffel.name} · ${eps.length} Folgen

`; eps.forEach(ep => { + if (!hasQuotes) { + html += `
`; + html += `
`; + html += `
${ep.id}
`; + if (ep.guest) html += `
${escHtml(ep.guest)}
`; + html += `
`; + html += `
`; + html += `
${escHtml(ep.title)}
`; + html += `
`; + html += `
`; + return; + } + const quotes = DATA.quotes.filter(q => q.episode === ep.id); const topQuotes = quotes.filter(q => q.isTopQuote);