#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:
parent
3243c23adb
commit
7019a7a04e
162
backend/app.py
162
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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user