@ -953,8 +953,9 @@ def _build_party_axis_figure(
pos_pole = theme . get ( " positive_pole " , " " )
pos_pole = theme . get ( " positive_pole " , " " )
neg_pole = theme . get ( " negative_pole " , " " )
neg_pole = theme . get ( " negative_pole " , " " )
left_label = theme . get ( " left_pole " , pos_pole if flip else neg_pole )
# Labels always from poles: negative_pole = LEFT, positive_pole = RIGHT
right_label = theme . get ( " right_pole " , neg_pole if flip else pos_pole )
left_label = neg_pole
right_label = pos_pole
fig . update_layout (
fig . update_layout (
height = 160 ,
height = 160 ,
@ -1073,8 +1074,9 @@ def _render_party_axis_chart_1d(
# Determine pole labels based on flip
# Determine pole labels based on flip
pos_pole = theme . get ( " positive_pole " , " " )
pos_pole = theme . get ( " positive_pole " , " " )
neg_pole = theme . get ( " negative_pole " , " " )
neg_pole = theme . get ( " negative_pole " , " " )
left_label = theme . get ( " left_pole " , pos_pole if flip else neg_pole )
# Labels always from poles: negative_pole = LEFT, positive_pole = RIGHT
right_label = theme . get ( " right_pole " , neg_pole if flip else pos_pole )
left_label = neg_pole
right_label = pos_pole
# Update layout with same format as components 1-2
# Update layout with same format as components 1-2
fig . update_layout (
fig . update_layout (
@ -1238,10 +1240,9 @@ def _render_svd_time_trajectory(
# Determine pole labels based on theme (use reference flip from current_parliament)
# Determine pole labels based on theme (use reference flip from current_parliament)
pos_pole = theme . get ( " positive_pole " , " " )
pos_pole = theme . get ( " positive_pole " , " " )
neg_pole = theme . get ( " negative_pole " , " " )
neg_pole = theme . get ( " negative_pole " , " " )
# Use the theme's flip value (computed from current_parliament) for label orientation
# Labels always from poles: negative_pole = LEFT, positive_pole = RIGHT
reference_flip = theme . get ( " flip " , False )
left_label = neg_pole
left_label = theme . get ( " left_pole " , pos_pole if reference_flip else neg_pole )
right_label = pos_pole
right_label = theme . get ( " right_pole " , neg_pole if reference_flip else pos_pole )
# Y-axis labels
# Y-axis labels
y_labels = { }
y_labels = { }
@ -2532,136 +2533,75 @@ def build_svd_components_tab(db_path: str) -> None:
motions = comp_map . get ( comp_sel , [ ] )
motions = comp_map . get ( comp_sel , [ ] )
# Party axis chart
# Party axis chart
# Default party scores already loaded earlier for sidebar controls
# Default party scores already loaded earlier for sidebar controls.
# For components 1 and 2, prefer MP-centroid values from the Procrustes-aligned
# ALL components 1-10 use raw (non-aligned) SVD vectors.
# positions_by_window so the compass matches the trajectories (MP-mean centroids).
# The compass uses Procrustes-aligned PCA — separate visualization.
if comp_sel in ( 1 , 2 ) :
# Get available windows from svd_vectors
try :
available_windows = get_uniform_dim_windows ( db_path )
positions_by_window , axis_def = load_positions ( db_path )
year_windows = sorted ( w for w in available_windows if w != " current_parliament " )
if axis_def is None :
has_current = " current_parliament " in available_windows
axis_def = { }
svd_windows = year_windows + ( [ " current_parliament " ] if has_current else [ ] )
# Window selector for components 1-2 (same windows as Political Compass)
def _svd_window_label ( w : str ) - > str :
year_windows = sorted (
if w == " current_parliament " :
w for w in positions_by_window if w != " current_parliament "
return " Huidig parliament "
)
return w
has_current = " current_parliament " in positions_by_window
windows = year_windows + ( [ " current_parliament " ] if has_current else [ ] )
def _window_label ( w : str ) - > str :
if w == " current_parliament " :
return " Huidig parlement "
return w
with col1 :
window = st . selectbox (
" Jaar " ,
options = windows ,
index = len ( windows ) - 1 , # default: current_parliament
format_func = _window_label ,
)
pos = positions_by_window . get ( window , { } )
# build party -> list of MP x/y coords
with col1 :
party_map = load_party_map ( db_path )
svd_window = st . selectbox (
per_party_coords : dict = { }
" Jaar " ,
party_mp_counts : dict = { } # Track MP count per party
options = svd_windows ,
for ent , ( x , y ) in pos . items ( ) :
index = len ( svd_windows ) - 1 , # default: current_parliament
party = party_map . get ( ent )
format_func = _svd_window_label ,
if party is None :
key = f " svd_window_ { comp_sel } " ,
continue
)
per_party_coords . setdefault ( party , [ ] ) . append ( ( x , y ) )
party_mp_counts [ party ] = party_mp_counts . get ( party , 0 ) + 1
# construct party_scores mapping: prefer MP centroid [x,y], fallback to default vector
party_scores = { }
for party in set (
list ( per_party_coords . keys ( ) ) + list ( party_scores_default . keys ( ) )
) :
coords = per_party_coords . get ( party )
if coords :
xs = [ c [ 0 ] for c in coords ]
ys = [ c [ 1 ] for c in coords ]
party_scores [ party ] = [ float ( np . mean ( xs ) ) , float ( np . mean ( ys ) ) ]
else :
# fallback: use the default single-window SVD mean vector
party_scores [ party ] = party_scores_default . get ( party , [ ] )
except Exception :
# Load party scores for the selected window
# On any error, fall back to the old behaviour
if svd_window == " current_parliament " :
logger . exception (
party_scores = party_scores_default
" Failed to derive party centroids from positions_by_window; falling back to load_party_axis_scores "
)
party_scores = party_scores_default
# For fallback, compute MP counts from party_mp_vectors
party_mp_counts = (
{ p : len ( v ) for p , v in party_mp_vectors . items ( ) }
if party_mp_vectors
else { }
)
else :
else :
# Components 3-10: use SVD vectors with year selection
party_scores = load_party_axis_scores_for_window ( db_path , svd_window )
# Get available windows from svd_vectors
available_windows = get_uniform_dim_windows ( db_path )
year_windows = sorted ( w for w in available_windows if w != " current_parliament " )
has_current = " current_parliament " in available_windows
svd_windows = year_windows + ( [ " current_parliament " ] if has_current else [ ] )
def _svd_window_label ( w : str ) - > str :
if w == " current_parliament " :
return " Huidig parlement "
return w
with col1 :
svd_window = st . selectbox (
" Jaar " ,
options = svd_windows ,
index = len ( svd_windows ) - 1 , # default: current_parliament
format_func = _svd_window_label ,
key = f " svd_window_ { comp_sel } " ,
)
# Load party scores for the selected window
if svd_window == " current_parliament " :
party_scores = party_scores_default
else :
party_scores = load_party_axis_scores_for_window ( db_path , svd_window )
# For components 3-10, c ompute MP counts from party_mp_vectors
# Compute MP counts from party_mp_vectors
party_mp_counts = (
party_mp_counts = (
{ p : len ( v ) for p , v in party_mp_vectors . items ( ) } if party_mp_vectors else { }
{ p : len ( v ) for p , v in party_mp_vectors . items ( ) } if party_mp_vectors else { }
)
)
# Auto-compute flip directions for all components based on party centroids
# Auto-compute flip directions for ALL components 1-10 based on party centroids.
# Use the selected window's party_scores for flip direction
# Each window's SVD has arbitrary sign orientation, so we compute flip per component
# This ensures right-wing parties consistently appear on the right side for THIS window
# to ensure canonical right parties (PVV, FVD, JA21, SGP) appear on the RIGHT.
computed_flips : Dict [ int , bool ] = { }
try :
try :
from analysis . svd_labels import compute_flip_direction
from analysis . svd_labels import compute_flip_direction
for comp in range ( 1 , 11 ) :
for comp in range ( 1 , 11 ) :
# Compute flip for the selected window (not current_parliament)
computed_flips [ comp ] = compute_flip_direction ( comp , party_scores )
flip = compute_flip_direction ( comp , party_scores )
if comp in SVD_THEMES :
SVD_THEMES [ comp ] [ " flip " ] = flip
except Exception :
except Exception :
# If flip computation fails, keep existing flip values in SVD_THEMES
# If flip computation fails, keep existing flip values from SVD_THEMES
pass
pass
# Convert party_scores (possibly [x,y] lists or legacy vectors) into explicit (x,y) coords
# Build theme override with computed flip for this component
party_coords : dict = { }
# (avoids mutating SVD_THEMES which persists stale values across Streamlit reruns)
for p , v in party_scores . items ( ) :
theme_with_flip = {
* * theme ,
" flip " : computed_flips . get ( comp_sel , theme . get ( " flip " , False ) ) ,
}
# Extract 1D scores for this component (ALL components use raw SVD values)
party_1d_coords : dict = { }
idx = comp_sel - 1 # Convert to 0-indexed
for party , scores in party_scores . items ( ) :
try :
try :
if v and len ( v ) > = 2 :
if scores and len ( scores ) > idx :
party_coords [ p ] = ( float ( v [ 0 ] ) , float ( v [ 1 ] ) )
party_1d_ coords [ party ] = ( float ( scores [ idx ] ) , )
except Exception :
except Exception :
continue
continue
# Filter parties by minimum MP count
# Filter parties by minimum MP count
if min_mps > 1 and party_mp_counts :
if min_mps > 1 and party_mp_counts :
valid_parties = { p for p , count in party_mp_counts . items ( ) if count > = min_mps }
valid_parties = { p for p , count in party_mp_counts . items ( ) if count > = min_mps }
party_coords = {
party_1d_ coords = {
p : coords for p , coords in party_coords . items ( ) if p in valid_parties
p : coords for p , coords in party_1d_ coords . items ( ) if p in valid_parties
}
}
# Render party axis chart (single window or time trajectory)
# Render party axis chart (single window or time trajectory)
@ -2675,60 +2615,21 @@ def build_svd_components_tab(db_path: str) -> None:
has_current = " current_parliament " in available_windows
has_current = " current_parliament " in available_windows
all_windows = year_windows + ( [ " current_parliament " ] if has_current else [ ] )
all_windows = year_windows + ( [ " current_parliament " ] if has_current else [ ] )
# For components 1-2, use Procrustes-aligned positions (same as single window)
# ALL components use raw (non-aligned) SVD vectors.
# For components 3-10, use SVD vectors
# Procrustes alignment rotates the full vector space which makes scores
if comp_sel in ( 1 , 2 ) :
# incomparable with the single-window view. Per-window flip computation
positions_by_window , _ = load_positions ( db_path )
# handles orientation alignment for the trajectory.
party_map = load_party_map ( db_path )
party_scores_by_window = load_party_scores_all_windows ( db_path , all_windows )
# 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 raw (non-aligned) SVD vectors for components 3-10.
# Procrustes alignment rotates the full vector space to align
# components 1-2 across windows, but this also transforms
# components 3-10, making their scores incomparable with the
# single-window view. Per-window flip computation handles
# orientation alignment for these components.
party_scores_by_window = load_party_scores_all_windows ( db_path , all_windows )
_render_svd_time_trajectory (
_render_svd_time_trajectory (
party_scores_by_window ,
party_scores_by_window ,
comp_sel ,
comp_sel ,
theme ,
theme_with_flip ,
selected_parties_for_trajectory ,
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
)
else :
else :
# Components 3-10 use 1D scores from SVD
# Single-window view: render 1D party axis chart
# Extract 1D scores for this component
_render_party_axis_chart_1d ( party_1d_coords , comp_sel , theme_with_flip )
party_1d_coords = { }
idx = comp_sel - 1 # Convert to 0-indexed
for party , scores in party_scores . items ( ) :
if scores and len ( scores ) > idx :
party_1d_coords [ party ] = ( scores [ idx ] , )
_render_party_axis_chart_1d ( party_1d_coords , comp_sel , theme )
# Batch-fetch motion details (title, date, policy_area, url, body_text, voting_results)
# Batch-fetch motion details (title, date, policy_area, url, body_text, voting_results)
motion_ids = [ m . get ( " motion_id " ) for m in motions if m . get ( " motion_id " ) is not None ]
motion_ids = [ m . get ( " motion_id " ) for m in motions if m . get ( " motion_id " ) is not None ]
@ -2764,9 +2665,9 @@ def build_svd_components_tab(db_path: str) -> None:
pos_motions = [ m for m in motions if float ( m . get ( " score " , 0.0 ) ) > = 0 ]
pos_motions = [ m for m in motions if float ( m . get ( " score " , 0.0 ) ) > = 0 ]
neg_motions = [ m for m in motions if float ( m . get ( " score " , 0.0 ) ) < 0 ]
neg_motions = [ m for m in motions if float ( m . get ( " score " , 0.0 ) ) < 0 ]
flip = theme . get ( " flip " , False ) if theme else False
flip = theme_with_flip . get ( " flip " , False ) if theme_with_flip else False
pos_pole = theme . get ( " positive_pole " , " " ) if theme else " "
pos_pole = theme_with_flip . get ( " positive_pole " , " " ) if theme_with_flip else " "
neg_pole = theme . get ( " negative_pole " , " " ) if theme else " "
neg_pole = theme_with_flip . get ( " negative_pole " , " " ) if theme_with_flip else " "
# Derive left/right labels from flip direction
# Derive left/right labels from flip direction
# flip=True: positive_pole on left, negative_pole on right
# flip=True: positive_pole on left, negative_pole on right