gwoe-antragspruefer/app/templates/v2/screens/auswertungen.html
Dotty Dotter 79003d6056 feat(#166): Berührungsgruppen-Aufschlüsselung im Stimmverhalten-Tab
Stimm-Index pro Beruehrungsgruppe (Matrix-Zeilen A-E) zusaetzlich zur
bestehenden Werte-Aufschluesselung (Spalten 1-5). Toggle-Buttons in der
3. Sub-Section schalten zwischen Werte/Gruppen.

- `aggregate_stimm_index_pro_gruppe()` analog zu `_pro_wert`, aber
  gruppiert nach `field[0]` (A-E) statt `field[-1]` (1-5).
- `_gruppen_score_for_assessment()` Helper.
- `GET /api/auswertungen/stimm-index-pro-gruppe`.
- UI-Toggle "Pro GWÖ-Wert" / "Pro Berührungsgruppe" mit `setMatrixAxis()`.
- 6 neue Tests, Suite jetzt 995 grün.

Beruehrungsgruppen-Labels (aus app/models.py:MATRIX_LABELS gekuerzt):
- A: Ausgelagerte Betriebe / Lieferant:innen
- B: Finanzpartner:innen / Steuerzahler:innen
- C: Politische Führung / Verwaltung / Ehrenamt
- D: Bürger:innen und Wirtschaft
- E: Staat, Gesellschaft und Natur

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

958 lines
36 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 4: Empfehlungs-Konsistenz Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">4. 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 5: Cross-BL Grouped Bar -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">5. 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 };
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();
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>`;
}
}
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 %}