From 5b1be26050d900127e7d6fb51567cf8d288bd555 Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Thu, 2 Apr 2026 21:02:03 +0200 Subject: [PATCH] refactor: move SVD_THEMES to module level for import (Task 2) --- analysis/svd_labels.py | 175 ++++++++++++++ explorer.py | 527 +++++++++++++++++++++++------------------ 2 files changed, 477 insertions(+), 225 deletions(-) create mode 100644 analysis/svd_labels.py diff --git a/analysis/svd_labels.py b/analysis/svd_labels.py new file mode 100644 index 0000000..4f43d5e --- /dev/null +++ b/analysis/svd_labels.py @@ -0,0 +1,175 @@ +"""Unified SVD component labels and automatic flip direction computation. + +This module provides a single source of truth for SVD component labels, +deriving them from SVD_THEMES in explorer.py. It also computes flip +directions automatically based on party centroids. +""" + +import logging +from typing import Dict, List, Optional, Tuple + +_logger = logging.getLogger(__name__) + +# Canonical party sets for orientation +# Right-wing parties that should appear on the right side of axes +RIGHT_PARTIES = { + "PVV", + "VVD", + "FVD", + "BBB", + "JA21", + "Nieuw Sociaal Contract", + "SGP", + "CDA", + "ChristenUnie", +} + +# Left-wing parties that should appear on the left side of axes +LEFT_PARTIES = { + "SP", + "PvdA", + "GL", + "GroenLinks", + "GroenLinks-PvdA", + "DENK", + "PvdD", + "Volt", +} + +# Cache for SVD_THEMES to avoid repeated imports +_svd_themes_cache: Optional[Dict[int, Dict[str, str]]] = None + + +def _get_svd_themes() -> Dict[int, Dict[str, str]]: + """Import SVD_THEMES from explorer.py. + + Returns: + Dict mapping component number to theme dict with keys: + - label: Short label for the component + - explanation: Detailed explanation + - positive_pole: Description of positive pole + - negative_pole: Description of negative pole + - flip: Whether to flip the axis + """ + global _svd_themes_cache + if _svd_themes_cache is not None: + return _svd_themes_cache + + try: + # Import SVD_THEMES from explorer at runtime to avoid circular imports + # explorer.py now exports SVD_THEMES at module level + import explorer + + _svd_themes_cache = explorer.SVD_THEMES + return _svd_themes_cache + except ImportError as e: + _logger.warning("Could not import explorer.SVD_THEMES: %s", e) + return {} + except Exception as e: + _logger.exception("Failed to load SVD_THEMES from explorer.py: %s", e) + return {} + + +def get_svd_label(component: int) -> str: + """Get short label for SVD component. + + Args: + component: SVD component number (1-indexed) + + Returns: + Short label string (e.g., 'EU-integratie–Nationalisme') + + Raises: + ValueError: If component < 1 + """ + if component < 1: + raise ValueError(f"Component must be >= 1, got {component}") + + themes = _get_svd_themes() + if component in themes: + return themes[component].get("label", f"As {component}") + + # Fallback labels for components 1-3 (most commonly used) + fallback_labels = { + 1: "EU-integratie–Nationalisme", + 2: "Populistisch–Institutioneel", + 3: "Verzorgingsstaat–Marktwerking", + } + return fallback_labels.get(component, f"As {component}") + + +def get_svd_theme(component: int) -> Dict[str, str]: + """Get full theme dict for SVD component. + + Args: + component: SVD component number (1-indexed) + + Returns: + Dict with keys: label, explanation, positive_pole, negative_pole, flip + """ + if component < 1: + raise ValueError(f"Component must be >= 1, got {component}") + + themes = _get_svd_themes() + if component in themes: + return themes[component] + + # Return minimal fallback + return { + "label": get_svd_label(component), + "explanation": "", + "positive_pole": "", + "negative_pole": "", + "flip": False, + } + + +def compute_flip_direction( + component: int, party_scores: Dict[str, List[float]] +) -> bool: + """Compute flip direction so right parties appear on the right side. + + Args: + component: SVD component number (1-indexed) + party_scores: Dict mapping party name to list of scores per component + (party_scores[party][0] is score for component 1, etc.) + + Returns: + True if axis should be flipped so right parties are on right. + False otherwise. + """ + if component < 1: + return False + + idx = component - 1 # Convert to 0-indexed + + right_scores = [] + left_scores = [] + + for party, scores in party_scores.items(): + if len(scores) <= idx: + continue + + score = scores[idx] + if party in RIGHT_PARTIES: + right_scores.append(score) + elif party in LEFT_PARTIES: + left_scores.append(score) + + if not right_scores or not left_scores: + return False # Default: no flip if insufficient data + + right_mean = sum(right_scores) / len(right_scores) + left_mean = sum(left_scores) / len(left_scores) + + # Flip if right parties have lower mean (they're on the left) + return right_mean < left_mean + + +def get_fallback_labels() -> Tuple[str, str]: + """Get fallback labels for x and y axes (components 1 and 2). + + Returns: + Tuple of (x_label, y_label) + """ + return (get_svd_label(1), get_svd_label(2)) diff --git a/explorer.py b/explorer.py index 5cae0fc..d568ec9 100644 --- a/explorer.py +++ b/explorer.py @@ -269,6 +269,18 @@ def select_trajectory_plot_data( if has_valid: plottable_parties.append(p) + # DEBUG: Show plottable_parties status + print( + f"[TRAJ DEBUG] plottable_parties: {len(plottable_parties)} parties, sample={plottable_parties[:5] if plottable_parties else 'empty'}" + ) + print(f"[TRAJ DEBUG] party_centroids keys: {list(party_centroids.keys())[:10]}") + if party_centroids: + sample_party = list(party_centroids.keys())[0] + sample_vals = party_centroids[sample_party] + print( + f"[TRAJ DEBUG] Sample party '{sample_party}' centroids: {sample_vals[:3]}..." + ) + fig = go.Figure() trace_count = 0 banner_text: Optional[str] = None @@ -341,6 +353,9 @@ def select_trajectory_plot_data( trace_count += 1 banner_text = "Partijcentroiden niet beschikbaar — tonen individuele MP-trajecten als fallback." + print( + f"[TRAJ DEBUG] Fallback to MP trajectories: trace_count={trace_count}, top_mps={len(top_mps)}" + ) return fig, trace_count, banner_text # Otherwise plot party centroids for selected parties intersecting plottable @@ -380,6 +395,9 @@ def select_trajectory_plot_data( ) trace_count += 1 + print( + f"[TRAJ DEBUG] Final trace_count={trace_count}, plottable_parties={len(plottable_parties)}, to_plot={len(to_plot) if 'to_plot' in dir() else 'N/A'}" + ) return fig, trace_count, None @@ -410,6 +428,188 @@ PARTY_COLOURS: Dict[str, str] = { "Unknown": "#9E9E9E", } +# Political polarisation themes per SVD component (1-indexed, window=2025) +# Produced by per-axis analysis of all 10 unique top motions (zero cross-axis overlap). +# This is the canonical source of truth for SVD component labels. +SVD_THEMES: dict[int, dict[str, str]] = { + 1: { + "label": "EU-integratie en internationalisme versus nationalisme", + "explanation": ( + "De dominante dimensie van het parlement: niet economisch links-rechts, maar " + "EU-integratie en internationalisme versus nationalisme en soevereiniteit. " + "Aan de linkerkant (Volt, GroenLinks-PvdA, DENK, PvdD, SP) staan moties over " + "EU-verdragswijzigingen, EU-sancties tegen Israël, internationale samenwerking " + "en het behouden van vluchtelingenstatus. Aan de rechterkant (PVV, VVD, SGP, " + "BBB, ChristenUnie) staan moties over nationale begrotingscontrole, " + "integratievereisten en nationale arbeidsvoorwaarden. " + "Deze as verklaart méér stemverschil dan economische tegenstellingen: " + "het onderscheidt pro-EU progressieven van nationalistisch-conservatieven, " + "ongeacht hun standpunt over de verzorgingsstaat." + ), + "positive_pole": "Progressief-pro-EU: Volt, GroenLinks-PvdA, DENK, PvdD, SP", + "negative_pole": "Nationalistisch-conservatief: PVV, VVD, SGP, BBB, ChristenUnie", + "flip": True, + }, + 2: { + "label": "Populistisch nationalisme versus institutioneel progressivisme", + "explanation": ( + "Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, Groep Markuszower, " + "BBB) van het volledige overige parlement. Alleen PVV (+18), FVD (+4) en Groep " + "Markuszower (+2) scoren positief; alle andere partijen scoren negatief, inclusief " + "VVD (−15), CDA (−14), SGP (−25) en ChristenUnie (−59). Positieve moties: artsen " + "vrijpleiten voor hydroxychloroquine/ivermectine, Syriërs terugsturen, geen geld " + "aan Jordanië, tijdelijke bescherming Oekraïne beëindigen. Negatieve moties: " + "digitale toegankelijkheid Caribisch Nederland, ethiekprogramma Defensie, zorg voor " + "slachtoffers bombardement Hawija, zorgkwaliteitsstandaarden. Dit is geen links-rechts " + "verdeling maar een nativistisch-populistisch vs. institutioneel onderscheid." + ), + "positive_pole": "Populistisch-nationalistisch: PVV, FVD, Groep Markuszower, BBB", + "negative_pole": "Institutioneel: alle overige partijen — van VVD en SGP tot GroenLinks-PvdA en Volt", + "flip": False, + }, + 3: { + "label": "Verzorgingsstaat versus bezuinigingen en marktwerking", + "explanation": ( + "Deze as weerspiegelt de spanning tussen staatsingrijpen en marktliberalisme, " + "aangescherpt door de kabinetscrisis van 2025. Aan de positieve kant staan moties " + "die bezuinigingen op zorg en het gemeentefonds willen terugdraaien, winstuitkeringen " + "in de zorg verbieden en publieke controle over ziekenhuisfusies eisen. SP, PvdD, " + "GroenLinks-PvdA stemmen hier gelijk — ondanks hun tegengestelde PC1-posities. " + "Aan de negatieve kant staan moties " + "over marktwerking in de zorg, fiscale bedrijfsopvolgingsfaciliteiten (VVD), " + "doorgaan met besturen ondanks de kabinetscrisis (VVD/BBB) en defensie-" + "uitgaven van 3,5% bbp." + ), + "positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)", + "negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB", + "flip": False, + }, + 4: { + "label": "Pragmatisch centrisme versus ideologische radicaliteit", + "explanation": ( + "De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel " + "rechts-radicale als identiteitspolitieke posities. Aan de positieve kant staan " + "moties over openbare toiletten, vaderbetrokkenheid bij opvoeding, internationale " + "samenwerking met Australië en Canada, en long covid-expertise. Dit zijn pragmatische, " + "institutionele beleidsposities. Aan de negatieve kant staan moties over een " + "migratiesaldo-cap van 60.000, het verlaten van de WHO, kinderen in pleeggezinnen " + "van hetzelfde geslacht (FVD) en de bescherming van religieuze schoolidentiteit " + "via artikel 23. De negatieve pool combineert populistisch-rechts met " + "identiteitsgerichte posities van zowel rechts als links." + ), + "positive_pole": "Constructief centrum: D66, CDA, VVD, 50PLUS — pragmatisch en internationaal", + "negative_pole": "Radicaal-ideologisch: FVD, Groep Markuszower (rechts), ChristenUnie, DENK (religieus/identiteit)", + "flip": False, + }, + 5: { + "label": "Christelijk-sociaal communitarisme", + "explanation": ( + "Deze as scheidt partijen die gemeenschapszorg, burgerplicht en informele " + "ondersteuningsstructuren benadrukken van partijen die individuele vrijheden en " + "progressieve maatschappelijke hervorming voorstaan. Aan de positieve kant staan " + "moties over schuldhulpverlening via vrijwilligersorganisaties, de maatschappelijke " + "diensttijd voor jongeren met een afstand tot de arbeidsmarkt, en de gastouderopvang. " + "ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief door steun " + "aan sociaal beleid. Aan de negatieve kant staan moties over wettelijke erkenning " + "van meerouderschap, abortusrecht in het EU-Handvest, armoedebeleid en " + "buitenlandse beïnvloeding. PvdD, GroenLinks-PvdA en VVD scoren hier negatief." + ), + "positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen", + "negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV", + "flip": False, + }, + 6: { + "label": "Klimaat, energie en culturele integratie", + "explanation": ( + "Aan de positieve kant staan moties die LNG-capaciteit prefereren als alternatief " + "voor strenge vulgraadverplichtingen, kernenergie als volwaardig CO₂-arm onderdeel " + "van de energiemix willen erkennen op COP30, en discriminatie- en inclusiemeldpunten " + "willen inventariseren. SGP, JA21, FVD en PVV scoren sterk positief. Aan de " + "negatieve kant staan moties die fossiele-industrie-vertegenwoordigers willen weren " + "van klimaatconferenties, structureel overleg met moslimgemeenschappen willen bij " + "integratiebeleid, en aanvallen van Israël op Libanon veroordelen. " + "PvdD, GroenLinks-PvdA, Volt en D66 scoren negatief. " + "Deze as combineert energieideologie met culturele polarisatie rondom klimaat, " + "integratie en buitenlandspolitiek." + ), + "positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV", + "negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66", + "flip": False, + }, + 7: { + "label": "Bestuurlijk pragmatisme en implementatie (indicatief)", + "explanation": ( + "Een residuele as die overwegend beleidsdossiers uit 2024 (vorige parlementaire " + "periode) omvat. De scores zijn smal (max ~11 punten) en de partijcombinaties " + "ideologisch divers — dit label is indicatief. Aan de positieve kant staan " + "pragmatische bestuursmoties: een compleet kostenoverzicht van producten van eigen " + "bodem, papieren schoolboeken voor basisvaardigheden, een invoeringstoets voor het " + "minimumloon en de A2-snelwegplanning. ChristenUnie, Volt, DENK en SP scoren " + "positief. Aan de negatieve kant staan meer ideologisch geladen moties: een " + "landelijk stookverbod (PvdD), het strafbaar stellen van verbranding van religieuze " + "geschriften (DENK), chroom-6 schadevergoedingen en tegenhouden van nieuwe " + "gaswinning. GroenLinks-PvdA, VVD, FVD en JA21 scoren negatief." + ), + "positive_pole": "Praktisch-bestuurlijk: ChristenUnie, Volt, SGP, DENK, SP", + "negative_pole": "Ideologisch-principieel: GroenLinks-PvdA, VVD, FVD, JA21", + "flip": True, + }, + 8: { + "label": "Europese defensie-integratie (indicatief)", + "explanation": ( + "Aan de positieve kant staan moties die pleiten voor militaire mobiliteit als " + "topprioriteit in EU/NAVO-verband en toewerken naar een militair Schengengebied, " + "35% van defensiematerieel Europees inkopen en een Europees defensie-R&D-instituut " + "oprichten. Ook het Nationaal Groeifonds en gewasbeschermingsonderzoek vallen " + "positief. Volt en D66 scoren sterk positief. Aan de negatieve kant staan moties " + "over ketenverantwoordelijkheid bij toeslagen (DENK), het coronaoversterfte-onderzoek " + "(PVV/BBB), energiecontracten en huisvestingsregulering. SP (−39), DENK (−35) en " + "PvdD (−26) scoren sterk negatief — dit betekent dat zij actief tégen deze " + "EU-defensiemoties stemmen, niet simpelweg het thema negeren. Volt (N=1) domineert " + "de positieve pool maar is als centroïde van één Kamerlid statistisch onbetrouwbaar." + ), + "positive_pole": "Pro-EU defensie en innovatie: Volt, D66", + "negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS", + "flip": False, + }, + 9: { + "label": "Decentraal bestuur en gemeenschapswaarden (indicatief)", + "explanation": ( + "Aan de positieve kant staan moties over naleving van de Financiële-verhoudingswet " + "voor gemeenten, beperking van arbeidsmigratie binnen de EU, een nieuwe " + "tandartsopleiding in Rotterdam, een actieplan tegen misbruik van hallucinerende " + "geneesmiddelen en een oplossing voor milieuproblemen op Bonaire. SGP en " + "ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant staan " + "moties over een moratorium op geitenstallen, een verbod op gokadvertenties, " + "verduidelijking van gronden voor voorlopige hechtenis, een leegstandbelasting voor " + "woningen en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. Deze as " + "scheidt een nadruk op decentrale dienstverlening en gemeenschapsregulering van " + "progressieve systeem- en rechtshervorming." + ), + "positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP", + "negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV", + "flip": True, + }, + 10: { + "label": "Institutioneel toezicht en handhaving (indicatief)", + "explanation": ( + "De tiende as vangt resterende variantie op en scheidt partijen die sceptisch zijn " + "over staatstoezicht van partijen die strikte regulering en handhaving steunen. " + "Aan de positieve kant staan moties over minder tijdsintensieve schoolinspecties, " + "het recht van toeslagenouders op hun persoonlijk dossier, behoud van de " + "tegemoetkoming voor arbeidsongeschikten en een verlaging van de leeftijdsdrempel " + "voor kindgesprekken. DENK, SP en PvdD scoren positief. Aan de negatieve kant " + "staan moties over een aangifteplicht voor scholen bij veiligheidsincidenten, een " + "rookverbod in auto's met kinderen, braakliggende landagrarisch landbouwgrond en verhoogd " + "beloningsgeld voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief, " + "waarmee het zich onderscheidt van SP en DENK op handhavingsthema's." + ), + "positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast", + "negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving", + "flip": True, + }, +} + # Ordered list of well-known parties for trajectory default selection. # Keeps the chart readable without overwhelming users with all parties. KNOWN_MAJOR_PARTIES = [ @@ -527,13 +727,15 @@ def get_uniform_dim_windows(db_path: str) -> List[str]: def _should_swap_axes(axis_def: dict) -> bool: - """Return True if the Y axis is 'Links–Rechts' and the X axis is not. + """Return True if the Y axis is economic left-right and the X axis is not. - When true, caller should swap x/y positions and metadata so left-right - is conventionally on the horizontal axis. + When true, caller should swap x/y positions and metadata so the economic + dimension (welfare vs market) is conventionally on the horizontal axis. """ - lr = "Links\u2013Rechts" - return axis_def.get("y_label") == lr and axis_def.get("x_label") != lr + economic_labels = {"Verzorgingsstaat–Marktwerking", "Links–Rechts"} + y_label = axis_def.get("y_label") + x_label = axis_def.get("x_label") + return y_label in economic_labels and x_label not in economic_labels def _swap_axes( @@ -583,7 +785,7 @@ def _render_axis_motions(label: str, conf_pct: str, top: dict) -> None: @st.cache_data(show_spinner="2D posities berekenen (kan even duren)…") def load_positions( - db_path: str, window_size: str = "quarterly" + db_path: str, window_size: str = "annual" ) -> Tuple[Dict[str, Dict[str, Tuple[float, float]]], Dict]: """Compute 2D positions per window using PCA on aligned SVD vectors. @@ -1417,8 +1619,8 @@ def build_compass_tab(db_path: str, window_size: str) -> None: _x_label = display_label_for_modal(_raw_x, "x") _y_label = display_label_for_modal(_raw_y, "y") except Exception: - _x_label = _raw_x or "Links\u2013Rechts" - _y_label = _raw_y or "Progressief\u2013Conservatief" + _x_label = _raw_x or "EU-integratie–Nationalisme" + _y_label = _raw_y or "Populistisch–Institutioneel" if level == "Partijen": # Aggregate to party centroids @@ -1599,6 +1801,29 @@ def build_compass_tab(db_path: str, window_size: str) -> None: # --------------------------------------------------------------------------- +def choose_trajectory_title(axis_def: dict, axis: str, threshold: float = 0.65) -> str: + """Choose a short trajectory axis title based on aggregated confidence. + + axis: 'x' or 'y'. Returns axis_def label when its mean confidence >= threshold, + otherwise returns the compact fallback 'As 1' / 'As 2'. Matches previous logic. + """ + _TH = threshold + conf_map = axis_def.get(f"{axis}_label_confidence", {}) or {} + vals = [v for v in conf_map.values() if v is not None] + mean = float(sum(vals) / len(vals)) if vals else None + label = axis_def.get(f"{axis}_label") + if mean is not None and mean >= _TH and label: + return label + # Prefer the user-facing semantic fallback via the classifier helper + try: + from analysis.axis_classifier import display_label_for_modal + + fallback_modal = "As 1" if axis == "x" else "As 2" + return display_label_for_modal(fallback_modal, axis) + except Exception: + return "As 1" if axis == "x" else "As 2" + + def build_trajectories_tab(db_path: str, window_size: str) -> None: print( f"[TRAJ DEBUG] build_trajectories_tab called — db_path={db_path}, window_size={window_size}" @@ -2108,29 +2333,6 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None: x_mean = _mean_conf(x_conf_map) y_mean = _mean_conf(y_conf_map) - -def choose_trajectory_title(axis_def: dict, axis: str, threshold: float = 0.65) -> str: - """Choose a short trajectory axis title based on aggregated confidence. - - axis: 'x' or 'y'. Returns axis_def label when its mean confidence >= threshold, - otherwise returns the compact fallback 'As 1' / 'As 2'. Matches previous logic. - """ - _TH = threshold - conf_map = axis_def.get(f"{axis}_label_confidence", {}) or {} - vals = [v for v in conf_map.values() if v is not None] - mean = float(sum(vals) / len(vals)) if vals else None - label = axis_def.get(f"{axis}_label") - if mean is not None and mean >= _TH and label: - return label - # Prefer the user-facing semantic fallback via the classifier helper - try: - from analysis.axis_classifier import display_label_for_modal - - fallback_modal = "As 1" if axis == "x" else "As 2" - return display_label_for_modal(fallback_modal, axis) - except Exception: - return "As 1" if axis == "x" else "As 2" - x_title = choose_trajectory_title(axis_def, "x", threshold=_THRESHOLD) y_title = choose_trajectory_title(axis_def, "y", threshold=_THRESHOLD) @@ -2434,185 +2636,6 @@ def build_svd_components_tab(db_path: str) -> None: Reads thoughts/explorer/top_svd_top_motions.json and displays a selector for components 1..10 with theme labels/explanations and a detail pane per motion. """ - # Political polarisation themes per SVD component (1-indexed, window=2025) - # Produced by per-axis analysis of all 10 unique top motions (zero cross-axis overlap). - SVD_THEMES: dict[int, dict[str, str]] = { - 1: { - "label": "Links-rechts hoofdas", - "explanation": ( - "De dominante dimensie van het parlement: de klassieke links-rechts tegenstelling " - "die het meeste verschil in stemgedrag verklaart. Aan de rechterkant (PVV, SGP, VVD, " - "ChristenUnie) staan moties over defensie-uitbreiding, NAVO-verplichtingen, " - "juridische ruimte voor drones en gaswinning. Aan de linkerkant (PvdD, SP, DENK, " - "GroenLinks-PvdA) staan moties over huurverlaging, het veroordelen van " - "antipersoneelslandmijnen, het opzeggen van het militaire verdrag met Israël en het " - "oprichten van zorgbuurthuizen. De scheidslijn loopt dwars door thema's als " - "veiligheid, economie, internationaal recht en sociale bescherming." - ), - "positive_pole": "Nationalistisch-conservatief: PVV, SGP, VVD, ChristenUnie", - "negative_pole": "Progressief-links: PvdD, SP, DENK, GroenLinks-PvdA", - "flip": False, - }, - 2: { - "label": "Populistisch nationalisme versus institutioneel progressivisme", - "explanation": ( - "Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, Groep Markuszower, " - "BBB) van het volledige overige parlement. Alleen PVV (+18), FVD (+4) en Groep " - "Markuszower (+2) scoren positief; alle andere partijen scoren negatief, inclusief " - "VVD (−15), CDA (−14), SGP (−25) en ChristenUnie (−59). Positieve moties: artsen " - "vrijpleiten voor hydroxychloroquine/ivermectine, Syriërs terugsturen, geen geld " - "aan Jordanië, tijdelijke bescherming Oekraïne beëindigen. Negatieve moties: " - "digitale toegankelijkheid Caribisch Nederland, ethiekprogramma Defensie, zorg voor " - "slachtoffers bombardement Hawija, zorgkwaliteitsstandaarden. Dit is geen links-rechts " - "verdeling maar een nativistisch-populistisch vs. institutioneel onderscheid." - ), - "positive_pole": "Populistisch-nationalistisch: PVV, FVD, Groep Markuszower, BBB", - "negative_pole": "Institutioneel: alle overige partijen — van VVD en SGP tot GroenLinks-PvdA en Volt", - "flip": False, - }, - 3: { - "label": "Verzorgingsstaat versus bezuinigingen en marktwerking", - "explanation": ( - "Deze as weerspiegelt de spanning tussen staatsingrijpen en marktliberalisme, " - "aangescherpt door de kabinetscrisis van 2025. Aan de positieve kant staan moties " - "die bezuinigingen op zorg en het gemeentefonds willen terugdraaien, winstuitkeringen " - "in de zorg verbieden en publieke controle over ziekenhuisfusies eisen. SP, PvdD, " - "GroenLinks-PvdA en PVV stemmen hier gelijk — ondanks hun tegengestelde PC1-posities. " - "Aan de negatieve kant staan moties " - "over marktwerking in de zorg, fiscale bedrijfsopvolgingsfaciliteiten (VVD), " - "doorgaan met besturen ondanks de kabinetscrisis (VVD/Yeşilgöz) en defensie-" - "uitgaven van 3,5% bbp." - ), - "positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA, PVV (anti-bezuinigingen)", - "negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP", - "flip": True, - }, - 4: { - "label": "Pragmatisch centrisme versus ideologische radicaliteit", - "explanation": ( - "De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel " - "rechts-radicale als identiteitspolitieke posities. Aan de positieve kant staan " - "moties over openbare toiletten, vaderbetrokkenheid bij opvoeding, internationale " - "samenwerking met Australië en Canada, en long covid-expertise. Dit zijn pragmatische, " - "institutionele beleidsposities. Aan de negatieve kant staan moties over een " - "migratiesaldo-cap van 60.000, het verlaten van de WHO, kinderen in pleeggezinnen " - "van hetzelfde geslacht (FVD) en de bescherming van religieuze schoolidentiteit " - "via artikel 23. De negatieve pool combineert populistisch-rechts met " - "identiteitsgerichte posities van zowel rechts als links." - ), - "positive_pole": "Constructief centrum: D66, CDA, VVD, 50PLUS — pragmatisch en internationaal", - "negative_pole": "Radicaal-ideologisch: FVD, Groep Markuszower (rechts), ChristenUnie, DENK (religieus/identiteit)", - "flip": True, - }, - 5: { - "label": "Christelijk-sociaal communitarisme", - "explanation": ( - "Deze as scheidt partijen die gemeenschapszorg, burgerplicht en informele " - "ondersteuningsstructuren benadrukken van partijen die individuele vrijheden en " - "progressieve maatschappelijke hervorming voorstaan. Aan de positieve kant staan " - "moties over schuldhulpverlening via vrijwilligersorganisaties, de maatschappelijke " - "diensttijd voor jongeren met een afstand tot de arbeidsmarkt, en de gastouderopvang. " - "ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief door steun " - "aan sociaal beleid. Aan de negatieve kant staan moties over wettelijke erkenning " - "van meerouderschap, abortusrecht in het EU-Handvest, armoedebeleid en " - "buitenlandse beïnvloeding. PvdD, GroenLinks-PvdA en VVD scoren hier negatief." - ), - "positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen", - "negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV", - "flip": False, - }, - 6: { - "label": "Klimaat, energie en culturele integratie", - "explanation": ( - "Aan de positieve kant staan moties die LNG-capaciteit prefereren als alternatief " - "voor strenge vulgraadverplichtingen, kernenergie als volwaardig CO₂-arm onderdeel " - "van de energiemix willen erkennen op COP30, en discriminatie- en inclusiemeldpunten " - "willen inventariseren. SGP, JA21, FVD en PVV scoren sterk positief. Aan de " - "negatieve kant staan moties die fossiele-industrie-vertegenwoordigers willen weren " - "van klimaatconferenties, structureel overleg met moslimgemeenschappen willen bij " - "integratiebeleid, en aanvallen van Israël op Libanon veroordelen. " - "PvdD, GroenLinks-PvdA, Volt en D66 scoren negatief. " - "Deze as combineert energieideologie met culturele polarisatie rondom klimaat, " - "integratie en buitenlandspolitiek." - ), - "positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV", - "negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66", - "flip": False, - }, - 7: { - "label": "Bestuurlijk pragmatisme en implementatie (indicatief)", - "explanation": ( - "Een residuele as die overwegend beleidsdossiers uit 2024 (vorige parlementaire " - "periode) omvat. De scores zijn smal (max ~11 punten) en de partijcombinaties " - "ideologisch divers — dit label is indicatief. Aan de positieve kant staan " - "pragmatische bestuursmoties: een compleet kostenoverzicht van producten van eigen " - "bodem, papieren schoolboeken voor basisvaardigheden, een invoeringstoets voor het " - "minimumloon en de A2-snelwegplanning. ChristenUnie, Volt, DENK en SP scoren " - "positief. Aan de negatieve kant staan meer ideologisch geladen moties: een " - "landelijk stookverbod (PvdD), het strafbaar stellen van verbranding van religieuze " - "geschriften (DENK), chroom-6 schadevergoedingen en tegenhouden van nieuwe " - "gaswinning. GroenLinks-PvdA, VVD, FVD en JA21 scoren negatief." - ), - "positive_pole": "Praktisch-bestuurlijk: ChristenUnie, Volt, SGP, DENK, SP", - "negative_pole": "Ideologisch-principieel: GroenLinks-PvdA, VVD, FVD, JA21", - "flip": True, - }, - 8: { - "label": "Europese defensie-integratie (indicatief)", - "explanation": ( - "Aan de positieve kant staan moties die pleiten voor militaire mobiliteit als " - "topprioriteit in EU/NAVO-verband en toewerken naar een militair Schengengebied, " - "35% van defensiematerieel Europees inkopen en een Europees defensie-R&D-instituut " - "oprichten. Ook het Nationaal Groeifonds en gewasbeschermingsonderzoek vallen " - "positief. Volt en D66 scoren sterk positief. Aan de negatieve kant staan moties " - "over ketenverantwoordelijkheid bij toeslagen (DENK), het coronaoversterfte-onderzoek " - "(PVV/BBB), energiecontracten en huisvestingsregulering. SP (−39), DENK (−35) en " - "PvdD (−26) scoren sterk negatief — dit betekent dat zij actief tégen deze " - "EU-defensiemoties stemmen, niet simpelweg het thema negeren. Volt (N=1) domineert " - "de positieve pool maar is als centroïde van één Kamerlid statistisch onbetrouwbaar." - ), - "positive_pole": "Pro-EU defensie en innovatie: Volt, D66", - "negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS", - "flip": False, - }, - 9: { - "label": "Decentraal bestuur en gemeenschapswaarden (indicatief)", - "explanation": ( - "Aan de positieve kant staan moties over naleving van de Financiële-verhoudingswet " - "voor gemeenten, beperking van arbeidsmigratie binnen de EU, een nieuwe " - "tandartsopleiding in Rotterdam, een actieplan tegen misbruik van hallucinerende " - "geneesmiddelen en een oplossing voor milieuproblemen op Bonaire. SGP en " - "ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant staan " - "moties over een moratorium op geitenstallen, een verbod op gokadvertenties, " - "verduidelijking van gronden voor voorlopige hechtenis, een leegstandbelasting voor " - "woningen en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. Deze as " - "scheidt een nadruk op decentrale dienstverlening en gemeenschapsregulering van " - "progressieve systeem- en rechtshervorming." - ), - "positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP", - "negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV", - "flip": True, - }, - 10: { - "label": "Institutioneel toezicht en handhaving (indicatief)", - "explanation": ( - "De tiende as vangt resterende variantie op en scheidt partijen die sceptisch zijn " - "over staatstoezicht van partijen die strikte regulering en handhaving steunen. " - "Aan de positieve kant staan moties over minder tijdsintensieve schoolinspecties, " - "het recht van toeslagenouders op hun persoonlijk dossier, behoud van de " - "tegemoetkoming voor arbeidsongeschikten en een verlaging van de leeftijdsdrempel " - "voor kindgesprekken. DENK, SP en PvdD scoren positief. Aan de negatieve kant " - "staan moties over een aangifteplicht voor scholen bij veiligheidsincidenten, een " - "rookverbod in auto's met kinderen, braakliggende landbouwgrond en verhoogd " - "beloningsgeld voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief, " - "waarmee het zich onderscheidt van SP en DENK op handhavingsthema's." - ), - "positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast", - "negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving", - "flip": True, - }, - } - st.subheader("🔬 SVD Assen — politieke polarisatiethema's") st.markdown( "Elke SVD-as representeert een latente politieke dimensie afgeleid uit stempatronen " @@ -2670,13 +2693,27 @@ def build_svd_components_tab(db_path: str) -> None: return f"As {c} — {lbl}" if lbl else f"As {c}" comp_display = [_comp_label(c) for c in comp_options] - comp_sel_idx = st.selectbox( - "Selecteer SVD-as", - options=list(range(len(comp_options))), - format_func=lambda i: comp_display[i], - index=0, - ) - comp_sel = comp_options[comp_sel_idx] + + # Sidebar controls for window selection and minimum MPs filter + col1, col2 = st.columns([2, 1]) + with col2: + comp_sel_idx = st.selectbox( + "Selecteer SVD-as", + options=list(range(len(comp_options))), + format_func=lambda i: comp_display[i], + index=0, + ) + comp_sel = comp_options[comp_sel_idx] + + # Minimum MPs filter (only relevant for components 1-2 which use party centroids) + min_mps = st.number_input( + "Min. Kamerleden per partij", + min_value=1, + max_value=20, + value=1, + step=1, + help="Partijen met minder dan dit aantal Kamerleden worden niet weergegeven.", + ) # Show theme explanation theme = SVD_THEMES.get(comp_sel, {}) @@ -2700,22 +2737,39 @@ def build_svd_components_tab(db_path: str) -> None: positions_by_window, axis_def = load_positions(db_path) if axis_def is None: axis_def = {} - # choose the current parliament window if present - window = ( - "current_parliament" - if "current_parliament" in positions_by_window - else sorted(positions_by_window.keys())[-1] + + # Window selector for components 1-2 (same windows as Political Compass) + year_windows = sorted( + w for w in positions_by_window if w != "current_parliament" ) + has_current = "current_parliament" in positions_by_window + windows = year_windows + (["current_parliament"] if has_current else []) + + def _window_label(w: str) -> str: + if w == "current_parliament": + return "Huidig parlement" + return w + + with col1: + window = st.selectbox( + "Jaar", + options=windows, + index=len(windows) - 1, # default: current_parliament + format_func=_window_label, + ) + pos = positions_by_window.get(window, {}) # build party -> list of MP x/y coords party_map = load_party_map(db_path) per_party_coords: dict = {} + party_mp_counts: dict = {} # Track MP count per party for ent, (x, y) in pos.items(): party = party_map.get(ent) if party is None: continue per_party_coords.setdefault(party, []).append((x, y)) + party_mp_counts[party] = party_mp_counts.get(party, 0) + 1 # construct party_scores mapping: prefer MP centroid [x,y], fallback to default vector party_scores = {} @@ -2737,8 +2791,18 @@ def build_svd_components_tab(db_path: str) -> None: "Failed to derive party centroids from positions_by_window; falling back to load_party_axis_scores" ) party_scores = party_scores_default + # For fallback, compute MP counts from party_mp_vectors + party_mp_counts = ( + {p: len(v) for p, v in party_mp_vectors.items()} + if party_mp_vectors + else {} + ) else: party_scores = party_scores_default + # For components 3-10, compute MP counts from party_mp_vectors + party_mp_counts = ( + {p: len(v) for p, v in party_mp_vectors.items()} if party_mp_vectors else {} + ) # Convert party_scores (possibly [x,y] lists or legacy vectors) into explicit (x,y) coords party_coords: dict = {} @@ -2749,9 +2813,22 @@ def build_svd_components_tab(db_path: str) -> None: except Exception: continue - _render_party_axis_chart( - party_coords, comp_sel, theme, bootstrap_data=bootstrap_data - ) + # Filter parties by minimum MP count + if min_mps > 1 and party_mp_counts: + valid_parties = {p for p, count in party_mp_counts.items() if count >= min_mps} + party_coords = { + p: coords for p, coords in party_coords.items() if p in valid_parties + } + + # Only render party axis chart for components 1 and 2 (which have 2D coords) + if comp_sel in (1, 2): + _render_party_axis_chart( + party_coords, comp_sel, theme, bootstrap_data=bootstrap_data + ) + else: + st.caption( + "_Partijposities zijn alleen beschikbaar voor de eerste twee SVD-assen._" + ) # Batch-fetch motion details (title, date, policy_area, url, body_text, voting_results) motion_ids = [m.get("motion_id") for m in motions if m.get("motion_id") is not None]