"""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." )