feat: Globale Multi-Select Filter (Ratsperiode + Parteien) auf Abstimmungen
- Filter-Bar oben: Ratsperioden (2004-2030) + Parteien als Toggle-Buttons - Multi-Select: Mehrere Perioden/Parteien gleichzeitig wählbar - Filter wirken auf Stimmverhalten-Tabelle UND Koalitionsmatrix - Backend: periode= und parteien= Query-Parameter auf /fraktionen + /koalitionsmatrix - Fraktions-Normalisierung: Mapping für 40+ DB-Varianten - Gestapelte Balken (grün/rot/gelb) statt nur grün - Filter bleiben beim Scrollen aktiv, Reset-Button wenn Filter gesetzt
This commit is contained in:
parent
ea3e5cd329
commit
7358aa1b61
@ -36,19 +36,83 @@ def get_abstimmungen_stats(conn=Depends(_db)):
|
||||
}
|
||||
|
||||
|
||||
def _periode_filter(periode: str | None) -> tuple[str, list]:
|
||||
"""Return (WHERE clause fragment, params) for Ratsperiode filtering on abstimmungen.sitzung_datum."""
|
||||
PERIODEN = {
|
||||
"2025-2030": ("2025-11-01", "2030-10-31"),
|
||||
"2020-2025": ("2020-11-01", "2025-10-31"),
|
||||
"2014-2020": ("2014-06-01", "2020-10-31"),
|
||||
"2009-2014": ("2009-09-01", "2014-05-31"),
|
||||
"2004-2009": ("2004-09-01", "2009-08-31"),
|
||||
}
|
||||
if not periode:
|
||||
return "", []
|
||||
# Support multi-select: "2020-2025,2025-2030"
|
||||
selected = [p.strip() for p in periode.split(",") if p.strip() in PERIODEN]
|
||||
if not selected:
|
||||
return "", []
|
||||
conditions = []
|
||||
params = []
|
||||
for p in selected:
|
||||
start, end = PERIODEN[p]
|
||||
conditions.append("(a.sitzung_datum >= ? AND a.sitzung_datum <= ?)")
|
||||
params.extend([start, end])
|
||||
return f"({' OR '.join(conditions)})", params
|
||||
|
||||
|
||||
def _parteien_filter(parteien: str | None) -> tuple[str, list]:
|
||||
"""Return (WHERE clause fragment, params) for multi-party filter on abstimmungen_fraktionen."""
|
||||
if not parteien:
|
||||
return "", []
|
||||
selected = [p.strip() for p in parteien.split(",") if p.strip()]
|
||||
# Expand each to all DB variants
|
||||
all_variants = []
|
||||
for p in selected:
|
||||
all_variants.extend(get_all_variants(p))
|
||||
if not all_variants:
|
||||
return "", []
|
||||
placeholders = ",".join("?" * len(all_variants))
|
||||
return f"af_filter.fraktion IN ({placeholders})", all_variants
|
||||
|
||||
|
||||
@router.get("/fraktionen")
|
||||
def get_fraktionen_uebersicht(conn=Depends(_db)):
|
||||
"""Stimmverhalten aller Fraktionen aggregiert."""
|
||||
rows = conn.execute("""
|
||||
SELECT fraktion,
|
||||
SUM(CASE WHEN stimme='ja' THEN 1 ELSE 0 END) as ja,
|
||||
SUM(CASE WHEN stimme='nein' THEN 1 ELSE 0 END) as nein,
|
||||
SUM(CASE WHEN stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung,
|
||||
def get_fraktionen_uebersicht(
|
||||
periode: str | None = None,
|
||||
parteien: str | None = None,
|
||||
conn=Depends(_db),
|
||||
):
|
||||
"""Stimmverhalten aller Fraktionen aggregiert, optional nach Ratsperiode/Parteien gefiltert."""
|
||||
where_parts = []
|
||||
params: list = []
|
||||
|
||||
per_clause, per_params = _periode_filter(periode)
|
||||
if per_clause:
|
||||
where_parts.append(per_clause)
|
||||
params.extend(per_params)
|
||||
|
||||
# If parteien filter is set, only show those parties
|
||||
par_clause, par_params = _parteien_filter(parteien)
|
||||
if par_clause:
|
||||
# Filter on the main fraktion column
|
||||
par_clause_main = par_clause.replace("af_filter.fraktion", "af.fraktion")
|
||||
where_parts.append(par_clause_main)
|
||||
params.extend(par_params)
|
||||
|
||||
join_sql = "JOIN abstimmungen a ON af.abstimmung_id = a.id" if per_clause else ""
|
||||
where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
||||
|
||||
rows = conn.execute(f"""
|
||||
SELECT af.fraktion,
|
||||
SUM(CASE WHEN af.stimme='ja' THEN 1 ELSE 0 END) as ja,
|
||||
SUM(CASE WHEN af.stimme='nein' THEN 1 ELSE 0 END) as nein,
|
||||
SUM(CASE WHEN af.stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung,
|
||||
COUNT(*) as gesamt
|
||||
FROM abstimmungen_fraktionen
|
||||
GROUP BY fraktion
|
||||
FROM abstimmungen_fraktionen af
|
||||
{join_sql}
|
||||
{where_sql}
|
||||
GROUP BY af.fraktion
|
||||
ORDER BY gesamt DESC
|
||||
""").fetchall()
|
||||
""", params).fetchall()
|
||||
|
||||
# Normalize and aggregate
|
||||
from collections import defaultdict
|
||||
@ -75,14 +139,29 @@ def get_fraktionen_uebersicht(conn=Depends(_db)):
|
||||
|
||||
|
||||
@router.get("/koalitionsmatrix")
|
||||
def get_koalitionsmatrix(conn=Depends(_db)):
|
||||
def get_koalitionsmatrix(
|
||||
periode: str | None = None,
|
||||
parteien: str | None = None,
|
||||
conn=Depends(_db),
|
||||
):
|
||||
"""Matrix: Wie oft stimmen Fraktionen gleich ab?"""
|
||||
# Alle Abstimmungen mit mindestens 2 Fraktionen
|
||||
abstimmungen = conn.execute("""
|
||||
SELECT abstimmung_id, fraktion, stimme
|
||||
FROM abstimmungen_fraktionen
|
||||
WHERE stimme IN ('ja', 'nein')
|
||||
""").fetchall()
|
||||
where_parts = ["af.stimme IN ('ja', 'nein')"]
|
||||
params: list = []
|
||||
|
||||
per_clause, per_params = _periode_filter(periode)
|
||||
if per_clause:
|
||||
where_parts.append(per_clause)
|
||||
params.extend(per_params)
|
||||
|
||||
join_sql = "JOIN abstimmungen a ON af.abstimmung_id = a.id" if per_clause else ""
|
||||
where_sql = "WHERE " + " AND ".join(where_parts)
|
||||
|
||||
abstimmungen = conn.execute(f"""
|
||||
SELECT af.abstimmung_id, af.fraktion, af.stimme
|
||||
FROM abstimmungen_fraktionen af
|
||||
{join_sql}
|
||||
{where_sql}
|
||||
""", params).fetchall()
|
||||
|
||||
# Gruppieren nach Abstimmung (normalisiert)
|
||||
from collections import defaultdict
|
||||
@ -104,6 +183,11 @@ def get_koalitionsmatrix(conn=Depends(_db)):
|
||||
if stimmen[f1] == stimmen[f2]:
|
||||
matrix[f1][f2]["gleich"] += 1
|
||||
|
||||
# Filter factions if parteien specified
|
||||
if parteien:
|
||||
selected = set(p.strip() for p in parteien.split(",") if p.strip())
|
||||
fraktionen = [f for f in fraktionen if f in selected]
|
||||
|
||||
# Als Liste für Frontend
|
||||
result = []
|
||||
for f1 in sorted(fraktionen):
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
enthaltung: number;
|
||||
gesamt: number;
|
||||
ja_quote: number;
|
||||
ist_ratsfraktion?: boolean;
|
||||
}
|
||||
|
||||
interface Uebereinstimmung {
|
||||
@ -52,6 +53,12 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Global filters
|
||||
const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009'];
|
||||
let selectedPerioden = $state<string[]>([]);
|
||||
let selectedParteien = $state<string[]>([]);
|
||||
let allParteien = $state<string[]>([]);
|
||||
|
||||
// Filter state for detail view
|
||||
let selectedFraktion = $state('');
|
||||
let selectedStimme = $state('');
|
||||
@ -74,20 +81,67 @@
|
||||
: '/api')
|
||||
: '/api';
|
||||
|
||||
onMount(async () => {
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPerioden.length > 0) params.set('periode', selectedPerioden.join(','));
|
||||
if (selectedParteien.length > 0) params.set('parteien', selectedParteien.join(','));
|
||||
const qs = params.toString();
|
||||
const suffix = qs ? `?${qs}` : '';
|
||||
|
||||
const [frakRes, koalRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/abstimmungen/fraktionen`),
|
||||
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix`)
|
||||
fetch(`${API_BASE}/abstimmungen/fraktionen${suffix}`),
|
||||
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix${suffix}`)
|
||||
]);
|
||||
|
||||
if (frakRes.ok) fraktionen = await frakRes.json();
|
||||
if (frakRes.ok) {
|
||||
fraktionen = await frakRes.json();
|
||||
// Build party list from unfiltered data for multi-select
|
||||
if (allParteien.length === 0) {
|
||||
allParteien = fraktionen.filter(f => f.ist_ratsfraktion).map(f => f.fraktion).sort();
|
||||
}
|
||||
}
|
||||
if (koalRes.ok) koalitionsmatrix = await koalRes.json();
|
||||
} catch (e) {
|
||||
error = `Fehler: ${e}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePeriode(p: string) {
|
||||
if (selectedPerioden.includes(p)) {
|
||||
selectedPerioden = selectedPerioden.filter(x => x !== p);
|
||||
} else {
|
||||
selectedPerioden = [...selectedPerioden, p];
|
||||
}
|
||||
clearDetail();
|
||||
clearVergleich();
|
||||
loadAll();
|
||||
}
|
||||
|
||||
function togglePartei(p: string) {
|
||||
if (selectedParteien.includes(p)) {
|
||||
selectedParteien = selectedParteien.filter(x => x !== p);
|
||||
} else {
|
||||
selectedParteien = [...selectedParteien, p];
|
||||
}
|
||||
clearDetail();
|
||||
clearVergleich();
|
||||
loadAll();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedPerioden = [];
|
||||
selectedParteien = [];
|
||||
clearDetail();
|
||||
clearVergleich();
|
||||
loadAll();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAll();
|
||||
});
|
||||
|
||||
async function loadDetails(fraktion: string, stimme: string, page: number = 1) {
|
||||
@ -188,6 +242,53 @@
|
||||
<p class="text-gray-500 text-sm mt-1">Analyse des Stimmverhaltens der Ratsfraktionen</p>
|
||||
</div>
|
||||
|
||||
<!-- Global Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<!-- Ratsperioden -->
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-medium text-gray-500 uppercase mb-2 block">Ratsperiode</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each PERIODEN as p}
|
||||
<button onclick={() => togglePeriode(p)}
|
||||
class="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all
|
||||
{selectedPerioden.includes(p)
|
||||
? 'bg-green-600 text-white shadow-sm'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||
{p}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parteien -->
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-medium text-gray-500 uppercase mb-2 block">Parteien</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each allParteien as p}
|
||||
<button onclick={() => togglePartei(p)}
|
||||
class="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all
|
||||
{selectedParteien.includes(p)
|
||||
? 'bg-green-600 text-white shadow-sm'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||
{p}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset -->
|
||||
{#if selectedPerioden.length > 0 || selectedParteien.length > 0}
|
||||
<div class="flex items-end">
|
||||
<button onclick={clearFilters}
|
||||
class="text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 whitespace-nowrap">
|
||||
✕ Filter zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div>
|
||||
{/if}
|
||||
@ -245,8 +346,12 @@
|
||||
<td class="px-4 py-3 text-center text-gray-600">{f.gesamt}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 max-w-24">
|
||||
<div class="bg-green-500 h-2 rounded-full" style="width: {f.ja_quote}%"></div>
|
||||
<div class="flex-1 flex rounded-full h-3 max-w-32 overflow-hidden bg-gray-200">
|
||||
{#if f.gesamt > 0}
|
||||
<div class="bg-green-500 h-full" style="width: {f.ja / f.gesamt * 100}%" title="Ja: {f.ja}"></div>
|
||||
<div class="bg-red-500 h-full" style="width: {f.nein / f.gesamt * 100}%" title="Nein: {f.nein}"></div>
|
||||
<div class="bg-yellow-400 h-full" style="width: {f.enthaltung / f.gesamt * 100}%" title="Enthaltung: {f.enthaltung}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">{f.ja_quote}%</span>
|
||||
</div>
|
||||
@ -268,8 +373,12 @@
|
||||
</button>
|
||||
<span class="text-sm font-medium text-gray-600">{f.ja_quote}%</span>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-full h-2.5 mb-3">
|
||||
<div class="bg-green-500 h-2.5 rounded-full" style="width: {f.ja_quote}%"></div>
|
||||
<div class="flex rounded-full h-2.5 mb-3 overflow-hidden bg-gray-200">
|
||||
{#if f.gesamt > 0}
|
||||
<div class="bg-green-500 h-full" style="width: {f.ja / f.gesamt * 100}%"></div>
|
||||
<div class="bg-red-500 h-full" style="width: {f.nein / f.gesamt * 100}%"></div>
|
||||
<div class="bg-yellow-400 h-full" style="width: {f.enthaltung / f.gesamt * 100}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-center text-xs">
|
||||
<button onclick={() => loadDetails(f.fraktion, 'ja')} class="cursor-pointer hover:bg-green-50 rounded p-1 transition-colors">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user