gwoe-antragspruefer/app/templates/v2/screens/auswertungen.html
Dotty Dotter 8136a1a10b feat(#172): Vote-Orphans-Banner + Bulk-Auto-Bewerten-Endpoint
Datenlage auf dev: 7281 Plenum-Votes, 96 Bewertungen, nur 19 Matches.
Stimmverhalten-Tab zeigt fast nichts, weil die meisten Vote-Drucksachen
keine Bewertung haben. Issue #172 schliesst die Luecke.

**Banner im Stimmverhalten-Tab:**
- Zeigt Anzahl + Verteilung pro BL der "Vote-only"-Drucksachen
- Nur sichtbar wenn count > 0
- Aktion: "Auto-Bewerten Top-N" mit Limit-Selector (5/10/20)

**Endpoint `GET /api/auswertungen/vote-orphans`:**
LEFT JOIN plenum_vote_results vs assessments, count + by_bundesland +
Top-N items sortiert nach parsed_at desc.

**Endpoint `POST /api/auswertungen/vote-orphans/auto-rate`:**
Admin-only, rate-limited 3/min. Nimmt Top-N Orphans, lädt Antragstext
per Adapter, enqueued einen Bewertungs-Job pro Drucksache. Defaults
limit=10, max 50. Per-skipped-reason-Liste in der Response (Adapter
fehlt, Empty-Text, Queue-full, etc.).

**Tests:** 4 neue (`TestGetVoteOrphans`), Suite 1071 gruen.

Helper `_enqueue_for_bl` aus dem Batch-Endpoint wird hier indirekt
wiederverwendet (gleiche Job-Queue-Pipeline).

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

1137 lines
43 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">
<!-- Vote-Orphans-Banner: Drucksachen mit Vote, ohne Bewertung (#172) -->
<div id="sv-orphans-banner" class="v2-kasten" style="display:none;background:rgba(247,148,29,0.10);border-color:rgba(247,148,29,0.4);margin-bottom:0.75rem;">
<p style="margin:0;font-size:12px;line-height:1.5;">
<strong id="sv-orphans-count"></strong> Drucksachen haben einen Plenum-Vote,
aber noch keine GWÖ-Bewertung. <span id="sv-orphans-by-bl" style="opacity:0.7;font-family:var(--font-mono);font-size:11px;"></span>
<br>
Das Stimmverhalten-Feature greift erst, wenn beide Seiten vorhanden sind.
</p>
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label style="font-size:11px;font-family:var(--font-mono);">Auto-Bewerten Top-N:</label>
<select id="sv-orphans-limit" style="font-family:var(--font-mono);font-size:11px;padding:3px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
</select>
<button type="button" onclick="bulkRateOrphans()"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;border:1px solid var(--ecg-teal);background:var(--ecg-teal);color:#fff;border-radius:3px;cursor:pointer;">
Top-N bewerten lassen
</button>
<span id="sv-orphans-result" style="font-size:11px;font-family:var(--font-mono);opacity:0.7;"></span>
</div>
</div>
<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;">
Bundesland:
<select id="sv-bl-filter" onchange="loadStimmverhalten()"
style="font-family:var(--font-mono);font-size:11px;padding:3px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);">
<option value="">— Alle Bundesländer —</option>
{% for code in bl_codes %}
<option value="{{ code }}">{{ code }}</option>
{% endfor %}
</select>
</label>
<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 bl = svGetBl();
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 globalem BL-Wechsel aktive Panels neu laden — ABER NICHT
// Stimmverhalten, das hat seinen eigenen lokalen BL-Filter (Issue #173).
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();
// Stimmverhalten reagiert NICHT auf globalen BL-Filter — eigener Selector.
});
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 ────────────────────────────────────────────
// Lokaler BL-Filter im Stimmverhalten-Tab — eigenstaendig vom globalen
// BL-Filter im Header (Issue #173). Default: "" (alle BL).
function svGetBl() {
const sel = document.getElementById('sv-bl-filter');
return sel ? sel.value : '';
}
async function loadStimmverhalten() {
const bl = svGetBl();
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
loadVoteOrphansBanner(bl);
loadStimmIndex(bl, exclude);
loadHeuchelei(bl);
loadMatrixHeatmap();
loadStimmIndexZeitreihe(bl, exclude);
loadEmpfehlungsKonsistenz(bl);
loadStimmIndexCrossBl(exclude);
}
async function loadVoteOrphansBanner(bl) {
const banner = document.getElementById('sv-orphans-banner');
const countEl = document.getElementById('sv-orphans-count');
const byBlEl = document.getElementById('sv-orphans-by-bl');
let url = '/api/auswertungen/vote-orphans?limit=10';
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const d = await r.json();
if (!d.count) {
banner.style.display = 'none';
return;
}
banner.style.display = '';
countEl.textContent = d.count.toLocaleString('de-DE');
const sortedBl = Object.entries(d.by_bundesland).sort((a, b) => b[1] - a[1]).slice(0, 8);
byBlEl.textContent = sortedBl.map(e => `${e[0]}:${e[1]}`).join(' · ');
} catch (e) {
banner.style.display = 'none';
}
}
async function bulkRateOrphans() {
const bl = svGetBl();
const limit = document.getElementById('sv-orphans-limit').value;
const result = document.getElementById('sv-orphans-result');
result.textContent = '… enqueue läuft';
const fd = new FormData();
if (bl) fd.append('bundesland', bl);
fd.append('limit', limit);
try {
const r = await fetch('/api/auswertungen/vote-orphans/auto-rate', { method: 'POST', body: fd });
if (r.status === 403) { result.textContent = 'Admin-Rechte fehlen'; return; }
if (r.status === 429) { result.textContent = 'Rate-Limit (3/min)'; return; }
if (!r.ok) {
const err = await r.json().catch(() => ({}));
result.textContent = 'Fehler: ' + (err.detail || r.statusText);
return;
}
const data = await r.json();
const skip = (data.skipped || []).length;
result.innerHTML = `${data.enqueued} enqueued${skip ? `, ${skip} skipped` : ''} — <a href="/v2/admin/queue" style="color:var(--ecg-teal);">Queue ansehen →</a>`;
setTimeout(() => loadVoteOrphansBanner(bl), 800);
} catch (e) {
result.textContent = 'Fehler: ' + e;
}
}
function downloadStimmverhaltenCsv() {
const bl = svGetBl();
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 %}