- cross-claim-erweitert (282 Linien) ist mit Abstand der dichteste Strang; default off, damit die Erkenntnis-fokussierten Signale (debates, widerspricht, belegt, answers) nicht ueberdeckt werden. - Neuer Quote-Theme-Tag-Layer analog zur Single-Mindmap aus #19: jeder Top-Quote bekommt zusaetzliche Verbindungen zu allen Theme-Tags aus seinem themes-Array, default off, per Toggle einblendbar. - Force-Layout: Quote-Theme-Links bekommen sehr niedrige Strength (0.03), damit sie das Layout nicht ueber die Hierarchie kippen. Effekt: Default-Sichtbarkeit von 1422 auf 835 Linien reduziert; per Toggle laesst sich gezielt die jeweilige Erkenntnisachse einblenden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3279 lines
137 KiB
HTML
3279 lines
137 KiB
HTML
<!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 NETWORK = null; // Mindmap-Querverbindungen (#19): similarity + argument links
|
||
let TRANSCRIPTS = null; // loaded on demand
|
||
const LINK_VISIBILITY = { 'quote-theme': false, 'episode-similar': true, 'arg-belegt': true, 'arg-widerspricht': true, 'arg-erweitert': true, 'arg-relativiert': false };
|
||
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(); CrossMindmapView.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>`;
|
||
}
|
||
// Claim-Match-Badges (#16 Stufe 2)
|
||
if (this.mode === 'claims' && it.match_counts) {
|
||
const RC = {belegt: '#86efac', widerspricht: '#f87171', erweitert: '#60a5fa'};
|
||
for (const [rel, cnt] of Object.entries(it.match_counts)) {
|
||
if (cnt > 0 && RC[rel]) {
|
||
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;color:${RC[rel]};border-color:${RC[rel]}44">${rel} ${cnt}</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>`;
|
||
}
|
||
}
|
||
}
|
||
// Best-Match-Link fuer Claims
|
||
let matchLink = '';
|
||
if (this.mode === 'claims' && it.best_match) {
|
||
const m = it.best_match;
|
||
const RC = {belegt: '#86efac', widerspricht: '#f87171', erweitert: '#60a5fa'};
|
||
const col = RC[m.relation] || 'var(--text-muted)';
|
||
const samePodcast = !m.target_podcast || m.target_podcast === CURRENT_PODCAST;
|
||
const arrow = samePodcast ? '→' : '↗';
|
||
const target = samePodcast ? escHtml(m.target_episode) : `${escHtml(m.target_podcast)} / ${escHtml(m.target_episode)}`;
|
||
if (samePodcast) {
|
||
matchLink = `<div style="margin-top:4px;font-size:12px;color:${col}" onclick="event.stopPropagation(); AnalysisView.jumpAnswer('${m.target_episode}', ${m.target_idx})">
|
||
${arrow} ${escHtml(m.relation)}: ${target}@p${m.target_idx}${m.reason ? ' — ' + escHtml(m.reason).slice(0, 80) : ''}
|
||
</div>`;
|
||
} else {
|
||
matchLink = `<div style="margin-top:4px;font-size:12px;color:${col};opacity:0.85">
|
||
${arrow} ${escHtml(m.relation)} in ${target}@p${m.target_idx}${m.reason ? ' — ' + escHtml(m.reason).slice(0, 80) : ''}
|
||
</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 += matchLink;
|
||
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(); CrossMindmapView.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(); CrossMindmapView.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(); CrossMindmapView.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; }
|
||
};
|
||
|
||
// ── Cross Mindmap (#8/#10 Combined) ──
|
||
const CrossMindmapView = {
|
||
visible: false,
|
||
data: null,
|
||
visibility: {
|
||
'cross-debate': true,
|
||
'cross-claim-belegt': true,
|
||
'cross-claim-widerspricht': true,
|
||
'cross-claim-erweitert': false, // 282 Stueck — visuell zu dicht, opt-in
|
||
'cross-answer': true,
|
||
'cross-similarity': false,
|
||
'quote-theme': false, // Quote-Tags zu allen Themen, opt-in
|
||
},
|
||
activeClusterId: null,
|
||
_refs: null,
|
||
|
||
STYLE: {
|
||
'cross-debate': { stroke: '#c084fc', dash: null, width: 1.8, opacity: 0.9 },
|
||
'cross-claim-belegt': { stroke: '#86efac', dash: null, width: 1.4, opacity: 0.8 },
|
||
'cross-claim-widerspricht': { stroke: '#f87171', dash: null, width: 1.8, opacity: 0.9 },
|
||
'cross-claim-erweitert': { stroke: '#60a5fa', dash: null, width: 1.2, opacity: 0.7 },
|
||
'cross-answer': { stroke: '#fb923c', dash: null, width: 1.4, opacity: 0.8 },
|
||
'cross-similarity': { stroke: '#7dd3fc', dash: '4,4', width: 0.7, opacity: 0.4 },
|
||
'quote-theme': { stroke: '#a3a3a3', dash: '1,3', width: 0.5, opacity: 0.3 },
|
||
},
|
||
|
||
async show(fromUrl = false) {
|
||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||
this.visible = true;
|
||
|
||
if (!fromUrl && window.history && window.location.pathname !== '/cross') {
|
||
window.history.pushState({ cross: true }, '', '/cross');
|
||
}
|
||
|
||
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>';
|
||
|
||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||
document.getElementById('tab-mindmap')?.classList.add('active');
|
||
document.getElementById('staffel-filters').innerHTML = '';
|
||
document.getElementById('app-title').innerHTML = `<span style="cursor:pointer" onclick="showPodcastList()" title="Zurück zur Übersicht">←</span> <span>Cross-Mindmap</span>`;
|
||
document.title = 'Cross-Mindmap — Podcast Mindmap';
|
||
|
||
const panel = document.getElementById('panel');
|
||
panel.innerHTML = `<div class="welcome" id="welcome-panel"><h2>Cross-Mindmap</h2><p class="subtitle">Lädt …</p></div>`;
|
||
|
||
try {
|
||
const r = await fetch(`${API_BASE}/api/analyses/cross-network?top_sim=3`);
|
||
this.data = await r.json();
|
||
} catch (e) {
|
||
panel.innerHTML = `<h2>Cross-Mindmap</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
|
||
return;
|
||
}
|
||
requestAnimationFrame(() => requestAnimationFrame(() => this.render()));
|
||
},
|
||
|
||
render() {
|
||
const svg = d3.select('#svg');
|
||
svg.selectAll('*').remove();
|
||
const container = document.getElementById('mindmap');
|
||
let W = container.clientWidth, H = container.clientHeight;
|
||
if (!W || W < 100) W = window.innerWidth - 400;
|
||
if (!H || H < 100) H = window.innerHeight - 52;
|
||
if (W < 200) W = 800;
|
||
if (H < 200) H = 600;
|
||
const isMobile = W < 600;
|
||
const sc = isMobile ? 0.55 : 1;
|
||
svg.attr('viewBox', `0 0 ${W} ${H}`).attr('preserveAspectRatio', 'xMidYMid meet');
|
||
|
||
const d = this.data;
|
||
const nodes = [], links = [];
|
||
const podcastColor = {};
|
||
(d.podcasts || []).forEach((p, i) => { podcastColor[p.id] = i === 0 ? '#dc7850' : '#60a5fa'; });
|
||
|
||
// Theme-id -> Cluster-id
|
||
const themeToCluster = {};
|
||
(d.theme_clusters || []).forEach(c => {
|
||
c.members.forEach(m => { themeToCluster[`${m.podcast_id}/${m.theme_id}`] = c.id; });
|
||
});
|
||
|
||
// 1. Cluster-Knoten: Cross-Cluster fixiert in der Mittelachse, Solo-Cluster lose
|
||
const crossClusters = (d.theme_clusters || []).filter(c => c.is_cross);
|
||
const soloClusters = (d.theme_clusters || []).filter(c => !c.is_cross);
|
||
crossClusters.forEach((c, i) => {
|
||
const fy = H * (0.3 + 0.4 * (i / Math.max(1, crossClusters.length - 1 || 1)));
|
||
nodes.push({
|
||
id: `cl-${c.id}`, type: 'cluster', cluster: c, label: c.label,
|
||
r: 18 * sc, color: '#fcd34d', fx: W / 2, fy: fy,
|
||
});
|
||
});
|
||
soloClusters.forEach(c => {
|
||
nodes.push({
|
||
id: `cl-${c.id}`, type: 'cluster-solo', cluster: c, label: c.label,
|
||
r: 9 * sc, color: '#71717a',
|
||
});
|
||
});
|
||
|
||
// 2. Theme-Knoten: an ihren Cluster gebunden + per forceX in Halbebene je Podcast
|
||
(d.themes || []).forEach(t => {
|
||
const cid = themeToCluster[`${t.podcast_id}/${t.id}`];
|
||
nodes.push({
|
||
id: `t-${t.podcast_id}-${t.id}`, type: 'theme',
|
||
podcast_id: t.podcast_id, themeId: t.id, label: t.label,
|
||
r: 14 * sc, color: t.color || podcastColor[t.podcast_id],
|
||
cluster: cid,
|
||
});
|
||
if (cid) links.push({ source: `cl-${cid}`, target: `t-${t.podcast_id}-${t.id}`, type: 'cluster-theme' });
|
||
});
|
||
|
||
// 3. Episoden-Knoten: pro Episode an ihre Themen gebunden
|
||
const epIndex = {};
|
||
(d.episodes || []).forEach(e => {
|
||
const k = `e-${e.podcast_id}-${e.id}`;
|
||
epIndex[`${e.podcast_id}:${e.id}`] = k;
|
||
nodes.push({
|
||
id: k, type: 'episode',
|
||
podcast_id: e.podcast_id, episode_id: e.id, title: e.title, guest: e.guest,
|
||
staffel: e.staffel, r: 6 * sc,
|
||
color: e.color || podcastColor[e.podcast_id] || '#777',
|
||
});
|
||
});
|
||
// theme -> episode-Verbindungen
|
||
(d.themes || []).forEach(t => {
|
||
(t.episode_ids || []).forEach(epId => {
|
||
const k = epIndex[`${t.podcast_id}:${epId}`];
|
||
if (k) links.push({ source: `t-${t.podcast_id}-${t.id}`, target: k, type: 'theme-episode' });
|
||
});
|
||
});
|
||
|
||
// 4. Top-Quote-Knoten + optionale Quote-Theme-Tag-Links
|
||
(d.top_quotes || []).forEach(q => {
|
||
const epK = epIndex[`${q.podcast_id}:${q.episode_id}`];
|
||
if (!epK) return;
|
||
const ep = nodes.find(n => n.id === epK);
|
||
const qK = `q-${q.podcast_id}-${q.id}`;
|
||
nodes.push({
|
||
id: qK, type: 'quote',
|
||
podcast_id: q.podcast_id, episode_id: q.episode_id, quote_id: q.id,
|
||
text: q.text, speaker: q.speaker, start_time: q.start_time,
|
||
r: 3 * sc, color: ep ? ep.color : '#aaa',
|
||
});
|
||
links.push({ source: epK, target: qK, type: 'episode-quote' });
|
||
// Quote ↔ Theme-Tags (default off)
|
||
(q.themes || []).forEach(tid => {
|
||
const tK = `t-${q.podcast_id}-${tid}`;
|
||
if (nodes.find(n => n.id === tK)) {
|
||
links.push({ source: qK, target: tK, type: 'quote-theme' });
|
||
}
|
||
});
|
||
});
|
||
|
||
// 5. Cross-Links (debates, claim_*, answers, similarity)
|
||
const cl = d.cross_links || {};
|
||
const addCross = (arr, type) => {
|
||
(arr || []).forEach(l => {
|
||
const a = epIndex[l.a], b = epIndex[l.b];
|
||
if (a && b) links.push({ source: a, target: b, type, meta: l });
|
||
});
|
||
};
|
||
addCross(cl.debates, 'cross-debate');
|
||
addCross(cl.claim_belegt, 'cross-claim-belegt');
|
||
addCross(cl.claim_widerspricht, 'cross-claim-widerspricht');
|
||
addCross(cl.claim_erweitert, 'cross-claim-erweitert');
|
||
addCross(cl.answers, 'cross-answer');
|
||
addCross(cl.similarity, 'cross-similarity');
|
||
|
||
// Force-Setup
|
||
const sim = d3.forceSimulation(nodes)
|
||
.force('link', d3.forceLink(links).id(n => n.id).distance(l => {
|
||
if (l.type === 'cluster-theme') return 100 * sc;
|
||
if (l.type === 'theme-episode') return 70 * sc;
|
||
if (l.type === 'episode-quote') return 18 * sc;
|
||
if (l.type === 'quote-theme') return 50 * sc;
|
||
if (l.type && l.type.startsWith('cross-')) return 220 * sc;
|
||
return 60 * sc;
|
||
}).strength(l => {
|
||
if (l.type === 'cluster-theme') return 0.4;
|
||
if (l.type === 'theme-episode') return 0.2;
|
||
if (l.type === 'episode-quote') return 0.4;
|
||
if (l.type === 'quote-theme') return 0.03;
|
||
if (l.type && l.type.startsWith('cross-')) return 0.04;
|
||
return 0.2;
|
||
}))
|
||
.force('charge', d3.forceManyBody().strength(n => {
|
||
if (n.type === 'cluster') return -1200 * sc;
|
||
if (n.type === 'cluster-solo') return -250 * sc;
|
||
if (n.type === 'theme') return -200 * sc;
|
||
if (n.type === 'episode') return -50 * sc;
|
||
return -8 * sc;
|
||
}))
|
||
.force('x', d3.forceX().strength(n => n.type === 'episode' ? 0.05 : 0).x(n => {
|
||
// Episode in die Halbebene des Podcasts ziehen
|
||
if (n.type !== 'episode') return W / 2;
|
||
const idx = (d.podcasts || []).findIndex(p => p.id === n.podcast_id);
|
||
return idx === 0 ? W * 0.25 : W * 0.75;
|
||
}))
|
||
.force('y', d3.forceY(H / 2).strength(0.02))
|
||
.force('collision', d3.forceCollide().radius(n => n.r + 2))
|
||
.alphaDecay(0.025);
|
||
|
||
const zoom = d3.zoom().scaleExtent([0.25, 3]).on('zoom', e => g.attr('transform', e.transform));
|
||
svg.call(zoom);
|
||
const g = svg.append('g');
|
||
|
||
// Hierarchische Linien (cluster-theme, theme-episode, episode-quote) zuerst, dann Cross darüber
|
||
const linkEls = g.append('g').selectAll('line').data(links).join('line')
|
||
.attr('class', l => `xl xl-${l.type}`)
|
||
.attr('stroke', l => {
|
||
if (this.STYLE[l.type]) return this.STYLE[l.type].stroke;
|
||
if (l.type === 'cluster-theme') return '#fcd34d';
|
||
if (l.type === 'theme-episode') {
|
||
const t = nodes.find(n => n.id === (typeof l.source === 'object' ? l.source.id : l.source));
|
||
return t ? t.color : '#374151';
|
||
}
|
||
if (l.type === 'episode-quote') return '#525252';
|
||
return '#374151';
|
||
})
|
||
.attr('stroke-width', l => this.STYLE[l.type]?.width ?? (l.type === 'cluster-theme' ? 1.4 : 0.7))
|
||
.attr('stroke-dasharray', l => this.STYLE[l.type]?.dash || null)
|
||
.attr('opacity', l => this._linkOpacity(l));
|
||
|
||
const nodeG = g.append('g');
|
||
|
||
const clusterNodes = nodeG.selectAll('.x-cluster').data(nodes.filter(n => n.type === 'cluster' || n.type === 'cluster-solo')).join('g')
|
||
.attr('class', 'x-cluster').style('cursor', 'pointer')
|
||
.on('click', (e, n) => CrossMindmapView.filterCluster(n.cluster.id));
|
||
clusterNodes.append('circle').attr('r', n => n.r)
|
||
.attr('fill', n => n.type === 'cluster' ? '#fcd34d33' : '#71717a22')
|
||
.attr('stroke', n => n.color).attr('stroke-width', n => n.type === 'cluster' ? 2 : 1);
|
||
clusterNodes.append('text').attr('dy', n => -n.r - 6).attr('text-anchor', 'middle')
|
||
.attr('font-size', n => n.type === 'cluster' ? '11px' : '9px')
|
||
.attr('fill', n => n.type === 'cluster' ? '#fcd34d' : 'var(--text-muted)')
|
||
.text(n => n.label.length > 32 ? n.label.slice(0, 30) + '…' : n.label);
|
||
|
||
const themeNodes = nodeG.selectAll('.x-theme').data(nodes.filter(n => n.type === 'theme')).join('g')
|
||
.attr('class', 'x-theme').style('cursor', 'pointer')
|
||
.on('click', (e, n) => CrossMindmapView.openTheme(n.podcast_id, n.themeId));
|
||
themeNodes.append('circle').attr('r', n => n.r).attr('fill', n => n.color + '55').attr('stroke', n => n.color).attr('stroke-width', 1.4);
|
||
themeNodes.append('text').attr('dy', n => -n.r - 4).attr('text-anchor', 'middle')
|
||
.attr('font-size', '10px').attr('fill', 'var(--text)')
|
||
.text(n => n.label.length > 26 ? n.label.slice(0, 24) + '…' : n.label);
|
||
themeNodes.append('title').text(n => n.label);
|
||
|
||
const epNodes = nodeG.selectAll('.x-episode').data(nodes.filter(n => n.type === 'episode')).join('g')
|
||
.attr('class', 'x-episode').style('cursor', 'pointer')
|
||
.on('click', (e, n) => CrossMindmapView.jumpEpisode(n.podcast_id, n.episode_id));
|
||
epNodes.append('circle').attr('r', n => n.r).attr('fill', 'transparent').attr('stroke', n => n.color).attr('stroke-width', 1.2);
|
||
epNodes.append('title').text(n => `${n.podcast_id}/${n.episode_id} · ${n.title || ''}`);
|
||
|
||
const quoteNodes = nodeG.selectAll('.x-quote').data(nodes.filter(n => n.type === 'quote')).join('g')
|
||
.attr('class', 'x-quote').style('cursor', 'pointer')
|
||
.on('click', (e, n) => CrossMindmapView.jumpQuote(n.podcast_id, n.episode_id, n.start_time));
|
||
quoteNodes.append('circle').attr('r', n => n.r).attr('fill', n => n.color).attr('opacity', 0.85)
|
||
.attr('stroke', '#f59e0b').attr('stroke-width', 0.7);
|
||
quoteNodes.append('title').text(n => (n.text || '').slice(0, 120));
|
||
|
||
sim.on('tick', () => {
|
||
linkEls.attr('x1', l => l.source.x).attr('y1', l => l.source.y).attr('x2', l => l.target.x).attr('y2', l => l.target.y);
|
||
clusterNodes.attr('transform', n => `translate(${n.x},${n.y})`);
|
||
themeNodes.attr('transform', n => `translate(${n.x},${n.y})`);
|
||
epNodes.attr('transform', n => `translate(${n.x},${n.y})`);
|
||
quoteNodes.attr('transform', n => `translate(${n.x},${n.y})`);
|
||
});
|
||
|
||
this._refs = { nodes, links, linkEls, themeNodes, epNodes, quoteNodes, clusterNodes };
|
||
|
||
this._renderToggles();
|
||
this._renderPanel();
|
||
},
|
||
|
||
_linkOpacity(l) {
|
||
if (this.STYLE[l.type]) {
|
||
return this.visibility[l.type] === false ? 0 : (this.STYLE[l.type].opacity ?? 1);
|
||
}
|
||
if (l.type === 'cluster-theme') return 0.7;
|
||
if (l.type === 'theme-episode') return 0.35;
|
||
if (l.type === 'episode-quote') return 0.45;
|
||
return 0.4;
|
||
},
|
||
|
||
_renderToggles() {
|
||
const mm = document.getElementById('mindmap');
|
||
const old = document.getElementById('cross-toggles');
|
||
if (old) old.remove();
|
||
const panel = document.createElement('div');
|
||
panel.id = 'cross-toggles';
|
||
panel.style.cssText = 'position:absolute;top:8px;right:8px;background:rgba(20,24,32,0.85);border:1px solid var(--border);border-radius:6px;padding:8px 10px;font-size:11px;display:flex;flex-direction:column;gap:4px;z-index:5;backdrop-filter:blur(4px);max-width:240px';
|
||
const labels = {
|
||
'cross-debate': 'Debatten',
|
||
'cross-claim-widerspricht': 'Widerspruch (Claims)',
|
||
'cross-claim-belegt': 'Belege (Claims)',
|
||
'cross-claim-erweitert': 'Erweiterung (Claims)',
|
||
'cross-answer': 'Frage→Antwort',
|
||
'cross-similarity': 'semantische Ähnlichkeit',
|
||
'quote-theme': 'Quote-Theme-Tags',
|
||
};
|
||
panel.innerHTML = `<div style="font-weight:600;color:var(--text-muted);margin-bottom:2px">Cross-Verbindungen</div>`;
|
||
Object.keys(labels).forEach(t => {
|
||
const style = this.STYLE[t];
|
||
const checked = this.visibility[t] !== false;
|
||
const dashCss = style.dash ? `background:repeating-linear-gradient(90deg,${style.stroke} 0,${style.stroke} 3px,transparent 3px,transparent 6px)` : `background:${style.stroke}`;
|
||
panel.innerHTML += `<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||
<input type="checkbox" data-link="${t}" ${checked ? 'checked' : ''} style="margin:0">
|
||
<span style="display:inline-block;width:18px;height:2px;${dashCss}"></span>
|
||
<span>${labels[t]}</span>
|
||
</label>`;
|
||
});
|
||
mm.style.position = 'relative';
|
||
mm.appendChild(panel);
|
||
panel.querySelectorAll('input').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
this.visibility[cb.dataset.link] = cb.checked;
|
||
if (this._refs?.linkEls) {
|
||
this._refs.linkEls.attr('opacity', l => this._linkOpacity(l));
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
_renderPanel() {
|
||
const panel = document.getElementById('panel');
|
||
const d = this.data;
|
||
const cl = d.cross_links || {};
|
||
const counts = Object.fromEntries(Object.entries(cl).map(([k, v]) => [k, v.length]));
|
||
let html = `<h2>Cross-Mindmap</h2>`;
|
||
html += `<p class="subtitle">${(d.podcasts||[]).map(p=>escHtml(p.name)).join(' ↔ ')} · ${d.episodes.length} Episoden, ${d.top_quotes.length} Top-Zitate</p>`;
|
||
html += `<p style="margin-top:6px;font-size:13px">`;
|
||
html += `<span class="theme-tag" style="border-color:#c084fc66;color:#c084fc">Debatten ${counts.debates}</span> `;
|
||
html += `<span class="theme-tag" style="border-color:#f8717166;color:#f87171">Widerspruch ${counts.claim_widerspricht}</span> `;
|
||
html += `<span class="theme-tag" style="border-color:#86efac66;color:#86efac">Belege ${counts.claim_belegt}</span> `;
|
||
html += `<span class="theme-tag" style="border-color:#60a5fa66;color:#60a5fa">Erweiterung ${counts.claim_erweitert}</span> `;
|
||
html += `<span class="theme-tag" style="border-color:#fb923c66;color:#fb923c">Frage→Antwort ${counts.answers}</span> `;
|
||
html += `<span class="theme-tag" style="border-color:#7dd3fc66;color:#7dd3fc">Ähnlichkeit ${counts.similarity}</span>`;
|
||
html += `</p>`;
|
||
const cross = (d.theme_clusters || []).filter(c => c.is_cross);
|
||
if (cross.length) {
|
||
html += `<h3 style="margin-top:14px;font-size:14px">Cross-Theme-Cluster</h3>`;
|
||
cross.forEach(c => {
|
||
html += `<div class="transcript-para" style="cursor:pointer;border-left:3px solid #fcd34d" onclick="CrossMindmapView.filterCluster('${escAttr(c.id)}')">`;
|
||
html += `<strong>${escHtml(c.label)}</strong>`;
|
||
html += `<div class="subtitle" style="margin-top:4px">`;
|
||
html += c.members.map(m => `<span class="theme-tag">${escHtml(m.podcast_id)} / ${escHtml(m.label)}</span>`).join(' ');
|
||
html += `</div></div>`;
|
||
});
|
||
}
|
||
if (counts.debates) {
|
||
html += `<h3 style="margin-top:14px;font-size:14px">Wichtige Debatten</h3>`;
|
||
(cl.debates || []).slice(0, 8).forEach(deb => {
|
||
html += `<div class="transcript-para" style="cursor:pointer" onclick="DebatesView.show()">`;
|
||
html += `<span class="theme-tag" style="border-color:#c084fc66;color:#c084fc">${escHtml(deb.a.split(':')[0])} ↔ ${escHtml(deb.b.split(':')[0])}</span> `;
|
||
html += `${escHtml(deb.topic)}`;
|
||
html += `</div>`;
|
||
});
|
||
}
|
||
panel.innerHTML = html;
|
||
},
|
||
|
||
filterCluster(clusterId) {
|
||
if (!this._refs) return;
|
||
this.activeClusterId = (this.activeClusterId === clusterId) ? null : clusterId;
|
||
const active = this.activeClusterId;
|
||
if (!active) {
|
||
this._refs.themeNodes.style('opacity', 1);
|
||
this._refs.epNodes.style('opacity', 1);
|
||
this._refs.quoteNodes.style('opacity', 1);
|
||
this._refs.linkEls.attr('opacity', l => this._linkOpacity(l));
|
||
return;
|
||
}
|
||
const cluster = (this.data.theme_clusters || []).find(c => c.id === active);
|
||
if (!cluster) return;
|
||
const memberThemeIds = new Set(cluster.members.map(m => `t-${m.podcast_id}-${m.theme_id}`));
|
||
const memberEpisodeIds = new Set();
|
||
cluster.members.forEach(m => {
|
||
const t = (this.data.themes || []).find(x => x.podcast_id === m.podcast_id && x.id === m.theme_id);
|
||
(t?.episode_ids || []).forEach(epId => memberEpisodeIds.add(`e-${m.podcast_id}-${epId}`));
|
||
});
|
||
this._refs.themeNodes.style('opacity', n => memberThemeIds.has(n.id) ? 1 : 0.15);
|
||
this._refs.epNodes.style('opacity', n => memberEpisodeIds.has(n.id) ? 1 : 0.15);
|
||
this._refs.quoteNodes.style('opacity', n => memberEpisodeIds.has(`e-${n.podcast_id}-${n.episode_id}`) ? 1 : 0.1);
|
||
this._refs.linkEls.attr('opacity', l => {
|
||
const sId = typeof l.source === 'object' ? l.source.id : l.source;
|
||
const tId = typeof l.target === 'object' ? l.target.id : l.target;
|
||
const involved = memberThemeIds.has(sId) || memberThemeIds.has(tId) || memberEpisodeIds.has(sId) || memberEpisodeIds.has(tId);
|
||
return involved ? this._linkOpacity(l) : 0.04;
|
||
});
|
||
},
|
||
|
||
openTheme(podcastId, themeId) {
|
||
selectPodcast(podcastId).then(() => {
|
||
setTimeout(() => {
|
||
const t = (DATA?.themes || []).find(x => x.id === themeId);
|
||
if (t && typeof showTheme === 'function') showTheme(t);
|
||
}, 400);
|
||
});
|
||
},
|
||
|
||
jumpEpisode(podcastId, episodeId) {
|
||
selectPodcast(podcastId).then(() => {
|
||
setTimeout(() => {
|
||
const ep = (DATA?.episodes || []).find(e => e.id === episodeId);
|
||
if (ep) showEpisode(ep);
|
||
}, 400);
|
||
});
|
||
},
|
||
|
||
jumpQuote(podcastId, episodeId, startTime) {
|
||
selectPodcast(podcastId).then(() => {
|
||
setTimeout(() => {
|
||
const ep = (DATA?.episodes || []).find(e => e.id === episodeId);
|
||
if (ep) {
|
||
showEpisode(ep);
|
||
if (startTime) setTimeout(() => playFrom(startTime, ep), 200);
|
||
}
|
||
}, 400);
|
||
});
|
||
},
|
||
|
||
hide() {
|
||
this.visible = false;
|
||
const t = document.getElementById('cross-toggles');
|
||
if (t) t.remove();
|
||
}
|
||
};
|
||
|
||
// ── 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(); CrossMindmapView.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(); CrossMindmapView.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(); CrossMindmapView.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();
|
||
// URL-Routing: /<podcast-id> oder /cross oeffnet direkt
|
||
const pathSegment = window.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/')[0];
|
||
const fromUrl = pathSegment && podcasts.find(p => p.id === pathSegment);
|
||
if (pathSegment === 'cross' && podcasts.length > 1) {
|
||
ALL_PODCASTS = podcasts;
|
||
await CrossMindmapView.show(true);
|
||
} else if (fromUrl) {
|
||
ALL_PODCASTS = podcasts;
|
||
await selectPodcast(fromUrl.id, /*fromUrl*/ true);
|
||
} else 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');
|
||
}
|
||
// Browser-Back/Forward
|
||
window.addEventListener('popstate', async () => {
|
||
const p = window.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/')[0];
|
||
if (!p) {
|
||
CrossMindmapView.hide();
|
||
showPodcastSelector(podcasts);
|
||
} else if (p === 'cross') {
|
||
await CrossMindmapView.show(true);
|
||
} else if (podcasts.find(x => x.id === p) && p !== CURRENT_PODCAST) {
|
||
await selectPodcast(p, true);
|
||
}
|
||
});
|
||
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, fromUrl = false) {
|
||
try {
|
||
const [resp, netResp] = await Promise.all([
|
||
fetch(`${API_BASE}/api/podcasts/${podcastId}`),
|
||
fetch(`${API_BASE}/api/podcasts/${podcastId}/network?top_per_episode=2`).catch(() => null),
|
||
]);
|
||
DATA = await resp.json();
|
||
NETWORK = netResp && netResp.ok ? await netResp.json() : null;
|
||
CURRENT_PODCAST = podcastId;
|
||
if (!fromUrl && window.history && window.location.pathname !== `/${podcastId}`) {
|
||
window.history.pushState({ podcast: podcastId }, '', `/${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 += '<button class="compare-btn" onclick="CrossMindmapView.show()">Cross-Mindmap</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 = '';
|
||
if (window.history && window.location.pathname !== '/') {
|
||
window.history.pushState({}, '', '/');
|
||
}
|
||
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' });
|
||
// #19: Quote ↔ Theme-Tags (zusaetzlich zur Episode-Verbindung)
|
||
if (hasThemes && Array.isArray(q.themes)) {
|
||
q.themes.forEach(tid => {
|
||
if (DATA.themes.find(t => t.id === tid)) {
|
||
links.push({ source: q.id, target: tid, type: 'quote-theme' });
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// #19: Episode ↔ Episode (semantische Aehnlichkeit) und Argument-Pair-Links
|
||
if (NETWORK) {
|
||
(NETWORK.similarity_links || []).forEach(s => {
|
||
// Nur intra-podcast und nur wenn beide Endpunkte als Episoden-Knoten existieren
|
||
if (s.target_podcast === CURRENT_PODCAST && episodeMap[s.source_episode] && episodeMap[s.target_episode]) {
|
||
links.push({ source: s.source_episode, target: s.target_episode, type: 'episode-similar', score: s.score });
|
||
}
|
||
});
|
||
(NETWORK.argument_links || []).forEach(a => {
|
||
if (a.target_podcast !== CURRENT_PODCAST) return;
|
||
if (!episodeMap[a.source_episode] || !episodeMap[a.target_episode]) return;
|
||
// Pro Pair: dominante Relation als eigene Linie (top-count entscheidet)
|
||
const counts = a.counts || {};
|
||
const order = ['widerspricht', 'belegt', 'erweitert', 'relativiert'];
|
||
let dom = null;
|
||
for (const r of order) {
|
||
if (counts[r]) { dom = r; break; }
|
||
}
|
||
if (!dom) return;
|
||
links.push({ source: a.source_episode, target: a.target_episode, type: `arg-${dom}`, n: counts[dom] });
|
||
});
|
||
}
|
||
|
||
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;
|
||
if (d.type === 'episode-similar') return 90 * sc;
|
||
if (d.type && d.type.startsWith('arg-')) return 110 * sc;
|
||
if (d.type === 'quote-theme') return 70 * sc;
|
||
return 50 * sc;
|
||
}).strength(d => {
|
||
if (d.type === 'center-theme') return 0.8;
|
||
if (d.type === 'theme-episode') return 0.3;
|
||
if (d.type === 'episode-similar') return 0.05;
|
||
if (d.type && d.type.startsWith('arg-')) return 0.08;
|
||
if (d.type === 'quote-theme') return 0.05;
|
||
return 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');
|
||
|
||
// #19: Link-Style je Typ
|
||
const LINK_STYLE = {
|
||
'episode-similar': { stroke: '#7dd3fc', dash: '4,4', width: 1.0, opacity: 0.55 },
|
||
'arg-belegt': { stroke: '#86efac', dash: null, width: 1.6, opacity: 0.85 },
|
||
'arg-widerspricht': { stroke: '#f87171', dash: null, width: 1.8, opacity: 0.9 },
|
||
'arg-erweitert': { stroke: '#60a5fa', dash: null, width: 1.4, opacity: 0.75 },
|
||
'arg-relativiert': { stroke: '#9ca3af', dash: '2,3', width: 1.0, opacity: 0.6 },
|
||
'quote-theme': { stroke: '#a3a3a3', dash: '1,3', width: 0.6, opacity: 0.35 },
|
||
};
|
||
|
||
const linkEls = g.append('g').selectAll('line').data(links).join('line')
|
||
.attr('class', d => `link link-${d.type}`)
|
||
.attr('stroke', d => {
|
||
if (LINK_STYLE[d.type]) return LINK_STYLE[d.type].stroke;
|
||
const src = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
|
||
return src ? src.color : '#374151';
|
||
})
|
||
.attr('stroke-dasharray', d => LINK_STYLE[d.type]?.dash || null)
|
||
.attr('stroke-width', d => LINK_STYLE[d.type]?.width ?? 1)
|
||
.attr('opacity', d => {
|
||
if (LINK_STYLE[d.type] && LINK_VISIBILITY[d.type] === false) return 0;
|
||
return LINK_STYLE[d.type]?.opacity ?? 1;
|
||
});
|
||
|
||
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;
|
||
window._linkStyle = LINK_STYLE;
|
||
|
||
// #19: Toggle-Panel rechts oben in der Mindmap
|
||
renderLinkToggles();
|
||
}
|
||
|
||
function renderLinkToggles() {
|
||
const mm = document.getElementById('mindmap');
|
||
let panel = document.getElementById('link-toggles');
|
||
if (panel) panel.remove();
|
||
panel = document.createElement('div');
|
||
panel.id = 'link-toggles';
|
||
panel.style.cssText = 'position:absolute;top:8px;right:8px;background:rgba(20,24,32,0.85);border:1px solid var(--border);border-radius:6px;padding:6px 8px;font-size:11px;display:flex;flex-direction:column;gap:3px;z-index:5;backdrop-filter:blur(4px)';
|
||
const labels = {
|
||
'episode-similar': 'aehnliche Episoden',
|
||
'arg-belegt': 'belegt',
|
||
'arg-widerspricht': 'widerspricht',
|
||
'arg-erweitert': 'erweitert',
|
||
'arg-relativiert': 'relativiert',
|
||
'quote-theme': 'Quote-Theme',
|
||
};
|
||
panel.innerHTML = `<div style="font-weight:600;color:var(--text-muted);margin-bottom:2px">Verbindungen</div>`;
|
||
Object.keys(labels).forEach(t => {
|
||
const style = window._linkStyle?.[t];
|
||
const checked = LINK_VISIBILITY[t] !== false;
|
||
panel.innerHTML += `<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||
<input type="checkbox" data-link="${t}" ${checked ? 'checked' : ''} style="margin:0">
|
||
<span style="display:inline-block;width:18px;height:2px;background:${style?.stroke || '#888'};${style?.dash ? 'background:repeating-linear-gradient(90deg,' + style.stroke + ' 0,' + style.stroke + ' 2px,transparent 2px,transparent 5px)' : ''}"></span>
|
||
<span>${labels[t]}</span>
|
||
</label>`;
|
||
});
|
||
mm.style.position = 'relative';
|
||
mm.appendChild(panel);
|
||
panel.querySelectorAll('input').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
LINK_VISIBILITY[cb.dataset.link] = cb.checked;
|
||
if (window._linkEls) {
|
||
window._linkEls.attr('opacity', d => {
|
||
if (window._linkStyle[d.type] && LINK_VISIBILITY[d.type] === false) return 0;
|
||
return window._linkStyle[d.type]?.opacity ?? 1;
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
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(); CrossMindmapView.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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function escAttr(s) {
|
||
// Fuer Werte in inline onclick='...': Anfuehrungszeichen, Slashes, Backslashes neutralisieren.
|
||
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '"').replace(/</g, '<');
|
||
}
|
||
|
||
// ============================================================
|
||
// #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>
|