podcast-mindmap/webapp/index.html
Dotty Dotter e1f6f18524 #14/#15/#16 Heatmaps und Drift-Kurve in den Analyse-Views
Backend:
- /api/analyses/density: Faktendichte je Episode in 20 Bins ueber die
  Paragraph-Achse, getrennt nach total und verifizierbar (#16).

Frontend:
- ShiftsView (#15): Inline-SVG-Sparkline ueber die gesamte Drift-Sequenz je Theme,
  mit Schwellen-Linie bei 50% und klickbaren Spike-Markern.
- GapsView (#14): Cluster-Heatmap mit zwei Zeilen (LdN, NEU DENKEN), Cluster-Breite
  proportional zur Cluster-Groesse, Farbe interpoliert von kuehl (geringer Anteil im
  Podcast) zu warm (hoher Anteil); Klick filtert die darunter liegende Liste.
- DensityView (#16): neue View 'Faktendichte', sortiert nach Claims/Absatz,
  pro Episode eine 20-Bin-Heatmap (gruen = verifizierbar, warm = normativ),
  Filter nach Podcast und Sortierung; Klick oeffnet die Episode.
- AnalysisView (#17 questions): zeigt jetzt 'Antwort: <Episode>@p<idx>'-Link
  fuer Fragen mit answered_by_episode; Klick navigiert zur Antwort-Stelle.
- escAttr-Helper, hide-Cascade um DensityView erweitert, Buttons in Selector
  und init() hinzugefuegt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:17:31 +02:00

2684 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f1117">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.json">
<title>Podcast Mindmap</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #252836;
--text: #e8e6e3;
--text-muted: #9ca3af;
--accent: #60a5fa;
--accent-green: #22c55e;
--border: #374151;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
height: 100vh;
}
#app {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-rows: 52px 1fr;
height: 100vh;
}
@media (max-width: 800px) {
#app {
grid-template-columns: 1fr;
grid-template-rows: 48px 45vh 1fr;
}
header { padding: 0 12px; gap: 8px; }
header h1 { font-size: 13px; }
.search-box { max-width: 120px; }
.filter-bar { gap: 3px; }
.filter-btn { padding: 3px 8px; font-size: 10px; }
#panel { border-left: none; border-top: 1px solid var(--border); }
}
/* ── Header ── */
header {
grid-column: 1 / -1;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
}
header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; white-space: nowrap; }
header h1 span { color: var(--accent); }
.search-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
color: var(--text);
font-size: 12px;
width: 200px;
outline: none;
}
.search-box:focus { border-color: var(--accent); }
.search-box::placeholder { color: var(--text-muted); }
.filter-bar { display: flex; gap: 6px; margin-left: auto; }
.filter-btn {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-muted); padding: 5px 12px; border-radius: 16px;
font-size: 11px; cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.filter-btn:hover, .filter-btn.active {
background: var(--accent); color: var(--bg); border-color: var(--accent);
}
/* ── Mindmap ── */
#mindmap { position: relative; overflow: hidden; background: var(--bg); }
#mindmap svg { width: 100%; height: 100%; }
.node-theme { cursor: pointer; }
.node-theme circle { stroke-width: 2; transition: r 0.3s, stroke-width 0.3s; }
.node-theme:hover circle { stroke-width: 4; }
.node-theme text { fill: var(--text); font-size: 11px; font-weight: 600; text-anchor: middle; pointer-events: none; }
.node-episode circle { stroke-width: 1.5; cursor: pointer; transition: r 0.3s; }
.node-episode:hover circle { r: 22; }
.node-episode text { fill: var(--text-muted); font-size: 9px; text-anchor: middle; pointer-events: none; }
.node-quote { cursor: pointer; }
.node-quote circle { transition: r 0.3s, opacity 0.3s; }
.node-quote:hover circle { r: 8; opacity: 1; }
.link { stroke: var(--border); stroke-opacity: 0.3; fill: none; }
.link-theme-episode { stroke-opacity: 0.15; stroke-width: 1; }
.link-episode-quote { stroke-opacity: 0.08; stroke-width: 0.5; }
/* ── Side Panel ── */
#panel {
background: var(--surface);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 80px; /* space for audio bar */
}
#panel h2 { font-size: 15px; font-weight: 600; }
#panel .subtitle { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
/* ── Quote Cards ── */
.quote-card {
background: var(--surface2);
border-radius: 8px;
padding: 12px;
border-left: 3px solid var(--border);
transition: background 0.15s;
position: relative;
}
.quote-card .quote-text {
font-size: 13px; line-height: 1.5; color: var(--text); font-style: italic;
cursor: default;
}
.quote-card .quote-meta {
margin-top: 6px; font-size: 11px; color: var(--text-muted);
display: flex; justify-content: space-between; align-items: center;
}
.quote-card.loaded { border-left-color: var(--accent); }
.quote-card.playing { border-left-color: var(--accent-green); background: #1a2e1a; }
/* Play button inside card — explicit, not the whole card */
.play-btn {
width: 28px; height: 28px; border-radius: 50%;
background: var(--accent); border: none; color: var(--bg);
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0; opacity: 0.7; transition: opacity 0.2s, transform 0.1s;
}
.play-btn:hover { opacity: 1; transform: scale(1.1); }
.play-btn:active { transform: scale(0.95); }
.play-btn svg { width: 12px; height: 12px; fill: currentColor; }
.no-audio .play-btn { display: none; }
.top-badge {
display: inline-block; background: #f59e0b; color: #000;
font-size: 9px; font-weight: 700; padding: 1px 6px;
border-radius: 3px; margin-left: 4px;
}
/* ── Transcript View ── */
.transcript-toggle {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-muted); padding: 6px 14px; border-radius: 6px;
font-size: 11px; cursor: pointer; transition: all 0.2s;
}
.transcript-toggle:hover { border-color: var(--accent); color: var(--text); }
.transcript-para {
font-size: 13px; line-height: 1.6; color: var(--text-muted);
padding: 8px 12px; border-radius: 6px; border-left: 2px solid transparent;
transition: background 0.3s, border-color 0.3s, color 0.3s;
cursor: pointer;
}
.transcript-para:hover { background: var(--surface2); }
.transcript-para.active {
background: var(--surface2); border-left-color: var(--accent-green);
color: var(--text);
}
.transcript-para .ts {
font-size: 10px; color: var(--text-muted); font-variant-numeric: tabular-nums;
margin-right: 6px;
}
/* ── Word-level highlighting (#12) ── */
.word {
transition: background 0.1s, color 0.1s;
border-radius: 2px; padding: 0 1px;
cursor: pointer;
}
.word:hover { background: var(--surface2); }
.word.word-active {
background: var(--accent-green)44;
color: var(--text);
}
.word.word-spoken {
color: var(--text);
}
/* ── Search Results ── */
.search-result {
background: var(--surface2); border-radius: 8px; padding: 12px;
border-left: 3px solid var(--accent); cursor: pointer;
transition: background 0.15s;
}
.search-result:hover { background: #2d3142; }
.search-result .sr-episode { font-size: 10px; color: var(--accent); font-weight: 600; }
.search-result .sr-text { font-size: 12px; line-height: 1.5; margin-top: 4px; }
.search-result mark { background: #f59e0b44; color: var(--text); border-radius: 2px; padding: 0 2px; }
/* ── Audio Bar ── */
#audio-bar {
position: fixed; bottom: 0; left: 0; right: 0; height: 64px;
background: var(--surface); border-top: 1px solid var(--border);
display: none; align-items: center; padding: 0 20px; gap: 12px; z-index: 100;
}
#audio-bar.visible { display: flex; }
#audio-bar .bar-play-btn {
background: var(--accent); border: none; color: var(--bg);
width: 36px; height: 36px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
#audio-bar .now-playing {
font-size: 12px; color: var(--text-muted); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer;
}
#audio-bar .now-playing:hover { color: var(--text); }
#audio-bar .now-playing strong { color: var(--text); }
#audio-bar .time {
font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums;
}
#audio-bar .bar-transcript-btn {
background: transparent; border: 1px solid var(--border); color: var(--text-muted);
padding: 4px 10px; border-radius: 4px; font-size: 10px; cursor: pointer;
}
#audio-bar .bar-transcript-btn:hover { border-color: var(--accent); color: var(--text); }
/* ── Backlinks (Obsidian-style) ── */
.backlinks {
margin-top: 8px; padding: 8px 0;
border-top: 1px solid var(--border);
}
.backlinks-title {
font-size: 10px; color: var(--text-muted); text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 6px;
}
.backlink {
font-size: 11px; color: var(--accent); cursor: pointer;
padding: 3px 8px; border-radius: 4px; display: block;
transition: background 0.15s;
}
.backlink:hover { background: var(--surface2); }
.backlink .bl-episode { font-weight: 600; }
.backlink .bl-preview { color: var(--text-muted); font-style: italic; }
.topic-tag {
display: inline-block; padding: 1px 7px; border-radius: 10px;
font-size: 9px; background: var(--surface2); color: var(--text-muted);
margin: 1px; border: 1px solid var(--border);
}
/* ── Soundbite Export ── */
.export-btn {
background: transparent; border: 1px solid var(--border);
color: var(--text-muted); padding: 3px 8px; border-radius: 4px;
font-size: 10px; cursor: pointer; transition: all 0.2s;
}
.export-btn:hover { border-color: var(--accent); color: var(--text); }
/* ── View Tabs ── */
.view-tabs {
display: flex; gap: 4px; margin-left: 12px;
}
.view-tab {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-muted); padding: 4px 10px; border-radius: 4px;
font-size: 11px; cursor: pointer; transition: all 0.2s;
}
.view-tab:hover { border-color: var(--accent); }
.view-tab.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
/* ── Podcast Selector ── */
.podcast-selector {
display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
padding: 40px 20px;
}
.podcast-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; width: 260px; cursor: pointer;
transition: all 0.2s;
}
.podcast-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.podcast-card.selected { border-color: var(--accent); background: var(--accent)11; }
.podcast-card h3 { font-size: 15px; margin-bottom: 4px; }
.podcast-card p { font-size: 12px; color: var(--text-muted); }
.podcast-card .pc-stats { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
/* ── Compare View ── */
.compare-section { margin-top: 16px; }
.compare-section h3 { font-size: 13px; color: var(--text); margin-bottom: 8px; }
.compare-stats { display: flex; gap: 12px; justify-content: center; margin-bottom: 16px; }
.compare-stat-card {
background: var(--surface2); border-radius: 8px; padding: 12px 16px;
text-align: center; flex: 1; max-width: 200px;
}
.compare-stat-card .stat-val { font-size: 20px; font-weight: 700; color: var(--accent); }
.compare-stat-card .stat-label { font-size: 10px; color: var(--text-muted); }
.shared-topics { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0; }
.shared-topic {
background: var(--accent)22; color: var(--accent); border: 1px solid var(--accent)44;
padding: 2px 8px; border-radius: 10px; font-size: 10px; cursor: pointer;
}
.shared-topic:hover { background: var(--accent)44; }
.cross-link-card {
background: var(--surface2); border-radius: 8px; padding: 10px;
margin-bottom: 6px; border-left: 3px solid #2a9d8f;
}
.cross-link-card .cl-source, .cross-link-card .cl-target {
font-size: 11px; line-height: 1.4;
}
.cross-link-card .cl-label { font-size: 9px; color: var(--text-muted); text-transform: uppercase; }
.cross-link-card .cl-arrow { text-align: center; color: #2a9d8f; font-size: 10px; margin: 4px 0; }
.cross-link-card .cl-score { font-size: 9px; color: #2a9d8f; float: right; }
.compare-actions { display: flex; gap: 8px; justify-content: center; margin: 12px 0; }
.compare-btn {
background: var(--surface2); border: 1px solid var(--border); color: var(--text-muted);
padding: 6px 14px; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 0.2s;
}
.compare-btn:hover { border-color: var(--accent); color: var(--text); }
.compare-btn.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
/* ── Cross-podcast toggle ── */
.cross-toggle {
display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-muted);
}
.cross-toggle input[type="checkbox"] { accent-color: var(--accent); }
/* ── Semantic link in transcript ── */
.semantic-link-card {
background: #2a9d8f11; border: 1px solid #2a9d8f33; border-radius: 6px;
padding: 6px 10px; margin: 4px 0; cursor: pointer; transition: background 0.15s;
}
.semantic-link-card:hover { background: #2a9d8f22; }
.semantic-link-card .sl-podcast { font-size: 9px; color: #2a9d8f; text-transform: uppercase; }
.semantic-link-card .sl-episode { font-size: 10px; color: var(--accent); font-weight: 600; }
.semantic-link-card .sl-text { font-size: 11px; color: var(--text-muted); font-style: italic; }
/* ── Semantic results ── */
.semantic-badge {
display: inline-block; background: #2a9d8f33; color: #2a9d8f;
font-size: 9px; font-weight: 600; padding: 1px 6px; border-radius: 3px;
}
.welcome { text-align: center; padding: 40px 20px; color: var(--text-muted); }
.welcome h2 { color: var(--text); margin-bottom: 8px; }
.welcome p { font-size: 13px; line-height: 1.6; }
.theme-tag {
display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 11px; font-weight: 500; margin: 2px; cursor: pointer;
}
#panel::-webkit-scrollbar { width: 6px; }
#panel::-webkit-scrollbar-track { background: transparent; }
#panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<div id="app">
<header>
<h1><span id="app-title">Podcast</span> Mindmap</h1>
<input type="search" class="search-box" id="search-input" placeholder="Transkripte durchsuchen…">
<label class="cross-toggle" id="cross-toggle" style="display:none" title="Suche über alle Podcasts">
<input type="checkbox" id="cross-search-cb"> Alle Podcasts
</label>
<div class="view-tabs">
<button class="view-tab active" id="tab-mindmap" onclick="switchView('mindmap')">Mindmap</button>
<button class="view-tab" id="tab-timeline" onclick="switchView('timeline')">Timeline</button>
</div>
<div class="filter-bar" id="staffel-filters"></div>
</header>
<div id="mindmap"><svg id="svg"></svg></div>
<div id="panel"><div class="welcome" id="welcome-panel"></div></div>
</div>
<div id="audio-bar">
<button class="bar-play-btn" id="bar-play-btn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path id="bar-play-icon" d="M8 5v14l11-7z"/>
</svg>
</button>
<div class="now-playing" id="now-playing" title="Klick: Transkript anzeigen"></div>
<span class="time" id="audio-time">0:00</span>
<button class="bar-transcript-btn" id="bar-transcript-btn">Transkript</button>
</div>
<audio id="main-audio" preload="none"></audio>
<script src="d3.v7.min.js"></script>
<script>
// ============================================================
// Podcast Mindmap App v2
// ============================================================
let DATA = null;
let TRANSCRIPTS = null; // loaded on demand
let simulation = null;
// Audio state — completely independent from panel state
const AudioPlayer = {
el: document.getElementById('main-audio'),
bar: document.getElementById('audio-bar'),
barPlayBtn: document.getElementById('bar-play-btn'),
barIcon: document.getElementById('bar-play-icon'),
nowPlaying: document.getElementById('now-playing'),
timeDisplay: document.getElementById('audio-time'),
currentQuote: null,
isPlaying: false,
PLAY_PATH: 'M8 5v14l11-7z',
PAUSE_PATH: 'M6 4h4v16H6zM14 4h4v16h-4z',
load(q) {
// Load quote into bar WITHOUT playing
if (!q.audioFile || q.startTime === null) return;
this.currentQuote = q;
const sameFile = this.el.src && this.el.src.endsWith(q.audioFile);
if (!sameFile) {
this.el.src = `audio/${CURRENT_PODCAST ? CURRENT_PODCAST + '/' : ''}${q.audioFile}`;
}
this.nowPlaying.innerHTML = `<strong>"${q.text.substring(0, 70)}…"</strong> — ${q.speaker} (${q.episode})`;
this.bar.classList.add('visible');
this._updateCardStates();
},
play(q) {
// Play a specific quote — requires user gesture
if (q) this.load(q);
if (!this.currentQuote) return;
const cq = this.currentQuote;
this.el.currentTime = cq.startTime;
const p = this.el.play();
if (p) p.then(() => {
if (Math.abs(this.el.currentTime - cq.startTime) > 2) {
this.el.currentTime = cq.startTime;
}
}).catch(() => {});
this.isPlaying = true;
this.barIcon.setAttribute('d', this.PAUSE_PATH);
this._setupTimeUpdater();
this._updateCardStates();
},
toggle() {
if (!this.currentQuote) return;
if (this.el.paused) {
this.el.play().catch(() => {});
this.isPlaying = true;
this.barIcon.setAttribute('d', this.PAUSE_PATH);
} else {
this.el.pause();
this.isPlaying = false;
this.barIcon.setAttribute('d', this.PLAY_PATH);
}
},
stop() {
this.el.pause();
this.isPlaying = false;
this.barIcon.setAttribute('d', this.PLAY_PATH);
this._updateCardStates();
},
_setupTimeUpdater() {
const cq = this.currentQuote;
this.el.ontimeupdate = () => {
if (cq.endTime && this.el.currentTime >= cq.endTime) {
this.stop();
}
this.timeDisplay.textContent = fmtTime(this.el.currentTime);
// Sync transcript if visible
if (TranscriptView.visible && TranscriptView.episodeId === cq.episode) {
TranscriptView.syncToTime(this.el.currentTime);
}
};
this.el.onended = () => this.stop();
},
_updateCardStates() {
document.querySelectorAll('.quote-card.loaded,.quote-card.playing').forEach(c => {
c.classList.remove('loaded', 'playing');
});
if (this.currentQuote) {
const card = document.getElementById(`card-${this.currentQuote.id}`);
if (card) card.classList.add(this.isPlaying ? 'playing' : 'loaded');
}
}
};
// Bar button handlers
AudioPlayer.barPlayBtn.onclick = () => {
if (AudioPlayer.currentQuote && AudioPlayer.el.paused && !AudioPlayer.el.src.endsWith(AudioPlayer.currentQuote.audioFile)) {
AudioPlayer.play();
} else {
AudioPlayer.toggle();
}
};
document.getElementById('bar-transcript-btn').onclick = () => {
if (AudioPlayer.currentQuote) {
TranscriptView.show(AudioPlayer.currentQuote.episode, AudioPlayer.el.currentTime);
}
};
document.getElementById('now-playing').onclick = () => {
if (AudioPlayer.currentQuote) {
TranscriptView.show(AudioPlayer.currentQuote.episode, AudioPlayer.el.currentTime);
}
};
// ── Transcript View ──
const TranscriptView = {
visible: false,
episodeId: null,
paragraphs: null,
userScrolled: false,
activeIdx: -1,
words: null, // Word-level timestamps for current episode
activeWordIdx: -1, // Currently highlighted word
async show(episodeId, seekTime) {
const epData = await this.loadEpisodeTranscript(episodeId);
if (!epData || !epData.paragraphs) {
// Fallback: try old method
if (!TRANSCRIPTS) await this.loadTranscripts();
const key = Object.keys(TRANSCRIPTS).find(k => k.startsWith(episodeId));
if (!key || !TRANSCRIPTS[key]) return;
this.paragraphs = TRANSCRIPTS[key].paragraphs;
} else {
this.paragraphs = epData.paragraphs;
}
this.episodeId = episodeId;
this.visible = true;
this.userScrolled = false;
const panel = document.getElementById('panel');
const ep = DATA.episodes.find(e => e.id === episodeId);
const staffel = DATA.staffeln.find(s => s.id === ep.staffel);
let html = `<h2 style="color:${staffel.color}">${ep.id}: ${ep.title} — Transkript</h2>`;
html += `<p class="subtitle">${ep.guest}</p>`;
// Try to load word-level timestamps
this.words = null;
this.activeWordIdx = -1;
if (CURRENT_PODCAST) {
try {
const wr = await fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/transcript/${episodeId}/words`);
if (wr.ok) {
const wd = await wr.json();
if (wd.available && wd.words.length > 0) this.words = wd.words;
}
} catch (e) {}
}
this.paragraphs.forEach((p, i) => {
html += `<div class="transcript-para" data-idx="${i}" onclick="TranscriptView.seekTo(${p.start})">`;
html += `<span class="ts">${fmtTime(p.start)}</span>`;
if (this.words) {
// Render words as clickable spans with timestamps
const paraWords = this.words.filter(w => w.start >= p.start - 0.5 && w.start < (p.end || p.start + 999));
if (paraWords.length > 0) {
paraWords.forEach(w => {
html += `<span class="word" data-ws="${w.start}" data-we="${w.end}" onclick="event.stopPropagation();TranscriptView.seekTo(${w.start})">${escHtml(w.word)} </span>`;
});
} else {
html += escHtml(p.text);
}
} else {
html += escHtml(p.text);
}
html += `</div>`;
});
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; };
if (seekTime !== undefined) this.syncToTime(seekTime);
},
syncToTime(time) {
if (!this.visible || !this.paragraphs) return;
// Paragraph-level sync
let idx = -1;
for (let i = 0; i < this.paragraphs.length; i++) {
if (time >= this.paragraphs[i].start) idx = i;
else break;
}
if (idx !== this.activeIdx) {
this.activeIdx = idx;
document.querySelectorAll('.transcript-para.active').forEach(el => el.classList.remove('active'));
if (idx >= 0) {
const el = document.querySelector(`.transcript-para[data-idx="${idx}"]`);
if (el) {
el.classList.add('active');
if (!this.userScrolled) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}
// 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;
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) {
const audio = AudioPlayer.el;
if (AudioPlayer.currentQuote && AudioPlayer.currentQuote.episode === this.episodeId) {
audio.currentTime = time;
if (audio.paused) AudioPlayer.toggle();
} else {
// Load episode audio and seek
const ep = DATA.episodes.find(e => e.id === this.episodeId);
if (ep && ep.audioFile) {
const fakeQuote = {
audioFile: ep.audioFile, startTime: time, endTime: null,
text: `${ep.title} ab ${fmtTime(time)}`, speaker: ep.guest,
episode: ep.id, id: '_transcript'
};
AudioPlayer.play(fakeQuote);
}
}
this.userScrolled = false;
},
hide() {
this.visible = false;
this.episodeId = null;
},
async loadTranscripts() {
if (TRANSCRIPTS && Object.keys(TRANSCRIPTS).length > 0) return;
TRANSCRIPTS = {};
try {
const resp = await fetch('srt_index.json');
if (resp.ok) { TRANSCRIPTS = await resp.json(); return; }
} catch (e) {}
try {
const resp = await fetch('data/srt_index.json');
if (resp.ok) { TRANSCRIPTS = await resp.json(); }
} catch (e2) {}
},
async loadEpisodeTranscript(episodeId) {
// Try API first
if (CURRENT_PODCAST) {
try {
const resp = await fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/transcript/${episodeId}`);
if (resp.ok) {
const data = await resp.json();
if (!TRANSCRIPTS) TRANSCRIPTS = {};
TRANSCRIPTS[episodeId] = data;
return data;
}
} catch (e) {}
}
// Fallback
await this.loadTranscripts();
const key = Object.keys(TRANSCRIPTS).find(k => k.startsWith(episodeId));
return key ? TRANSCRIPTS[key] : null;
}
};
// ── 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(); DebatesView.hide(); ArgumentsView.hide(); DensityView.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>`;
}
let answerLink = '';
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>`;
if (it.answered_by_episode && (a === 'yes' || a === 'partial')) {
const samePodcast = !it.answered_by_podcast || it.answered_by_podcast === CURRENT_PODCAST;
const arrow = samePodcast ? '→' : '↗';
const target = samePodcast ? `${escHtml(it.answered_by_episode)}` : `${escHtml(it.answered_by_podcast)} / ${escHtml(it.answered_by_episode)}`;
if (samePodcast) {
answerLink = `<div style="margin-top:4px;font-size:12px;color:var(--accent-green)" onclick="event.stopPropagation(); AnalysisView.jumpAnswer('${it.answered_by_episode}', ${it.answered_by_idx})">
${arrow} Antwort: ${target}@p${it.answered_by_idx}
</div>`;
} else {
answerLink = `<div style="margin-top:4px;font-size:12px;color:var(--accent-green);opacity:0.85">
${arrow} Antwort in ${target}@p${it.answered_by_idx}
</div>`;
}
}
}
html += `<div class="transcript-para" onclick="AnalysisView.jumpTo(${it.start_time || 0})">`;
html += `<span class="ts">${ts}</span>`;
html += badges;
html += escHtml(text);
html += answerLink;
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);
},
jumpAnswer(episodeId, paraIdx) {
if (!CURRENT_PODCAST) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (!ep) return;
showEpisode(ep);
// Transkript an der Para-Stelle aufschlagen — paragraph_idx ueber Transkript laden
setTimeout(async () => {
try {
const r = await fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/transcript/${episodeId}`);
const d = await r.json();
const para = d.paragraphs && d.paragraphs[paraIdx];
if (para && typeof para.start === 'number') {
TranscriptView.show(episodeId, para.start);
}
} catch (_) { /* fallback: Episode wurde geoeffnet */ }
}, 250);
},
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(); DebatesView.hide(); ArgumentsView.hide(); DensityView.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>`;
// Heatmap: Cluster x Podcast, Farbintensitaet nach Anteil im Podcast
const clusters = (d.clusters || []).slice().sort((a, b) => (b.size || 0) - (a.size || 0));
if (clusters.length && podcasts.length >= 2) {
const maxSize = Math.max(...clusters.map(c => c.size || 0)) || 1;
html += `<div style="margin-top:14px"><strong style="font-size:13px">Cluster-Verteilung</strong> <span class="subtitle">(grosse Clustern zuerst, Farbe = Anteil je Podcast)</span></div>`;
html += `<div style="display:grid;grid-template-columns:120px 1fr;gap:6px;margin-top:6px;align-items:center;font-size:12px">`;
podcasts.forEach(p => {
html += `<div style="text-align:right;color:var(--text-muted);padding-right:6px">${escHtml(p)}</div>`;
html += `<div style="display:flex;gap:1px;height:28px">`;
clusters.forEach(c => {
const pSize = (c.per_podcast || {})[p] || 0;
const total = c.size || 1;
const share = pSize / total;
const widthPct = ((c.size || 0) / maxSize * 100).toFixed(2);
// Color: dark blue for low share, accent-warm for high share, opacity scaled
const intensity = Math.min(1, share);
// Lerp between cool (#1e3a8a) and warm (#dc7850)
const r = Math.round(30 + (220 - 30) * intensity);
const g = Math.round(58 + (120 - 58) * intensity);
const b = Math.round(138 + (80 - 138) * intensity);
const bg = `rgba(${r},${g},${b},${(0.25 + intensity * 0.65).toFixed(2)})`;
const title = `${c.label.split(',').slice(0, 4).join(',')} · ${p}: ${pSize}/${total} (${(share*100).toFixed(0)}%)`;
html += `<div title="${escAttr(title)}" style="flex:0 0 ${widthPct}%;min-width:6px;background:${bg};border-radius:2px;cursor:pointer" onclick="GapsView.scrollToCluster(${c.id})"></div>`;
});
html += `</div>`;
});
html += `</div>`;
}
html += `<div style="margin-top:14px;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);
},
scrollToCluster(clusterId) {
// Filter auf Cluster-ID, falls eine entsprechende Gap existiert; sonst nur Heatmap-Hover.
const gap = (this.data?.gaps || []).find(g => g.cluster_id === clusterId);
if (gap && gap.missing_in) { this.setMissing(gap.missing_in); }
},
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(); DebatesView.hide(); ArgumentsView.hide(); DensityView.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>`;
// Inline-Sparkline der gesamten Drift-Sequenz
const allDrifts = s.drifts || [];
if (allDrifts.length >= 2) {
const W = 360, H = 44, PAD = 4;
const maxScale = Math.max(0.6, ...allDrifts.map(dr => dr.drift || 0));
const pts = allDrifts.map((dr, i) => {
const x = PAD + (W - 2 * PAD) * i / (allDrifts.length - 1);
const y = H - PAD - (H - 2 * PAD) * Math.min(1, (dr.drift || 0) / maxScale);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
// Threshold-Linie bei 50% drift
const yThr = H - PAD - (H - 2 * PAD) * Math.min(1, 0.5 / maxScale);
// Spike-Marker
let spikeMarks = '';
allDrifts.forEach((dr, i) => {
if ((dr.drift || 0) >= 0.5) {
const x = PAD + (W - 2 * PAD) * i / (allDrifts.length - 1);
const y = H - PAD - (H - 2 * PAD) * Math.min(1, (dr.drift || 0) / maxScale);
const safeFrom = escAttr(dr.from);
const safeTo = escAttr(dr.to);
spikeMarks += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--accent-warm)" style="cursor:pointer" onclick="ShiftsView.jumpTo('${escAttr(s.podcast)}','${safeTo}')"><title>${safeFrom}${safeTo}: ${(dr.drift*100).toFixed(0)}%</title></circle>`;
}
});
html += `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;margin:6px 0">`;
html += `<line x1="${PAD}" y1="${yThr.toFixed(1)}" x2="${W-PAD}" y2="${yThr.toFixed(1)}" stroke="var(--border)" stroke-dasharray="2,3" stroke-width="0.6"/>`;
html += `<polyline fill="none" stroke="var(--accent)" stroke-width="1.4" points="${pts}"/>`;
html += spikeMarks;
html += `</svg>`;
}
// 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 (allDrifts oben definiert)
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; }
};
// ── Debates View (#18 Cross-Podcast-Debatte) ──
const DebatesView = {
visible: false,
data: null,
topicFilter: null,
expanded: {},
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); ArgumentsView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Debatten</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/analyses/debates?limit=200`);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Debatten</h2><p class="subtitle">Keine Debatten-Analyse vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Debatten</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>Debatten</h2>`;
html += `<p class="subtitle">${d.debates.length} kuratierte Gegenüberstellungen über ${d.podcasts.length} Podcasts</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>`;
// Topic-Filter (Top 12 + alle)
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Themen', d.debates.length, !this.topicFilter, `DebatesView.setTopic(null)`);
(d.topics || []).slice(0, 12).forEach(t => {
html += chip(escHtml(t.topic), t.count, this.topicFilter === t.topic, `DebatesView.setTopic('${escAttr(t.topic)}')`);
});
html += `</div>`;
let filtered = d.debates;
if (this.topicFilter) filtered = filtered.filter(x => x.topic === this.topicFilter);
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Debatten zum Filter.</p>`;
panel.innerHTML = html;
return;
}
filtered.forEach(deb => {
const key = `${deb.id}`;
const isOpen = this.expanded[key];
const srcEpClick = deb.source_podcast === CURRENT_PODCAST
? `onclick="DebatesView.jumpTo('${deb.source_episode}', ${deb.source_start || 0})"` : '';
const tgtEpClick = deb.target_podcast === CURRENT_PODCAST
? `onclick="DebatesView.jumpTo('${deb.target_episode}', ${deb.target_start || 0})"` : '';
html += `<div class="transcript-para" style="cursor:default;margin-top:10px">`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:6px;align-items:flex-start">`;
html += `<strong>${escHtml(deb.topic)}</strong>`;
html += `<span class="ts" style="white-space:nowrap">Score ${(deb.score || 0).toFixed(2)}</span>`;
html += `</div>`;
// Split-Screen Quotes
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0">`;
html += `<div style="padding:8px;border-left:3px solid var(--accent);${srcEpClick ? 'cursor:pointer' : ''}" ${srcEpClick}>`;
html += `<div class="ts" style="margin-bottom:4px">${escHtml(deb.source_pname)} · ${escHtml(deb.source_episode)}${deb.source_guest ? ' (' + escHtml(deb.source_guest) + ')' : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((deb.source_text || '').slice(0, 240))}${(deb.source_text || '').length > 240 ? '…' : ''}</div>`;
html += `</div>`;
html += `<div style="padding:8px;border-left:3px solid var(--accent-warm);${tgtEpClick ? 'cursor:pointer' : ''}" ${tgtEpClick}>`;
html += `<div class="ts" style="margin-bottom:4px">${escHtml(deb.target_pname)} · ${escHtml(deb.target_episode)}${deb.target_guest ? ' (' + escHtml(deb.target_guest) + ')' : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((deb.target_text || '').slice(0, 240))}${(deb.target_text || '').length > 240 ? '…' : ''}</div>`;
html += `</div>`;
html += `</div>`;
// Synthese
if (deb.agreement) {
html += `<div style="margin-top:6px"><span class="theme-tag" style="font-size:10px;color:#86efac;border-color:#86efac44">Übereinstimmung</span> <span style="font-size:13px">${escHtml(deb.agreement)}</span></div>`;
}
if (deb.divergence && deb.divergence.toLowerCase() !== 'keine wesentliche divergenz') {
html += `<div style="margin-top:4px"><span class="theme-tag" style="font-size:10px;color:#fda4af;border-color:#fda4af44">Divergenz</span> <span style="font-size:13px">${escHtml(deb.divergence)}</span></div>`;
}
if (deb.insight) {
html += `<div style="margin-top:4px"><span class="theme-tag" style="font-size:10px;color:#fcd34d;border-color:#fcd34d44">Erkenntnis</span> <span style="font-size:13px">${escHtml(deb.insight)}</span></div>`;
}
html += `</div>`;
});
panel.innerHTML = html;
},
setTopic(t) { this.topicFilter = t; this.render(); },
jumpTo(episodeId, startTime) {
if (!CURRENT_PODCAST) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (ep) {
showEpisode(ep);
if (startTime) setTimeout(() => playFrom(startTime, ep), 200);
}
},
hide() { this.visible = false; }
};
// ── Arguments View (#13 Argumentketten-Tracker) ──
const ArgumentsView = {
visible: false,
data: null,
relationFilter: null,
podcastFilter: null,
RELATION_COLORS: {
'erweitert': '#60a5fa', // blau
'widerspricht': '#f87171', // rot
'belegt': '#86efac', // gruen
'relativiert': '#9ca3af', // grau
'gleicher_punkt': '#a78bfa', // violett
'kein_bezug': '#525252'
},
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Argumentketten</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/analyses/arguments?limit=200`);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Argumentketten</h2><p class="subtitle">Keine Argument-Analyse vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Argumentketten</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>Argumentketten</h2>`;
html += `<p class="subtitle">Wie sich Aussagen logisch zueinander verhalten · ${d.links.length} Verknüpfungen sichtbar</p>`;
const chip = (label, count, active, onclick, color) => {
const ac = active ? `background:${color || 'var(--accent)'}33;border-color:${color || 'var(--accent)'};color:var(--text)` : (color ? `border-color:${color}66;color:${color}` : '');
return `<span class="theme-tag" style="cursor:pointer;${ac}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
};
// Relation-Filter
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Relationen', d.links.length, !this.relationFilter, `ArgumentsView.setRelation(null)`);
(d.relations || []).forEach(r => {
html += chip(r.relation, r.count, this.relationFilter === r.relation, `ArgumentsView.setRelation('${r.relation}')`, this.RELATION_COLORS[r.relation]);
});
html += `</div>`;
// Podcast-Filter
if ((d.podcasts || []).length > 1) {
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Podcasts', null, !this.podcastFilter, `ArgumentsView.setPodcast(null)`);
d.podcasts.forEach(p => {
html += chip(escHtml(p.name), null, this.podcastFilter === p.id, `ArgumentsView.setPodcast('${p.id}')`);
});
html += `</div>`;
}
let filtered = d.links;
if (this.relationFilter) filtered = filtered.filter(x => x.relation === this.relationFilter);
if (this.podcastFilter) filtered = filtered.filter(x => x.source_podcast === this.podcastFilter || x.target_podcast === this.podcastFilter);
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Verknüpfungen zum Filter.</p>`;
panel.innerHTML = html;
return;
}
filtered.slice(0, 80).forEach(a => {
const c = this.RELATION_COLORS[a.relation] || '#888';
const arrow = a.relation === 'widerspricht' ? '⇄' : (a.relation === 'gleicher_punkt' ? '≡' : '→');
const conf = (a.confidence || 0) * 100;
const srcEpClick = a.source_podcast === CURRENT_PODCAST
? `onclick="ArgumentsView.jumpTo('${a.source_episode}', ${a.source_start || 0})"` : '';
const tgtEpClick = a.target_podcast === CURRENT_PODCAST
? `onclick="ArgumentsView.jumpTo('${a.target_episode}', ${a.target_start || 0})"` : '';
html += `<div class="transcript-para" style="cursor:default;margin-top:8px;border-left:3px solid ${c}">`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:6px">`;
html += `<span style="color:${c};font-weight:600;font-size:13px">${escHtml(a.relation)} ${arrow}</span>`;
html += `<span class="ts">Konfidenz ${conf.toFixed(0)}%</span>`;
html += `</div>`;
html += `<div class="${srcEpClick ? 'transcript-para' : ''}" style="margin:4px 0;padding:4px 0;${srcEpClick ? 'cursor:pointer' : ''}" ${srcEpClick}>`;
html += `<div class="ts">A: ${escHtml(a.source_episode)}${a.source_guest ? ' · ' + escHtml(a.source_guest) : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((a.source_text || '').slice(0, 220))}${(a.source_text || '').length > 220 ? '…' : ''}</div>`;
html += `</div>`;
html += `<div style="margin:4px 0;padding:4px 0;${tgtEpClick ? 'cursor:pointer' : ''}" ${tgtEpClick}>`;
html += `<div class="ts">B: ${escHtml(a.target_episode)}${a.target_guest ? ' · ' + escHtml(a.target_guest) : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((a.target_text || '').slice(0, 220))}${(a.target_text || '').length > 220 ? '…' : ''}</div>`;
html += `</div>`;
if (a.explanation && !a.explanation.startsWith('rerun-failed')) {
html += `<div class="subtitle" style="margin-top:4px;font-style:italic">${escHtml(a.explanation)}</div>`;
}
html += `</div>`;
});
if (filtered.length > 80) {
html += `<p class="subtitle" style="margin-top:8px">… ${filtered.length - 80} weitere durch Filter eingrenzen.</p>`;
}
panel.innerHTML = html;
},
setRelation(r) { this.relationFilter = r; this.render(); },
setPodcast(p) { this.podcastFilter = p; this.render(); },
jumpTo(episodeId, startTime) {
if (!CURRENT_PODCAST) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (ep) {
showEpisode(ep);
if (startTime) setTimeout(() => playFrom(startTime, ep), 200);
}
},
hide() { this.visible = false; }
};
// ── Density View (#16 Faktendichte / Claim-Density-Map) ──
const DensityView = {
visible: false,
data: null,
podcastFilter: null,
sort: 'density', // 'density' | 'order'
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Faktendichte</h2><p class="subtitle">Lädt …</p>`;
try {
const url = CURRENT_PODCAST ? `${API_BASE}/api/analyses/density?podcast_id=${CURRENT_PODCAST}` : `${API_BASE}/api/analyses/density`;
const r = await fetch(url);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Faktendichte</h2><p class="subtitle">Keine Claim-Daten vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Faktendichte</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 eps = this.data.episodes || [];
const podcasts = [...new Set(eps.map(e => e.podcast_id))];
let html = `<h2>Faktendichte</h2>`;
html += `<p class="subtitle">${eps.length} Episoden mit Claims · Heatmap je Episode in ${this.data.bins} Bins über die Paragraph-Achse</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>`;
if (podcasts.length > 1) {
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle', eps.length, !this.podcastFilter, `DensityView.setPodcast(null)`);
podcasts.forEach(p => {
const n = eps.filter(e => e.podcast_id === p).length;
html += chip(escHtml(p), n, this.podcastFilter === p, `DensityView.setPodcast('${p}')`);
});
html += `</div>`;
}
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('nach Faktendichte', null, this.sort === 'density', `DensityView.setSort('density')`);
html += chip('chronologisch', null, this.sort === 'order', `DensityView.setSort('order')`);
html += `</div>`;
let filtered = eps;
if (this.podcastFilter) filtered = filtered.filter(e => e.podcast_id === this.podcastFilter);
if (this.sort === 'density') {
filtered = filtered.slice().sort((a, b) => (b.claims_per_para || 0) - (a.claims_per_para || 0));
} else {
filtered = filtered.slice().sort((a, b) => a.episode_id.localeCompare(b.episode_id));
}
const maxBin = filtered.length
? Math.max(...filtered.flatMap(e => e.density_bins || []))
: 1;
filtered.slice(0, 60).forEach(e => {
const click = `onclick="DensityView.jumpTo('${escAttr(e.podcast_id)}','${escAttr(e.episode_id)}')"`;
const verPct = e.total_claims ? ((e.verifiable_claims / e.total_claims) * 100).toFixed(0) : '0';
const bins = e.density_bins || [];
const verBins = e.verifiable_bins || [];
// Heatmap inline
const cells = bins.map((c, i) => {
const v = verBins[i] || 0;
const intensity = c / (maxBin || 1);
// Verifizierbar = grün, restlich (Meinung) = warm
const greenShare = c > 0 ? v / c : 0;
const r = Math.round(220 - 100 * greenShare);
const g = Math.round(120 + 100 * greenShare);
const b = Math.round(80 + 50 * greenShare);
const op = (0.15 + intensity * 0.7).toFixed(2);
return `<div title="Bin ${i+1}: ${c} Claims (${v} verifizierbar)" style="flex:1;background:rgba(${r},${g},${b},${op});border-radius:1.5px"></div>`;
}).join('');
html += `<div class="transcript-para" style="cursor:pointer" ${click}>`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:4px;align-items:baseline">`;
html += `<span><strong>${escHtml(e.episode_id)}</strong> ${escHtml(e.title)}${e.guest ? ` · ${escHtml(e.guest)}` : ''}</span>`;
html += `<span class="ts">${e.total_claims} Claims · ${verPct}% verifizierbar · ${(e.claims_per_para || 0).toFixed(2)}/Absatz</span>`;
html += `</div>`;
html += `<div style="display:flex;gap:1px;height:18px">${cells}</div>`;
html += `</div>`;
});
if (filtered.length > 60) {
html += `<p class="subtitle" style="margin-top:8px">… ${filtered.length - 60} weitere durch Filter eingrenzen.</p>`;
}
panel.innerHTML = html;
},
setPodcast(p) { this.podcastFilter = p; this.render(); },
setSort(s) { this.sort = s; 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() {
const input = document.getElementById('search-input');
let debounce = null;
input.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => this.run(input.value.trim()), 300);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { input.value = ''; this.clear(); }
});
},
async run(query) {
if (query.length < 3) { this.clear(); return; }
const crossSearch = document.getElementById('cross-search-cb')?.checked;
const pidParam = crossSearch ? '' : (CURRENT_PODCAST ? `&podcast_id=${CURRENT_PODCAST}` : '');
// Try semantic search via API first
if (CURRENT_PODCAST || crossSearch) {
try {
const resp = await fetch(`${API_BASE}/api/semantic-search?q=${encodeURIComponent(query)}${pidParam}`);
if (resp.ok) {
const apiResults = await resp.json();
if (apiResults.length > 0) {
this.showSemanticResults(apiResults, query);
return;
}
}
} catch (e) {}
// Fallback to text search via API
try {
const resp = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(query)}${pidParam}`);
if (resp.ok) {
const apiResults = await resp.json();
if (apiResults.length > 0) {
this.showApiResults(apiResults, query);
return;
}
}
} catch (e) {}
}
// Client-side fallback
if (!TRANSCRIPTS || Object.keys(TRANSCRIPTS).length === 0) await TranscriptView.loadTranscripts();
const results = [];
const qLower = query.toLowerCase();
// Search transcripts
for (const [key, epData] of Object.entries(TRANSCRIPTS)) {
const epId = key.split('-')[0]; // S1E1-Wachstum → S1E1
const ep = DATA.episodes.find(e => e.id === epId);
if (!ep) continue;
epData.paragraphs.forEach(p => {
const idx = p.text.toLowerCase().indexOf(qLower);
if (idx === -1) return;
// Get context around match
const start = Math.max(0, idx - 60);
const end = Math.min(p.text.length, idx + query.length + 60);
let snippet = (start > 0 ? '…' : '') + p.text.substring(start, end) + (end < p.text.length ? '…' : '');
results.push({
episodeId: epId,
episode: ep,
start: p.start,
snippet: snippet,
query: query
});
});
}
// Also search quotes
DATA.quotes.forEach(q => {
const text = q.verbatim || q.text;
if (text.toLowerCase().includes(qLower)) {
results.push({
episodeId: q.episode,
episode: DATA.episodes.find(e => e.id === q.episode),
start: q.startTime,
snippet: text,
query: query,
isQuote: true,
quote: q
});
}
});
this.showResults(results, query);
},
showResults(results, query) {
const panel = document.getElementById('panel');
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
if (results.length === 0) {
panel.innerHTML = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`;
return;
}
let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
results.slice(0, 50).forEach(r => {
const staffel = DATA.staffeln.find(s => s.id === r.episode.staffel);
const highlighted = this.highlight(r.snippet, r.query);
html += `<div class="search-result" onclick="Search.goTo('${r.episodeId}', ${r.start})">`;
html += `<div class="sr-episode" style="color:${staffel.color}">${r.episodeId}: ${r.episode.title}${r.episode.guest}`;
if (r.start !== null) html += ` · ${fmtTime(r.start)}`;
html += `</div>`;
html += `<div class="sr-text">${highlighted}</div>`;
html += `</div>`;
});
if (results.length > 50) {
html += `<p class="subtitle">… und ${results.length - 50} weitere Treffer</p>`;
}
panel.innerHTML = html;
},
showSemanticResults(results, query) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.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 => {
html += `<div class="search-result" onclick="Search.goTo('${r.episode_id}', ${r.start_time})">`;
html += `<div class="sr-episode">${r.podcast_id}/${r.episode_id}: ${r.episode_title || ''}${r.guest || ''} · ${r.start_time !== null ? fmtTime(r.start_time) : ''} <span class="semantic-badge">${(r.score * 100).toFixed(0)}%</span></div>`;
html += `<div class="sr-text">${escHtml(r.text_preview || '')}</div>`;
html += `</div>`;
});
panel.innerHTML = html;
},
showApiResults(results, query) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
const panel = document.getElementById('panel');
let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
results.forEach(r => {
const highlighted = this.highlight(r.text.substring(0, 200), query);
html += `<div class="search-result" onclick="Search.goTo('${r.episode_id}', ${r.start_time})">`;
html += `<div class="sr-episode">${r.episode_id}: ${r.title || ''}${r.guest || ''} · ${r.start_time !== null ? fmtTime(r.start_time) : ''}</div>`;
html += `<div class="sr-text">${highlighted}</div>`;
html += `</div>`;
});
panel.innerHTML = html;
},
highlight(text, query) {
const escaped = escHtml(text);
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return escaped.replace(re, '<mark>$1</mark>');
},
goTo(episodeId, startTime) {
TranscriptView.show(episodeId, startTime);
if (startTime !== null) {
const ep = DATA.episodes.find(e => e.id === episodeId);
if (ep && ep.audioFile) {
AudioPlayer.load({
audioFile: ep.audioFile, startTime: startTime, endTime: null,
text: `${ep.title} ab ${fmtTime(startTime)}`, speaker: ep.guest,
episode: episodeId, id: '_search'
});
}
}
},
clear() {
// Show welcome
const panel = document.getElementById('panel');
panel.innerHTML = document.getElementById('welcome-panel')?.outerHTML || '';
}
};
// ── Data Loading ──
// Detect mode: API backend or static files
const API_BASE = ''; // Same origin
let CURRENT_PODCAST = null;
async function loadApp() {
try {
// Try API first
const resp = await fetch(`${API_BASE}/api/podcasts`);
if (resp.ok) {
const podcasts = await resp.json();
if (podcasts.length === 1) {
// Single podcast → load directly
await selectPodcast(podcasts[0].id);
} else if (podcasts.length > 1) {
// Multiple podcasts → show selector
showPodcastSelector(podcasts);
} else {
throw new Error('No podcasts found');
}
return;
}
} catch (e) {
// Fallback: static files
console.log('API not available, falling back to static files');
}
// Static file fallback
try {
const resp = await fetch('mindmap_data.json');
DATA = await resp.json();
CURRENT_PODCAST = 'default';
init();
} catch (e) {
console.error('Failed to load data:', e);
}
}
async function selectPodcast(podcastId) {
try {
const resp = await fetch(`${API_BASE}/api/podcasts/${podcastId}`);
DATA = await resp.json();
CURRENT_PODCAST = podcastId;
// Reset mindmap area (might have been used for selector)
const mindmap = document.getElementById('mindmap');
mindmap.style.overflow = 'hidden';
mindmap.style.display = '';
mindmap.style.alignItems = '';
mindmap.style.justifyContent = '';
mindmap.style.flexDirection = '';
mindmap.style.padding = '';
mindmap.innerHTML = '<svg id="svg"></svg>';
// Clear filters + timeline
document.getElementById('staffel-filters').innerHTML = '';
const tl = document.getElementById('timeline-container');
if (tl) { tl.remove(); timelineBuilt = false; }
// Reset to mindmap view
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
document.getElementById('tab-mindmap')?.classList.add('active');
TRANSCRIPTS = null;
init();
} catch (e) {
console.error('Failed to load podcast:', e);
}
}
let ALL_PODCASTS = [];
function showPodcastSelector(podcasts) {
ALL_PODCASTS = podcasts;
const panel = document.getElementById('panel');
const mindmap = document.getElementById('mindmap');
// Show cross-search toggle if multiple podcasts
if (podcasts.length > 1) {
document.getElementById('cross-toggle').style.display = '';
}
// Show selector full-width in mindmap area
mindmap.innerHTML = '';
mindmap.style.overflow = 'auto';
mindmap.style.display = 'flex';
mindmap.style.alignItems = 'center';
mindmap.style.justifyContent = 'center';
mindmap.style.flexDirection = 'column';
mindmap.style.padding = '20px';
let selectorHtml = '<div class="welcome"><h2>Podcast Mindmap</h2><p>Wähle einen Podcast oder vergleiche zwei.</p></div>';
selectorHtml += '<div class="podcast-selector" id="podcast-selector">';
podcasts.forEach(p => {
selectorHtml += `<div class="podcast-card" id="pc-${p.id}" onclick="selectPodcast('${p.id}')">`;
selectorHtml += `<h3>${escHtml(p.name)}</h3>`;
selectorHtml += `<p>${escHtml(p.description || '')}</p>`;
selectorHtml += `</div>`;
});
selectorHtml += '</div>';
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 += '<button class="compare-btn" onclick="DebatesView.show()">Debatten</button>';
selectorHtml += '<button class="compare-btn" onclick="ArgumentsView.show()">Argumentketten</button>';
selectorHtml += '<button class="compare-btn" onclick="DensityView.show()">Faktendichte</button>';
selectorHtml += '</div>';
}
selectorHtml += '<div id="compare-result"></div>';
mindmap.innerHTML = selectorHtml;
// Panel: minimal welcome — ID beibehalten, init() referenziert sie
panel.innerHTML = '<div class="welcome" id="welcome-panel"><h2>Willkommen</h2><p>Wähle links einen Podcast.</p></div>';
document.getElementById('app-title').textContent = 'Podcast';
document.title = 'Podcast Mindmap';
}
// ── #8: Compare Podcasts ──
let compareMode = false;
let compareSelection = [];
function startCompare() {
if (ALL_PODCASTS.length < 2) return;
compareMode = true;
compareSelection = [];
document.querySelectorAll('.podcast-card').forEach(c => c.classList.remove('selected'));
const result = document.getElementById('compare-result');
if (result) result.innerHTML = '<p class="subtitle" style="text-align:center">Wähle zwei Podcasts zum Vergleichen.</p>';
// Override click handlers temporarily
ALL_PODCASTS.forEach(p => {
const card = document.getElementById(`pc-${p.id}`);
if (card) card.onclick = () => toggleCompareSelect(p.id);
});
}
function toggleCompareSelect(id) {
const card = document.getElementById(`pc-${id}`);
const idx = compareSelection.indexOf(id);
if (idx >= 0) {
compareSelection.splice(idx, 1);
card.classList.remove('selected');
} else {
if (compareSelection.length >= 2) return;
compareSelection.push(id);
card.classList.add('selected');
}
if (compareSelection.length === 2) {
runCompare(compareSelection[0], compareSelection[1]);
}
}
async function runCompare(a, b) {
const result = document.getElementById('compare-result');
if (!result) return;
result.innerHTML = '<p class="subtitle" style="text-align:center">Vergleiche…</p>';
try {
const resp = await fetch(`${API_BASE}/api/compare?a=${a}&b=${b}`);
if (!resp.ok) throw new Error('API error');
const data = await resp.json();
showCompareResult(data, a, b);
} catch (e) {
result.innerHTML = '<p class="subtitle" style="text-align:center">Vergleich nicht verfügbar.</p>';
}
}
function showCompareResult(data, a, b) {
const result = document.getElementById('compare-result');
const sa = data.stats[a], sb = data.stats[b];
let html = '<div class="compare-section">';
html += '<h3 style="text-align:center">Vergleich</h3>';
// Stats
html += '<div class="compare-stats">';
html += `<div class="compare-stat-card"><div class="stat-val">${sa.episodes + sb.episodes}</div><div class="stat-label">Episoden gesamt</div></div>`;
html += `<div class="compare-stat-card"><div class="stat-val">${data.shared_topics.length}</div><div class="stat-label">Gemeinsame Themen</div></div>`;
html += `<div class="compare-stat-card"><div class="stat-val">${data.cross_links_count}</div><div class="stat-label">Semantische Querverbindungen</div></div>`;
html += '</div>';
// Shared topics
if (data.shared_topics.length > 0) {
html += '<h3>Gemeinsame Themen</h3>';
html += '<div class="shared-topics">';
data.shared_topics.forEach(t => {
html += `<span class="shared-topic" onclick="searchTopic('${escHtml(t)}')">${escHtml(t.replace(/_/g, ' '))}</span>`;
});
html += '</div>';
}
// Top cross-links
if (data.top_cross_links.length > 0) {
html += '<h3 style="margin-top:12px">Stärkste Querverbindungen</h3>';
data.top_cross_links.slice(0, 10).forEach(link => {
html += '<div class="cross-link-card">';
html += `<div class="cl-score">${(link.score * 100).toFixed(0)}%</div>`;
html += `<div class="cl-label">${escHtml(link.source_podcast)}</div>`;
html += `<div class="cl-source"><strong>${escHtml(link.source_title)}</strong>: ${escHtml(link.source_text)}</div>`;
html += '<div class="cl-arrow">↕</div>';
html += `<div class="cl-label">${escHtml(link.target_podcast)}</div>`;
html += `<div class="cl-target"><strong>${escHtml(link.target_title)}</strong>: ${escHtml(link.target_text)}</div>`;
html += '</div>';
});
}
// Back buttons
html += '<div class="compare-actions" style="margin-top:16px">';
html += `<button class="compare-btn" onclick="selectPodcast('${a}')">${escHtml(sa.name)} öffnen</button>`;
html += `<button class="compare-btn" onclick="selectPodcast('${b}')">${escHtml(sb.name)} öffnen</button>`;
html += '</div>';
html += '</div>';
result.innerHTML = html;
// Reset compare mode
compareMode = false;
ALL_PODCASTS.forEach(p => {
const card = document.getElementById(`pc-${p.id}`);
if (card) card.onclick = () => selectPodcast(p.id);
});
}
function searchTopic(topic) {
const input = document.getElementById('search-input');
input.value = topic.replace(/_/g, ' ');
// Enable cross-podcast search for topic search
document.getElementById('cross-search-cb').checked = true;
Search.run(input.value);
}
// Back to podcast list
function showPodcastList() {
CURRENT_PODCAST = null;
document.getElementById('svg').innerHTML = '';
document.getElementById('staffel-filters').innerHTML = '';
showPodcastSelector(ALL_PODCASTS);
}
loadApp();
function init() {
const name = DATA.name || 'Podcast';
document.title = name + ' — Mindmap';
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;display:flex;flex-wrap:wrap;gap:6px">
<button class="transcript-toggle" onclick="GapsView.show()">Leerstellen</button>
<button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button>
<button class="transcript-toggle" onclick="DebatesView.show()">Debatten</button>
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
<button class="transcript-toggle" onclick="DensityView.show()">Faktendichte</button>
</p>`
: `<p style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
<button class="transcript-toggle" onclick="DensityView.show()">Faktendichte</button>
</p>`;
// Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen
let welcome = document.getElementById('welcome-panel');
if (!welcome) {
const panel = document.getElementById('panel');
panel.innerHTML = '<div class="welcome" id="welcome-panel"></div>';
welcome = document.getElementById('welcome-panel');
}
welcome.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>
${gapsBtn}`;
buildFilters();
// Wait for DOM to render the SVG element before building the graph
requestAnimationFrame(() => { requestAnimationFrame(() => { buildGraph(); }); });
Search.init();
}
// ── Staffel Filters ──
let activeStaffel = 0;
function buildFilters() {
const bar = document.getElementById('staffel-filters');
const allBtn = document.createElement('button');
allBtn.className = 'filter-btn active';
allBtn.textContent = 'Alle';
allBtn.dataset.staffel = '0';
allBtn.onclick = () => filterStaffel(0);
bar.appendChild(allBtn);
DATA.staffeln.forEach(s => {
const btn = document.createElement('button');
btn.className = 'filter-btn';
btn.textContent = `S${s.id}: ${s.name}`;
btn.dataset.staffel = s.id;
btn.onclick = () => filterStaffel(s.id);
bar.appendChild(btn);
});
}
function filterStaffel(id) {
activeStaffel = id;
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', parseInt(b.dataset.staffel) === id);
});
updateVisibility();
}
// ── Graph ──
function buildGraph() {
const svg = d3.select('#svg');
const container = document.getElementById('mindmap');
let W = container.clientWidth;
let H = container.clientHeight;
// Fallback if container not yet laid out
if (!W || W < 100) W = window.innerWidth - 400;
if (!H || H < 100) H = window.innerHeight - 52;
if (W < 200) W = 600;
if (H < 200) H = 400;
const isMobile = W < 600;
const sc = isMobile ? 0.6 : 1;
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);
const n = { id: ep.id, type: 'episode', label: ep.id, title: ep.title, guest: ep.guest,
staffel: ep.staffel, audioFile: ep.audioFile, r: 16 * sc, color: st ? st.color : '#666' };
nodes.push(n);
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,
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 => {
if (d.type === 'center-theme') return 160 * sc;
if (d.type === 'theme-episode') return 100 * sc;
return 50 * sc;
}).strength(d => d.type === 'center-theme' ? 0.8 : d.type === 'theme-episode' ? 0.3 : 0.2))
.force('charge', d3.forceManyBody().strength(d => {
if (d.type === 'center') return -800 * sc;
if (d.type === 'theme') return -400 * sc;
if (d.type === 'episode') return -150 * sc;
return -30 * sc;
}))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.05))
.force('collision', d3.forceCollide().radius(d => d.r + 4))
.alphaDecay(0.02);
const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
const g = svg.append('g');
const linkEls = g.append('g').selectAll('line').data(links).join('line')
.attr('class', d => `link link-${d.type}`)
.attr('stroke', d => {
const src = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
return src ? src.color : '#374151';
});
const nodeG = g.append('g');
// Quote nodes — tap shows in panel, does NOT play
const quoteNodes = nodeG.selectAll('.node-quote').data(nodes.filter(n => n.type === 'quote')).join('g')
.attr('class', 'node-quote').call(drag(simulation));
quoteNodes.append('circle').attr('r', d => d.r).attr('fill', d => d.color)
.attr('opacity', d => d.isTopQuote ? 0.9 : 0.4)
.attr('stroke', d => d.isTopQuote ? '#f59e0b' : 'none')
.attr('stroke-width', d => d.isTopQuote ? 2 : 0);
const epNodes = nodeG.selectAll('.node-episode').data(nodes.filter(n => n.type === 'episode')).join('g')
.attr('class', 'node-episode').call(drag(simulation));
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' || 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);
const centerNode = nodeG.selectAll('.node-center').data(nodes.filter(n => n.type === 'center')).join('g').attr('class', 'node-center');
centerNode.append('circle').attr('r', d => d.r).attr('fill', d => d.color + '22').attr('stroke', d => d.color).attr('stroke-width', 2);
centerNode.append('text').attr('text-anchor', 'middle').attr('fill', '#60a5fa').attr('font-size', '11px').attr('font-weight', '700')
.selectAll('tspan').data(d => d.label.split('\n')).join('tspan')
.attr('x', 0).attr('dy', (d, i) => i === 0 ? '-0.3em' : '1.2em').text(d => d);
simulation.on('tick', () => {
linkEls.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
quoteNodes.attr('transform', d => `translate(${d.x},${d.y})`);
epNodes.attr('transform', d => `translate(${d.x},${d.y})`);
themeNodes.attr('transform', d => `translate(${d.x},${d.y})`);
centerNode.attr('transform', d => `translate(${d.x},${d.y})`);
});
window._nodes = nodes; window._quoteNodes = quoteNodes;
window._epNodes = epNodes; window._themeNodes = themeNodes; window._linkEls = linkEls;
}
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;
});
}
// ── Drag with tap detection ──
function drag(sim) {
let sx, sy, moved;
return d3.drag()
.on('start', (e, d) => {
if (!e.active) sim.alphaTarget(0.1).restart();
d.fx = d.x; d.fy = d.y; sx = e.x; sy = e.y; moved = false;
})
.on('drag', (e, d) => {
d.fx = e.x; d.fy = e.y;
if (Math.sqrt((e.x - sx) ** 2 + (e.y - sy) ** 2) > 5) moved = true;
})
.on('end', (e, d) => {
if (!e.active) sim.alphaTarget(0);
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);
}
});
}
// ── Panel: Theme ──
function showTheme(theme) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.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));
let html = `<h2 style="color:${theme.color}">${td.label}</h2>`;
html += `<p class="subtitle">${td.description}</p>`;
html += `<p class="subtitle">${quotes.length} Zitate aus ${td.episodes.length} Episoden</p>`;
html += '<div style="margin-top:8px">';
td.episodes.forEach(epId => {
const ep = DATA.episodes.find(e => e.id === epId);
if (ep) {
const st = DATA.staffeln.find(s => s.id === ep.staffel);
html += `<span class="theme-tag" style="background:${st.color}22;color:${st.color};border:1px solid ${st.color}44" onclick="showEpisodeById('${ep.id}')">${ep.id} ${ep.guest}</span> `;
}
});
html += '</div>';
quotes.forEach(q => { html += buildQuoteCard(q, theme.color); });
panel.innerHTML = html;
}
// ── 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);
const quotes = DATA.quotes.filter(q => q.episode === epData.id);
let html = `<h2 style="color:${staffel.color}">${epData.id}: ${epData.title}</h2>`;
html += `<p class="subtitle">Gast: ${epData.guest} · Staffel ${epData.staffel}: ${staffel.name}</p>`;
html += `<p class="subtitle">${quotes.length} Zitate</p>`;
// Action buttons
if (epData.audioFile) {
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));
html += '<div style="margin-top:8px">';
epThemes.forEach(t => {
html += `<span class="theme-tag" style="background:${t.color}22;color:${t.color};border:1px solid ${t.color}44;cursor:pointer" onclick="showThemeById('${t.id}')">${t.label}</span> `;
});
html += '</div>';
quotes.forEach(q => { html += buildQuoteCard(q, staffel.color); });
panel.innerHTML = html;
}
function showEpisodeById(id) {
const ep = DATA.episodes.find(e => e.id === id);
if (ep) showEpisode(ep);
}
function showThemeById(id) {
const theme = window._nodes.find(n => n.id === id);
if (theme) showTheme(theme);
}
// ── Panel: Quote detail (from mindmap tap) ──
function showQuoteDetail(q) {
const quoteData = DATA.quotes.find(dq => dq.id === q.id) || q;
AudioPlayer.load(quoteData);
}
// ── Quote Card ──
function buildQuoteCard(q, color) {
const hasAudio = q.audioFile && q.startTime !== null;
const cls = hasAudio ? '' : ' no-audio';
const topBadge = q.isTopQuote ? '<span class="top-badge">TOP 10</span>' : '';
const timeStr = q.startTime !== null ? fmtTime(q.startTime) : '';
return `
<div class="quote-card${cls}" id="card-${q.id}" style="border-left-color:${color}">
<div class="quote-text">"${escHtml(q.verbatim || q.text)}"</div>
<div class="quote-meta">
<span>${q.speaker} · ${q.episode}${timeStr ? ' · ' + timeStr : ''}${topBadge}</span>
${hasAudio ? `<button class="export-btn" onclick="event.stopPropagation();exportSoundbite('${q.id}')" title="Audio-Clip herunterladen">Clip ↓</button>
<button class="play-btn" onclick="event.stopPropagation();playQuoteById('${q.id}')"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></button>` : ''}
</div>
</div>`;
}
function playQuoteById(id) {
const q = DATA.quotes.find(q => q.id === id);
if (q) AudioPlayer.play(q);
}
// ── Helpers ──
function fmtTime(sec) {
if (sec === null || sec === undefined) return '';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escAttr(s) {
// Fuer Werte in inline onclick='...': Anfuehrungszeichen, Slashes, Backslashes neutralisieren.
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
// ============================================================
// #2: Obsidian-style Backlinks
// ============================================================
let TOPICS = null;
async function loadTopics() {
if (TOPICS) return;
try {
const r = await fetch('topics_index.json');
TOPICS = await r.json();
} catch (e) {
try {
const r = await fetch('data/topics_index.json');
TOPICS = await r.json();
} catch (e2) { TOPICS = { tagged_paragraphs: {}, crossrefs: [] }; }
}
}
function buildBacklinks(episodeKey, paraIdx) {
if (!TOPICS || !TOPICS.crossrefs) return '';
const refs = TOPICS.crossrefs.filter(r =>
(r.source.episode === episodeKey && r.source.paragraph === paraIdx) ||
(r.target.episode === episodeKey && r.target.paragraph === paraIdx)
).slice(0, 5);
if (refs.length === 0) return '';
let html = '<div class="backlinks"><div class="backlinks-title">Verwandte Stellen</div>';
refs.forEach(ref => {
const other = (ref.source.episode === episodeKey && ref.source.paragraph === paraIdx)
? ref.target : ref.source;
const otherEpId = other.episode.split('-')[0];
const ep = DATA.episodes.find(e => e.id === otherEpId);
const epName = ep ? `${ep.id}: ${ep.title}` : other.episode;
// Get preview text
let preview = '';
if (TOPICS.tagged_paragraphs[other.episode] && TOPICS.tagged_paragraphs[other.episode][other.paragraph]) {
preview = TOPICS.tagged_paragraphs[other.episode][other.paragraph].text_preview;
}
html += `<span class="backlink" onclick="TranscriptView.show('${otherEpId}')">`;
html += `<span class="bl-episode">${epName}</span> `;
html += `<span class="bl-preview">${escHtml(preview.substring(0, 60))}…</span>`;
html += `</span>`;
});
html += '</div>';
return html;
}
function buildTopicTags(episodeKey, paraIdx) {
if (!TOPICS || !TOPICS.tagged_paragraphs[episodeKey]) return '';
const para = TOPICS.tagged_paragraphs[episodeKey][paraIdx];
if (!para || !para.tags || para.tags.length === 0) return '';
return '<div style="margin-top:4px">' +
para.tags.map(t => `<span class="topic-tag">${t.replace('_', ' ')}</span>`).join('') +
'</div>';
}
// Patch TranscriptView.show to include backlinks + semantic cross-links
const _origTranscriptShow = TranscriptView.show.bind(TranscriptView);
TranscriptView.show = async function(episodeId, seekTime) {
await loadTopics();
await _origTranscriptShow(episodeId, seekTime);
// Find the episode key in TOPICS
const epKey = Object.keys(TOPICS.tagged_paragraphs || {}).find(k => k.startsWith(episodeId));
// Add topic tags, backlinks, and semantic cross-links to each paragraph
const paraEls = document.querySelectorAll('.transcript-para');
for (const el of paraEls) {
const idx = parseInt(el.dataset.idx);
// Topic tags + keyword backlinks
if (epKey) {
const tags = buildTopicTags(epKey, idx);
const links = buildBacklinks(epKey, idx);
if (tags || links) {
el.insertAdjacentHTML('beforeend', tags + links);
}
}
// #10: Semantic cross-podcast links (lazy-load on expand)
if (CURRENT_PODCAST) {
const expandBtn = document.createElement('span');
expandBtn.className = 'backlink';
expandBtn.style.fontSize = '10px';
expandBtn.style.color = '#2a9d8f';
expandBtn.textContent = '↔ Verwandte Stellen…';
expandBtn.onclick = (e) => { e.stopPropagation(); loadSemanticLinks(expandBtn, episodeId, idx); };
el.appendChild(expandBtn);
}
}
};
// ============================================================
// #10: Cross-Podcast Semantic Links
// ============================================================
async function loadSemanticLinks(btn, episodeId, paraIdx) {
btn.textContent = 'Lade…';
btn.onclick = null;
try {
// Try precomputed first, then live
let results = [];
try {
const resp = await fetch(`${API_BASE}/api/similar-precomputed/${CURRENT_PODCAST}/${episodeId}/${paraIdx}?limit=5`);
if (resp.ok) results = await resp.json();
} catch (e) {}
if (results.length === 0) {
const resp = await fetch(`${API_BASE}/api/similar/${CURRENT_PODCAST}/${episodeId}/${paraIdx}?limit=5&cross_podcast=true`);
if (resp.ok) results = await resp.json();
}
if (results.length === 0) {
btn.textContent = 'Keine verwandten Stellen gefunden.';
btn.style.cursor = 'default';
return;
}
// Replace button with results
const container = document.createElement('div');
container.className = 'backlinks';
container.innerHTML = '<div class="backlinks-title">Semantisch verwandte Stellen</div>';
results.forEach(r => {
const isCross = r.podcast_id !== CURRENT_PODCAST;
const card = document.createElement('div');
card.className = 'semantic-link-card';
card.onclick = () => {
if (isCross) {
// Switch podcast and navigate
selectPodcast(r.podcast_id).then(() => {
TranscriptView.show(r.episode_id, r.start_time);
});
} else {
TranscriptView.show(r.episode_id, r.start_time);
}
};
let inner = '';
if (isCross) inner += `<div class="sl-podcast">${escHtml(r.podcast_id)}</div>`;
inner += `<div class="sl-episode">${escHtml(r.episode_id)}: ${escHtml(r.episode_title || '')}${escHtml(r.guest || '')}`;
inner += ` <span class="semantic-badge">${(r.score * 100).toFixed(0)}%</span></div>`;
inner += `<div class="sl-text">"${escHtml(r.text_preview || '')}"</div>`;
card.innerHTML = inner;
container.appendChild(card);
});
btn.replaceWith(container);
} catch (e) {
btn.textContent = 'Fehler beim Laden.';
}
}
// ============================================================
// #6: Soundbite Export
// ============================================================
async function exportSoundbite(quoteId) {
const q = DATA.quotes.find(q => q.id === quoteId);
if (!q || !q.audioFile || q.startTime === null) return;
const btn = document.querySelector(`#card-${quoteId} .export-btn`);
if (btn) btn.textContent = 'Lädt…';
try {
const response = await fetch(`audio/${CURRENT_PODCAST ? CURRENT_PODCAST + '/' : ''}${q.audioFile}`);
const arrayBuffer = await response.arrayBuffer();
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const start = q.startTime;
const end = q.endTime || (q.startTime + 30);
const duration = end - start;
const sampleRate = audioBuffer.sampleRate;
const startSample = Math.floor(start * sampleRate);
const numSamples = Math.floor(duration * sampleRate);
// Create new buffer with just the clip
const channels = audioBuffer.numberOfChannels;
const clipBuffer = audioCtx.createBuffer(channels, numSamples, sampleRate);
for (let ch = 0; ch < channels; ch++) {
const src = audioBuffer.getChannelData(ch);
const dst = clipBuffer.getChannelData(ch);
for (let i = 0; i < numSamples; i++) {
dst[i] = src[startSample + i] || 0;
}
}
// Encode as WAV
const wav = encodeWAV(clipBuffer);
const blob = new Blob([wav], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${q.episode}-${q.speaker.replace(/\s+/g, '_')}-${Math.floor(start)}s.wav`;
a.click();
URL.revokeObjectURL(url);
if (btn) btn.textContent = 'Clip ↓';
audioCtx.close();
} catch (e) {
console.error('Export failed:', e);
if (btn) btn.textContent = 'Fehler';
}
}
function encodeWAV(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
const blockAlign = numChannels * bitDepth / 8;
const byteRate = sampleRate * blockAlign;
const dataSize = buffer.length * blockAlign;
const headerSize = 44;
const wav = new ArrayBuffer(headerSize + dataSize);
const view = new DataView(wav);
// RIFF header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeString(view, 36, 'data');
view.setUint32(40, dataSize, true);
// Interleave samples
let offset = 44;
for (let i = 0; i < buffer.length; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(ch)[i]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
}
return wav;
}
function writeString(view, offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
// ============================================================
// #7: Timeline View
// ============================================================
let timelineBuilt = false;
function switchView(view) {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
document.getElementById(`tab-${view}`).classList.add('active');
if (view === 'mindmap') {
document.getElementById('mindmap').style.display = '';
if (timelineBuilt) {
const tlEl = document.getElementById('timeline-container');
if (tlEl) tlEl.style.display = 'none';
}
} else if (view === 'timeline') {
document.getElementById('mindmap').style.display = 'none';
buildTimeline();
}
}
function buildTimeline() {
let container = document.getElementById('timeline-container');
if (!container) {
container = document.createElement('div');
container.id = 'timeline-container';
container.style.cssText = 'grid-row:2; overflow-y:auto; padding:20px; background:var(--bg);';
document.getElementById('mindmap').parentNode.insertBefore(container, document.getElementById('mindmap').nextSibling);
}
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} <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);
html += `<div style="display:flex;gap:12px;margin-bottom:16px;align-items:flex-start">`;
// Episode marker
html += `<div style="min-width:60px;text-align:right">`;
html += `<div style="font-size:12px;font-weight:600;color:${staffel.color}">${ep.id}</div>`;
html += `<div style="font-size:10px;color:var(--text-muted)">${ep.guest}</div>`;
html += `</div>`;
// Timeline bar
html += `<div style="flex:1;position:relative">`;
html += `<div style="font-size:13px;font-weight:500;margin-bottom:4px;cursor:pointer" onclick="showEpisodeById('${ep.id}')">${ep.title}</div>`;
// Quote dots on timeline
html += `<div style="height:24px;background:var(--surface2);border-radius:12px;position:relative;overflow:hidden">`;
// Find max time for this episode
const maxTime = Math.max(...quotes.filter(q => q.endTime).map(q => q.endTime), 3600);
quotes.forEach(q => {
if (q.startTime === null) return;
const left = (q.startTime / maxTime) * 100;
const isTop = q.isTopQuote;
html += `<div style="position:absolute;left:${left}%;top:50%;transform:translate(-50%,-50%);
width:${isTop ? 10 : 6}px;height:${isTop ? 10 : 6}px;border-radius:50%;
background:${isTop ? '#f59e0b' : staffel.color};opacity:${isTop ? 1 : 0.5};
cursor:pointer" title="${escHtml((q.verbatim || q.text).substring(0, 60))}"
onclick="event.stopPropagation();showQuoteInPanel('${q.id}')"></div>`;
});
html += `</div>`; // timeline bar
// Top quotes below bar
if (topQuotes.length > 0) {
topQuotes.forEach(q => {
html += `<div style="font-size:11px;color:var(--text-muted);font-style:italic;margin-top:4px;padding-left:8px;border-left:2px solid #f59e0b">`;
html += `"${escHtml((q.verbatim || q.text).substring(0, 100))}…" <span style="color:var(--text)">${q.speaker}</span>`;
html += `</div>`;
});
}
html += `</div>`; // content
html += `</div>`; // row
});
html += `</div>`; // staffel
});
html += '</div>';
container.innerHTML = html;
timelineBuilt = true;
}
// ============================================================
// #9: PWA — Service Worker Registration
// ============================================================
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
}
function showQuoteInPanel(quoteId) {
const q = DATA.quotes.find(q => q.id === quoteId);
if (!q) return;
const ep = DATA.episodes.find(e => e.id === q.episode);
if (ep) showEpisode(ep);
// Scroll to and highlight the quote card
setTimeout(() => {
const card = document.getElementById(`card-${quoteId}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.style.outline = '2px solid var(--accent)';
setTimeout(() => card.style.outline = '', 2000);
}
}, 100);
}
</script>
</body>
</html>