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 = `
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.guest} · ${this.items.length} Einträge
`; + html += ``; + + // Type-Filter + const types = [...new Set(this.items.map(i => i[typeKey]))].sort(); + 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 += `Lädt …
`; + try { + const r = await fetch(`${API_BASE}/api/analyses/gaps`); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `Keine Leerstellen-Analyse vorhanden.
`; + return; + } + this.data = data; + } catch (e) { + panel.innerHTML = `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 = `${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 += `Keine Leerstellen mit aktuellem Filter.
`; + } + + filtered.forEach(g => { + html += `Lädt …
`; + try { + const r = await fetch(`${API_BASE}/api/analyses/shifts`); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `Keine Shift-Analyse vorhanden.
`; + return; + } + this.data = data; + } catch (e) { + panel.innerHTML = `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 = `${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 += `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 += `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 = `` + : ''; document.getElementById('welcome-panel').innerHTML = `
${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 = '