#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:
parent
d6ccea006a
commit
83669c528b
@ -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' });
|
||||
|
||||
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 += `<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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user