#14/#15/#16 Heatmaps und Drift-Kurve in den Analyse-Views
Backend: - /api/analyses/density: Faktendichte je Episode in 20 Bins ueber die Paragraph-Achse, getrennt nach total und verifizierbar (#16). Frontend: - ShiftsView (#15): Inline-SVG-Sparkline ueber die gesamte Drift-Sequenz je Theme, mit Schwellen-Linie bei 50% und klickbaren Spike-Markern. - GapsView (#14): Cluster-Heatmap mit zwei Zeilen (LdN, NEU DENKEN), Cluster-Breite proportional zur Cluster-Groesse, Farbe interpoliert von kuehl (geringer Anteil im Podcast) zu warm (hoher Anteil); Klick filtert die darunter liegende Liste. - DensityView (#16): neue View 'Faktendichte', sortiert nach Claims/Absatz, pro Episode eine 20-Bin-Heatmap (gruen = verifizierbar, warm = normativ), Filter nach Podcast und Sortierung; Klick oeffnet die Episode. - AnalysisView (#17 questions): zeigt jetzt 'Antwort: <Episode>@p<idx>'-Link fuer Fragen mit answered_by_episode; Klick navigiert zur Antwort-Stelle. - escAttr-Helper, hide-Cascade um DensityView erweitert, Buttons in Selector und init() hinzugefuegt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b73534d1c3
commit
e1f6f18524
@ -243,6 +243,71 @@ def get_shifts_analysis(podcast: Optional[str] = None, theme: Optional[str] = No
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/analyses/density")
|
||||||
|
def get_density(podcast_id: Optional[str] = None, bins: int = 20):
|
||||||
|
"""Faktendichte (#16): claims-Verteilung je Episode in N Bins ueber die Paragraph-Achse."""
|
||||||
|
db = get_db()
|
||||||
|
if not _table_exists(db, "claims"):
|
||||||
|
db.close()
|
||||||
|
return {"available": False, "episodes": []}
|
||||||
|
where = "WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
if podcast_id:
|
||||||
|
where += " AND e.podcast_id = ?"
|
||||||
|
params.append(podcast_id)
|
||||||
|
rows = db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT e.podcast_id, e.id AS episode_id, e.title, e.guest, e.staffel,
|
||||||
|
(SELECT MAX(p.idx) FROM paragraphs p
|
||||||
|
WHERE p.podcast_id = e.podcast_id AND p.episode_id = e.id) AS max_para,
|
||||||
|
(SELECT COUNT(*) FROM paragraphs p
|
||||||
|
WHERE p.podcast_id = e.podcast_id AND p.episode_id = e.id) AS n_para
|
||||||
|
FROM episodes e
|
||||||
|
{where}
|
||||||
|
ORDER BY e.podcast_id, e.id
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
n_para = r["n_para"] or 0
|
||||||
|
max_para = r["max_para"] or 0
|
||||||
|
if n_para == 0:
|
||||||
|
continue
|
||||||
|
claim_rows = db.execute(
|
||||||
|
"SELECT paragraph_idx, claim_type, verifiable FROM claims "
|
||||||
|
"WHERE podcast_id = ? AND episode_id = ?",
|
||||||
|
(r["podcast_id"], r["episode_id"]),
|
||||||
|
).fetchall()
|
||||||
|
total_claims = len(claim_rows)
|
||||||
|
verifiable = sum(1 for c in claim_rows if c["verifiable"])
|
||||||
|
bin_counts = [0] * bins
|
||||||
|
bin_verifiable = [0] * bins
|
||||||
|
denom = max(max_para, 1)
|
||||||
|
for c in claim_rows:
|
||||||
|
idx = c["paragraph_idx"] or 0
|
||||||
|
b = min(bins - 1, int(idx * bins / (denom + 1)))
|
||||||
|
bin_counts[b] += 1
|
||||||
|
if c["verifiable"]:
|
||||||
|
bin_verifiable[b] += 1
|
||||||
|
out.append({
|
||||||
|
"podcast_id": r["podcast_id"],
|
||||||
|
"episode_id": r["episode_id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"guest": r["guest"],
|
||||||
|
"staffel": r["staffel"],
|
||||||
|
"n_paragraphs": n_para,
|
||||||
|
"total_claims": total_claims,
|
||||||
|
"verifiable_claims": verifiable,
|
||||||
|
"density_bins": bin_counts,
|
||||||
|
"verifiable_bins": bin_verifiable,
|
||||||
|
"claims_per_para": (total_claims / n_para) if n_para else 0.0,
|
||||||
|
})
|
||||||
|
db.close()
|
||||||
|
return {"available": True, "bins": bins, "episodes": out}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/analyses/debates")
|
@app.get("/api/analyses/debates")
|
||||||
def get_debates(topic: Optional[str] = None, source_podcast: Optional[str] = None,
|
def get_debates(topic: Optional[str] = None, source_podcast: Optional[str] = None,
|
||||||
target_podcast: Optional[str] = None, limit: int = 200):
|
target_podcast: Optional[str] = None, limit: int = 200):
|
||||||
|
|||||||
@ -746,7 +746,7 @@ const AnalysisView = {
|
|||||||
|
|
||||||
async show(episodeId, mode) {
|
async show(episodeId, mode) {
|
||||||
if (!CURRENT_PODCAST) return;
|
if (!CURRENT_PODCAST) return;
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
this.episodeId = episodeId;
|
this.episodeId = episodeId;
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
@ -822,16 +822,33 @@ const AnalysisView = {
|
|||||||
if (this.mode === 'claims' && it.verifiable) {
|
if (this.mode === 'claims' && it.verifiable) {
|
||||||
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;opacity:0.7">verifizierbar</span>`;
|
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;opacity:0.7">verifizierbar</span>`;
|
||||||
}
|
}
|
||||||
|
let answerLink = '';
|
||||||
if (this.mode === 'questions') {
|
if (this.mode === 'questions') {
|
||||||
const a = it.answered;
|
const a = it.answered;
|
||||||
const lbl = {no:'offen', partial:'teilweise', yes:'beantwortet', self_answered:'selbst beantwortet'}[a] || a;
|
const lbl = {no:'offen', partial:'teilweise', yes:'beantwortet', self_answered:'selbst beantwortet'}[a] || a;
|
||||||
const col = a === 'no' ? 'var(--accent-warm)' : (a === 'yes' ? 'var(--accent-green)' : 'var(--text-muted)');
|
const col = a === 'no' ? 'var(--accent-warm)' : (a === 'yes' ? 'var(--accent-green)' : 'var(--text-muted)');
|
||||||
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;color:${col};border-color:${col}44">${lbl}</span>`;
|
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;color:${col};border-color:${col}44">${lbl}</span>`;
|
||||||
|
|
||||||
|
if (it.answered_by_episode && (a === 'yes' || a === 'partial')) {
|
||||||
|
const samePodcast = !it.answered_by_podcast || it.answered_by_podcast === CURRENT_PODCAST;
|
||||||
|
const arrow = samePodcast ? '→' : '↗';
|
||||||
|
const target = samePodcast ? `${escHtml(it.answered_by_episode)}` : `${escHtml(it.answered_by_podcast)} / ${escHtml(it.answered_by_episode)}`;
|
||||||
|
if (samePodcast) {
|
||||||
|
answerLink = `<div style="margin-top:4px;font-size:12px;color:var(--accent-green)" onclick="event.stopPropagation(); AnalysisView.jumpAnswer('${it.answered_by_episode}', ${it.answered_by_idx})">
|
||||||
|
${arrow} Antwort: ${target}@p${it.answered_by_idx}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
answerLink = `<div style="margin-top:4px;font-size:12px;color:var(--accent-green);opacity:0.85">
|
||||||
|
${arrow} Antwort in ${target}@p${it.answered_by_idx}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
html += `<div class="transcript-para" onclick="AnalysisView.jumpTo(${it.start_time || 0})">`;
|
html += `<div class="transcript-para" onclick="AnalysisView.jumpTo(${it.start_time || 0})">`;
|
||||||
html += `<span class="ts">${ts}</span>`;
|
html += `<span class="ts">${ts}</span>`;
|
||||||
html += badges;
|
html += badges;
|
||||||
html += escHtml(text);
|
html += escHtml(text);
|
||||||
|
html += answerLink;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
panel.innerHTML = html;
|
panel.innerHTML = html;
|
||||||
@ -844,6 +861,24 @@ const AnalysisView = {
|
|||||||
TranscriptView.show(this.episodeId, time);
|
TranscriptView.show(this.episodeId, time);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
jumpAnswer(episodeId, paraIdx) {
|
||||||
|
if (!CURRENT_PODCAST) return;
|
||||||
|
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
|
||||||
|
if (!ep) return;
|
||||||
|
showEpisode(ep);
|
||||||
|
// Transkript an der Para-Stelle aufschlagen — paragraph_idx ueber Transkript laden
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/transcript/${episodeId}`);
|
||||||
|
const d = await r.json();
|
||||||
|
const para = d.paragraphs && d.paragraphs[paraIdx];
|
||||||
|
if (para && typeof para.start === 'number') {
|
||||||
|
TranscriptView.show(episodeId, para.start);
|
||||||
|
}
|
||||||
|
} catch (_) { /* fallback: Episode wurde geoeffnet */ }
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
|
||||||
hide() { this.visible = false; this.episodeId = null; this.items = null; }
|
hide() { this.visible = false; this.episodeId = null; this.items = null; }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -855,7 +890,7 @@ const GapsView = {
|
|||||||
minSize: 0,
|
minSize: 0,
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
const panel = document.getElementById('panel');
|
const panel = document.getElementById('panel');
|
||||||
panel.innerHTML = `<h2>Leerstellen</h2><p class="subtitle">Lädt …</p>`;
|
panel.innerHTML = `<h2>Leerstellen</h2><p class="subtitle">Lädt …</p>`;
|
||||||
@ -885,7 +920,36 @@ const GapsView = {
|
|||||||
const chip = (label, count, active, onclick) =>
|
const chip = (label, count, active, onclick) =>
|
||||||
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
|
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
|
||||||
|
|
||||||
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
|
// Heatmap: Cluster x Podcast, Farbintensitaet nach Anteil im Podcast
|
||||||
|
const clusters = (d.clusters || []).slice().sort((a, b) => (b.size || 0) - (a.size || 0));
|
||||||
|
if (clusters.length && podcasts.length >= 2) {
|
||||||
|
const maxSize = Math.max(...clusters.map(c => c.size || 0)) || 1;
|
||||||
|
html += `<div style="margin-top:14px"><strong style="font-size:13px">Cluster-Verteilung</strong> <span class="subtitle">(grosse Clustern zuerst, Farbe = Anteil je Podcast)</span></div>`;
|
||||||
|
html += `<div style="display:grid;grid-template-columns:120px 1fr;gap:6px;margin-top:6px;align-items:center;font-size:12px">`;
|
||||||
|
podcasts.forEach(p => {
|
||||||
|
html += `<div style="text-align:right;color:var(--text-muted);padding-right:6px">${escHtml(p)}</div>`;
|
||||||
|
html += `<div style="display:flex;gap:1px;height:28px">`;
|
||||||
|
clusters.forEach(c => {
|
||||||
|
const pSize = (c.per_podcast || {})[p] || 0;
|
||||||
|
const total = c.size || 1;
|
||||||
|
const share = pSize / total;
|
||||||
|
const widthPct = ((c.size || 0) / maxSize * 100).toFixed(2);
|
||||||
|
// Color: dark blue for low share, accent-warm for high share, opacity scaled
|
||||||
|
const intensity = Math.min(1, share);
|
||||||
|
// Lerp between cool (#1e3a8a) and warm (#dc7850)
|
||||||
|
const r = Math.round(30 + (220 - 30) * intensity);
|
||||||
|
const g = Math.round(58 + (120 - 58) * intensity);
|
||||||
|
const b = Math.round(138 + (80 - 138) * intensity);
|
||||||
|
const bg = `rgba(${r},${g},${b},${(0.25 + intensity * 0.65).toFixed(2)})`;
|
||||||
|
const title = `${c.label.split(',').slice(0, 4).join(',')} · ${p}: ${pSize}/${total} (${(share*100).toFixed(0)}%)`;
|
||||||
|
html += `<div title="${escAttr(title)}" style="flex:0 0 ${widthPct}%;min-width:6px;background:${bg};border-radius:2px;cursor:pointer" onclick="GapsView.scrollToCluster(${c.id})"></div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div style="margin-top:14px;display:flex;flex-wrap:wrap;gap:6px">`;
|
||||||
html += chip('alle Podcasts', d.gaps.length, !this.missingFilter, `GapsView.setMissing(null)`);
|
html += chip('alle Podcasts', d.gaps.length, !this.missingFilter, `GapsView.setMissing(null)`);
|
||||||
podcasts.forEach(p => {
|
podcasts.forEach(p => {
|
||||||
const n = d.gaps.filter(g => g.missing_in === p).length;
|
const n = d.gaps.filter(g => g.missing_in === p).length;
|
||||||
@ -930,6 +994,12 @@ const GapsView = {
|
|||||||
if (ep) showEpisode(ep);
|
if (ep) showEpisode(ep);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
scrollToCluster(clusterId) {
|
||||||
|
// Filter auf Cluster-ID, falls eine entsprechende Gap existiert; sonst nur Heatmap-Hover.
|
||||||
|
const gap = (this.data?.gaps || []).find(g => g.cluster_id === clusterId);
|
||||||
|
if (gap && gap.missing_in) { this.setMissing(gap.missing_in); }
|
||||||
|
},
|
||||||
|
|
||||||
hide() { this.visible = false; }
|
hide() { this.visible = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -942,7 +1012,7 @@ const ShiftsView = {
|
|||||||
expanded: {},
|
expanded: {},
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
const panel = document.getElementById('panel');
|
const panel = document.getElementById('panel');
|
||||||
panel.innerHTML = `<h2>Narrative Shifts</h2><p class="subtitle">Lädt …</p>`;
|
panel.innerHTML = `<h2>Narrative Shifts</h2><p class="subtitle">Lädt …</p>`;
|
||||||
@ -1013,6 +1083,36 @@ const ShiftsView = {
|
|||||||
if (spikes.length) html += ` · <span style="color:var(--accent-warm)">${spikes.length} Spike${spikes.length > 1 ? 's' : ''}</span>`;
|
if (spikes.length) html += ` · <span style="color:var(--accent-warm)">${spikes.length} Spike${spikes.length > 1 ? 's' : ''}</span>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Inline-Sparkline der gesamten Drift-Sequenz
|
||||||
|
const allDrifts = s.drifts || [];
|
||||||
|
if (allDrifts.length >= 2) {
|
||||||
|
const W = 360, H = 44, PAD = 4;
|
||||||
|
const maxScale = Math.max(0.6, ...allDrifts.map(dr => dr.drift || 0));
|
||||||
|
const pts = allDrifts.map((dr, i) => {
|
||||||
|
const x = PAD + (W - 2 * PAD) * i / (allDrifts.length - 1);
|
||||||
|
const y = H - PAD - (H - 2 * PAD) * Math.min(1, (dr.drift || 0) / maxScale);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}).join(' ');
|
||||||
|
// Threshold-Linie bei 50% drift
|
||||||
|
const yThr = H - PAD - (H - 2 * PAD) * Math.min(1, 0.5 / maxScale);
|
||||||
|
// Spike-Marker
|
||||||
|
let spikeMarks = '';
|
||||||
|
allDrifts.forEach((dr, i) => {
|
||||||
|
if ((dr.drift || 0) >= 0.5) {
|
||||||
|
const x = PAD + (W - 2 * PAD) * i / (allDrifts.length - 1);
|
||||||
|
const y = H - PAD - (H - 2 * PAD) * Math.min(1, (dr.drift || 0) / maxScale);
|
||||||
|
const safeFrom = escAttr(dr.from);
|
||||||
|
const safeTo = escAttr(dr.to);
|
||||||
|
spikeMarks += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--accent-warm)" style="cursor:pointer" onclick="ShiftsView.jumpTo('${escAttr(s.podcast)}','${safeTo}')"><title>${safeFrom} → ${safeTo}: ${(dr.drift*100).toFixed(0)}%</title></circle>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html += `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;margin:6px 0">`;
|
||||||
|
html += `<line x1="${PAD}" y1="${yThr.toFixed(1)}" x2="${W-PAD}" y2="${yThr.toFixed(1)}" stroke="var(--border)" stroke-dasharray="2,3" stroke-width="0.6"/>`;
|
||||||
|
html += `<polyline fill="none" stroke="var(--accent)" stroke-width="1.4" points="${pts}"/>`;
|
||||||
|
html += spikeMarks;
|
||||||
|
html += `</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Top-Drifts (Spikes oder Top 3)
|
// Top-Drifts (Spikes oder Top 3)
|
||||||
const top = spikes.length ? spikes : (s.drifts || []).slice().sort((a,b) => (b.drift||0)-(a.drift||0)).slice(0, 3);
|
const top = spikes.length ? spikes : (s.drifts || []).slice().sort((a,b) => (b.drift||0)-(a.drift||0)).slice(0, 3);
|
||||||
top.forEach(dr => {
|
top.forEach(dr => {
|
||||||
@ -1027,8 +1127,7 @@ const ShiftsView = {
|
|||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle für vollständige Drift-Sequenz
|
// Toggle für vollständige Drift-Sequenz (allDrifts oben definiert)
|
||||||
const allDrifts = s.drifts || [];
|
|
||||||
if (allDrifts.length > top.length) {
|
if (allDrifts.length > top.length) {
|
||||||
html += `<div style="margin-top:6px"><span class="theme-tag" style="cursor:pointer;font-size:11px" onclick="ShiftsView.toggle('${key}')">${isOpen ? 'verkürzen' : `alle ${allDrifts.length} Übergänge zeigen`}</span></div>`;
|
html += `<div style="margin-top:6px"><span class="theme-tag" style="cursor:pointer;font-size:11px" onclick="ShiftsView.toggle('${key}')">${isOpen ? 'verkürzen' : `alle ${allDrifts.length} Übergänge zeigen`}</span></div>`;
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@ -1289,6 +1388,115 @@ const ArgumentsView = {
|
|||||||
hide() { this.visible = false; }
|
hide() { this.visible = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Density View (#16 Faktendichte / Claim-Density-Map) ──
|
||||||
|
const DensityView = {
|
||||||
|
visible: false,
|
||||||
|
data: null,
|
||||||
|
podcastFilter: null,
|
||||||
|
sort: 'density', // 'density' | 'order'
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
|
this.visible = true;
|
||||||
|
const panel = document.getElementById('panel');
|
||||||
|
panel.innerHTML = `<h2>Faktendichte</h2><p class="subtitle">Lädt …</p>`;
|
||||||
|
try {
|
||||||
|
const url = CURRENT_PODCAST ? `${API_BASE}/api/analyses/density?podcast_id=${CURRENT_PODCAST}` : `${API_BASE}/api/analyses/density`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
const data = await r.json();
|
||||||
|
if (!data.available) {
|
||||||
|
panel.innerHTML = `<h2>Faktendichte</h2><p class="subtitle">Keine Claim-Daten vorhanden.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.data = data;
|
||||||
|
} catch (e) {
|
||||||
|
panel.innerHTML = `<h2>Faktendichte</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.visible || !this.data) return;
|
||||||
|
const panel = document.getElementById('panel');
|
||||||
|
const eps = this.data.episodes || [];
|
||||||
|
const podcasts = [...new Set(eps.map(e => e.podcast_id))];
|
||||||
|
let html = `<h2>Faktendichte</h2>`;
|
||||||
|
html += `<p class="subtitle">${eps.length} Episoden mit Claims · Heatmap je Episode in ${this.data.bins} Bins über die Paragraph-Achse</p>`;
|
||||||
|
|
||||||
|
const chip = (label, count, active, onclick) =>
|
||||||
|
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
|
||||||
|
|
||||||
|
if (podcasts.length > 1) {
|
||||||
|
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
|
||||||
|
html += chip('alle', eps.length, !this.podcastFilter, `DensityView.setPodcast(null)`);
|
||||||
|
podcasts.forEach(p => {
|
||||||
|
const n = eps.filter(e => e.podcast_id === p).length;
|
||||||
|
html += chip(escHtml(p), n, this.podcastFilter === p, `DensityView.setPodcast('${p}')`);
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
|
||||||
|
html += chip('nach Faktendichte', null, this.sort === 'density', `DensityView.setSort('density')`);
|
||||||
|
html += chip('chronologisch', null, this.sort === 'order', `DensityView.setSort('order')`);
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
let filtered = eps;
|
||||||
|
if (this.podcastFilter) filtered = filtered.filter(e => e.podcast_id === this.podcastFilter);
|
||||||
|
if (this.sort === 'density') {
|
||||||
|
filtered = filtered.slice().sort((a, b) => (b.claims_per_para || 0) - (a.claims_per_para || 0));
|
||||||
|
} else {
|
||||||
|
filtered = filtered.slice().sort((a, b) => a.episode_id.localeCompare(b.episode_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBin = filtered.length
|
||||||
|
? Math.max(...filtered.flatMap(e => e.density_bins || []))
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
filtered.slice(0, 60).forEach(e => {
|
||||||
|
const click = `onclick="DensityView.jumpTo('${escAttr(e.podcast_id)}','${escAttr(e.episode_id)}')"`;
|
||||||
|
const verPct = e.total_claims ? ((e.verifiable_claims / e.total_claims) * 100).toFixed(0) : '0';
|
||||||
|
const bins = e.density_bins || [];
|
||||||
|
const verBins = e.verifiable_bins || [];
|
||||||
|
// Heatmap inline
|
||||||
|
const cells = bins.map((c, i) => {
|
||||||
|
const v = verBins[i] || 0;
|
||||||
|
const intensity = c / (maxBin || 1);
|
||||||
|
// Verifizierbar = grün, restlich (Meinung) = warm
|
||||||
|
const greenShare = c > 0 ? v / c : 0;
|
||||||
|
const r = Math.round(220 - 100 * greenShare);
|
||||||
|
const g = Math.round(120 + 100 * greenShare);
|
||||||
|
const b = Math.round(80 + 50 * greenShare);
|
||||||
|
const op = (0.15 + intensity * 0.7).toFixed(2);
|
||||||
|
return `<div title="Bin ${i+1}: ${c} Claims (${v} verifizierbar)" style="flex:1;background:rgba(${r},${g},${b},${op});border-radius:1.5px"></div>`;
|
||||||
|
}).join('');
|
||||||
|
html += `<div class="transcript-para" style="cursor:pointer" ${click}>`;
|
||||||
|
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:4px;align-items:baseline">`;
|
||||||
|
html += `<span><strong>${escHtml(e.episode_id)}</strong> ${escHtml(e.title)}${e.guest ? ` · ${escHtml(e.guest)}` : ''}</span>`;
|
||||||
|
html += `<span class="ts">${e.total_claims} Claims · ${verPct}% verifizierbar · ${(e.claims_per_para || 0).toFixed(2)}/Absatz</span>`;
|
||||||
|
html += `</div>`;
|
||||||
|
html += `<div style="display:flex;gap:1px;height:18px">${cells}</div>`;
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
if (filtered.length > 60) {
|
||||||
|
html += `<p class="subtitle" style="margin-top:8px">… ${filtered.length - 60} weitere durch Filter eingrenzen.</p>`;
|
||||||
|
}
|
||||||
|
panel.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPodcast(p) { this.podcastFilter = p; this.render(); },
|
||||||
|
setSort(s) { this.sort = s; this.render(); },
|
||||||
|
|
||||||
|
jumpTo(podcastId, episodeId) {
|
||||||
|
if (CURRENT_PODCAST !== podcastId) return;
|
||||||
|
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
|
||||||
|
if (ep) showEpisode(ep);
|
||||||
|
},
|
||||||
|
|
||||||
|
hide() { this.visible = false; }
|
||||||
|
};
|
||||||
|
|
||||||
// ── Search ──
|
// ── Search ──
|
||||||
const Search = {
|
const Search = {
|
||||||
init() {
|
init() {
|
||||||
@ -1385,7 +1593,7 @@ const Search = {
|
|||||||
|
|
||||||
showResults(results, query) {
|
showResults(results, query) {
|
||||||
const panel = document.getElementById('panel');
|
const panel = document.getElementById('panel');
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
panel.innerHTML = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`;
|
panel.innerHTML = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`;
|
||||||
@ -1413,7 +1621,7 @@ const Search = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showSemanticResults(results, query) {
|
showSemanticResults(results, query) {
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
const panel = document.getElementById('panel');
|
const panel = document.getElementById('panel');
|
||||||
let html = `<h2>${results.length} semantische Treffer für "${escHtml(query)}" <span class="semantic-badge">KI</span></h2>`;
|
let html = `<h2>${results.length} semantische Treffer für "${escHtml(query)}" <span class="semantic-badge">KI</span></h2>`;
|
||||||
results.forEach(r => {
|
results.forEach(r => {
|
||||||
@ -1426,7 +1634,7 @@ const Search = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showApiResults(results, query) {
|
showApiResults(results, query) {
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
const panel = document.getElementById('panel');
|
const panel = document.getElementById('panel');
|
||||||
let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
|
let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
|
||||||
results.forEach(r => {
|
results.forEach(r => {
|
||||||
@ -1570,6 +1778,7 @@ function showPodcastSelector(podcasts) {
|
|||||||
selectorHtml += '<button class="compare-btn" onclick="ShiftsView.show()">Narrative Shifts</button>';
|
selectorHtml += '<button class="compare-btn" onclick="ShiftsView.show()">Narrative Shifts</button>';
|
||||||
selectorHtml += '<button class="compare-btn" onclick="DebatesView.show()">Debatten</button>';
|
selectorHtml += '<button class="compare-btn" onclick="DebatesView.show()">Debatten</button>';
|
||||||
selectorHtml += '<button class="compare-btn" onclick="ArgumentsView.show()">Argumentketten</button>';
|
selectorHtml += '<button class="compare-btn" onclick="ArgumentsView.show()">Argumentketten</button>';
|
||||||
|
selectorHtml += '<button class="compare-btn" onclick="DensityView.show()">Faktendichte</button>';
|
||||||
selectorHtml += '</div>';
|
selectorHtml += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1722,8 +1931,12 @@ function init() {
|
|||||||
<button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button>
|
<button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button>
|
||||||
<button class="transcript-toggle" onclick="DebatesView.show()">Debatten</button>
|
<button class="transcript-toggle" onclick="DebatesView.show()">Debatten</button>
|
||||||
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
|
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
|
||||||
|
<button class="transcript-toggle" onclick="DensityView.show()">Faktendichte</button>
|
||||||
</p>`
|
</p>`
|
||||||
: `<p style="margin-top:12px"><button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button></p>`;
|
: `<p style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">
|
||||||
|
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
|
||||||
|
<button class="transcript-toggle" onclick="DensityView.show()">Faktendichte</button>
|
||||||
|
</p>`;
|
||||||
// Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen
|
// Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen
|
||||||
let welcome = document.getElementById('welcome-panel');
|
let welcome = document.getElementById('welcome-panel');
|
||||||
if (!welcome) {
|
if (!welcome) {
|
||||||
@ -1956,7 +2169,7 @@ function drag(sim) {
|
|||||||
|
|
||||||
// ── Panel: Theme ──
|
// ── Panel: Theme ──
|
||||||
function showTheme(theme) {
|
function showTheme(theme) {
|
||||||
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
|
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide();
|
||||||
const panel = document.getElementById('panel');
|
const panel = document.getElementById('panel');
|
||||||
const td = DATA.themes.find(t => t.id === theme.id);
|
const td = DATA.themes.find(t => t.id === theme.id);
|
||||||
const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id));
|
const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user