#20 Cross-Podcast-Mindmap mit Cross-Daten als Bruecken

Backend:
- /api/analyses/cross-network: Aggregat-Endpoint, der die Cross-Mindmap in einem
  Roundtrip versorgt — theme_clusters, themes, episodes, top_quotes (isTopQuote)
  und alle Cross-Link-Listen (debates, claim_belegt/-widerspricht/-erweitert,
  answers, similarity top-N pro source-episode + target-podcast).
- Filter aus den bestehenden Endpoints uebernommen: kein_bezug, error,
  Outro-Floskeln werden ignoriert.

Frontend (CrossMindmapView komplett umgebaut):
- Force-Graph mit vier Schichten: Cross-Theme-Cluster fix in der Mittelachse
  (gold, fett), Solo-Cluster lose, Themen je Podcast als zweite Schicht,
  Episoden ueber forceX in die Halbebene des Podcasts gezogen, Top-Quotes als
  Punkte am jeweiligen Episode-Knoten.
- Sechs Cross-Link-Typen mit eigenem Style:
  cross-debate (lila), claim-belegt (gruen), claim-widerspricht (rot),
  claim-erweitert (blau), answer (orange), similarity (hellblau gestrichelt,
  default aus).
- Toggle-Panel rechts oben (Vorlage: renderLinkToggles aus #19) je
  Verbindungstyp; Updates nur die opacity, kein Rebuild der Simulation.
- Klick auf Theme/Episode/Quote oeffnet den jeweiligen Single-Podcast-Modus
  und navigiert weiter (showEpisode + playFrom).
- Klick auf einen Cross-Cluster filtert die Mindmap auf seine Mitglieder
  (filterCluster) — Themen, Episoden und ihre Cross-Linien werden hervorgehoben,
  Rest gedaempft.
- Panel rechts: Counter je Cross-Typ als farbige Chips, Cross-Theme-Cluster-
  Karten und die Top-Debatten als Direkt-Einstieg in DebatesView.

Routing:
- Neue Route /cross laedt die Cross-Mindmap direkt; loadApp und der
  popstate-Handler unterstuetzen sie analog zu /ldn und /neu-denken.

Datenlage live: 246 Knoten, 1422 Linien (97 Debatten, 199 belegt, 58
widerspricht, 282 erweitert, 236 Antworten, 305 similarity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 20:52:04 +02:00
parent 3243c23adb
commit 7019a7a04e
2 changed files with 501 additions and 97 deletions

View File

@ -340,6 +340,168 @@ def get_shifts_analysis(podcast: Optional[str] = None, theme: Optional[str] = No
}
@app.get("/api/analyses/cross-network")
def get_cross_network(top_sim: int = 3, min_score: float = 0.65):
"""Cross-Podcast-Mindmap-Aggregat (#20): alles, was die Cross-Mindmap braucht,
in einem Roundtrip Theme-Cluster, Themen, Episoden, Top-Quotes und
Cross-Link-Listen (debates, claim-Pairs, answer-Pairs, similarity-Pairs)."""
db = get_db()
# Theme-Cluster aus theme_clusters.json
theme_clusters = []
cluster_path = Path(DATA_DIR) / "theme_clusters.json"
if cluster_path.exists():
try:
with open(cluster_path) as f:
tc = json.load(f)
theme_clusters = tc.get("clusters", [])
except Exception:
pass
podcasts = [dict(r) for r in db.execute("SELECT id, name FROM podcasts").fetchall()]
pids = [p["id"] for p in podcasts]
# Themen mit zugeordneten Episode-IDs
theme_rows = db.execute(
"SELECT podcast_id, id, label, description, color, episodes_json FROM themes"
).fetchall()
themes = []
for r in theme_rows:
try:
eps = json.loads(r["episodes_json"] or "[]")
except Exception:
eps = []
themes.append({
"podcast_id": r["podcast_id"],
"id": r["id"],
"label": r["label"],
"description": r["description"],
"color": r["color"],
"episode_ids": eps,
})
# Episoden + Staffel-Farbe
ep_rows = db.execute(
"SELECT e.podcast_id, e.id, e.title, e.guest, e.staffel, s.color "
"FROM episodes e LEFT JOIN staffeln s "
"ON e.podcast_id = s.podcast_id AND e.staffel = s.id "
"ORDER BY e.podcast_id, e.id"
).fetchall()
episodes = [dict(r) for r in ep_rows]
# Top-Quotes (isTopQuote)
q_rows = db.execute(
"SELECT id, podcast_id, episode_id, text, speaker, themes_json, start_time "
"FROM quotes WHERE is_top_quote = 1 ORDER BY podcast_id, episode_id"
).fetchall()
top_quotes = []
for q in q_rows:
try:
qt = json.loads(q["themes_json"] or "[]")
except Exception:
qt = []
top_quotes.append({
"id": q["id"], "podcast_id": q["podcast_id"], "episode_id": q["episode_id"],
"text": q["text"], "speaker": q["speaker"], "themes": qt,
"start_time": q["start_time"],
})
cross_links = {
"similarity": [],
"debates": [],
"claim_belegt": [],
"claim_widerspricht": [],
"claim_erweitert": [],
"answers": [],
}
# Similarity: top_sim pairs je (source_podcast, source_episode, target_podcast)
if len(pids) > 1:
sim_rows = db.execute(
"SELECT podcast_id, source_episode, target_podcast, target_episode, MAX(score) AS score "
"FROM semantic_links "
"WHERE podcast_id != target_podcast AND score >= ? "
"GROUP BY podcast_id, source_episode, target_podcast, target_episode "
"ORDER BY podcast_id, source_episode, score DESC",
(min_score,),
).fetchall()
per_src = {}
for r in sim_rows:
key = (r["podcast_id"], r["source_episode"], r["target_podcast"])
n = per_src.get(key, 0)
if n >= top_sim:
continue
cross_links["similarity"].append({
"a": f"{r['podcast_id']}:{r['source_episode']}",
"b": f"{r['target_podcast']}:{r['target_episode']}",
"score": float(r["score"]),
})
per_src[key] = n + 1
# Debates: jede zaehlt
if _table_exists(db, "debates"):
deb_rows = db.execute(
"SELECT id, source_podcast, source_episode, target_podcast, target_episode, topic "
"FROM debates WHERE topic IS NOT NULL AND topic != '' AND topic != 'error'"
).fetchall()
for r in deb_rows:
cross_links["debates"].append({
"a": f"{r['source_podcast']}:{r['source_episode']}",
"b": f"{r['target_podcast']}:{r['target_episode']}",
"topic": r["topic"], "debate_id": r["id"],
})
# Claim-Matches cross-podcast, je Episode-Pair die dominante Relation
if _table_exists(db, "claim_matches"):
cm_rows = db.execute(
"SELECT cl.podcast_id AS src_pid, cl.episode_id AS src_ep, "
"cm.target_podcast AS tgt_pid, cm.target_episode AS tgt_ep, "
"cm.relation, COUNT(*) AS n "
"FROM claim_matches cm JOIN claims cl ON cm.claim_id = cl.id "
"WHERE cm.target_podcast != cl.podcast_id AND cm.relation != 'kein_bezug' "
"GROUP BY cl.podcast_id, cl.episode_id, cm.target_podcast, cm.target_episode, cm.relation"
).fetchall()
# Aggregiere pro Pair: relation->count
pair_map = {}
for r in cm_rows:
key = (r["src_pid"], r["src_ep"], r["tgt_pid"], r["tgt_ep"])
pair_map.setdefault(key, {})[r["relation"]] = r["n"]
for (sp, se, tp, te), counts in pair_map.items():
for rel in ("belegt", "widerspricht", "erweitert"):
if counts.get(rel):
bucket = f"claim_{rel}"
cross_links[bucket].append({
"a": f"{sp}:{se}", "b": f"{tp}:{te}", "n": counts[rel],
})
# Question-Answer-Pairs cross-podcast
if _table_exists(db, "questions"):
ans_rows = db.execute(
"SELECT podcast_id AS src_pid, episode_id AS src_ep, "
"answered_by_podcast AS tgt_pid, answered_by_episode AS tgt_ep, COUNT(*) AS n "
"FROM questions "
"WHERE answered_by_podcast IS NOT NULL AND answered_by_episode IS NOT NULL "
"AND podcast_id != answered_by_podcast "
"GROUP BY podcast_id, episode_id, answered_by_podcast, answered_by_episode"
).fetchall()
for r in ans_rows:
cross_links["answers"].append({
"a": f"{r['src_pid']}:{r['src_ep']}",
"b": f"{r['tgt_pid']}:{r['tgt_ep']}",
"n": r["n"],
})
db.close()
return {
"podcasts": podcasts,
"theme_clusters": theme_clusters,
"themes": themes,
"episodes": episodes,
"top_quotes": top_quotes,
"cross_links": cross_links,
}
@app.get("/api/analyses/cross-themes")
def get_cross_themes():
"""Cross-Podcast-Themen-Cluster (#8/#10): Themen aus verschiedenen Podcasts,

View File

@ -1532,16 +1532,40 @@ const DensityView = {
const CrossMindmapView = {
visible: false,
data: null,
podcastsData: null,
visibility: {
'cross-debate': true,
'cross-claim-belegt': true,
'cross-claim-widerspricht': true,
'cross-claim-erweitert': false,
'cross-answer': true,
'cross-similarity': false,
},
activeClusterId: null,
_refs: null,
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); CrossMindmapView.hide();
STYLE: {
'cross-debate': { stroke: '#c084fc', dash: null, width: 1.8, opacity: 0.9 },
'cross-claim-belegt': { stroke: '#86efac', dash: null, width: 1.4, opacity: 0.8 },
'cross-claim-widerspricht': { stroke: '#f87171', dash: null, width: 1.8, opacity: 0.9 },
'cross-claim-erweitert': { stroke: '#60a5fa', dash: null, width: 1.2, opacity: 0.7 },
'cross-answer': { stroke: '#fb923c', dash: null, width: 1.4, opacity: 0.8 },
'cross-similarity': { stroke: '#7dd3fc', dash: '4,4', width: 0.7, opacity: 0.4 },
},
async show(fromUrl = false) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
this.visible = true;
// Mindmap-Container fuer SVG aufbereiten
if (!fromUrl && window.history && window.location.pathname !== '/cross') {
window.history.pushState({ cross: true }, '', '/cross');
}
const mindmap = document.getElementById('mindmap');
mindmap.style.overflow = 'hidden';
mindmap.style.display = '';
mindmap.style.alignItems = '';
mindmap.style.justifyContent = '';
mindmap.style.flexDirection = '';
mindmap.style.padding = '';
mindmap.innerHTML = '<svg id="svg"></svg>';
@ -1552,25 +1576,15 @@ const CrossMindmapView = {
document.title = 'Cross-Mindmap — Podcast Mindmap';
const panel = document.getElementById('panel');
panel.innerHTML = `<div class="welcome"><h2>Cross-Mindmap</h2><p>Themen aus verschiedenen Podcasts in einer Visualisierung. Cross-Cluster verbinden Themen, die semantisch zusammengehören.</p><p class="subtitle">Lädt …</p></div>`;
panel.innerHTML = `<div class="welcome" id="welcome-panel"><h2>Cross-Mindmap</h2><p class="subtitle">Lädt …</p></div>`;
try {
// Cluster-Daten holen
const cr = await fetch(`${API_BASE}/api/analyses/cross-themes`);
this.data = await cr.json();
// Daten beider Podcasts holen
const pr = await fetch(`${API_BASE}/api/podcasts`);
const podcasts = await pr.json();
this.podcastsData = {};
for (const p of podcasts) {
const r = await fetch(`${API_BASE}/api/podcasts/${p.id}`);
this.podcastsData[p.id] = await r.json();
}
const r = await fetch(`${API_BASE}/api/analyses/cross-network?top_sim=3`);
this.data = await r.json();
} catch (e) {
panel.innerHTML = `<h2>Cross-Mindmap</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
return;
}
requestAnimationFrame(() => requestAnimationFrame(() => this.render()));
},
@ -1584,121 +1598,318 @@ const CrossMindmapView = {
if (W < 200) W = 800;
if (H < 200) H = 600;
const isMobile = W < 600;
const sc = isMobile ? 0.6 : 1;
const sc = isMobile ? 0.55 : 1;
svg.attr('viewBox', `0 0 ${W} ${H}`).attr('preserveAspectRatio', 'xMidYMid meet');
const d = this.data;
const nodes = [], links = [];
const podcastIds = Object.keys(this.podcastsData);
const colors = { 0: '#60a5fa', 1: '#dc7850' }; // blau / orange als Hub-Farben
const podcastColor = {};
(d.podcasts || []).forEach((p, i) => { podcastColor[p.id] = i === 0 ? '#dc7850' : '#60a5fa'; });
// Hubs links und rechts
podcastIds.forEach((pid, i) => {
const data = this.podcastsData[pid];
const cx = i === 0 ? W * 0.28 : W * 0.72;
const cy = H * 0.5;
// Theme-id -> Cluster-id
const themeToCluster = {};
(d.theme_clusters || []).forEach(c => {
c.members.forEach(m => { themeToCluster[`${m.podcast_id}/${m.theme_id}`] = c.id; });
});
// 1. Cluster-Knoten: Cross-Cluster fixiert in der Mittelachse, Solo-Cluster lose
const crossClusters = (d.theme_clusters || []).filter(c => c.is_cross);
const soloClusters = (d.theme_clusters || []).filter(c => !c.is_cross);
crossClusters.forEach((c, i) => {
const fy = H * (0.3 + 0.4 * (i / Math.max(1, crossClusters.length - 1 || 1)));
nodes.push({
id: `hub-${pid}`, type: 'hub', pid,
label: data.name,
r: 36 * sc, fx: cx, fy: cy, color: colors[i] || '#888',
id: `cl-${c.id}`, type: 'cluster', cluster: c, label: c.label,
r: 18 * sc, color: '#fcd34d', fx: W / 2, fy: fy,
});
});
// Themen je Podcast
podcastIds.forEach(pid => {
const data = this.podcastsData[pid];
(data.themes || []).forEach(t => {
soloClusters.forEach(c => {
nodes.push({
id: `t-${pid}-${t.id}`, type: 'theme', pid,
themeId: t.id, label: t.label.length > 22 ? t.label.slice(0, 20) + '…' : t.label,
fullLabel: t.label, r: 22 * sc, color: t.color,
});
links.push({ source: `hub-${pid}`, target: `t-${pid}-${t.id}`, type: 'hub-theme' });
id: `cl-${c.id}`, type: 'cluster-solo', cluster: c, label: c.label,
r: 9 * sc, color: '#71717a',
});
});
// Cross-Links zwischen Themen verschiedener Podcasts
const crossClusters = (this.data?.clusters || []).filter(c => c.is_cross);
crossClusters.forEach(c => {
const memberIds = c.members.map(m => `t-${m.podcast_id}-${m.theme_id}`);
// Vollverbinde alle Mitglieder eines Cross-Clusters
for (let i = 0; i < memberIds.length; i++) {
for (let j = i + 1; j < memberIds.length; j++) {
links.push({ source: memberIds[i], target: memberIds[j], type: 'cross', cluster: c.label });
}
}
// 2. Theme-Knoten: an ihren Cluster gebunden + per forceX in Halbebene je Podcast
(d.themes || []).forEach(t => {
const cid = themeToCluster[`${t.podcast_id}/${t.id}`];
nodes.push({
id: `t-${t.podcast_id}-${t.id}`, type: 'theme',
podcast_id: t.podcast_id, themeId: t.id, label: t.label,
r: 14 * sc, color: t.color || podcastColor[t.podcast_id],
cluster: cid,
});
if (cid) links.push({ source: `cl-${cid}`, target: `t-${t.podcast_id}-${t.id}`, type: 'cluster-theme' });
});
// 3. Episoden-Knoten: pro Episode an ihre Themen gebunden
const epIndex = {};
(d.episodes || []).forEach(e => {
const k = `e-${e.podcast_id}-${e.id}`;
epIndex[`${e.podcast_id}:${e.id}`] = k;
nodes.push({
id: k, type: 'episode',
podcast_id: e.podcast_id, episode_id: e.id, title: e.title, guest: e.guest,
staffel: e.staffel, r: 6 * sc,
color: e.color || podcastColor[e.podcast_id] || '#777',
});
});
// theme -> episode-Verbindungen
(d.themes || []).forEach(t => {
(t.episode_ids || []).forEach(epId => {
const k = epIndex[`${t.podcast_id}:${epId}`];
if (k) links.push({ source: `t-${t.podcast_id}-${t.id}`, target: k, type: 'theme-episode' });
});
});
// 4. Top-Quote-Knoten
(d.top_quotes || []).forEach(q => {
const epK = epIndex[`${q.podcast_id}:${q.episode_id}`];
if (!epK) return;
const ep = nodes.find(n => n.id === epK);
nodes.push({
id: `q-${q.podcast_id}-${q.id}`, type: 'quote',
podcast_id: q.podcast_id, episode_id: q.episode_id, quote_id: q.id,
text: q.text, speaker: q.speaker, start_time: q.start_time,
r: 3 * sc, color: ep ? ep.color : '#aaa',
});
links.push({ source: epK, target: `q-${q.podcast_id}-${q.id}`, type: 'episode-quote' });
});
// 5. Cross-Links (debates, claim_*, answers, similarity)
const cl = d.cross_links || {};
const addCross = (arr, type) => {
(arr || []).forEach(l => {
const a = epIndex[l.a], b = epIndex[l.b];
if (a && b) links.push({ source: a, target: b, type, meta: l });
});
};
addCross(cl.debates, 'cross-debate');
addCross(cl.claim_belegt, 'cross-claim-belegt');
addCross(cl.claim_widerspricht, 'cross-claim-widerspricht');
addCross(cl.claim_erweitert, 'cross-claim-erweitert');
addCross(cl.answers, 'cross-answer');
addCross(cl.similarity, 'cross-similarity');
// Force-Setup
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(d => d.type === 'hub-theme' ? 130 * sc : 220 * sc).strength(d => d.type === 'cross' ? 0.06 : 0.6))
.force('charge', d3.forceManyBody().strength(d => d.type === 'hub' ? -1200 * sc : -250 * sc))
.force('collision', d3.forceCollide().radius(d => d.r + 6))
.alphaDecay(0.02);
.force('link', d3.forceLink(links).id(n => n.id).distance(l => {
if (l.type === 'cluster-theme') return 100 * sc;
if (l.type === 'theme-episode') return 70 * sc;
if (l.type === 'episode-quote') return 18 * sc;
if (l.type && l.type.startsWith('cross-')) return 220 * sc;
return 60 * sc;
}).strength(l => {
if (l.type === 'cluster-theme') return 0.4;
if (l.type === 'theme-episode') return 0.2;
if (l.type === 'episode-quote') return 0.4;
if (l.type && l.type.startsWith('cross-')) return 0.04;
return 0.2;
}))
.force('charge', d3.forceManyBody().strength(n => {
if (n.type === 'cluster') return -1200 * sc;
if (n.type === 'cluster-solo') return -250 * sc;
if (n.type === 'theme') return -200 * sc;
if (n.type === 'episode') return -50 * sc;
return -8 * sc;
}))
.force('x', d3.forceX().strength(n => n.type === 'episode' ? 0.05 : 0).x(n => {
// Episode in die Halbebene des Podcasts ziehen
if (n.type !== 'episode') return W / 2;
const idx = (d.podcasts || []).findIndex(p => p.id === n.podcast_id);
return idx === 0 ? W * 0.25 : W * 0.75;
}))
.force('y', d3.forceY(H / 2).strength(0.02))
.force('collision', d3.forceCollide().radius(n => n.r + 2))
.alphaDecay(0.025);
const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', e => g.attr('transform', e.transform));
const zoom = d3.zoom().scaleExtent([0.25, 3]).on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
const g = svg.append('g');
// Hierarchische Linien (cluster-theme, theme-episode, episode-quote) zuerst, dann Cross darüber
const linkEls = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', d => d.type === 'cross' ? '#fcd34d' : '#374151')
.attr('stroke-width', d => d.type === 'cross' ? 2.4 : 1)
.attr('stroke-dasharray', d => d.type === 'cross' ? '6,3' : null)
.attr('opacity', d => d.type === 'cross' ? 0.85 : 0.5);
// Cross-Cluster-Label auf den Verbindungen
const crossLabels = g.append('g').selectAll('text').data(links.filter(l => l.type === 'cross')).join('text')
.attr('font-size', '10px').attr('fill', '#fcd34d').attr('text-anchor', 'middle')
.text(d => d.cluster);
.attr('class', l => `xl xl-${l.type}`)
.attr('stroke', l => {
if (this.STYLE[l.type]) return this.STYLE[l.type].stroke;
if (l.type === 'cluster-theme') return '#fcd34d';
if (l.type === 'theme-episode') {
const t = nodes.find(n => n.id === (typeof l.source === 'object' ? l.source.id : l.source));
return t ? t.color : '#374151';
}
if (l.type === 'episode-quote') return '#525252';
return '#374151';
})
.attr('stroke-width', l => this.STYLE[l.type]?.width ?? (l.type === 'cluster-theme' ? 1.4 : 0.7))
.attr('stroke-dasharray', l => this.STYLE[l.type]?.dash || null)
.attr('opacity', l => this._linkOpacity(l));
const nodeG = g.append('g');
const themeNodes = nodeG.selectAll('.cross-theme').data(nodes.filter(n => n.type === 'theme')).join('g')
.attr('class', 'cross-theme').style('cursor', 'pointer')
.on('click', (e, d) => CrossMindmapView.openTheme(d.pid, d.themeId));
themeNodes.append('circle').attr('r', d => d.r).attr('fill', d => d.color + '55').attr('stroke', d => d.color).attr('stroke-width', 1.6);
themeNodes.append('text').attr('dy', d => -d.r - 6).attr('text-anchor', 'middle').attr('font-size', '11px').attr('fill', 'var(--text)').text(d => d.label);
themeNodes.append('title').text(d => d.fullLabel);
const hubNodes = nodeG.selectAll('.cross-hub').data(nodes.filter(n => n.type === 'hub')).join('g')
.attr('class', 'cross-hub').style('cursor', 'pointer')
.on('click', (e, d) => selectPodcast(d.pid));
hubNodes.append('circle').attr('r', d => d.r).attr('fill', d => d.color + '22').attr('stroke', d => d.color).attr('stroke-width', 2.5);
hubNodes.append('text').attr('text-anchor', 'middle').attr('font-size', '12px').attr('font-weight', '700').attr('fill', d => d.color)
.selectAll('tspan').data(d => d.label.split(/\s+/)).join('tspan').attr('x', 0).attr('dy', (d, i) => i === 0 ? '-0.3em' : '1.2em').text(d => d);
const clusterNodes = nodeG.selectAll('.x-cluster').data(nodes.filter(n => n.type === 'cluster' || n.type === 'cluster-solo')).join('g')
.attr('class', 'x-cluster').style('cursor', 'pointer')
.on('click', (e, n) => CrossMindmapView.filterCluster(n.cluster.id));
clusterNodes.append('circle').attr('r', n => n.r)
.attr('fill', n => n.type === 'cluster' ? '#fcd34d33' : '#71717a22')
.attr('stroke', n => n.color).attr('stroke-width', n => n.type === 'cluster' ? 2 : 1);
clusterNodes.append('text').attr('dy', n => -n.r - 6).attr('text-anchor', 'middle')
.attr('font-size', n => n.type === 'cluster' ? '11px' : '9px')
.attr('fill', n => n.type === 'cluster' ? '#fcd34d' : 'var(--text-muted)')
.text(n => n.label.length > 32 ? n.label.slice(0, 30) + '…' : n.label);
const themeNodes = nodeG.selectAll('.x-theme').data(nodes.filter(n => n.type === 'theme')).join('g')
.attr('class', 'x-theme').style('cursor', 'pointer')
.on('click', (e, n) => CrossMindmapView.openTheme(n.podcast_id, n.themeId));
themeNodes.append('circle').attr('r', n => n.r).attr('fill', n => n.color + '55').attr('stroke', n => n.color).attr('stroke-width', 1.4);
themeNodes.append('text').attr('dy', n => -n.r - 4).attr('text-anchor', 'middle')
.attr('font-size', '10px').attr('fill', 'var(--text)')
.text(n => n.label.length > 26 ? n.label.slice(0, 24) + '…' : n.label);
themeNodes.append('title').text(n => n.label);
const epNodes = nodeG.selectAll('.x-episode').data(nodes.filter(n => n.type === 'episode')).join('g')
.attr('class', 'x-episode').style('cursor', 'pointer')
.on('click', (e, n) => CrossMindmapView.jumpEpisode(n.podcast_id, n.episode_id));
epNodes.append('circle').attr('r', n => n.r).attr('fill', 'transparent').attr('stroke', n => n.color).attr('stroke-width', 1.2);
epNodes.append('title').text(n => `${n.podcast_id}/${n.episode_id} · ${n.title || ''}`);
const quoteNodes = nodeG.selectAll('.x-quote').data(nodes.filter(n => n.type === 'quote')).join('g')
.attr('class', 'x-quote').style('cursor', 'pointer')
.on('click', (e, n) => CrossMindmapView.jumpQuote(n.podcast_id, n.episode_id, n.start_time));
quoteNodes.append('circle').attr('r', n => n.r).attr('fill', n => n.color).attr('opacity', 0.85)
.attr('stroke', '#f59e0b').attr('stroke-width', 0.7);
quoteNodes.append('title').text(n => (n.text || '').slice(0, 120));
sim.on('tick', () => {
linkEls.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
crossLabels.attr('x', d => (d.source.x + d.target.x) / 2).attr('y', d => (d.source.y + d.target.y) / 2 - 4);
themeNodes.attr('transform', d => `translate(${d.x},${d.y})`);
hubNodes.attr('transform', d => `translate(${d.x},${d.y})`);
linkEls.attr('x1', l => l.source.x).attr('y1', l => l.source.y).attr('x2', l => l.target.x).attr('y2', l => l.target.y);
clusterNodes.attr('transform', n => `translate(${n.x},${n.y})`);
themeNodes.attr('transform', n => `translate(${n.x},${n.y})`);
epNodes.attr('transform', n => `translate(${n.x},${n.y})`);
quoteNodes.attr('transform', n => `translate(${n.x},${n.y})`);
});
// Panel: Cluster-Liste
this._refs = { nodes, links, linkEls, themeNodes, epNodes, quoteNodes, clusterNodes };
this._renderToggles();
this._renderPanel();
},
_linkOpacity(l) {
if (this.STYLE[l.type]) {
return this.visibility[l.type] === false ? 0 : (this.STYLE[l.type].opacity ?? 1);
}
if (l.type === 'cluster-theme') return 0.7;
if (l.type === 'theme-episode') return 0.35;
if (l.type === 'episode-quote') return 0.45;
return 0.4;
},
_renderToggles() {
const mm = document.getElementById('mindmap');
const old = document.getElementById('cross-toggles');
if (old) old.remove();
const panel = document.createElement('div');
panel.id = 'cross-toggles';
panel.style.cssText = 'position:absolute;top:8px;right:8px;background:rgba(20,24,32,0.85);border:1px solid var(--border);border-radius:6px;padding:8px 10px;font-size:11px;display:flex;flex-direction:column;gap:4px;z-index:5;backdrop-filter:blur(4px);max-width:240px';
const labels = {
'cross-debate': 'Debatten',
'cross-claim-widerspricht': 'Widerspruch (Claims)',
'cross-claim-belegt': 'Belege (Claims)',
'cross-claim-erweitert': 'Erweiterung (Claims)',
'cross-answer': 'Frage→Antwort',
'cross-similarity': 'semantische Ähnlichkeit',
};
panel.innerHTML = `<div style="font-weight:600;color:var(--text-muted);margin-bottom:2px">Cross-Verbindungen</div>`;
Object.keys(labels).forEach(t => {
const style = this.STYLE[t];
const checked = this.visibility[t] !== false;
const dashCss = style.dash ? `background:repeating-linear-gradient(90deg,${style.stroke} 0,${style.stroke} 3px,transparent 3px,transparent 6px)` : `background:${style.stroke}`;
panel.innerHTML += `<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" data-link="${t}" ${checked ? 'checked' : ''} style="margin:0">
<span style="display:inline-block;width:18px;height:2px;${dashCss}"></span>
<span>${labels[t]}</span>
</label>`;
});
mm.style.position = 'relative';
mm.appendChild(panel);
panel.querySelectorAll('input').forEach(cb => {
cb.addEventListener('change', () => {
this.visibility[cb.dataset.link] = cb.checked;
if (this._refs?.linkEls) {
this._refs.linkEls.attr('opacity', l => this._linkOpacity(l));
}
});
});
},
_renderPanel() {
const panel = document.getElementById('panel');
const d = this.data;
const cl = d.cross_links || {};
const counts = Object.fromEntries(Object.entries(cl).map(([k, v]) => [k, v.length]));
let html = `<h2>Cross-Mindmap</h2>`;
const cross = crossClusters;
html += `<p class="subtitle">${this.data.clusters.length} Themen-Cluster · ${cross.length} davon cross-podcast (Cosinus-Schwelle ${(this.data.threshold || 0).toFixed(2)})</p>`;
html += `<p class="subtitle">${(d.podcasts||[]).map(p=>escHtml(p.name)).join(' ↔ ')} · ${d.episodes.length} Episoden, ${d.top_quotes.length} Top-Zitate</p>`;
html += `<p style="margin-top:6px;font-size:13px">`;
html += `<span class="theme-tag" style="border-color:#c084fc66;color:#c084fc">Debatten ${counts.debates}</span> `;
html += `<span class="theme-tag" style="border-color:#f8717166;color:#f87171">Widerspruch ${counts.claim_widerspricht}</span> `;
html += `<span class="theme-tag" style="border-color:#86efac66;color:#86efac">Belege ${counts.claim_belegt}</span> `;
html += `<span class="theme-tag" style="border-color:#60a5fa66;color:#60a5fa">Erweiterung ${counts.claim_erweitert}</span> `;
html += `<span class="theme-tag" style="border-color:#fb923c66;color:#fb923c">Frage→Antwort ${counts.answers}</span> `;
html += `<span class="theme-tag" style="border-color:#7dd3fc66;color:#7dd3fc">Ähnlichkeit ${counts.similarity}</span>`;
html += `</p>`;
const cross = (d.theme_clusters || []).filter(c => c.is_cross);
if (cross.length) {
html += `<h3 style="margin-top:14px;font-size:14px">Cross-Cluster</h3>`;
html += `<h3 style="margin-top:14px;font-size:14px">Cross-Theme-Cluster</h3>`;
cross.forEach(c => {
html += `<div class="transcript-para" style="cursor:default;border-left:3px solid #fcd34d">`;
html += `<div class="transcript-para" style="cursor:pointer;border-left:3px solid #fcd34d" onclick="CrossMindmapView.filterCluster('${escAttr(c.id)}')">`;
html += `<strong>${escHtml(c.label)}</strong>`;
html += `<div class="subtitle" style="margin-top:4px">`;
html += c.members.map(m => `<span class="theme-tag" style="cursor:pointer" onclick="CrossMindmapView.openTheme('${escAttr(m.podcast_id)}','${escAttr(m.theme_id)}')">${escHtml(m.podcast_id)} / ${escHtml(m.label)}</span>`).join(' ');
html += c.members.map(m => `<span class="theme-tag">${escHtml(m.podcast_id)} / ${escHtml(m.label)}</span>`).join(' ');
html += `</div></div>`;
});
}
html += `<h3 style="margin-top:14px;font-size:14px">Solo-Cluster (kein Cross-Match)</h3>`;
this.data.clusters.filter(c => !c.is_cross).forEach(c => {
const m = c.members[0];
html += `<div class="transcript-para" style="cursor:pointer" onclick="CrossMindmapView.openTheme('${escAttr(m.podcast_id)}','${escAttr(m.theme_id)}')">`;
html += `<span class="theme-tag" style="font-size:10px">${escHtml(m.podcast_id)}</span> ${escHtml(c.label)}`;
if (counts.debates) {
html += `<h3 style="margin-top:14px;font-size:14px">Wichtige Debatten</h3>`;
(cl.debates || []).slice(0, 8).forEach(deb => {
html += `<div class="transcript-para" style="cursor:pointer" onclick="DebatesView.show()">`;
html += `<span class="theme-tag" style="border-color:#c084fc66;color:#c084fc">${escHtml(deb.a.split(':')[0])} ↔ ${escHtml(deb.b.split(':')[0])}</span> `;
html += `${escHtml(deb.topic)}`;
html += `</div>`;
});
}
panel.innerHTML = html;
},
filterCluster(clusterId) {
if (!this._refs) return;
this.activeClusterId = (this.activeClusterId === clusterId) ? null : clusterId;
const active = this.activeClusterId;
if (!active) {
this._refs.themeNodes.style('opacity', 1);
this._refs.epNodes.style('opacity', 1);
this._refs.quoteNodes.style('opacity', 1);
this._refs.linkEls.attr('opacity', l => this._linkOpacity(l));
return;
}
const cluster = (this.data.theme_clusters || []).find(c => c.id === active);
if (!cluster) return;
const memberThemeIds = new Set(cluster.members.map(m => `t-${m.podcast_id}-${m.theme_id}`));
const memberEpisodeIds = new Set();
cluster.members.forEach(m => {
const t = (this.data.themes || []).find(x => x.podcast_id === m.podcast_id && x.id === m.theme_id);
(t?.episode_ids || []).forEach(epId => memberEpisodeIds.add(`e-${m.podcast_id}-${epId}`));
});
this._refs.themeNodes.style('opacity', n => memberThemeIds.has(n.id) ? 1 : 0.15);
this._refs.epNodes.style('opacity', n => memberEpisodeIds.has(n.id) ? 1 : 0.15);
this._refs.quoteNodes.style('opacity', n => memberEpisodeIds.has(`e-${n.podcast_id}-${n.episode_id}`) ? 1 : 0.1);
this._refs.linkEls.attr('opacity', l => {
const sId = typeof l.source === 'object' ? l.source.id : l.source;
const tId = typeof l.target === 'object' ? l.target.id : l.target;
const involved = memberThemeIds.has(sId) || memberThemeIds.has(tId) || memberEpisodeIds.has(sId) || memberEpisodeIds.has(tId);
return involved ? this._linkOpacity(l) : 0.04;
});
},
openTheme(podcastId, themeId) {
selectPodcast(podcastId).then(() => {
// Theme-Knoten in der normalen Mindmap fokussieren
setTimeout(() => {
const t = (DATA?.themes || []).find(x => x.id === themeId);
if (t && typeof showTheme === 'function') showTheme(t);
@ -1706,7 +1917,32 @@ const CrossMindmapView = {
});
},
hide() { this.visible = false; }
jumpEpisode(podcastId, episodeId) {
selectPodcast(podcastId).then(() => {
setTimeout(() => {
const ep = (DATA?.episodes || []).find(e => e.id === episodeId);
if (ep) showEpisode(ep);
}, 400);
});
},
jumpQuote(podcastId, episodeId, startTime) {
selectPodcast(podcastId).then(() => {
setTimeout(() => {
const ep = (DATA?.episodes || []).find(e => e.id === episodeId);
if (ep) {
showEpisode(ep);
if (startTime) setTimeout(() => playFrom(startTime, ep), 200);
}
}, 400);
});
},
hide() {
this.visible = false;
const t = document.getElementById('cross-toggles');
if (t) t.remove();
}
};
// ── Search ──
@ -1897,10 +2133,13 @@ async function loadApp() {
const resp = await fetch(`${API_BASE}/api/podcasts`);
if (resp.ok) {
const podcasts = await resp.json();
// URL-Routing: /<podcast-id> oeffnet direkt diesen Podcast
const pathPodcast = window.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/')[0];
const fromUrl = pathPodcast && podcasts.find(p => p.id === pathPodcast);
if (fromUrl) {
// URL-Routing: /<podcast-id> oder /cross oeffnet direkt
const pathSegment = window.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/')[0];
const fromUrl = pathSegment && podcasts.find(p => p.id === pathSegment);
if (pathSegment === 'cross' && podcasts.length > 1) {
ALL_PODCASTS = podcasts;
await CrossMindmapView.show(true);
} else if (fromUrl) {
ALL_PODCASTS = podcasts;
await selectPodcast(fromUrl.id, /*fromUrl*/ true);
} else if (podcasts.length === 1) {
@ -1916,7 +2155,10 @@ async function loadApp() {
window.addEventListener('popstate', async () => {
const p = window.location.pathname.replace(/^\//, '').replace(/\/$/, '').split('/')[0];
if (!p) {
CrossMindmapView.hide();
showPodcastSelector(podcasts);
} else if (p === 'cross') {
await CrossMindmapView.show(true);
} else if (podcasts.find(x => x.id === p) && p !== CURRENT_PODCAST) {
await selectPodcast(p, true);
}