podcast-mindmap/webapp/index.html

890 lines
22 KiB
HTML
Raw Normal View History

<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
</body>
</html>