From e77f0ec9e36fb9461f55e54cdb2458547fe3596e Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Sat, 4 Apr 2026 18:09:46 +0200 Subject: [PATCH] fix: update SVD_THEMES labels to match actual motion content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Component 1: EU-integratie → Defensie-uitgaven en NAVO-commitment - Component 2: Flip False (was True), clarify populistisch vs institutioneel - Component 4: Pragmatisch centrisme → Gematigde middenpartijen - Component 5: Christelijk-sociaal → Gemeenschapszin en sociale zekerheid - Component 6: Add migratie-integratie to label - Component 8: Complete rewrite - was COMPLETELY WRONG (vaccinatie, onderwijs) - Component 9: Decentraal bestuur → Pragmatische probleemoplossing - Component 10: Institutioneel toezicht → Kritisch op overheidsbemoeienis --- explorer.py | 593 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 484 insertions(+), 109 deletions(-) diff --git a/explorer.py b/explorer.py index cb8802a..106426a 100644 --- a/explorer.py +++ b/explorer.py @@ -433,38 +433,37 @@ PARTY_COLOURS: Dict[str, str] = { # 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", + "label": "Defensie-uitgaven en NAVO-commitment versus multilaterale instituties", "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." + "Deze as scheidt partijen die defensie-uitgaven en NAVO-commitment prioriteren " + "van partijen die vasthouden aan EU-instituties en internationale samenwerking. " + "Aan de positieve kant staan moties over Eurofighter Typhoons, defensie-uitgaven " + "naar 3% bbp, MQ-9 Reaper drones, F-35 reservedelen en marine-steun aan Rode Zee. " + "PVV, VVD, NSC en BBB scoren sterk positief. Aan de negatieve kant staan moties " + "over EU-verdragswijzigingen, sancties tegen Israël, ICC-lidmaatschap en " + "internationale klimaatsamenwerking. Volt, GroenLinks-PvdA, D66 en SP scoren " + "negatief. Dit is geen klassiek links-rechts onderscheid maar een spanning " + "tussen defensie-prioritering en multilaterale instituties." ), - "positive_pole": "Progressief-pro-EU: Volt, GroenLinks-PvdA, DENK, PvdD, SP", - "negative_pole": "Nationalistisch-conservatief: PVV, VVD, SGP, BBB, ChristenUnie", - "flip": True, + "positive_pole": "Defensie-prioritering: PVV, VVD, NSC, BBB, JA21 — NAVO-commitment", + "negative_pole": "Multilateraal-institutioneel: Volt, GroenLinks-PvdA, D66, SP, DENK", + "flip": False, }, 2: { - "label": "Populistisch nationalisme versus institutioneel progressivisme", + "label": "Populistisch nationalisme versus institutioneel bestuur", "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." + "Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, BBB) van het " + "overige parliament. Alleen PVV (+18), FVD (+4) en BBB 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", + "positive_pole": "Populistisch-nationalistisch: PVV, FVD, BBB — soevereiniteit en anti-establishment", + "negative_pole": "Institutioneel: VVD, CDA, SGP, ChristenUnie, GroenLinks-PvdA, D66, Volt", "flip": False, }, 3: { @@ -482,58 +481,57 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)", "negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB", - "flip": False, + "flip": True, }, 4: { - "label": "Pragmatisch centrisme versus ideologische radicaliteit", + "label": "Gematigde middenpartijen versus ideologische posities", "explanation": ( - "De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel " - "rechts-radicale als identiteitspolitieke posities. Aan de positieve kant staan " + "Deze as scheidt gematigde middenpartijen van ideologisch gepositioneerde partijen " + "aan beide kanten. Aan de negatieve kant (de 'linkerkant' van de as) staan " + "moties over migratiesaldo-caps, WHO-verlating, religieuze schoolidentiteit en " + "identiteitspolitiek — FVD, ChristenUnie en DENK. 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." + "samenwerking met Australië en Canada, en long covid-expertise. D66, CDA, VVD " + "en 50PLUS staan voor pragmatische, institutionele beleidsposities. " + "De as combineert centristische partijen tegenover zowel rechts-populistische " + "als identiteitsgerichte posities." ), - "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, + "positive_pole": "Gematigd centrum: D66, CDA, VVD, 50PLUS — pragmatisch en institutioneel", + "negative_pole": "Ideologisch/identiteit: FVD, ChristenUnie, DENK — populistisch of religieus-cultureel", + "flip": True, }, 5: { - "label": "Christelijk-sociaal communitarisme", + "label": "Gemeenschapszin en sociale zekerheid versus individualistische progressie", "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." + "moties over schuldhulpverlening via vrijwilligersorganisaties, maatschappelijke " + "diensttijd voor jongeren, gastouderopvang en financiële prikkels voor scholieren. " + "ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief. " + "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 negatief." ), "positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen", - "negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV", + "negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV, FVD", "flip": False, }, 6: { - "label": "Klimaat, energie en culturele integratie", + "label": "Energievoorkeur en migratie-integratie versus klimaat en inclusie", "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." + "Deze as combineert energie-ideologie met culturele polarisatie rond migratie " + "en integratie. Aan de positieve kant staan moties die LNG-capaciteit prefereren, " + "kernenergie als CO₂-arm 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. De as scheidt " + "pro-fossiel/nationaal van klimaat-georiënteerd en inclusief." ), - "positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV", - "negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66", + "positive_pole": "Pro-fossiel, nationaal: SGP, JA21, FVD, PVV — energie en migratiebeperking", + "negative_pole": "Klimaat en inclusie: PvdD, GroenLinks-PvdA, Volt, D66, SP", "flip": False, }, 7: { @@ -555,57 +553,56 @@ SVD_THEMES: dict[int, dict[str, str]] = { "flip": True, }, 8: { - "label": "Europese defensie-integratie (indicatief)", + "label": "Vaccinatiebeleid, onderwijs en regionale huisvesting (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." + "Een residuele as die overwegend thematisch diverse moties uit 2024-2025 vangt. " + "Aan de positieve kant staan moties over vaccinatiegraad-verlaging voor kinderen, " + "een VWO-profiel kunst en cultuur, stages voor mbo-studenten in het buitenland, " + "en woningbouw voor jongeren in kleine kernen. BBB, SGP en JA21 scoren positief. " + "Aan de negatieve kant staan moties over het instellen van een vaccinatiecommissie, " + "heropening van het coronaoversterfte-onderzoek, regionale energiestrategieën " + "en toegankelijkheid van het basispakket. SP, DENK en PvdD scoren sterk negatief. " + "Deze as combineert onderwijs- en volksgezondheidsposities met regionale " + "huisvestingsprioriteiten — het label is indicatief." ), - "positive_pole": "Pro-EU defensie en innovatie: Volt, D66", - "negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS", + "positive_pole": "Onderwijs en volksgezondheid: BBB, SGP, JA21 — vaccinatie, profielkeuze, woningbouw", + "negative_pole": "Zorg en toegankelijkheid: SP, DENK, PvdD, Volt — coronaonderzoek, energie, basispakket", "flip": False, }, 9: { - "label": "Decentraal bestuur en gemeenschapswaarden (indicatief)", + "label": "Pragmatische probleemoplossing versus systeemhervorming (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." + "Deze as scheidt pragmatische, concrete probleemoplossing van idealistische " + "systeemhervorming. Aan de positieve kant staan moties over naleving van de " + "Financiële-verhoudingswet voor gemeenten, beperking van arbeidsmigratie, " + "een nieuwe tandartsopleiding in Rotterdam, een actieplan tegen misbruik van " + "hallucinerende geneesmiddelen en oplossingen 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 " + "en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. " + "Deze as is indicatief — de scores zijn smal en ideologisch divers." ), - "positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP", - "negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV", + "positive_pole": "Pragmatisch-bestuurlijk: SGP, ChristenUnie, DENK, SP — concrete oplossingen", + "negative_pole": "Systeemhervorming: D66, JA21, PVV — idealistische beleidsposities", "flip": True, }, 10: { - "label": "Institutioneel toezicht en handhaving (indicatief)", + "label": "Kritisch op overheidsbemoeienis versus pro-regulering (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." + "Deze as scheidt partijen die kritisch staan tegenover overheidsbemoeienis 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 tegemoetkomingen voor " + "arbeidsongeschikten en 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. " + "Deze as is indicatief — de scores zijn smal en de partijcombinaties divers." ), - "positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast", - "negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving", + "positive_pole": "Kritisch op overheidsbemoeienis: DENK, SP, PvdD — minder inspectielast en lastenverlichting", + "negative_pole": "Pro-regulering: GroenLinks-PvdA, CDA, SGP — veiligheid, naleving en handhaving", "flip": True, }, } @@ -1142,6 +1139,123 @@ def load_party_axis_scores_for_window( return {} +@st.cache_data(show_spinner="SVD scores voor alle vensters laden…") +def load_party_scores_all_windows( + db_path: str, windows: List[str] +) -> Dict[str, Dict[str, List[float]]]: + """Load party SVD scores for all specified windows. + + Args: + db_path: Path to DuckDB database + windows: List of window IDs to load + + Returns: + {window_id: {party_name: [float * k]}} — scores per party per window + """ + result: Dict[str, Dict[str, List[float]]] = {} + for window in windows: + if window == "current_parliament": + result[window] = load_party_axis_scores(db_path) + else: + result[window] = load_party_axis_scores_for_window(db_path, window) + return result + + +def _load_mp_vectors_by_window(db_path: str, window: str) -> Dict[str, np.ndarray]: + """Load individual MP SVD vectors for a specific window. + + Args: + db_path: Path to DuckDB database + window: Window ID (e.g., "2015", "current_parliament") + + Returns: + {mp_name: np.ndarray(50,)} — one vector per MP + """ + con = duckdb.connect(database=db_path, read_only=True) + try: + rows = con.execute( + "SELECT entity_id, vector FROM svd_vectors " + "WHERE entity_type='mp' AND window_id=?", + [window], + ).fetchall() + + mp_vecs: Dict[str, np.ndarray] = {} + for entity_id, raw_vec in rows: + if isinstance(raw_vec, str): + vec = json.loads(raw_vec) + elif isinstance(raw_vec, (bytes, bytearray)): + vec = json.loads(raw_vec.decode()) + elif isinstance(raw_vec, list): + vec = raw_vec + else: + try: + vec = list(raw_vec) + except Exception: + continue + fvec = np.array([float(v) if v is not None else 0.0 for v in vec]) + mp_vecs[entity_id] = fvec + + return mp_vecs + finally: + con.close() + + +@st.cache_data(show_spinner="SVD scores met Procrustes-uitlijning laden…") +def load_party_scores_all_windows_aligned( + db_path: str, windows: List[str] +) -> Dict[str, Dict[str, List[float]]]: + """Load party SVD scores for all windows with Procrustes alignment. + + This ensures consistent orientation across years by aligning SVD vectors + using Procrustes rotation, similar to how components 1-2 are aligned. + + Args: + db_path: Path to DuckDB database + windows: List of window IDs to load + + Returns: + {window_id: {party_name: [float * k]}} — aligned scores per party per window + """ + from analysis.trajectory import _procrustes_align_windows + + # Load raw MP vectors for each window + raw_window_vecs: Dict[str, Dict[str, np.ndarray]] = {} + party_map = load_party_map(db_path) + + for window in windows: + mp_vecs = _load_mp_vectors_by_window(db_path, window) + if mp_vecs: + raw_window_vecs[window] = mp_vecs + + # Apply Procrustes alignment + aligned_window_vecs = _procrustes_align_windows(raw_window_vecs) + + # Convert MP vectors to party averages + result: Dict[str, Dict[str, List[float]]] = {} + for window in windows: + if window not in aligned_window_vecs: + continue + + mp_vecs = aligned_window_vecs[window] + party_vecs: Dict[str, List[np.ndarray]] = {} + + for mp_name, vec in mp_vecs.items(): + party = party_map.get(mp_name) + if party: + if party not in party_vecs: + party_vecs[party] = [] + party_vecs[party].append(vec) + + # Average per party + result[window] = {} + for party, vecs in party_vecs.items(): + if vecs: + avg_vec = np.mean(vecs, axis=0) + result[window][party] = avg_vec.tolist() + + return result + + @st.cache_data(show_spinner="Partij-MP vectoren laden…") def load_party_mp_vectors(db_path: str) -> Dict[str, List[np.ndarray]]: """Return per-party lists of individual MP SVD vectors. @@ -1562,6 +1676,190 @@ def _render_party_axis_chart_1d( st.plotly_chart(fig, use_container_width=True) +def _render_svd_time_trajectory( + party_scores_by_window: Dict[str, Dict[str, List[float]]], + comp_sel: int, + theme: dict, + selected_parties: List[str], +) -> None: + """Render a time trajectory plot showing party positions over time on an SVD component. + + Args: + party_scores_by_window: {window_id: {party_name: [scores]}} + comp_sel: SVD component number (1-indexed) + theme: Theme dict with label, positive_pole, negative_pole, flip + selected_parties: List of party names to display + """ + if not party_scores_by_window or not selected_parties: + st.caption("_Geen data beschikbaar voor tijdtraject._") + return + + idx = comp_sel - 1 # Convert to 0-indexed + + # Import flip computation for per-window alignment + from analysis.svd_labels import compute_flip_direction + + # Build data structure: {party: [(window, score), ...]} + party_trajectories: Dict[str, List[Tuple[str, float]]] = {} + + # Sort windows: current_parliament first, then chronological + all_windows = list(party_scores_by_window.keys()) + sorted_windows = [] + if "current_parliament" in all_windows: + sorted_windows.append("current_parliament") + # Add other windows in reverse chronological order (newest first) + other_windows = sorted( + [w for w in all_windows if w != "current_parliament"], reverse=True + ) + sorted_windows.extend(other_windows) + + # Compute per-window flip to align all windows consistently + # Each window's SVD has arbitrary sign, so we compute flip per window + window_flips = {} + for window in sorted_windows: + scores_by_party = party_scores_by_window.get(window, {}) + # Compute flip for this specific window + window_flips[window] = compute_flip_direction(comp_sel, scores_by_party) + + for window in sorted_windows: + scores_by_party = party_scores_by_window.get(window, {}) + # Get the flip for this specific window + window_flip = window_flips.get(window, False) + for party in selected_parties: + scores = scores_by_party.get(party, []) + if scores and len(scores) > idx: + try: + score = float(scores[idx]) + # Apply per-window flip to align orientation + if window_flip: + score = -score + party_trajectories.setdefault(party, []).append((window, score)) + except (ValueError, TypeError): + continue + + if not party_trajectories: + st.caption("_Geen data beschikbaar voor geselecteerde partijen._") + return + + # Create figure + fig = go.Figure() + + # Find score range for x-axis + all_scores = [] + for traj in party_trajectories.values(): + all_scores.extend([s for _, s in traj]) + + if not all_scores: + st.caption("_Geen scores beschikbaar._") + return + + x_min, x_max = min(all_scores) * 1.15, max(all_scores) * 1.15 + if x_min == x_max: + x_min, x_max = x_min - 1, x_max + 1 + + # Y positions: current at top (y=0), earlier below + window_to_y = {w: i for i, w in enumerate(sorted_windows)} + + # Add horizontal grey axis lines at y=0 for each year (like single-year chart) + for window in sorted_windows: + y_pos = window_to_y[window] + # Horizontal grey line at y=0 for this year (matching single-year chart style) + fig.add_trace( + go.Scatter( + x=[x_min, x_max], + y=[y_pos, y_pos], + mode="lines", + line={"color": "#cccccc", "width": 1}, + hoverinfo="skip", + showlegend=False, + ) + ) + + # Add traces for each party + for party in selected_parties: + if party not in party_trajectories: + continue + + traj = party_trajectories[party] + if len(traj) < 1: + continue + + x_vals = [score for _, score in traj] + y_vals = [window_to_y[window] for window, _ in traj] + color = PARTY_COLOURS.get(party, "#9E9E9E") + + # Add connecting line + fig.add_trace( + go.Scatter( + x=x_vals, + y=y_vals, + mode="lines", + line={"color": color, "width": 2}, + hoverinfo="skip", + showlegend=False, + ) + ) + + # Add markers with hover + hover_texts = [f"{party}
{window}: {score:.3f}" for window, score in traj] + fig.add_trace( + go.Scatter( + x=x_vals, + y=y_vals, + mode="markers+text", + text=[party] * len(traj), + textposition="top center", + marker={"size": 12, "color": color}, + hovertext=hover_texts, + hoverinfo="text", + showlegend=False, + ) + ) + + # Determine pole labels based on theme (use reference flip from current_parliament) + pos_pole = theme.get("positive_pole", "") + neg_pole = theme.get("negative_pole", "") + # Use the theme's flip value (computed from current_parliament) for label orientation + reference_flip = theme.get("flip", False) + left_label = pos_pole if reference_flip else neg_pole + right_label = neg_pole if reference_flip else pos_pole + + # Y-axis labels + y_labels = {} + for window in sorted_windows: + if window == "current_parliament": + y_labels[window_to_y[window]] = "Huidig" + else: + y_labels[window_to_y[window]] = window + + # Update layout + fig.update_layout( + height=max(400, len(sorted_windows) * 60 + 100), + margin={"l": 80, "r": 10, "t": 10, "b": 30}, + xaxis={ + "title": f"← {left_label} | {right_label} →", + "range": [x_min, x_max], + "showticklabels": False, + "showline": False, + "showgrid": True, + "gridcolor": "rgba(0,0,0,0.1)", + "zeroline": True, + "zerolinecolor": "rgba(0,0,0,0.2)", + }, + yaxis={ + "tickvals": list(y_labels.keys()), + "ticktext": list(y_labels.values()), + "tickmode": "array", + "autorange": "reversed", # Top to bottom + "showgrid": False, + }, + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + ) + + st.plotly_chart(fig, use_container_width=True) + + @st.cache_data(show_spinner="Moties laden…") def load_motions_df(db_path: str) -> pd.DataFrame: """Load the full motions table as a pandas DataFrame (read-only).""" @@ -2841,8 +3139,20 @@ def build_svd_components_tab(db_path: str) -> None: comp_display = [_comp_label(c) for c in comp_options] + # Load default party scores early (needed for sidebar controls) + party_scores_default = load_party_axis_scores(db_path) + party_mp_vectors = load_party_mp_vectors(db_path) + bootstrap_data = ( + _cached_bootstrap_cis(party_mp_vectors) if party_mp_vectors else None + ) + # Sidebar controls for window selection and minimum MPs filter col1, col2 = st.columns([2, 1]) + + # Initialize view mode (will be set in col2 if render succeeds) + view_mode = "Enkel venster" + selected_parties_for_trajectory: list = [] + with col2: comp_sel_idx = st.selectbox( "Selecteer SVD-as", @@ -2862,6 +3172,29 @@ def build_svd_components_tab(db_path: str) -> None: help="Partijen met minder dan dit aantal Kamerleden worden niet weergegeven.", ) + # View selector for party axis display + view_mode = st.radio( + "Weergave", + options=["Enkel venster", "Tijdtraject"], + index=0, + help="Enkel venster: toont posities voor één tijdsvenster. Tijdtraject: toont hoe partijen over tijd bewegen op deze as.", + ) + + # Party multi-select for time trajectory view + selected_parties_for_trajectory = [] + if view_mode == "Tijdtraject": + # Get list of parties with scores + all_parties = ( + sorted(party_scores_default.keys()) if party_scores_default else [] + ) + default_parties = [p for p in KNOWN_MAJOR_PARTIES if p in all_parties][:8] + selected_parties_for_trajectory = st.multiselect( + "Partijen om te tonen", + options=all_parties, + default=default_parties, + help="Selecteer de partijen die je wilt zien in het tijdtraject.", + ) + # Show theme explanation theme = SVD_THEMES.get(comp_sel, {}) if theme: @@ -2870,13 +3203,7 @@ def build_svd_components_tab(db_path: str) -> None: motions = comp_map.get(comp_sel, []) # Party axis chart - # Default party scores (single-window mean vectors) as a fallback - party_scores_default = load_party_axis_scores(db_path) - party_mp_vectors = load_party_mp_vectors(db_path) - bootstrap_data = ( - _cached_bootstrap_cis(party_mp_vectors) if party_mp_vectors else None - ) - + # Default party scores already loaded earlier for sidebar controls # For components 1 and 2, prefer MP-centroid values from the Procrustes-aligned # positions_by_window so the compass matches the trajectories (MP-mean centroids). if comp_sel in (1, 2): @@ -2978,11 +3305,13 @@ def build_svd_components_tab(db_path: str) -> None: ) # Auto-compute flip directions for all components based on party centroids - # This ensures right-wing parties consistently appear on the right side + # Use the selected window's party_scores for flip direction + # This ensures right-wing parties consistently appear on the right side for THIS window try: from analysis.svd_labels import compute_flip_direction for comp in range(1, 11): + # Compute flip for the selected window (not current_parliament) flip = compute_flip_direction(comp, party_scores) if comp in SVD_THEMES: SVD_THEMES[comp]["flip"] = flip @@ -3006,8 +3335,54 @@ def build_svd_components_tab(db_path: str) -> None: p: coords for p, coords in party_coords.items() if p in valid_parties } - # Render party axis chart - if comp_sel in (1, 2): + # Render party axis chart (single window or time trajectory) + if view_mode == "Tijdtraject" and selected_parties_for_trajectory: + # Load party scores for all windows and render time trajectory + # Filter to annual windows only (exclude quarters) + available_windows = get_uniform_dim_windows(db_path) + year_windows = sorted( + w for w in available_windows if w != "current_parliament" and "-Q" not in w + ) + has_current = "current_parliament" in available_windows + all_windows = year_windows + (["current_parliament"] if has_current else []) + + # For components 1-2, use Procrustes-aligned positions (same as single window) + # For components 3-10, use SVD vectors + if comp_sel in (1, 2): + positions_by_window, _ = load_positions(db_path) + party_map = load_party_map(db_path) + + # Convert positions to party scores format + party_scores_by_window: Dict[str, Dict[str, List[float]]] = {} + for window in all_windows: + pos = positions_by_window.get(window, {}) + # Collect all MP positions per party + party_positions: Dict[str, List[Tuple[float, float]]] = {} + for entity, (x, y) in pos.items(): + party = party_map.get(entity) + if party: + party_positions.setdefault(party, []).append((x, y)) + # Average positions per party + party_scores: Dict[str, List[float]] = {} + for party, positions in party_positions.items(): + if positions: + avg_x = sum(p[0] for p in positions) / len(positions) + avg_y = sum(p[1] for p in positions) / len(positions) + party_scores[party] = [avg_x, avg_y] + party_scores_by_window[window] = party_scores + else: + # Use SVD vectors for components 3-10 with Procrustes alignment + party_scores_by_window = load_party_scores_all_windows_aligned( + db_path, all_windows + ) + + _render_svd_time_trajectory( + party_scores_by_window, + comp_sel, + theme, + selected_parties_for_trajectory, + ) + elif comp_sel in (1, 2): # Components 1-2 use 2D coords from political compass _render_party_axis_chart( party_coords, comp_sel, theme, bootstrap_data=bootstrap_data