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" %}
|
{% set v2_active_nav = "cluster" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
|
<script src="/static/d3.v7.min.js" defer></script>
|
||||||
<style>
|
<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 {
|
.cluster-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -228,6 +237,7 @@ function showCluster(idx) {
|
|||||||
${size} Anträge · Ø Score ${avgScore}
|
${size} Anträge · Ø Score ${avgScore}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="cluster-graph"></div>
|
||||||
<div role="list">
|
<div role="list">
|
||||||
${members.map(m => {
|
${members.map(m => {
|
||||||
// m kann String (Drucksache-ID) oder Objekt sein
|
// m kann String (Drucksache-ID) oder Objekt sein
|
||||||
@ -248,6 +258,93 @@ function showCluster(idx) {
|
|||||||
</a>`;
|
</a>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>`;
|
</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() {
|
function showList() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user