gwoe-antragspruefer/app/templates/v2/screens/cluster.html
Dotty Dotter 53f8d2cad5 fix(Phase 17 audit): Cluster-Sort nutzte members.length statt size/drucksachen.length
Audit-Befund: alte UI sortierte _clusters nach (members || []).length —
Backend liefert aber size + drucksachen, members ist leer. Folge: alle
Cards hatten size 0 als Sort-Wert, Reihenfolge war effektiv random.

Backwards-compat-Lookup mit drucksachen → members → size-Fallback.

(Andere c.members-Lookups in antrag_detail.html + aktuelle-themen.html
betreffen News-Cluster, deren API tatsächlich 'members' liefert — kein Bug.)
2026-05-06 23:52:17 +02:00

369 lines
12 KiB
HTML

{% extends "v2/base.html" %}
{% block title %}Cluster — GWÖ-Antragsprüfer{% endblock %}
{% 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;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
padding: 10px 12px;
background: var(--ecg-bg-subtle);
border-radius: 4px;
font-size: 12px;
font-family: var(--font-mono);
}
.cluster-toolbar select {
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 8px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
background: var(--ecg-card-bg);
color: var(--ecg-dark);
}
.cluster-card {
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.1s;
}
.cluster-card:hover { border-color: var(--ecg-teal); }
.cluster-card h3 {
font-family: var(--font-display);
font-size: 14px;
color: var(--ecg-teal);
margin: 0 0 4px;
}
.cluster-meta {
display: flex;
gap: 10px;
font-size: 11px;
font-family: var(--font-mono);
opacity: 0.65;
margin-bottom: 6px;
flex-wrap: wrap;
}
.cluster-score {
font-weight: 700;
color: var(--ecg-green);
}
.cluster-fraktionen {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 6px;
}
.fraktion-bar {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-family: var(--font-mono);
padding: 2px 6px;
border-radius: 3px;
background: var(--ecg-bg-subtle);
border: 1px solid var(--ecg-border);
}
/* Cluster detail panel */
#cluster-detail {
display: none;
margin-top: 1rem;
}
#cluster-detail-back {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ecg-teal);
cursor: pointer;
margin-bottom: 10px;
display: inline-block;
}
#cluster-detail-back:hover { text-decoration: underline; }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1.5rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Cluster</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Thematisch ähnliche Anträge · Cosine-Similarity über Embeddings
</p>
</div>
<!-- Toolbar -->
<div class="cluster-toolbar">
<label for="cl-bl">Bundesland:</label>
<select id="cl-bl" onchange="loadClusters()">
<option value="">Bundesweit</option>
{% for code in bl_codes %}
<option value="{{ code }}">{{ code }}</option>
{% endfor %}
</select>
<label for="cl-thr" style="margin-left:8px;">Schwellenwert:</label>
<input type="range" id="cl-thr" min="0.40" max="0.80" step="0.05" value="0.55"
style="width:100px;"
oninput="document.getElementById('cl-thr-val').textContent = parseFloat(this.value).toFixed(2)"
onchange="loadClusters()">
<span id="cl-thr-val" style="min-width:30px;">0.55</span>
<button onclick="loadClusters()"
style="font-family:var(--font-mono);font-size:11px;padding:4px 10px;
background:var(--ecg-teal);color:#fff;border:none;border-radius:3px;cursor:pointer;
margin-left:8px;">
Aktualisieren
</button>
</div>
<!-- Main list view -->
<div id="cluster-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Cluster …</div>
</div>
<!-- Detail view (shown when cluster is clicked) -->
<div id="cluster-detail">
<span id="cluster-detail-back" onclick="showList()">← Zurück zur Übersicht</span>
<div id="cluster-detail-content"></div>
</div>
<!-- Link to classic Force-Graph -->
<div style="margin-top:1.5rem;font-size:11px;font-family:var(--font-mono);opacity:0.6;">
Vollständige Force-Graph-Visualisierung:
<a href="/classic?mode=clusters" style="color:var(--ecg-teal);">Klassische Ansicht →</a>
</div>
{% endblock %}
{% block body_scripts %}
<script>
let _clusters = [];
async function loadClusters() {
const listEl = document.getElementById('cluster-list');
const bl = document.getElementById('cl-bl').value;
const thr = document.getElementById('cl-thr').value;
listEl.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Cluster …</div>';
document.getElementById('cluster-detail').style.display = 'none';
listEl.style.display = '';
let url = '/api/clusters?threshold=' + thr;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const resp = await fetch(url);
const data = await resp.json();
_clusters = data.clusters || [];
if (!_clusters.length) {
listEl.innerHTML = '<div class="v2-kasten outline-green"><h4>Keine Cluster gefunden</h4><p>Mit diesem Schwellenwert entstehen keine Gruppen. Versuchen Sie einen niedrigeren Wert.</p></div>';
return;
}
// Sort by size descending
// Backend liefert size + drucksachen; alte UI nutzte members.length.
const _clusterSize = (cl) => (cl.size != null
? cl.size
: (cl.drucksachen || cl.members || []).length);
_clusters.sort((a, b) => _clusterSize(b) - _clusterSize(a));
// Top-10 list
const top = _clusters.slice(0, 10);
listEl.innerHTML = top.map((cl, idx) => renderClusterCard(cl, idx)).join('');
} catch (e) {
listEl.innerHTML = '<div style="color:var(--ecg-dark);font-family:var(--font-mono);font-size:12px;">Fehler: ' + e.message + '</div>';
}
}
function renderClusterCard(cl, idx) {
// Backend liefert drucksachen+avg_gwoe_score; alte UI-Variante hatte members+avg_score.
const members = cl.drucksachen || cl.members || [];
const avgScoreRaw = cl.avg_gwoe_score != null ? cl.avg_gwoe_score : cl.avg_score;
const avgScore = avgScoreRaw != null ? parseFloat(avgScoreRaw).toFixed(1) : '—';
const label = cl.label || cl.title || ('Cluster ' + (idx + 1));
const dom = cl.dominant_fraktion || '';
const size = cl.size != null ? cl.size : members.length;
const fraktionen = cl.fraktionen || (dom ? {[dom]: size} : {});
const frakBars = Object.entries(fraktionen)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([f, n]) => `<span class="fraktion-bar">${f} <strong>${n}</strong></span>`)
.join('');
return `<div class="cluster-card" onclick="showCluster(${idx})">
<h3>${label}</h3>
<div class="cluster-meta">
<span>${size} Antrag${size !== 1 ? 'e' : ''}</span>
<span class="cluster-score">Ø ${avgScore}</span>
</div>
${frakBars ? `<div class="cluster-fraktionen">${frakBars}</div>` : ''}
</div>`;
}
function showCluster(idx) {
const cl = _clusters[idx];
if (!cl) return;
document.getElementById('cluster-list').style.display = 'none';
const detail = document.getElementById('cluster-detail');
const content = document.getElementById('cluster-detail-content');
detail.style.display = 'block';
const members = cl.drucksachen || cl.members || [];
const label = cl.label || cl.title || ('Cluster ' + (idx + 1));
const avgScoreRaw = cl.avg_gwoe_score != null ? cl.avg_gwoe_score : cl.avg_score;
const avgScore = avgScoreRaw != null ? parseFloat(avgScoreRaw).toFixed(1) : '—';
const size = cl.size != null ? cl.size : members.length;
content.innerHTML = `
<div style="margin-bottom:1rem;">
<h2 style="font-family:var(--font-display);font-size:18px;color:var(--ecg-teal);">${label}</h2>
<p style="font-family:var(--font-mono);font-size:12px;opacity:0.65;">
${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
const isObj = typeof m === 'object' && m !== null;
const ds = isObj ? (m.drucksache || '') : m;
const bl = isObj ? (m.bundesland || '') : '';
const titel = isObj ? (m.titel || ds) : ds;
const score = isObj && m.gwoe_score != null ? parseFloat(m.gwoe_score).toFixed(1) : null;
const url = `/antrag/${encodeURIComponent(ds)}` + (bl ? `?bundesland=${encodeURIComponent(bl)}` : '');
return `
<a href="${url}" class="v2-result-row" style="display:block;text-decoration:none;">
<div class="v2-result-meta">
${bl ? `<span class="v2-chip" style="font-size:10px;">${bl}</span>` : ''}
<span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">${ds}</span>
</div>
<div class="v2-result-title">${titel}</div>
${score ? `<div style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-teal);font-weight:700;">Score ${score}</div>` : ''}
</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';
};
// Edges sind Index-basiert (a/b sind Positionen in rawNodes).
// d3.forceLink mappt per id-Lookup → wir nutzen den Index als id-String.
const nodes = rawNodes.map((n, i) => ({
id: String(i),
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: String(e.a),
target: String(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() {
document.getElementById('cluster-list').style.display = '';
document.getElementById('cluster-detail').style.display = 'none';
}
// Initial load
loadClusters();
</script>
{% endblock %}