890 lines
22 KiB
HTML
890 lines
22 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;
|
||
|
|
--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 380px;
|
||
|
|
grid-template-rows: 56px 1fr;
|
||
|
|
height: 100vh;
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 800px) {
|
||
|
|
#app {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
grid-template-rows: 48px 50vh 1fr;
|
||
|
|
}
|
||
|
|
header { padding: 0 12px; }
|
||
|
|
header h1 { font-size: 14px; }
|
||
|
|
.filter-bar { gap: 4px; }
|
||
|
|
.filter-btn { padding: 4px 10px; font-size: 11px; }
|
||
|
|
#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 24px;
|
||
|
|
gap: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
header h1 {
|
||
|
|
font-size: 18px;
|
||
|
|
font-weight: 600;
|
||
|
|
letter-spacing: -0.02em;
|
||
|
|
}
|
||
|
|
|
||
|
|
header h1 span { color: var(--accent); }
|
||
|
|
|
||
|
|
.filter-bar {
|
||
|
|
display: flex;
|
||
|
|
gap: 8px;
|
||
|
|
margin-left: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-btn {
|
||
|
|
background: var(--surface2);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
color: var(--text-muted);
|
||
|
|
padding: 6px 14px;
|
||
|
|
border-radius: 20px;
|
||
|
|
font-size: 12px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-btn:hover, .filter-btn.active {
|
||
|
|
background: var(--accent);
|
||
|
|
color: var(--bg);
|
||
|
|
border-color: var(--accent);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Mindmap Canvas */
|
||
|
|
#mindmap {
|
||
|
|
position: relative;
|
||
|
|
overflow: hidden;
|
||
|
|
background: var(--bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
#mindmap svg {
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Nodes */
|
||
|
|
.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: 12px;
|
||
|
|
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: 20px;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
#panel h2 {
|
||
|
|
font-size: 16px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text);
|
||
|
|
}
|
||
|
|
|
||
|
|
#panel .subtitle {
|
||
|
|
font-size: 13px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
margin-top: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card {
|
||
|
|
background: var(--surface2);
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 14px;
|
||
|
|
border-left: 3px solid var(--accent);
|
||
|
|
cursor: pointer;
|
||
|
|
transition: transform 0.15s, background 0.15s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card:hover {
|
||
|
|
transform: translateX(4px);
|
||
|
|
background: #2d3142;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card.playing {
|
||
|
|
border-left-color: #22c55e;
|
||
|
|
background: #1a2e1a;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card .quote-text {
|
||
|
|
font-size: 13px;
|
||
|
|
line-height: 1.5;
|
||
|
|
color: var(--text);
|
||
|
|
font-style: italic;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card .quote-meta {
|
||
|
|
margin-top: 8px;
|
||
|
|
font-size: 11px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card .play-icon {
|
||
|
|
width: 24px;
|
||
|
|
height: 24px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--accent);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
flex-shrink: 0;
|
||
|
|
opacity: 0.7;
|
||
|
|
transition: opacity 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.quote-card:hover .play-icon { opacity: 1; }
|
||
|
|
|
||
|
|
.quote-card .play-icon svg {
|
||
|
|
width: 12px;
|
||
|
|
height: 12px;
|
||
|
|
fill: var(--bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-audio .play-icon { 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: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Audio Player 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 24px;
|
||
|
|
gap: 16px;
|
||
|
|
z-index: 100;
|
||
|
|
}
|
||
|
|
|
||
|
|
#audio-bar.visible { display: flex; }
|
||
|
|
|
||
|
|
#audio-bar .now-playing {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
flex: 1;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
#audio-bar .now-playing strong {
|
||
|
|
color: var(--text);
|
||
|
|
}
|
||
|
|
|
||
|
|
#audio-bar button {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
#audio-bar .time {
|
||
|
|
font-size: 11px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
font-variant-numeric: tabular-nums;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Welcome state */
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Scrollbar */
|
||
|
|
#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> Themen-Mindmap</h1>
|
||
|
|
<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 id="play-pause-btn">
|
||
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||
|
|
<path id="play-pause-icon" d="M8 5v14l11-7z"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
<div class="now-playing" id="now-playing"></div>
|
||
|
|
<span class="time" id="audio-time">00:00</span>
|
||
|
|
</div>
|
||
|
|
<audio id="main-audio" preload="none"></audio>
|
||
|
|
|
||
|
|
<script src="d3.v7.min.js"></script>
|
||
|
|
<script>
|
||
|
|
// ============================================================
|
||
|
|
// Podcast Mindmap App
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
let DATA = null;
|
||
|
|
let simulation = null;
|
||
|
|
let currentAudio = null;
|
||
|
|
let currentQuoteId = null;
|
||
|
|
|
||
|
|
const PLAY_SVG = 'M8 5v14l11-7z';
|
||
|
|
const PAUSE_SVG = 'M6 4h4v16H6zM14 4h4v16h-4z';
|
||
|
|
|
||
|
|
// Load data
|
||
|
|
fetch('mindmap_data.json')
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(data => { DATA = data; init(); })
|
||
|
|
.catch(e => console.error('Failed to load data:', e));
|
||
|
|
|
||
|
|
function init() {
|
||
|
|
// Set dynamic titles from data
|
||
|
|
const name = DATA.name || 'Podcast';
|
||
|
|
document.title = name + ' — Themen-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 in der Mindmap.</p>`;
|
||
|
|
|
||
|
|
buildFilters();
|
||
|
|
buildGraph();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Staffel Filters ----
|
||
|
|
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.style.setProperty('--color', s.color);
|
||
|
|
btn.dataset.staffel = s.id;
|
||
|
|
btn.onclick = () => filterStaffel(s.id);
|
||
|
|
bar.appendChild(btn);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
let activeStaffel = 0;
|
||
|
|
|
||
|
|
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.5;
|
||
|
|
const isMobile = W < 600;
|
||
|
|
const scale = isMobile ? 0.6 : 1;
|
||
|
|
|
||
|
|
// Set SVG viewBox for proper scaling
|
||
|
|
svg.attr('viewBox', `0 0 ${W} ${H}`).attr('preserveAspectRatio', 'xMidYMid meet');
|
||
|
|
|
||
|
|
// Build nodes and links
|
||
|
|
const nodes = [];
|
||
|
|
const links = [];
|
||
|
|
const themeMap = {};
|
||
|
|
const episodeMap = {};
|
||
|
|
|
||
|
|
// Center node
|
||
|
|
nodes.push({
|
||
|
|
id: 'center',
|
||
|
|
type: 'center',
|
||
|
|
label: (DATA.name || 'PODCAST').replace(/\s+/g, '\n'),
|
||
|
|
r: 40 * scale,
|
||
|
|
fx: W / 2,
|
||
|
|
fy: H / 2,
|
||
|
|
color: '#60a5fa'
|
||
|
|
});
|
||
|
|
|
||
|
|
// Theme nodes
|
||
|
|
DATA.themes.forEach(t => {
|
||
|
|
const maxLen = isMobile ? 18 : 25;
|
||
|
|
const node = {
|
||
|
|
id: t.id,
|
||
|
|
type: 'theme',
|
||
|
|
label: t.label.length > maxLen ? t.label.substring(0, maxLen - 3) + '…' : t.label,
|
||
|
|
fullLabel: t.label,
|
||
|
|
description: t.description,
|
||
|
|
r: 28 * scale,
|
||
|
|
color: t.color,
|
||
|
|
episodes: t.episodes
|
||
|
|
};
|
||
|
|
nodes.push(node);
|
||
|
|
themeMap[t.id] = node;
|
||
|
|
links.push({ source: 'center', target: t.id, type: 'center-theme' });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Episode nodes
|
||
|
|
DATA.episodes.forEach(ep => {
|
||
|
|
const staffel = DATA.staffeln.find(s => s.id === ep.staffel);
|
||
|
|
const node = {
|
||
|
|
id: ep.id,
|
||
|
|
type: 'episode',
|
||
|
|
label: ep.id,
|
||
|
|
title: ep.title,
|
||
|
|
guest: ep.guest,
|
||
|
|
staffel: ep.staffel,
|
||
|
|
audioFile: ep.audioFile,
|
||
|
|
r: 16 * scale,
|
||
|
|
color: staffel ? staffel.color : '#666'
|
||
|
|
};
|
||
|
|
nodes.push(node);
|
||
|
|
episodeMap[ep.id] = node;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Links: theme → episode
|
||
|
|
DATA.themes.forEach(t => {
|
||
|
|
t.episodes.forEach(epId => {
|
||
|
|
if (episodeMap[epId]) {
|
||
|
|
links.push({ source: t.id, target: epId, type: 'theme-episode' });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Quote nodes (only top quotes and quotes with audio get visible nodes)
|
||
|
|
const visibleQuotes = DATA.quotes.filter(q => q.isTopQuote || q.startTime !== null);
|
||
|
|
visibleQuotes.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,
|
||
|
|
r: (q.isTopQuote ? 6 : 4) * scale,
|
||
|
|
color: ep ? ep.color : '#666',
|
||
|
|
staffel: ep ? ep.staffel : 0
|
||
|
|
});
|
||
|
|
links.push({ source: q.episode, target: q.id, type: 'episode-quote' });
|
||
|
|
});
|
||
|
|
|
||
|
|
// D3 Force Simulation
|
||
|
|
simulation = d3.forceSimulation(nodes)
|
||
|
|
.force('link', d3.forceLink(links).id(d => d.id).distance(d => {
|
||
|
|
if (d.type === 'center-theme') return 160 * scale;
|
||
|
|
if (d.type === 'theme-episode') return 100 * scale;
|
||
|
|
return 50 * scale;
|
||
|
|
}).strength(d => {
|
||
|
|
if (d.type === 'center-theme') return 0.8;
|
||
|
|
if (d.type === 'theme-episode') return 0.3;
|
||
|
|
return 0.2;
|
||
|
|
}))
|
||
|
|
.force('charge', d3.forceManyBody().strength(d => {
|
||
|
|
const s = scale;
|
||
|
|
if (d.type === 'center') return -800 * s;
|
||
|
|
if (d.type === 'theme') return -400 * s;
|
||
|
|
if (d.type === 'episode') return -150 * s;
|
||
|
|
return -30 * s;
|
||
|
|
}))
|
||
|
|
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.05))
|
||
|
|
.force('collision', d3.forceCollide().radius(d => d.r + 4))
|
||
|
|
.alphaDecay(0.02);
|
||
|
|
|
||
|
|
// Zoom
|
||
|
|
const zoom = d3.zoom()
|
||
|
|
.scaleExtent([0.3, 3])
|
||
|
|
.on('zoom', e => g.attr('transform', e.transform));
|
||
|
|
|
||
|
|
svg.call(zoom);
|
||
|
|
|
||
|
|
const g = svg.append('g');
|
||
|
|
|
||
|
|
// Links
|
||
|
|
const linkG = g.append('g');
|
||
|
|
const linkEls = linkG.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';
|
||
|
|
});
|
||
|
|
|
||
|
|
// Node groups
|
||
|
|
const nodeG = g.append('g');
|
||
|
|
|
||
|
|
// Quote nodes
|
||
|
|
const quoteNodes = nodeG.selectAll('.node-quote')
|
||
|
|
.data(nodes.filter(n => n.type === 'quote'))
|
||
|
|
.join('g')
|
||
|
|
.attr('class', 'node-quote')
|
||
|
|
.on('click', (e, d) => playQuote(d))
|
||
|
|
.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);
|
||
|
|
|
||
|
|
// Episode nodes
|
||
|
|
const epNodes = nodeG.selectAll('.node-episode')
|
||
|
|
.data(nodes.filter(n => n.type === 'episode'))
|
||
|
|
.join('g')
|
||
|
|
.attr('class', 'node-episode')
|
||
|
|
.on('click', (e, d) => showEpisode(d))
|
||
|
|
.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);
|
||
|
|
|
||
|
|
// Theme nodes
|
||
|
|
const themeNodes = nodeG.selectAll('.node-theme')
|
||
|
|
.data(nodes.filter(n => n.type === 'theme'))
|
||
|
|
.join('g')
|
||
|
|
.attr('class', 'node-theme')
|
||
|
|
.on('click', (e, d) => showTheme(d))
|
||
|
|
.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);
|
||
|
|
|
||
|
|
// Center node
|
||
|
|
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);
|
||
|
|
|
||
|
|
// Tick
|
||
|
|
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})`);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Store references
|
||
|
|
window._nodes = nodes;
|
||
|
|
window._links = links;
|
||
|
|
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 for mobile) ----
|
||
|
|
function drag(sim) {
|
||
|
|
let dragStartX, dragStartY, dragMoved;
|
||
|
|
|
||
|
|
return d3.drag()
|
||
|
|
.on('start', (e, d) => {
|
||
|
|
if (!e.active) sim.alphaTarget(0.1).restart();
|
||
|
|
d.fx = d.x; d.fy = d.y;
|
||
|
|
dragStartX = e.x; dragStartY = e.y;
|
||
|
|
dragMoved = false;
|
||
|
|
})
|
||
|
|
.on('drag', (e, d) => {
|
||
|
|
d.fx = e.x; d.fy = e.y;
|
||
|
|
const dx = e.x - dragStartX, dy = e.y - dragStartY;
|
||
|
|
if (Math.sqrt(dx * dx + dy * dy) > 5) dragMoved = true;
|
||
|
|
})
|
||
|
|
.on('end', (e, d) => {
|
||
|
|
if (!e.active) sim.alphaTarget(0);
|
||
|
|
if (d.type !== 'center') { d.fx = null; d.fy = null; }
|
||
|
|
// Tap (not drag) → trigger click action
|
||
|
|
if (!dragMoved) {
|
||
|
|
if (d.type === 'theme') showTheme(d);
|
||
|
|
else if (d.type === 'episode') showEpisode(d);
|
||
|
|
else if (d.type === 'quote') playQuote(d);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Panel: Show Theme ----
|
||
|
|
function showTheme(theme) {
|
||
|
|
const panel = document.getElementById('panel');
|
||
|
|
const themeData = 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}">${themeData.label}</h2>`;
|
||
|
|
html += `<p class="subtitle">${themeData.description}</p>`;
|
||
|
|
html += `<p class="subtitle">${quotes.length} Zitate aus ${themeData.episodes.length} Episoden</p>`;
|
||
|
|
|
||
|
|
// Episode tags
|
||
|
|
html += '<div style="margin-top:8px">';
|
||
|
|
themeData.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">${ep.id} ${ep.guest}</span> `;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
html += '</div>';
|
||
|
|
|
||
|
|
// Quotes
|
||
|
|
quotes.forEach(q => {
|
||
|
|
html += buildQuoteCard(q, theme.color);
|
||
|
|
});
|
||
|
|
|
||
|
|
panel.innerHTML = html;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Panel: Show Episode ----
|
||
|
|
function showEpisode(ep) {
|
||
|
|
const panel = document.getElementById('panel');
|
||
|
|
const epData = DATA.episodes.find(e => e.id === ep.id);
|
||
|
|
const staffel = DATA.staffeln.find(s => s.id === epData.staffel);
|
||
|
|
const quotes = DATA.quotes.filter(q => q.episode === ep.id);
|
||
|
|
|
||
|
|
let html = `<h2 style="color:${staffel.color}">${ep.id}: ${epData.title}</h2>`;
|
||
|
|
html += `<p class="subtitle">Gast: ${epData.guest}<br>Staffel ${epData.staffel}: ${staffel.name}</p>`;
|
||
|
|
html += `<p class="subtitle">${quotes.length} Zitate${epData.audioFile ? ' · Audio verfügbar' : ''}</p>`;
|
||
|
|
|
||
|
|
// Theme tags
|
||
|
|
const epThemes = DATA.themes.filter(t => t.episodes.includes(ep.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 showThemeById(id) {
|
||
|
|
const theme = window._nodes.find(n => n.id === id);
|
||
|
|
if (theme) showTheme(theme);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Quote Card HTML ----
|
||
|
|
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}"
|
||
|
|
onclick="${hasAudio ? `playQuoteById('${q.id}')` : ''}">
|
||
|
|
<div class="quote-text">"${escHtml(q.verbatim || q.text)}"</div>
|
||
|
|
<div class="quote-meta">
|
||
|
|
<span>${q.speaker} · ${q.episode}${timeStr ? ' · ' + timeStr : ''}${topBadge}</span>
|
||
|
|
${hasAudio ? `<span class="play-icon"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span>` : ''}
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Audio Playback ----
|
||
|
|
function playQuoteById(id) {
|
||
|
|
const q = DATA.quotes.find(q => q.id === id);
|
||
|
|
if (q) playQuote(q);
|
||
|
|
}
|
||
|
|
|
||
|
|
function playQuote(q) {
|
||
|
|
if (!q.audioFile || q.startTime === null) return;
|
||
|
|
|
||
|
|
const audio = document.getElementById('main-audio');
|
||
|
|
const bar = document.getElementById('audio-bar');
|
||
|
|
const nowPlaying = document.getElementById('now-playing');
|
||
|
|
const btn = document.getElementById('play-pause-btn');
|
||
|
|
const icon = document.getElementById('play-pause-icon');
|
||
|
|
|
||
|
|
// Stop current playback
|
||
|
|
audio.pause();
|
||
|
|
document.querySelectorAll('.quote-card.playing').forEach(c => c.classList.remove('playing'));
|
||
|
|
|
||
|
|
// Highlight card
|
||
|
|
const card = document.getElementById(`card-${q.id}`);
|
||
|
|
if (card) card.classList.add('playing');
|
||
|
|
|
||
|
|
currentQuoteId = q.id;
|
||
|
|
const endTime = q.endTime;
|
||
|
|
|
||
|
|
// Clear old event handlers
|
||
|
|
audio.ontimeupdate = null;
|
||
|
|
audio.onended = null;
|
||
|
|
|
||
|
|
// Set source — only reload if different file
|
||
|
|
const newSrc = `audio/${q.audioFile}`;
|
||
|
|
const sameFile = audio.src && audio.src.endsWith(q.audioFile);
|
||
|
|
|
||
|
|
if (!sameFile) {
|
||
|
|
audio.src = newSrc;
|
||
|
|
}
|
||
|
|
|
||
|
|
// iOS Safari requires play() in the same call stack as the user gesture.
|
||
|
|
// So we play first (even from wrong position), then seek once loaded.
|
||
|
|
audio.currentTime = q.startTime;
|
||
|
|
const playPromise = audio.play();
|
||
|
|
if (playPromise) {
|
||
|
|
playPromise.then(() => {
|
||
|
|
// Seek after play starts (needed when source just changed)
|
||
|
|
if (Math.abs(audio.currentTime - q.startTime) > 2) {
|
||
|
|
audio.currentTime = q.startTime;
|
||
|
|
}
|
||
|
|
}).catch(() => {
|
||
|
|
// Autoplay blocked — try after load
|
||
|
|
audio.addEventListener('canplay', function handler() {
|
||
|
|
audio.removeEventListener('canplay', handler);
|
||
|
|
audio.currentTime = q.startTime;
|
||
|
|
audio.play().catch(() => {});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
icon.setAttribute('d', PAUSE_SVG);
|
||
|
|
|
||
|
|
// Stop at end time
|
||
|
|
audio.ontimeupdate = () => {
|
||
|
|
if (endTime && audio.currentTime >= endTime) {
|
||
|
|
audio.pause();
|
||
|
|
icon.setAttribute('d', PLAY_SVG);
|
||
|
|
}
|
||
|
|
document.getElementById('audio-time').textContent = fmtTime(audio.currentTime);
|
||
|
|
};
|
||
|
|
|
||
|
|
audio.onended = () => icon.setAttribute('d', PLAY_SVG);
|
||
|
|
|
||
|
|
nowPlaying.innerHTML = `<strong>"${q.text.substring(0, 80)}…"</strong> — ${q.speaker} (${q.episode})`;
|
||
|
|
bar.classList.add('visible');
|
||
|
|
|
||
|
|
btn.onclick = () => {
|
||
|
|
if (audio.paused) {
|
||
|
|
audio.play();
|
||
|
|
icon.setAttribute('d', PAUSE_SVG);
|
||
|
|
} else {
|
||
|
|
audio.pause();
|
||
|
|
icon.setAttribute('d', PLAY_SVG);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Helpers ----
|
||
|
|
function fmtTime(sec) {
|
||
|
|
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, '"');
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|