|
|
"""Compass tab for the parliamentary explorer."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import datetime as _dt
|
|
|
import re
|
|
|
from typing import Dict, Tuple
|
|
|
|
|
|
import numpy as np
|
|
|
import pandas as pd
|
|
|
|
|
|
from analysis import config
|
|
|
import analysis.explorer_data as explorer_data
|
|
|
from analysis.tabs._rendering import px, st
|
|
|
|
|
|
PARTY_COLOURS = config.PARTY_COLOURS
|
|
|
|
|
|
|
|
|
def build_compass_tab(db_path: str, window_size: str) -> None:
|
|
|
"""Build the Politiek Kompas tab."""
|
|
|
st.subheader("Politiek Kompas")
|
|
|
st.markdown(
|
|
|
"2D projectie van Kamerlid posities op basis van stemgedrag (PCA op SVD-vectoren)."
|
|
|
)
|
|
|
|
|
|
# Compass always uses annual windows regardless of the sidebar window_size setting.
|
|
|
positions_by_window, axis_def = explorer_data.load_positions(db_path, "annual")
|
|
|
if axis_def is None:
|
|
|
axis_def = {}
|
|
|
if not positions_by_window:
|
|
|
st.warning(
|
|
|
"Geen positiedata beschikbaar. Controleer of de pipeline is gedraaid."
|
|
|
)
|
|
|
return
|
|
|
|
|
|
party_map = explorer_data.load_party_map(db_path)
|
|
|
active_mps = explorer_data.load_active_mps(db_path)
|
|
|
|
|
|
_current_year = str(_dt.date.today().year)
|
|
|
year_windows = sorted(
|
|
|
w
|
|
|
for w in positions_by_window
|
|
|
if w != "current_parliament" and w != _current_year
|
|
|
)
|
|
|
has_current = "current_parliament" in positions_by_window
|
|
|
windows = year_windows + (["current_parliament"] if has_current else [])
|
|
|
|
|
|
_SPARSE_YEARS = {"2016", "2017", "2018"}
|
|
|
_THRESHOLD = 0.65
|
|
|
|
|
|
def _window_label(w: str) -> str:
|
|
|
if w == "current_parliament":
|
|
|
return "Huidig parlement"
|
|
|
if w in _SPARSE_YEARS:
|
|
|
return f"{w} ⚠️"
|
|
|
return w
|
|
|
|
|
|
col1, col2 = st.columns([3, 1])
|
|
|
with col2:
|
|
|
window_idx = st.selectbox(
|
|
|
"Jaar",
|
|
|
options=windows,
|
|
|
index=len(windows) - 1,
|
|
|
format_func=_window_label,
|
|
|
)
|
|
|
level = st.radio(
|
|
|
"Weergave",
|
|
|
options=["Kamerleden", "Partijen"],
|
|
|
index=0,
|
|
|
horizontal=True,
|
|
|
)
|
|
|
min_mps = st.number_input(
|
|
|
"Min. Kamerleden per partij",
|
|
|
min_value=1,
|
|
|
max_value=20,
|
|
|
value=3,
|
|
|
step=1,
|
|
|
help="Partijen met minder dan dit aantal zetels worden niet weergegeven.",
|
|
|
)
|
|
|
|
|
|
pos = positions_by_window.get(window_idx, {})
|
|
|
if not pos:
|
|
|
st.info(f"Geen data voor venster {window_idx}")
|
|
|
return
|
|
|
|
|
|
if window_idx == "current_parliament":
|
|
|
pos = {mp: xy for mp, xy in pos.items() if mp in active_mps}
|
|
|
|
|
|
def _strip_paren(name: str) -> str:
|
|
|
return re.sub(r"\s*\([^)]*\)", "", name).strip()
|
|
|
|
|
|
deduped: Dict[str, Tuple[float, float]] = {}
|
|
|
for name, (x, y) in pos.items():
|
|
|
base = _strip_paren(name)
|
|
|
if base in deduped:
|
|
|
ox, oy = deduped[base]
|
|
|
deduped[base] = ((ox + x) / 2, (oy + y) / 2)
|
|
|
else:
|
|
|
deduped[base] = (x, y)
|
|
|
pos = deduped
|
|
|
|
|
|
rows = []
|
|
|
for name, (x, y) in pos.items():
|
|
|
party = party_map.get(name) or party_map.get(_strip_paren(name), "Unknown")
|
|
|
rows.append({"name": name, "x": x, "y": y, "party": party})
|
|
|
|
|
|
df_pos = pd.DataFrame(rows)
|
|
|
|
|
|
party_counts = df_pos[df_pos["party"] != "Unknown"]["party"].value_counts()
|
|
|
valid_parties = set(party_counts[party_counts >= min_mps].index)
|
|
|
df_pos = df_pos[df_pos["party"].isin(valid_parties)]
|
|
|
|
|
|
if df_pos.empty:
|
|
|
st.info("Geen partijen met genoeg Kamerleden voor dit venster.")
|
|
|
return
|
|
|
|
|
|
_raw_x = axis_def.get("x_label")
|
|
|
_raw_y = axis_def.get("y_label")
|
|
|
|
|
|
try:
|
|
|
from analysis.axis_classifier import display_label_for_modal
|
|
|
|
|
|
_x_label = display_label_for_modal(_raw_x, "x")
|
|
|
_y_label = display_label_for_modal(_raw_y, "y")
|
|
|
except Exception:
|
|
|
from analysis.svd_labels import get_fallback_labels
|
|
|
|
|
|
_x_fallback, _y_fallback = get_fallback_labels()
|
|
|
_x_label = _raw_x or _x_fallback
|
|
|
_y_label = _raw_y or _y_fallback
|
|
|
|
|
|
if level == "Partijen":
|
|
|
df_party = df_pos.groupby("party", as_index=False).agg(
|
|
|
x=("x", "mean"), y=("y", "mean"), n=("name", "count")
|
|
|
)
|
|
|
df_party["name"] = df_party["party"]
|
|
|
colour_map = {
|
|
|
p: PARTY_COLOURS.get(p, "#9E9E9E") for p in df_party["party"].unique()
|
|
|
}
|
|
|
fig = px.scatter(
|
|
|
df_party,
|
|
|
x="x",
|
|
|
y="y",
|
|
|
color="party",
|
|
|
text="party",
|
|
|
hover_name="party",
|
|
|
hover_data={"party": False, "x": ":.3f", "y": ":.3f", "n": True},
|
|
|
color_discrete_map=colour_map,
|
|
|
title=f"Politiek Kompas — {_window_label(window_idx)} (partijen)",
|
|
|
labels={
|
|
|
"x": _x_label,
|
|
|
"y": _y_label,
|
|
|
"n": "Kamerleden",
|
|
|
},
|
|
|
)
|
|
|
fig.update_traces(textposition="top center", marker_size=14)
|
|
|
else:
|
|
|
colour_map = {
|
|
|
p: PARTY_COLOURS.get(p, "#9E9E9E") for p in df_pos["party"].unique()
|
|
|
}
|
|
|
fig = px.scatter(
|
|
|
df_pos,
|
|
|
x="x",
|
|
|
y="y",
|
|
|
color="party",
|
|
|
hover_name="name",
|
|
|
hover_data={"party": True, "x": ":.3f", "y": ":.3f"},
|
|
|
color_discrete_map=colour_map,
|
|
|
title=f"Politiek Kompas — {_window_label(window_idx)}",
|
|
|
labels={"x": _x_label, "y": _y_label},
|
|
|
)
|
|
|
|
|
|
fig.update_layout(
|
|
|
height=600,
|
|
|
legend_title_text="Partij",
|
|
|
xaxis={"range": [-1, 1]},
|
|
|
yaxis={"range": [-0.6, 0.6]},
|
|
|
)
|
|
|
with col1:
|
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
|
_x_interp = axis_def.get("x_interpretation", {}).get(window_idx, "")
|
|
|
if (
|
|
|
_x_interp
|
|
|
and axis_def.get("x_quality", {}).get(window_idx, 1.0) < _THRESHOLD
|
|
|
):
|
|
|
st.caption(_x_interp)
|
|
|
|
|
|
# Voting discipline analysis
|
|
|
st.markdown("---")
|
|
|
st.markdown(
|
|
|
"**Stemdiscipline analyse:** De Rice-index meet hoe eensgezind partijen stemmen "
|
|
|
"tijdens hoofdelijke stemmingen. Een score van 100% betekent dat alle MPs van "
|
|
|
"een partij hetzelfde stemden; 50% wijst op een gelijke splitsing binnen de partij. "
|
|
|
"Partijen met hoge discipline (>95%) zoals PVV en SGP stemmen als een blok, wat "
|
|
|
"wijst op sterke partijdiscipline en homogene membership. Lagere discipline (<85%) "
|
|
|
"bij partijen als PvdA of SP kan duiden op interne factiestrijd, gewetensvragen "
|
|
|
"bij ethische thema's, of een brede ideologische koers die ruimte laat voor "
|
|
|
"afwijkende meningen. De discipline varieert ook per onderwerp — ethische kwesties "
|
|
|
"tonen vaak meer interne verschillen dan economische thema's."
|
|
|
)
|
|
|
|