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>
431 lines
14 KiB
HTML
431 lines
14 KiB
HTML
{% extends "v2/base.html" %}
|
||
|
||
{% block title %}Auswertungen — GWÖ-Antragsprüfer{% endblock %}
|
||
|
||
{% set v2_active_nav = "auswertungen" %}
|
||
|
||
{% block head_extra %}
|
||
<script src="/static/chart.umd.min.js"></script>
|
||
<style>
|
||
.auswert-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 1rem;
|
||
border-bottom: 2px solid var(--ecg-border);
|
||
padding-bottom: 0;
|
||
}
|
||
.auswert-tab {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 6px 14px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: var(--ecg-dark);
|
||
opacity: 0.55;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -2px;
|
||
transition: opacity 0.1s;
|
||
}
|
||
.auswert-tab:hover { opacity: 0.85; }
|
||
.auswert-tab.active {
|
||
opacity: 1;
|
||
border-bottom-color: var(--ecg-teal);
|
||
color: var(--ecg-teal);
|
||
}
|
||
.auswert-panel { display: none; }
|
||
.auswert-panel.active { display: block; }
|
||
.controls-bar {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1rem;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
}
|
||
.controls-bar select {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
padding: 5px 8px;
|
||
border: 1px solid var(--ecg-border);
|
||
border-radius: 3px;
|
||
background: var(--ecg-card-bg);
|
||
color: var(--ecg-dark);
|
||
}
|
||
.controls-bar button {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
padding: 5px 12px;
|
||
border: 1px solid var(--ecg-border);
|
||
border-radius: 3px;
|
||
background: var(--ecg-card-bg);
|
||
color: var(--ecg-dark);
|
||
cursor: pointer;
|
||
}
|
||
.controls-bar button.primary {
|
||
background: var(--ecg-teal);
|
||
color: #fff;
|
||
border-color: var(--ecg-teal);
|
||
}
|
||
.matrix-wrap {
|
||
overflow-x: auto;
|
||
background: var(--ecg-card-bg);
|
||
border: 1px solid var(--ecg-border);
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
}
|
||
table.gwoe-matrix {
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
min-width: 400px;
|
||
}
|
||
table.gwoe-matrix th, table.gwoe-matrix td {
|
||
border: 1px solid var(--ecg-border);
|
||
padding: 5px 8px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
table.gwoe-matrix th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||
table.gwoe-matrix th.row-h { text-align: left; }
|
||
table.gwoe-matrix .s-high { background: rgba(136,158,51,0.22); font-weight: 700; }
|
||
table.gwoe-matrix .s-mid { background: rgba(247,148,29,0.15); }
|
||
table.gwoe-matrix .s-low { background: rgba(200,0,0,0.13); font-weight: 700; }
|
||
table.gwoe-matrix .empty { color: var(--ecg-dark); opacity: 0.3; }
|
||
table.gwoe-matrix td.clickable { cursor: pointer; }
|
||
table.gwoe-matrix td.clickable:hover { background: rgba(0,157,165,0.1); }
|
||
.meta-line {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
opacity: 0.6;
|
||
margin-top: 8px;
|
||
}
|
||
/* Modal */
|
||
.v2-modal-backdrop {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.45);
|
||
z-index: 500;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.v2-modal-backdrop.show { display: flex; }
|
||
.v2-modal {
|
||
background: var(--ecg-card-bg);
|
||
border-radius: 6px;
|
||
padding: 20px 24px;
|
||
max-width: 580px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
position: relative;
|
||
}
|
||
.v2-modal h2 {
|
||
font-family: var(--font-display);
|
||
font-size: 16px;
|
||
color: var(--ecg-teal);
|
||
margin: 0 0 12px;
|
||
}
|
||
.v2-modal-close {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 14px;
|
||
background: none;
|
||
border: none;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
color: var(--ecg-dark);
|
||
opacity: 0.5;
|
||
}
|
||
.v2-modal-close:hover { opacity: 1; }
|
||
table.modal-table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
font-size: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
table.modal-table th, table.modal-table td {
|
||
border: 1px solid var(--ecg-border);
|
||
padding: 4px 8px;
|
||
text-align: left;
|
||
}
|
||
table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||
</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;">Auswertungen</h1>
|
||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||
Bundesland × Partei · Thema × Fraktion · Cluster
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="auswert-tabs" role="tablist">
|
||
<button class="auswert-tab active" role="tab" onclick="switchTab('bl-partei', this)">BL × Partei</button>
|
||
<button class="auswert-tab" role="tab" onclick="switchTab('themen', this)">Thema × Fraktion</button>
|
||
<button class="auswert-tab" role="tab" onclick="switchTab('cluster', this)">Cluster</button>
|
||
</div>
|
||
|
||
<!-- Panel 1: BL × Partei -->
|
||
<div class="auswert-panel active" id="panel-bl-partei">
|
||
<div class="controls-bar">
|
||
<label for="wp-filter">Wahlperiode:</label>
|
||
<select id="wp-filter">
|
||
<option value="">— alle WPs —</option>
|
||
{% for wp in wahlperioden %}
|
||
<option value="{{ wp }}">{{ wp }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label for="bl-filter">Bundesland:</label>
|
||
<select id="bl-filter">
|
||
<option value="">Alle</option>
|
||
{% for code in bl_codes %}
|
||
<option value="{{ code }}">{{ code }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<button class="primary" onclick="loadBlMatrix()">Laden</button>
|
||
<button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button>
|
||
</div>
|
||
<div id="bl-matrix-wrap" class="matrix-wrap">
|
||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>
|
||
</div>
|
||
<div id="bl-matrix-meta" class="meta-line"></div>
|
||
</div>
|
||
|
||
<!-- Panel 2: Thema × Fraktion -->
|
||
<div class="auswert-panel" id="panel-themen">
|
||
<div class="controls-bar">
|
||
<label for="themen-bl-filter">Bundesland:</label>
|
||
<select id="themen-bl-filter" onchange="loadThemenMatrix()">
|
||
<option value="">Alle</option>
|
||
{% for code in bl_codes %}
|
||
<option value="{{ code }}">{{ code }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div id="themen-matrix-wrap" class="matrix-wrap">
|
||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Panel 3: Cluster-Link -->
|
||
<div class="auswert-panel" id="panel-cluster">
|
||
<div class="v2-kasten outline-blue">
|
||
<h4>Cluster-Ansicht</h4>
|
||
<p style="font-size:12px;">
|
||
Die interaktive Cluster-Übersicht finden Sie unter
|
||
<a href="/v2/cluster" style="color:var(--ecg-teal);">/v2/cluster</a>.
|
||
Sie zeigt thematisch ähnliche Anträge als redaktionelle Liste und verlinkt
|
||
zur Force-Graph-Visualisierung.
|
||
</p>
|
||
<a href="/v2/cluster"
|
||
style="display:inline-block;margin-top:8px;font-family:var(--font-mono);font-size:11px;
|
||
padding:6px 14px;background:var(--ecg-teal);color:#fff;border-radius:3px;text-decoration:none;">
|
||
Zur Cluster-Ansicht →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Zeitreihen-Modal -->
|
||
<div class="v2-modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
|
||
<div class="v2-modal" onclick="event.stopPropagation()">
|
||
<button class="v2-modal-close" onclick="closeModal()">×</button>
|
||
<h2 id="modal-title">Zeitreihe</h2>
|
||
<div id="modal-body" style="font-size:12px;">Lade …</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block body_scripts %}
|
||
<script>
|
||
let _tabLoaded = { 'bl-partei': false, 'themen': false };
|
||
|
||
function switchTab(id, btn) {
|
||
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.auswert-panel').forEach(p => p.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('panel-' + id).classList.add('active');
|
||
|
||
if (id === 'themen' && !_tabLoaded.themen) {
|
||
loadThemenMatrix();
|
||
_tabLoaded.themen = true;
|
||
}
|
||
}
|
||
|
||
function scoreClass(avg) {
|
||
if (avg == null) return '';
|
||
if (avg >= 6) return 's-high';
|
||
if (avg >= 3) return 's-mid';
|
||
return 's-low';
|
||
}
|
||
|
||
async function loadBlMatrix() {
|
||
const wrap = document.getElementById('bl-matrix-wrap');
|
||
const metaEl = document.getElementById('bl-matrix-meta');
|
||
const wp = document.getElementById('wp-filter').value;
|
||
const bl = document.getElementById('bl-filter').value;
|
||
|
||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>';
|
||
metaEl.textContent = '';
|
||
|
||
let url = '/api/auswertungen/matrix';
|
||
const params = [];
|
||
if (wp) params.push('wahlperiode=' + encodeURIComponent(wp));
|
||
if (bl) params.push('bundesland=' + encodeURIComponent(bl));
|
||
if (params.length) url += '?' + params.join('&');
|
||
|
||
try {
|
||
const r = await fetch(url);
|
||
const data = await r.json();
|
||
if (!data.bundeslaender || !data.bundeslaender.length) {
|
||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine Assessments für diesen Filter.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Bundesland</th>';
|
||
for (const partei of data.parteien) html += `<th>${partei}</th>`;
|
||
html += '</tr></thead><tbody>';
|
||
|
||
for (const bundesland of data.bundeslaender) {
|
||
html += `<tr><th class="row-h">${bundesland}</th>`;
|
||
for (const partei of data.parteien) {
|
||
const cell = (data.cells[bundesland] || {})[partei];
|
||
if (cell) {
|
||
html += `<td class="clickable ${scoreClass(cell.avg)}"
|
||
onclick="showZeitreihe('${bundesland.replace(/'/g,"\\'")}','${partei.replace(/'/g,"\\'")}')">
|
||
${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small>
|
||
</td>`;
|
||
} else {
|
||
html += '<td class="empty">—</td>';
|
||
}
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
wrap.innerHTML = html;
|
||
metaEl.textContent = `${data.total} Assessments | Filter: ${data.filter_wp || 'alle WPs'}`;
|
||
} catch (e) {
|
||
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadThemenMatrix() {
|
||
const wrap = document.getElementById('themen-matrix-wrap');
|
||
const bl = document.getElementById('themen-bl-filter').value;
|
||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>';
|
||
|
||
let url = '/api/auswertungen/themen-matrix';
|
||
if (bl) url += '?bundesland=' + encodeURIComponent(bl);
|
||
|
||
try {
|
||
const r = await fetch(url);
|
||
const data = await r.json();
|
||
if (!data.themen || !data.themen.length) {
|
||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Noch zu wenige Assessments für Themen-Analyse.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Thema</th>';
|
||
for (const frak of data.fraktionen) html += `<th>${frak}</th>`;
|
||
html += '</tr></thead><tbody>';
|
||
|
||
for (const thema of data.themen) {
|
||
html += `<tr><th class="row-h">${thema}</th>`;
|
||
for (const frak of data.fraktionen) {
|
||
const cell = (data.cells[thema] || {})[frak];
|
||
if (cell) {
|
||
html += `<td class="${scoreClass(cell.avg)}" title="${thema} × ${frak}: Ø ${cell.avg}/10 (${cell.n} Anträge)">
|
||
${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small>
|
||
</td>`;
|
||
} else {
|
||
html += '<td class="empty">—</td>';
|
||
}
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
wrap.innerHTML = html;
|
||
} catch (e) {
|
||
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
|
||
}
|
||
}
|
||
|
||
async function showZeitreihe(bundesland, partei) {
|
||
const backdrop = document.getElementById('modal-backdrop');
|
||
const title = document.getElementById('modal-title');
|
||
const body = document.getElementById('modal-body');
|
||
title.textContent = bundesland + ' × ' + partei;
|
||
body.innerHTML = '<p style="font-family:var(--font-mono);font-size:12px;opacity:0.6;">Lade Zeitreihe …</p>';
|
||
backdrop.classList.add('show');
|
||
|
||
try {
|
||
const r = await fetch(`/api/auswertungen/zeitreihe?bundesland=${encodeURIComponent(bundesland)}&partei=${encodeURIComponent(partei)}`);
|
||
const z = await r.json();
|
||
|
||
if (!z.wahlperioden || !z.wahlperioden.length) {
|
||
body.innerHTML = '<p style="font-family:var(--font-mono);font-size:12px;opacity:0.6;">Keine Daten für diese Kombination.</p>';
|
||
return;
|
||
}
|
||
|
||
body.innerHTML =
|
||
'<canvas id="zeitreihe-chart" style="max-height:260px;margin-bottom:1rem;"></canvas>' +
|
||
'<table class="modal-table"><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>' +
|
||
z.wahlperioden.map(row => `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`).join('') +
|
||
'</tbody></table>';
|
||
|
||
if (window.Chart) {
|
||
const ctx = document.getElementById('zeitreihe-chart');
|
||
new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: z.wahlperioden.map(r => 'WP ' + r.wp),
|
||
datasets: [{
|
||
label: `Ø GWÖ-Score ${partei} (${bundesland})`,
|
||
data: z.wahlperioden.map(r => r.avg),
|
||
borderColor: '#009da5',
|
||
backgroundColor: 'rgba(0,157,165,0.1)',
|
||
fill: true,
|
||
tension: 0.3,
|
||
pointRadius: 5,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
scales: {
|
||
y: { min: 0, max: 10, title: { display: true, text: 'GWÖ-Score' } }
|
||
},
|
||
plugins: {
|
||
tooltip: {
|
||
callbacks: {
|
||
afterLabel: (ctx) => `n=${z.wahlperioden[ctx.dataIndex].n} Anträge`
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} catch (e) {
|
||
body.innerHTML = `<p style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</p>`;
|
||
}
|
||
}
|
||
|
||
function closeModal(ev) {
|
||
if (!ev || ev.target.id === 'modal-backdrop') {
|
||
document.getElementById('modal-backdrop').classList.remove('show');
|
||
}
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') document.getElementById('modal-backdrop').classList.remove('show');
|
||
});
|
||
|
||
// Load BL-Matrix on init
|
||
loadBlMatrix();
|
||
</script>
|
||
{% endblock %}
|