fix: update SVD_THEMES labels to match actual motion content

- 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
main
Sven Geboers 4 weeks ago
parent 33edb334c4
commit e77f0ec9e3
  1. 593
      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}<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

Loading…
Cancel
Save