podcast-mindmap/webapp/index.html
Dotty Dotter 67bf9ec7b1 Webapp v2: UX-sicheres Audio, Transkript-Mitlesen, Volltextsuche
- #3: Audio startet nicht mehr durch Card-Klick. Separater Play-Button
  pro Zitat, Audio-Bar mit Play/Pause. Bewusste Aktion statt Versehen.
- #4: Navigation durch Themes/Episoden stoppt laufendes Audio nicht.
  Audio-State ist komplett vom Panel-State getrennt.
- #1: Transkript-Ansicht mit synchronem Mitlesen. Aktiver Absatz wird
  hervorgehoben und auto-gescrollt. Klick auf Absatz springt im Audio.
- #5: Suchfeld im Header durchsucht alle Transkripte und Zitate.
  Treffer mit Kontext, Klick öffnet Transkript an der Stelle.

AudioPlayer als eigenständiges Objekt, TranscriptView und Search
als separate Module. Alle dynamisch aus mindmap_data.json + srt_index.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 03:01:59 +02:00

906 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
/* ── 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); }
.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…">
<div class="filter-bar" id="staffel-filters"></div>
</header>
<div id="mindmap"><svg id="svg"></svg></div>
<div id="panel"><div class="welcome" id="welcome-panel"></div></div>
</div>
<div id="audio-bar">
<button class="bar-play-btn" id="bar-play-btn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path id="bar-play-icon" d="M8 5v14l11-7z"/>
</svg>
</button>
<div class="now-playing" id="now-playing" title="Klick: Transkript anzeigen"></div>
<span class="time" id="audio-time">0:00</span>
<button class="bar-transcript-btn" id="bar-transcript-btn">Transkript</button>
</div>
<audio id="main-audio" preload="none"></audio>
<script src="d3.v7.min.js"></script>
<script>
// ============================================================
// Podcast Mindmap App v2
// ============================================================
let DATA = null;
let TRANSCRIPTS = null; // loaded on demand
let simulation = null;
// Audio state — completely independent from panel state
const AudioPlayer = {
el: document.getElementById('main-audio'),
bar: document.getElementById('audio-bar'),
barPlayBtn: document.getElementById('bar-play-btn'),
barIcon: document.getElementById('bar-play-icon'),
nowPlaying: document.getElementById('now-playing'),
timeDisplay: document.getElementById('audio-time'),
currentQuote: null,
isPlaying: false,
PLAY_PATH: 'M8 5v14l11-7z',
PAUSE_PATH: 'M6 4h4v16H6zM14 4h4v16h-4z',
load(q) {
// Load quote into bar WITHOUT playing
if (!q.audioFile || q.startTime === null) return;
this.currentQuote = q;
const sameFile = this.el.src && this.el.src.endsWith(q.audioFile);
if (!sameFile) {
this.el.src = `audio/${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,
async show(episodeId, seekTime) {
if (!TRANSCRIPTS) await this.loadTranscripts();
const key = Object.keys(TRANSCRIPTS).find(k => k.startsWith(episodeId.replace('E', 'E')));
if (!key || !TRANSCRIPTS[key]) return;
this.episodeId = episodeId;
this.paragraphs = TRANSCRIPTS[key].paragraphs;
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>`;
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>`;
html += escHtml(p.text);
html += `</div>`;
});
panel.innerHTML = html;
// Detect user scroll
panel.onscroll = () => { this.userScrolled = true; };
if (seekTime !== undefined) this.syncToTime(seekTime);
},
syncToTime(time) {
if (!this.visible || !this.paragraphs) return;
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) return;
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' });
}
}
}
},
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() {
try {
const resp = await fetch('srt_index.json');
TRANSCRIPTS = await resp.json();
} catch (e) {
// Try from data subdir
try {
const resp = await fetch('data/srt_index.json');
TRANSCRIPTS = await resp.json();
} catch (e2) {
TRANSCRIPTS = {};
}
}
}
};
// ── 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; }
if (!TRANSCRIPTS) 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();
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;
},
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 ──
fetch('mindmap_data.json')
.then(r => r.json())
.then(data => { DATA = data; init(); })
.catch(e => console.error('Failed to load data:', e));
function init() {
const name = DATA.name || 'Podcast';
document.title = name + ' — Mindmap';
document.getElementById('app-title').textContent = name;
document.getElementById('welcome-panel').innerHTML = `
<h2>${name}</h2>
<p>${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>`;
buildFilters();
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');
const W = container.clientWidth || window.innerWidth;
const H = container.clientHeight || window.innerHeight * 0.45;
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 = {};
nodes.push({ id: 'center', type: 'center', label: (DATA.name || 'PODCAST').replace(/\s+/g, '\n'),
r: 40 * sc, fx: W / 2, fy: H / 2, color: '#60a5fa' });
DATA.themes.forEach(t => {
const ml = isMobile ? 18 : 25;
nodes.push({ id: t.id, type: 'theme', label: t.label.length > ml ? t.label.substring(0, ml - 3) + '…' : t.label,
fullLabel: t.label, description: t.description, r: 28 * sc, color: t.color, episodes: t.episodes });
links.push({ source: 'center', target: t.id, type: 'center-theme' });
});
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;
});
DATA.themes.forEach(t => t.episodes.forEach(epId => {
if (episodeMap[epId]) links.push({ source: t.id, target: epId, type: 'theme-episode' });
}));
DATA.quotes.filter(q => q.isTopQuote || q.startTime !== null).forEach(q => {
const ep = episodeMap[q.episode];
nodes.push({ id: q.id, type: 'quote', text: q.text, speaker: q.speaker, episode: q.episode,
themes: q.themes, startTime: q.startTime, endTime: q.endTime, audioFile: q.audioFile,
isTopQuote: q.isTopQuote, verbatim: q.verbatim,
r: (q.isTopQuote ? 6 : 4) * sc, color: ep ? ep.color : '#666', staffel: ep ? ep.staffel : 0 });
links.push({ source: q.episode, target: q.id, type: 'episode-quote' });
});
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(d => {
if (d.type === 'center-theme') return 160 * sc;
if (d.type === 'theme-episode') return 100 * sc;
return 50 * sc;
}).strength(d => d.type === 'center-theme' ? 0.8 : d.type === 'theme-episode' ? 0.3 : 0.2))
.force('charge', d3.forceManyBody().strength(d => {
if (d.type === 'center') return -800 * sc;
if (d.type === 'theme') return -400 * sc;
if (d.type === 'episode') return -150 * sc;
return -30 * sc;
}))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.05))
.force('collision', d3.forceCollide().radius(d => d.r + 4))
.alphaDecay(0.02);
const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
const g = svg.append('g');
const linkEls = g.append('g').selectAll('line').data(links).join('line')
.attr('class', d => `link link-${d.type}`)
.attr('stroke', d => {
const src = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
return src ? src.color : '#374151';
});
const nodeG = g.append('g');
// Quote nodes — tap shows in panel, does NOT play
const quoteNodes = nodeG.selectAll('.node-quote').data(nodes.filter(n => n.type === 'quote')).join('g')
.attr('class', 'node-quote').call(drag(simulation));
quoteNodes.append('circle').attr('r', d => d.r).attr('fill', d => d.color)
.attr('opacity', d => d.isTopQuote ? 0.9 : 0.4)
.attr('stroke', d => d.isTopQuote ? '#f59e0b' : 'none')
.attr('stroke-width', d => d.isTopQuote ? 2 : 0);
const epNodes = nodeG.selectAll('.node-episode').data(nodes.filter(n => n.type === 'episode')).join('g')
.attr('class', 'node-episode').call(drag(simulation));
epNodes.append('circle').attr('r', d => d.r).attr('fill', 'transparent').attr('stroke', d => d.color).attr('stroke-width', 1.5);
epNodes.append('text').attr('dy', 4).text(d => d.label);
const themeNodes = nodeG.selectAll('.node-theme').data(nodes.filter(n => n.type === 'theme')).join('g')
.attr('class', 'node-theme').call(drag(simulation));
themeNodes.append('circle').attr('r', d => d.r).attr('fill', d => d.color + '33').attr('stroke', d => d.color);
themeNodes.append('text').attr('dy', d => -d.r - 8).text(d => d.label);
const centerNode = nodeG.selectAll('.node-center').data(nodes.filter(n => n.type === 'center')).join('g').attr('class', 'node-center');
centerNode.append('circle').attr('r', d => d.r).attr('fill', d => d.color + '22').attr('stroke', d => d.color).attr('stroke-width', 2);
centerNode.append('text').attr('text-anchor', 'middle').attr('fill', '#60a5fa').attr('font-size', '11px').attr('font-weight', '700')
.selectAll('tspan').data(d => d.label.split('\n')).join('tspan')
.attr('x', 0).attr('dy', (d, i) => i === 0 ? '-0.3em' : '1.2em').text(d => d);
simulation.on('tick', () => {
linkEls.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
quoteNodes.attr('transform', d => `translate(${d.x},${d.y})`);
epNodes.attr('transform', d => `translate(${d.x},${d.y})`);
themeNodes.attr('transform', d => `translate(${d.x},${d.y})`);
centerNode.attr('transform', d => `translate(${d.x},${d.y})`);
});
window._nodes = nodes; window._quoteNodes = quoteNodes;
window._epNodes = epNodes; window._themeNodes = themeNodes; window._linkEls = linkEls;
}
function updateVisibility() {
const s = activeStaffel;
window._quoteNodes.style('display', d => s === 0 || d.staffel === s ? null : 'none');
window._epNodes.style('display', d => s === 0 || d.staffel === s ? null : 'none');
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);
if (tgt && tgt.staffel && tgt.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 === 'episode') showEpisode(d);
else if (d.type === 'quote') showQuoteDetail(d);
}
});
}
// ── Panel: Theme ──
function showTheme(theme) {
TranscriptView.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();
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>`;
// Transcript button
if (epData.audioFile) {
html += `<button class="transcript-toggle" onclick="TranscriptView.show('${epData.id}')">Transkript lesen</button>`;
}
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="play-btn" onclick="event.stopPropagation();playQuoteById('${q.id}')"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></button>` : ''}
</div>
</div>`;
}
function playQuoteById(id) {
const q = DATA.quotes.find(q => q.id === id);
if (q) AudioPlayer.play(q);
}
// ── Helpers ──
function fmtTime(sec) {
if (sec === null || sec === undefined) return '';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
</body>
</html>