|
|
|
|
@ -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}<br>{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 |
|
|
|
|
|