Obsidian-style Backlinks, Soundbite-Export, Timeline-Ansicht. Topic-Index: 728 Absätze getaggt, 14434 Querverweise. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1245 lines
44 KiB
HTML
1245 lines
44 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); }
|
|
|
|
/* ── 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); }
|
|
|
|
.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="view-tabs">
|
|
<button class="view-tab active" id="tab-mindmap" onclick="switchView('mindmap')">Mindmap</button>
|
|
<button class="view-tab" id="tab-timeline" onclick="switchView('timeline')">Timeline</button>
|
|
</div>
|
|
<div class="filter-bar" id="staffel-filters"></div>
|
|
</header>
|
|
<div id="mindmap"><svg id="svg"></svg></div>
|
|
<div id="panel"><div class="welcome" id="welcome-panel"></div></div>
|
|
</div>
|
|
|
|
<div id="audio-bar">
|
|
<button class="bar-play-btn" id="bar-play-btn">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
|
<path id="bar-play-icon" d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</button>
|
|
<div class="now-playing" id="now-playing" title="Klick: Transkript anzeigen"></div>
|
|
<span class="time" id="audio-time">0:00</span>
|
|
<button class="bar-transcript-btn" id="bar-transcript-btn">Transkript</button>
|
|
</div>
|
|
<audio id="main-audio" preload="none"></audio>
|
|
|
|
<script src="d3.v7.min.js"></script>
|
|
<script>
|
|
// ============================================================
|
|
// Podcast Mindmap App v2
|
|
// ============================================================
|
|
|
|
let DATA = null;
|
|
let TRANSCRIPTS = null; // loaded on demand
|
|
let simulation = null;
|
|
|
|
// Audio state — completely independent from panel state
|
|
const AudioPlayer = {
|
|
el: document.getElementById('main-audio'),
|
|
bar: document.getElementById('audio-bar'),
|
|
barPlayBtn: document.getElementById('bar-play-btn'),
|
|
barIcon: document.getElementById('bar-play-icon'),
|
|
nowPlaying: document.getElementById('now-playing'),
|
|
timeDisplay: document.getElementById('audio-time'),
|
|
currentQuote: null,
|
|
isPlaying: false,
|
|
|
|
PLAY_PATH: 'M8 5v14l11-7z',
|
|
PAUSE_PATH: 'M6 4h4v16H6zM14 4h4v16h-4z',
|
|
|
|
load(q) {
|
|
// Load quote into bar WITHOUT playing
|
|
if (!q.audioFile || q.startTime === null) return;
|
|
this.currentQuote = q;
|
|
const sameFile = this.el.src && this.el.src.endsWith(q.audioFile);
|
|
if (!sameFile) {
|
|
this.el.src = `audio/${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="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, '"');
|
|
}
|
|
|
|
// ============================================================
|
|
// #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
|
|
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));
|
|
if (!epKey) return;
|
|
|
|
// Add topic tags and backlinks to each paragraph
|
|
document.querySelectorAll('.transcript-para').forEach(el => {
|
|
const idx = parseInt(el.dataset.idx);
|
|
const tags = buildTopicTags(epKey, idx);
|
|
const links = buildBacklinks(epKey, idx);
|
|
if (tags || links) {
|
|
el.insertAdjacentHTML('beforeend', tags + links);
|
|
}
|
|
});
|
|
};
|
|
|
|
// ============================================================
|
|
// #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/${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 = '';
|
|
|
|
let html = '<div style="max-width:900px;margin:0 auto">';
|
|
|
|
DATA.staffeln.forEach(staffel => {
|
|
const eps = DATA.episodes.filter(e => e.staffel === staffel.id);
|
|
html += `<div style="margin-bottom:24px">`;
|
|
html += `<h3 style="color:${staffel.color};font-size:14px;margin-bottom:12px">Staffel ${staffel.id}: ${staffel.name}</h3>`;
|
|
|
|
eps.forEach(ep => {
|
|
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;
|
|
}
|
|
|
|
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>
|