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>
248 lines
7.7 KiB
HTML
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 %}
|