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>
1050 lines
39 KiB
HTML
1050 lines
39 KiB
HTML
{% extends "v2/base.html" %}
|
||
|
||
{% block title %}Auswertungen — GWÖ-Antragsprüfer{% endblock %}
|
||
|
||
{% set v2_active_nav = "auswertungen" %}
|
||
|
||
{% block head_extra %}
|
||
<script src="/static/chart.umd.min.js"></script>
|
||
<style>
|
||
.auswert-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 1rem;
|
||
border-bottom: 2px solid var(--ecg-border);
|
||
padding-bottom: 0;
|
||
}
|
||
.auswert-tab {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 6px 14px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: var(--ecg-dark);
|
||
opacity: 0.55;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -2px;
|
||
transition: opacity 0.1s;
|
||
}
|
||
.auswert-tab:hover { opacity: 0.85; }
|
||
.auswert-tab.active {
|
||
opacity: 1;
|
||
border-bottom-color: var(--ecg-teal);
|
||
color: var(--ecg-teal);
|
||
}
|
||
.auswert-panel { display: none; }
|
||
.auswert-panel.active { display: block; }
|
||
.controls-bar {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1rem;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
}
|
||
.controls-bar select {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
padding: 5px 8px;
|
||
border: 1px solid var(--ecg-border);
|
||
border-radius: 3px;
|
||
background: var(--ecg-card-bg);
|
||
color: var(--ecg-dark);
|
||
}
|
||
.controls-bar button {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
padding: 5px 12px;
|
||
border: 1px solid var(--ecg-border);
|
||
border-radius: 3px;
|
||
background: var(--ecg-card-bg);
|
||
color: var(--ecg-dark);
|
||
cursor: pointer;
|
||
}
|
||
.controls-bar button.primary {
|
||
background: var(--ecg-teal);
|
||
color: #fff;
|
||
border-color: var(--ecg-teal);
|
||
}
|
||
.matrix-wrap {
|
||
overflow-x: auto;
|
||
background: var(--ecg-card-bg);
|
||
border: 1px solid var(--ecg-border);
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
}
|
||
table.gwoe-matrix {
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
min-width: 400px;
|
||
}
|
||
table.gwoe-matrix th, table.gwoe-matrix td {
|
||
border: 1px solid var(--ecg-border);
|
||
padding: 5px 8px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
table.gwoe-matrix th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||
table.gwoe-matrix th.row-h { text-align: left; }
|
||
table.gwoe-matrix .s-high { background: rgba(136,158,51,0.22); font-weight: 700; }
|
||
table.gwoe-matrix .s-mid { background: rgba(247,148,29,0.15); }
|
||
table.gwoe-matrix .s-low { background: rgba(200,0,0,0.13); font-weight: 700; }
|
||
table.gwoe-matrix .empty { color: var(--ecg-dark); opacity: 0.3; }
|
||
table.gwoe-matrix td.clickable { cursor: pointer; }
|
||
table.gwoe-matrix td.clickable:hover { background: rgba(0,157,165,0.1); }
|
||
.meta-line {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
opacity: 0.6;
|
||
margin-top: 8px;
|
||
}
|
||
/* Modal */
|
||
.v2-modal-backdrop {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.45);
|
||
z-index: 500;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.v2-modal-backdrop.show { display: flex; }
|
||
.v2-modal {
|
||
background: var(--ecg-card-bg);
|
||
border-radius: 6px;
|
||
padding: 20px 24px;
|
||
max-width: 580px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
position: relative;
|
||
}
|
||
.v2-modal h2 {
|
||
font-family: var(--font-display);
|
||
font-size: 16px;
|
||
color: var(--ecg-teal);
|
||
margin: 0 0 12px;
|
||
}
|
||
.v2-modal-close {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 14px;
|
||
background: none;
|
||
border: none;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
color: var(--ecg-dark);
|
||
opacity: 0.5;
|
||
}
|
||
.v2-modal-close:hover { opacity: 1; }
|
||
table.modal-table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
font-size: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
table.modal-table th, table.modal-table td {
|
||
border: 1px solid var(--ecg-border);
|
||
padding: 4px 8px;
|
||
text-align: left;
|
||
}
|
||
table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block main %}
|
||
<div style="padding:0 0 1.5rem;">
|
||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Auswertungen</h1>
|
||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||
Bundesland × Partei · Thema × Fraktion · 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 A–E:
|
||
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 < 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()">×</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<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 %}
|