From 7019a7a04e0a37f499299d2ab42b4b38dea1e3e2 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Tue, 28 Apr 2026 20:52:04 +0200 Subject: [PATCH] #20 Cross-Podcast-Mindmap mit Cross-Daten als Bruecken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app.py | 162 +++++++++++++++++ webapp/index.html | 436 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 501 insertions(+), 97 deletions(-) diff --git a/backend/app.py b/backend/app.py index adb4202..d4c41cf 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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, diff --git a/webapp/index.html b/webapp/index.html index b592885..024df4d 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -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 = ''; @@ -1552,25 +1576,15 @@ const CrossMindmapView = { document.title = 'Cross-Mindmap — Podcast Mindmap'; const panel = document.getElementById('panel'); - panel.innerHTML = `

Cross-Mindmap

Themen aus verschiedenen Podcasts in einer Visualisierung. Cross-Cluster verbinden Themen, die semantisch zusammengehören.

Lädt …

`; + panel.innerHTML = `

Cross-Mindmap

Lädt …

`; 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 = `

Cross-Mindmap

Fehler: ${escHtml(e.message)}

`; 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, + }); + }); + soloClusters.forEach(c => { + nodes.push({ + id: `cl-${c.id}`, type: 'cluster-solo', cluster: c, label: c.label, + r: 9 * sc, color: '#71717a', }); }); - // Themen je Podcast - podcastIds.forEach(pid => { - const data = this.podcastsData[pid]; - (data.themes || []).forEach(t => { - 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' }); + // 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' }); }); }); - // 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 }); - } - } + // 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 = `
Cross-Verbindungen
`; + 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 += ``; + }); + 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 = `

Cross-Mindmap

`; - const cross = crossClusters; - html += `

${this.data.clusters.length} Themen-Cluster · ${cross.length} davon cross-podcast (Cosinus-Schwelle ${(this.data.threshold || 0).toFixed(2)})

`; + html += `

${(d.podcasts||[]).map(p=>escHtml(p.name)).join(' ↔ ')} · ${d.episodes.length} Episoden, ${d.top_quotes.length} Top-Zitate

`; + html += `

`; + html += `Debatten ${counts.debates} `; + html += `Widerspruch ${counts.claim_widerspricht} `; + html += `Belege ${counts.claim_belegt} `; + html += `Erweiterung ${counts.claim_erweitert} `; + html += `Frage→Antwort ${counts.answers} `; + html += `Ähnlichkeit ${counts.similarity}`; + html += `

`; + const cross = (d.theme_clusters || []).filter(c => c.is_cross); if (cross.length) { - html += `

Cross-Cluster

`; + html += `

Cross-Theme-Cluster

`; cross.forEach(c => { - html += `
`; + html += `
`; html += `${escHtml(c.label)}`; html += `
`; - html += c.members.map(m => `${escHtml(m.podcast_id)} / ${escHtml(m.label)}`).join(' '); + html += c.members.map(m => `${escHtml(m.podcast_id)} / ${escHtml(m.label)}`).join(' '); html += `
`; }); } - html += `

Solo-Cluster (kein Cross-Match)

`; - this.data.clusters.filter(c => !c.is_cross).forEach(c => { - const m = c.members[0]; - html += `
`; - html += `${escHtml(m.podcast_id)} ${escHtml(c.label)}`; - html += `
`; - }); + if (counts.debates) { + html += `

Wichtige Debatten

`; + (cl.debates || []).slice(0, 8).forEach(deb => { + html += `
`; + html += `${escHtml(deb.a.split(':')[0])} ↔ ${escHtml(deb.b.split(':')[0])} `; + html += `${escHtml(deb.topic)}`; + html += `
`; + }); + } 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: / 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: / 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); }