gwoe-antragspruefer/app/templates/v2/screens/auswertungen.html
Dotty Dotter 1e381d23ab feat(#168): Über-Zeit-Drift im Stimmverhalten-Tab
Stimm-Index pro Fraktion über Quartale. Linien-Chart pro Fraktion,
Lücken bei Quartalen mit n<3 (Ja UND Nein). Macht sichtbar, ob sich die
Gemeinwohl-Affinität einer Fraktion innerhalb der Wahlperiode verschiebt.

- `_quarter_for(datum)` Helper: ISO-Datum → "YYYY-Qn".
- `aggregate_stimm_index_zeitreihe()` analog zu pro_wert/pro_gruppe,
  aber nach Quartal-Bucket statt Achse.
- `GET /api/auswertungen/stimm-index-zeitreihe?parteien=CDU,SPD,...`
- 4. Sub-Section im Stimmverhalten-Tab: Multi-Linien-Chart mit
  Partei-Farben (CDU schwarz, SPD rot, GRÜNE grün, FDP gelb, AfD blau,
  LINKE pink, BSW lila, SSW navy, BVB-FW orange).

Bei aktueller Sparse-Datenmenge (35 Assessments × 4 Quartale) ist der
Chart heute meist leer — Infrastruktur ist ready, fuellt sich automatisch
mit Issue #44 Batch-Bewertung.

Tests: 10 neue (4 _quarter_for, 6 aggregate). Suite jetzt 1005 grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:03:53 +02:00

1050 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 · Stimmverhalten · 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('stimmverhalten', this)">Stimmverhalten</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>
<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">
<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: Stimmverhalten × Gemeinwohl -->
<div class="auswert-panel" id="panel-stimmverhalten">
<div class="v2-kasten outline-blue" style="margin-bottom:1rem;">
<h4 style="margin-top:0;">Stimmverhalten × Gemeinwohl-Orientierung</h4>
<p style="font-size:12px;line-height:1.5;">
Verschneidung von <strong>GWÖ-Bewertung pro Antrag</strong> mit dem
tatsächlichen <strong>Plenum-Stimmverhalten der Fraktionen</strong>.
Beantwortet die Frage: Welche Fraktion stimmt häufiger Anträgen mit
hoher Gemeinwohl-Bewertung zu, welche lehnt sie eher ab?
</p>
<p style="font-size:11px;line-height:1.5;opacity:0.8;">
<strong>Datenbasis:</strong> Anträge, die sowohl eine GWÖ-Bewertung
<em>als auch</em> einen Plenum-Vote haben. Heute noch dünn — wächst
mit der Anzahl Bewertungen.
<strong>Eigene Anträge sind per Default ausgeschlossen</strong>, weil
Antragsteller-Fraktionen quasi immer „ja" stimmen — das würde den Index
verzerren.
</p>
<div style="display:flex;align-items:center;gap:1rem;margin-top:6px;flex-wrap:wrap;">
<label style="display:inline-flex;align-items:center;gap:6px;font-size:11px;
font-family:var(--font-mono);cursor:pointer;">
<input type="checkbox" id="sv-exclude-antragsteller" checked
onchange="loadStimmverhalten()" />
Eigene Anträge ausschließen
</label>
<button type="button" onclick="downloadStimmverhaltenCsv()"
style="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);cursor:pointer;">
CSV-Export (Long-Format)
</button>
</div>
</div>
<!-- Sub 1: Stimm-Index Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">1. Gemeinwohl-Stimm-Index pro Fraktion</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Ø-GWÖ-Score der <em>Ja</em>-Stimmen <strong>minus</strong> Ø-GWÖ-Score der
<em>Nein</em>-Stimmen. Positiv = stimmt eher Gemeinwohl-affinen Anträgen zu.
Domain: 10..+10.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-index-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-index-meta" class="meta-line"></div>
<div id="sv-index-insufficient" style="font-size:11px;font-family:var(--font-mono);
opacity:0.6;margin-top:6px;"></div>
<!-- Sub 2: Heuchelei-Score Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">2. Heuchelei-Quote pro Fraktion</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Anteil der Anträge mit <strong>Wahlprogramm-Treue ≥ 7/10</strong>
(passt inhaltlich zum Wahlprogramm der Fraktion), bei denen die Fraktion
trotzdem <em>Nein</em> gestimmt hat. Hohe Werte = häufige Inkonsistenz
zwischen Wahlversprechen und Stimmverhalten.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-heuchelei-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-heuchelei-meta" class="meta-line"></div>
<!-- Sub 3: Pro GWÖ-Wert / Pro Berührungsgruppe (Toggle) -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">3. Stimm-Index pro Matrix-Achse</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Aufschlüsselung der Stimm-Indizes pro <strong>GWÖ-Wert</strong>
(Spalten: Würde, Solidarität, Nachhaltigkeit, Gerechtigkeit,
Demokratie) oder pro <strong>Berührungsgruppe</strong> (Zeilen AE:
Lieferant:innen, Finanzpartner, Politik, Bürger, Staat). Pro Zelle:
Stimm-Index analog Aussage 1, aber gegen den Achsen-Score statt den
Gesamt-Score. Domain pro Zelle: 5..+5.
</p>
<div style="display:inline-flex;gap:4px;margin-bottom:0.5rem;">
<button type="button" id="sv-axis-werte" class="sv-axis-btn active"
onclick="setMatrixAxis('werte')"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;
border:1px solid var(--ecg-border);border-radius:3px;
cursor:pointer;background:var(--ecg-teal);color:#fff;">
Pro GWÖ-Wert
</button>
<button type="button" id="sv-axis-gruppen" class="sv-axis-btn"
onclick="setMatrixAxis('gruppen')"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;
border:1px solid var(--ecg-border);border-radius:3px;
cursor:pointer;background:var(--ecg-card-bg);color:var(--ecg-dark);">
Pro Berührungsgruppe
</button>
</div>
<div id="sv-wert-heatmap" class="matrix-wrap"></div>
<!-- Sub 3b: Über-Zeit-Drift Line Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">4. Über-Zeit-Drift (Quartal × Fraktion)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Stimm-Index pro Fraktion über die Quartale der laufenden Wahlperiode.
Pro Fraktion eine Linie. Lücken in Quartalen mit zu wenig Daten (n &lt; 3
pro Vote-Richtung). Macht sichtbar, ob sich die Gemeinwohl-Affinität
einer Fraktion verschiebt.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-zeitreihe-chart" style="max-height:400px;"></canvas>
</div>
<div id="sv-zeitreihe-meta" class="meta-line"></div>
<!-- Sub 5: Empfehlungs-Konsistenz Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">5. Empfehlungs-Konsistenz (gegen GWÖ-Empfehlung)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Anteil der Anträge mit GWÖ-Empfehlung „Uneingeschränkt unterstützen" oder
„Unterstützen mit Änderungen", bei denen die Fraktion trotzdem
<em>Nein</em> gestimmt hat. Orthogonal zur Heuchelei-Quote — prüft NICHT
gegen Wahlprogramm-Treue, sondern gegen die GWÖ-Empfehlung des Systems.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-empfehlung-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-empfehlung-meta" class="meta-line"></div>
<!-- Sub 6: Cross-BL Grouped Bar -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">6. Stimm-Index pro Bundesland (Cross-BL)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Gleiche Fraktion in mehreren Ländern. Macht regionale Drift sichtbar
— gleiche Partei, unterschiedlicher Stimm-Index? Nur Fraktionen in ≥2 BL
mit ausreichender Datenbasis.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-cross-bl-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-cross-bl-meta" class="meta-line"></div>
</div>
<!-- Panel 4: 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()">&times;</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, 'stimmverhalten': false };
let _svCharts = { index: null, heuchelei: null, empfehlung: null, crossBl: null, zeitreihe: null };
let _svMatrixAxis = 'werte'; // 'werte' or 'gruppen'
function setMatrixAxis(axis) {
_svMatrixAxis = axis;
const werteBtn = document.getElementById('sv-axis-werte');
const gruppenBtn = document.getElementById('sv-axis-gruppen');
if (axis === 'werte') {
werteBtn.style.background = 'var(--ecg-teal)';
werteBtn.style.color = '#fff';
gruppenBtn.style.background = 'var(--ecg-card-bg)';
gruppenBtn.style.color = 'var(--ecg-dark)';
} else {
gruppenBtn.style.background = 'var(--ecg-teal)';
gruppenBtn.style.color = '#fff';
werteBtn.style.background = 'var(--ecg-card-bg)';
werteBtn.style.color = 'var(--ecg-dark)';
}
loadMatrixHeatmap();
}
function loadMatrixHeatmap() {
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
if (_svMatrixAxis === 'gruppen') {
loadStimmIndexProGruppe(bl, exclude);
} else {
loadStimmIndexProWert(bl, exclude);
}
}
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;
}
if (id === 'stimmverhalten' && !_tabLoaded.stimmverhalten) {
loadStimmverhalten();
_tabLoaded.stimmverhalten = true;
}
}
// Bei BL-Wechsel aktive Panels neu laden
window.addEventListener('v2-bl-changed', function () {
var activePanel = document.querySelector('.auswert-panel.active');
if (!activePanel) return;
if (activePanel.id === 'panel-bl-partei') loadBlMatrix();
if (activePanel.id === 'panel-themen') loadThemenMatrix();
if (activePanel.id === 'panel-stimmverhalten') loadStimmverhalten();
});
document.addEventListener('DOMContentLoaded', function () { loadBlMatrix(); });
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 blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
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 blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
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');
});
// ─── Stimmverhalten × Gemeinwohl ────────────────────────────────────────────
async function loadStimmverhalten() {
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
loadStimmIndex(bl, exclude);
loadHeuchelei(bl);
loadMatrixHeatmap();
loadStimmIndexZeitreihe(bl, exclude);
loadEmpfehlungsKonsistenz(bl);
loadStimmIndexCrossBl(exclude);
}
function downloadStimmverhaltenCsv() {
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
let url = `/api/auswertungen/stimmverhalten.csv?exclude_antragsteller=${exclude}`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
window.location.href = url;
}
function svColor(idx) {
// Diverging color: positiv = teal/grün, negativ = rot, null = grau
if (idx == null) return 'rgba(120,120,120,0.4)';
if (idx >= 0) return `rgba(0,157,165,${Math.min(0.85, 0.3 + Math.abs(idx) / 10)})`;
return `rgba(200,30,30,${Math.min(0.85, 0.3 + Math.abs(idx) / 10)})`;
}
async function loadStimmIndex(bl, exclude) {
const meta = document.getElementById('sv-index-meta');
const insufficientEl = document.getElementById('sv-index-insufficient');
let url = `/api/auswertungen/stimm-index?exclude_antragsteller=${exclude}&min_n=5`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.index) _svCharts.index.destroy();
const ausreichend = data.fraktionen.filter(f => f.ausreichend && f.stimm_index != null);
const nicht = data.fraktionen.filter(f => !f.ausreichend);
if (!ausreichend.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — keine Fraktion erreicht das Mindest-N (5 Ja UND 5 Nein).`;
insufficientEl.textContent = '';
return;
}
const ctx = document.getElementById('sv-index-chart');
_svCharts.index = new Chart(ctx, {
type: 'bar',
data: {
labels: ausreichend.map(f => f.partei),
datasets: [{
label: 'Stimm-Index',
data: ausreichend.map(f => f.stimm_index),
backgroundColor: ausreichend.map(f => svColor(f.stimm_index)),
}]
},
options: {
indexAxis: 'y',
responsive: true,
scales: {
x: { min: -10, max: 10, title: { display: true, text: 'Stimm-Index (10..+10)' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const f = ausreichend[ctx.dataIndex];
return [
`Ø GWÖ Ja: ${f.avg_gwoe_ja}/10 (n=${f.n_ja})`,
`Ø GWÖ Nein: ${f.avg_gwoe_nein}/10 (n=${f.n_nein})`,
`Enthaltung: n=${f.n_enth}`,
];
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge mit GWÖ-Bewertung × Plenum-Vote.`;
if (nicht.length) {
insufficientEl.innerHTML = '<strong>Nicht aussagekräftig (N&lt;5):</strong> '
+ nicht.map(f => `${f.partei} (n_ja=${f.n_ja}, n_nein=${f.n_nein})`).join(' · ');
} else {
insufficientEl.textContent = '';
}
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
async function loadHeuchelei(bl) {
const meta = document.getElementById('sv-heuchelei-meta');
let url = '/api/auswertungen/heuchelei?score_threshold=7&min_n=5';
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.heuchelei) _svCharts.heuchelei.destroy();
const filtered = data.fraktionen.filter(f => f.ausreichend && f.heuchelei_quote != null);
if (!filtered.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — zu wenige Anträge mit Wahlprogramm-Treue ≥ 7.`;
return;
}
const ctx = document.getElementById('sv-heuchelei-chart');
_svCharts.heuchelei = new Chart(ctx, {
type: 'bar',
data: {
labels: filtered.map(f => f.partei),
datasets: [{
label: 'Heuchelei-Quote',
data: filtered.map(f => Math.round(f.heuchelei_quote * 1000) / 10),
backgroundColor: filtered.map(f => `rgba(200,30,30,${Math.min(0.85, 0.25 + f.heuchelei_quote)})`),
}]
},
options: {
indexAxis: 'y',
responsive: true,
scales: {
x: { min: 0, max: 100, title: { display: true, text: 'Heuchelei-Quote (%)' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const f = filtered[ctx.dataIndex];
return [
`Anträge passend zum Programm: ${f.n_im_programm}`,
`davon Nein gestimmt: ${f.n_nein_trotz_programm}`,
`davon Ja gestimmt: ${f.n_ja_passt}`,
`davon Enthaltung: ${f.n_enth_passt}`,
];
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — Threshold: Wahlprogramm-Treue ≥ 7/10.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
function wertHeatColor(idx) {
// Wert-Spalten haben Domain -5..+5 (rating-Skala der Matrix)
if (idx == null) return 'rgba(120,120,120,0.1)';
if (idx >= 0) return `rgba(136,158,51,${Math.min(0.7, 0.15 + Math.abs(idx) / 5)})`;
return `rgba(200,0,0,${Math.min(0.7, 0.15 + Math.abs(idx) / 5)})`;
}
async function loadStimmIndexProWert(bl, exclude) {
const wrap = document.getElementById('sv-wert-heatmap');
let url = `/api/auswertungen/stimm-index-pro-wert?exclude_antragsteller=${exclude}&min_n=3`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Heatmap …</div>';
try {
const r = await fetch(url);
const data = await r.json();
if (!data.fraktionen.length) {
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine Daten für diesen Filter.</div>';
return;
}
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Fraktion</th>';
for (const w of data.werte) html += `<th>${w}</th>`;
html += '</tr></thead><tbody>';
for (const partei of data.fraktionen) {
html += `<tr><th class="row-h">${partei}</th>`;
for (const wert of data.werte) {
const cell = (data.cells[partei] || {})[wert];
if (cell && cell.stimm_index != null && cell.ausreichend) {
const color = wertHeatColor(cell.stimm_index);
const sign = cell.stimm_index >= 0 ? '+' : '';
html += `<td style="background:${color};" title="${partei} × ${wert}: Index ${sign}${cell.stimm_index} (n_ja=${cell.n_ja}, n_nein=${cell.n_nein})">
${sign}${cell.stimm_index.toFixed(1)}
</td>`;
} else {
html += '<td class="empty">—</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
html += `<div class="meta-line">Datenbasis: ${data.n_assessments_matched} Anträge.
Mindest-N pro Zelle: 3. Skala 5..+5 (Wert-Score-Differenz Ja minus Nein).</div>`;
wrap.innerHTML = html;
} catch (e) {
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
const SV_PARTEI_COLORS = {
'CDU': '#1a1a1a', 'CSU': '#1a1a1a',
'SPD': '#e3000f',
'GRÜNE': '#46962b',
'FDP': '#ffed00',
'AfD': '#0489db',
'LINKE': '#be3075',
'BSW': '#7d1f8a',
'SSW': '#003d8f',
'BVB-FW': '#f7941d',
'FREIE WÄHLER': '#f7941d',
};
function svParteiColor(p) { return SV_PARTEI_COLORS[p] || '#888'; }
async function loadStimmIndexZeitreihe(bl, exclude) {
const meta = document.getElementById('sv-zeitreihe-meta');
let url = `/api/auswertungen/stimm-index-zeitreihe?exclude_antragsteller=${exclude}&min_n_per_bucket=3`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.zeitreihe) _svCharts.zeitreihe.destroy();
const fraktionen = data.fraktionen.filter(p => {
// Mindestens 1 Bucket mit ausreichend Daten
return data.buckets.some(b => data.detail[p][b].ausreichend);
});
if (!fraktionen.length || !data.buckets.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — kein Quartal erreicht das Mindest-N (3 Ja UND 3 Nein pro Fraktion).`;
return;
}
const datasets = fraktionen.map(p => ({
label: p,
data: data.series[p],
borderColor: svParteiColor(p),
backgroundColor: svParteiColor(p) + '33',
fill: false,
spanGaps: true,
tension: 0.2,
pointRadius: 4,
}));
const ctx = document.getElementById('sv-zeitreihe-chart');
_svCharts.zeitreihe = new Chart(ctx, {
type: 'line',
data: { labels: data.buckets, datasets: datasets },
options: {
responsive: true,
scales: {
y: { min: -10, max: 10, title: { display: true, text: 'Stimm-Index (10..+10)' } },
x: { title: { display: true, text: 'Quartal' } },
},
plugins: {
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const p = ctx.dataset.label;
const b = data.buckets[ctx.dataIndex];
const d = data.detail[p][b];
return `n_ja=${d.n_ja}, n_nein=${d.n_nein}`;
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge über ${data.buckets.length} Quartale. Mindest-N pro Bucket: 3 Ja UND 3 Nein.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
async function loadStimmIndexProGruppe(bl, exclude) {
const wrap = document.getElementById('sv-wert-heatmap');
let url = `/api/auswertungen/stimm-index-pro-gruppe?exclude_antragsteller=${exclude}&min_n=3`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Heatmap …</div>';
try {
const r = await fetch(url);
const data = await r.json();
if (!data.fraktionen.length) {
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine Daten für diesen Filter.</div>';
return;
}
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Fraktion</th>';
for (const g of data.gruppen) html += `<th style="font-size:10px;max-width:120px;white-space:normal;">${g}</th>`;
html += '</tr></thead><tbody>';
for (const partei of data.fraktionen) {
html += `<tr><th class="row-h">${partei}</th>`;
for (const gruppe of data.gruppen) {
const cell = (data.cells[partei] || {})[gruppe];
if (cell && cell.stimm_index != null && cell.ausreichend) {
const color = wertHeatColor(cell.stimm_index);
const sign = cell.stimm_index >= 0 ? '+' : '';
html += `<td style="background:${color};" title="${partei} × ${gruppe}: Index ${sign}${cell.stimm_index} (n_ja=${cell.n_ja}, n_nein=${cell.n_nein})">
${sign}${cell.stimm_index.toFixed(1)}
</td>`;
} else {
html += '<td class="empty">—</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
html += `<div class="meta-line">Datenbasis: ${data.n_assessments_matched} Anträge.
Mindest-N pro Zelle: 3. Skala 5..+5 (Gruppen-Score-Differenz Ja minus Nein).</div>`;
wrap.innerHTML = html;
} catch (e) {
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
async function loadEmpfehlungsKonsistenz(bl) {
const meta = document.getElementById('sv-empfehlung-meta');
let url = '/api/auswertungen/empfehlungs-konsistenz?min_n=3';
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.empfehlung) _svCharts.empfehlung.destroy();
const filtered = data.fraktionen.filter(f => f.ausreichend && f.konsistenz_quote != null);
if (!filtered.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} positiv-empfohlene Anträge — keine Fraktion erreicht das Mindest-N (3).`;
return;
}
const ctx = document.getElementById('sv-empfehlung-chart');
_svCharts.empfehlung = new Chart(ctx, {
type: 'bar',
data: {
labels: filtered.map(f => f.partei),
datasets: [{
label: 'Inkonsistenz-Quote',
data: filtered.map(f => Math.round(f.konsistenz_quote * 1000) / 10),
backgroundColor: filtered.map(f => `rgba(200,30,30,${Math.min(0.85, 0.25 + f.konsistenz_quote)})`),
}]
},
options: {
indexAxis: 'y',
responsive: true,
scales: {
x: { min: 0, max: 100, title: { display: true, text: 'Inkonsistenz-Quote (%) — NEIN trotz GWÖ-Empfehlung' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const f = filtered[ctx.dataIndex];
return [
`Anträge mit GWÖ-Empfehlung+: ${f.n_empfohlen}`,
`davon Nein gestimmt: ${f.n_nein_trotz_empfehlung}`,
`davon Ja gestimmt: ${f.n_ja}`,
`davon Enthaltung: ${f.n_enth}`,
];
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge mit GWÖ-Empfehlung „Uneingeschränkt unterstützen" oder „Unterstützen mit Änderungen".`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
async function loadStimmIndexCrossBl(exclude) {
const meta = document.getElementById('sv-cross-bl-meta');
let url = `/api/auswertungen/stimm-index-cross-bl?exclude_antragsteller=${exclude}&min_n=3`;
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.crossBl) _svCharts.crossBl.destroy();
if (!data.fraktionen.length) {
meta.textContent = `Keine Fraktion in ≥2 Bundesländern mit ausreichender Datenbasis.`;
return;
}
// Datasets: pro Bundesland eine Bar-Reihe
const datasets = data.bundeslaender.map((bl, i) => {
const colors = ['rgba(0,157,165,0.7)', 'rgba(247,148,29,0.7)', 'rgba(136,158,51,0.7)',
'rgba(200,30,30,0.7)', 'rgba(150,100,200,0.7)', 'rgba(80,80,80,0.7)',
'rgba(50,150,50,0.7)', 'rgba(220,180,20,0.7)'];
return {
label: bl,
data: data.fraktionen.map(p => {
const cell = (data.cells[p] || {})[bl];
return (cell && cell.ausreichend) ? cell.stimm_index : null;
}),
backgroundColor: colors[i % colors.length],
};
});
const ctx = document.getElementById('sv-cross-bl-chart');
_svCharts.crossBl = new Chart(ctx, {
type: 'bar',
data: { labels: data.fraktionen, datasets: datasets },
options: {
responsive: true,
scales: {
y: { min: -10, max: 10, title: { display: true, text: 'Stimm-Index (10..+10)' } }
},
plugins: {
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const partei = data.fraktionen[ctx.dataIndex];
const bl = ctx.dataset.label;
const cell = (data.cells[partei] || {})[bl];
if (!cell) return '';
return `n_ja=${cell.n_ja}, n_nein=${cell.n_nein}`;
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge. ${data.fraktionen.length} Fraktion(en) in ≥2 BL mit n≥3 ja UND n≥3 nein.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
// Load BL-Matrix on init
loadBlMatrix();
</script>
{% endblock %}