gwoe-antragspruefer/app/templates/v2/screens/cluster.html
Dotty Dotter 565849bd84 feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
  (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
  Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze

Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
  /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie

Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00

248 lines
7.7 KiB
HTML

{% extends "v2/base.html" %}
{% block title %}Cluster — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "cluster" %}
{% block head_extra %}
<style>
.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) {
const members = cl.members || [];
const avgScore = cl.avg_score != null ? parseFloat(cl.avg_score).toFixed(1) : '—';
const label = cl.label || cl.title || ('Cluster ' + (idx + 1));
const fraktionen = cl.fraktionen || {};
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>${members.length} Antrag${members.length !== 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 = '';
const members = cl.members || [];
const label = cl.label || cl.title || ('Cluster ' + (idx + 1));
const avgScore = cl.avg_score != null ? parseFloat(cl.avg_score).toFixed(1) : '—';
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;">
${members.length} Anträge · Ø Score ${avgScore}
</p>
</div>
<div role="list">
${members.map(m => `
<a href="/antrag/${encodeURIComponent(m.drucksache || m)}"
class="v2-result-row" style="display:block;text-decoration:none;">
<div class="v2-result-meta">
<span class="v2-chip" style="font-size:10px;">${m.bundesland || ''}</span>
<span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">${m.drucksache || m}</span>
</div>
<div class="v2-result-title">${m.titel || m.drucksache || m}</div>
${m.gwoe_score != null ? `<div style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-teal);font-weight:700;">Score ${parseFloat(m.gwoe_score).toFixed(1)}</div>` : ''}
</a>`).join('')}
</div>`;
}
function showList() {
document.getElementById('cluster-list').style.display = '';
document.getElementById('cluster-detail').style.display = 'none';
}
// Initial load
loadClusters();
</script>
{% endblock %}