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:
parent
56c68d3398
commit
fdac89ab47
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user