Beim Klick auf eine Cluster-Card setzte showCluster() detail.style.display = ''. Da #cluster-detail per CSS aber 'display:none' hat, fiel der Style auf 'none' zurueck — Detail-View blieb unsichtbar, Force-Graph wurde nie gesehen. Fix: explizit 'block' setzen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
359 lines
12 KiB
HTML
359 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
|
|
_clusters.sort((a, b) => (b.members || []).length - (a.members || []).length);
|
|
|
|
// 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';
|
|
};
|
|
|
|
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() {
|
|
document.getElementById('cluster-list').style.display = '';
|
|
document.getElementById('cluster-detail').style.display = 'none';
|
|
}
|
|
|
|
// Initial load
|
|
loadClusters();
|
|
</script>
|
|
{% endblock %}
|