feat: Force-Graph-Visualisierung im /v2/cluster-Detail

Beim Klick auf einen Cluster wird jetzt zusätzlich zur Antragsliste
ein d3-Force-Graph eingeblendet. Knoten = Drucksachen, Kantendicke =
Cosine-Similarity, Knotenfarbe = dominante Fraktion. Klick auf einen
Knoten oeffnet das Antrag-Detail.

Daten kommen aus dem bereits vorhandenen /api/clusters-Response
(nodes/edges-Felder, vorher ungenutzt). Layout: forceSimulation mit
link/charge/center/collide. d3.v7.min.js wird im head_extra geladen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-06 18:21:15 +02:00
parent 56c68d3398
commit fdac89ab47

View File

@ -5,7 +5,16 @@
{% set v2_active_nav = "cluster" %}
{% block head_extra %}
<script src="/static/d3.v7.min.js" defer></script>
<style>
#cluster-graph {
margin: 1rem 0;
border: 1px solid var(--hairline);
border-radius: 6px;
background: var(--ecg-card-bg);
overflow: hidden;
}
#cluster-graph svg { display: block; width: 100%; height: 420px; }
.cluster-toolbar {
display: flex;
gap: 8px;
@ -228,6 +237,7 @@ function showCluster(idx) {
${size} Anträge · Ø Score ${avgScore}
</p>
</div>
<div id="cluster-graph"></div>
<div role="list">
${members.map(m => {
// m kann String (Drucksache-ID) oder Objekt sein
@ -248,6 +258,93 @@ function showCluster(idx) {
</a>`;
}).join('')}
</div>`;
// Force-Graph rendern (wenn d3 + nodes/edges da sind)
renderClusterGraph(cl);
}
function renderClusterGraph(cl) {
const container = document.getElementById('cluster-graph');
if (!container || typeof d3 === 'undefined') return;
const rawNodes = cl.nodes || [];
const rawEdges = cl.edges || [];
if (!rawNodes.length) {
container.innerHTML = '<div style="padding:14px;font-family:var(--font-mono);font-size:11px;opacity:0.55;">Keine Graph-Daten verfügbar.</div>';
return;
}
container.innerHTML = '';
const width = container.clientWidth || 720;
const height = 420;
const fraktionColor = (f) => {
const map = {
'CDU': '#000', 'CSU': '#0a3', 'SPD': '#e30613',
'GRÜNE': '#1faf38', 'FDP': '#ffd400', 'AfD': '#0089cf',
'LINKE': '#be3075', 'BSW': '#7b2d8e', 'FW': '#f57f17',
};
return map[f] || '#888';
};
const nodes = rawNodes.map(n => ({
id: n.drucksache,
drucksache: n.drucksache,
title: n.title || n.titel || n.drucksache,
bundesland: n.bundesland || '',
fraktion: (n.fraktionen && n.fraktionen[0]) || '',
score: n.gwoe_score != null ? parseFloat(n.gwoe_score) : 5,
}));
const links = rawEdges.map(e => ({ source: e.a, target: e.b, sim: e.sim || 0.5 }));
const svg = d3.select(container).append('svg')
.attr('width', width).attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id)
.distance(l => (1 - (l.sim || 0)) * 220 + 50)
.strength(l => l.sim || 0.5))
.force('charge', d3.forceManyBody().strength(-380))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(d => Math.sqrt(Math.max(1, d.score)) * 6 + 6));
const link = svg.append('g')
.attr('stroke', '#999').attr('stroke-opacity', 0.4)
.selectAll('line').data(links).join('line')
.attr('stroke-width', d => Math.max(0.5, (d.sim || 0.5) * 3));
const node = svg.append('g').selectAll('g')
.data(nodes).join('g')
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }))
.on('click', (e, d) => {
const url = `/antrag/${encodeURIComponent(d.drucksache)}` + (d.bundesland ? `?bundesland=${encodeURIComponent(d.bundesland)}` : '');
window.location.href = url;
});
node.append('circle')
.attr('r', d => Math.sqrt(Math.max(1, d.score)) * 5 + 4)
.attr('fill', d => fraktionColor(d.fraktion))
.attr('fill-opacity', 0.85)
.attr('stroke', '#fff').attr('stroke-width', 1.5);
node.append('title')
.text(d => `${d.title}\n${d.bundesland} · ${d.fraktion || '?'} · Score ${d.score.toFixed(1)}/10\nKlick öffnet Detail`);
node.append('text')
.attr('x', 0)
.attr('y', d => Math.sqrt(Math.max(1, d.score)) * 5 + 14)
.attr('text-anchor', 'middle')
.attr('font-size', '9px').attr('fill', '#333')
.text(d => (d.title || d.drucksache).substring(0, 32));
sim.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
function showList() {