|
|
|
|
@ -69,6 +69,26 @@ KNOWN_MAJOR_PARTIES = [ |
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Current parliament parties (used for party-level SVD lookups) |
|
|
|
|
# Keep both common abbreviations and full names that may appear in the DB |
|
|
|
|
CURRENT_PARLIAMENT_PARTIES = frozenset( |
|
|
|
|
[ |
|
|
|
|
"VVD", |
|
|
|
|
"PVV", |
|
|
|
|
"D66", |
|
|
|
|
"GroenLinks-PvdA", |
|
|
|
|
"GroenLinks", |
|
|
|
|
"PvdA", |
|
|
|
|
"CDA", |
|
|
|
|
"SP", |
|
|
|
|
"NSC", |
|
|
|
|
"CU", |
|
|
|
|
"ChristenUnie", |
|
|
|
|
"BBB", |
|
|
|
|
] |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
|
|
|
# Cached loaders |
|
|
|
|
# --------------------------------------------------------------------------- |
|
|
|
|
@ -204,10 +224,41 @@ def load_party_axis_scores(db_path: str) -> Dict[str, List[float]]: |
|
|
|
|
f"AND entity_id IN ({placeholders})", |
|
|
|
|
party_list, |
|
|
|
|
).fetchall() |
|
|
|
|
return { |
|
|
|
|
row[0]: json.loads(row[1]) if isinstance(row[1], str) else list(row[1]) |
|
|
|
|
for row in rows |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
out: Dict[str, List[float]] = {} |
|
|
|
|
for row in rows: |
|
|
|
|
party = row[0] |
|
|
|
|
vec_field = row[1] |
|
|
|
|
try: |
|
|
|
|
if vec_field is None: |
|
|
|
|
# skip missing vectors |
|
|
|
|
continue |
|
|
|
|
# string-encoded JSON vector |
|
|
|
|
if isinstance(vec_field, str): |
|
|
|
|
vec = json.loads(vec_field) |
|
|
|
|
# bytes (some DB drivers may return bytes) |
|
|
|
|
elif isinstance(vec_field, (bytes, bytearray)): |
|
|
|
|
try: |
|
|
|
|
vec = json.loads(vec_field.decode("utf-8")) |
|
|
|
|
except Exception: |
|
|
|
|
# fallback: attempt to eval as list-like |
|
|
|
|
vec = list(vec_field) |
|
|
|
|
# already a list/tuple/np.ndarray-like |
|
|
|
|
elif isinstance(vec_field, (list, tuple, np.ndarray)): |
|
|
|
|
vec = list(vec_field) |
|
|
|
|
else: |
|
|
|
|
# unknown type: attempt best-effort conversion |
|
|
|
|
vec = list(vec_field) |
|
|
|
|
|
|
|
|
|
# ensure all entries are floats |
|
|
|
|
vec_floats = [float(x) for x in vec] |
|
|
|
|
out[party] = vec_floats |
|
|
|
|
except Exception: |
|
|
|
|
# skip malformed rows but keep processing others |
|
|
|
|
logger.debug("Skipping malformed vector for party %s", party) |
|
|
|
|
continue |
|
|
|
|
|
|
|
|
|
return out |
|
|
|
|
except Exception: |
|
|
|
|
logger.exception("Failed to load party axis scores") |
|
|
|
|
return {} |
|
|
|
|
@ -249,55 +300,77 @@ def _render_party_axis_chart( |
|
|
|
|
""" |
|
|
|
|
# Validate component selection |
|
|
|
|
if not isinstance(comp_sel, int) or comp_sel < 1: |
|
|
|
|
st.caption("_Ongeldige SVD-as geselecteerd._") |
|
|
|
|
st.caption("Ongeldige SVD-as geselecteerd.") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
if not party_scores: |
|
|
|
|
st.caption("_Partijdata niet beschikbaar_") |
|
|
|
|
st.caption("Partijdata zijn niet beschikbaar.") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
axis_idx = comp_sel - 1 |
|
|
|
|
|
|
|
|
|
# Determine maximum available vector dimension to validate selection |
|
|
|
|
max_dim = 0 |
|
|
|
|
for v in party_scores.values(): |
|
|
|
|
try: |
|
|
|
|
if isinstance(v, (list, tuple, np.ndarray)): |
|
|
|
|
max_dim = max(max_dim, len(v)) |
|
|
|
|
except Exception: |
|
|
|
|
continue |
|
|
|
|
|
|
|
|
|
if axis_idx >= max_dim: |
|
|
|
|
st.caption( |
|
|
|
|
f"Geselecteerde component ({comp_sel}) valt buiten het bereik van de beschikbare vectoren ({max_dim} dimensies)." |
|
|
|
|
) |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
parties: List[str] = [] |
|
|
|
|
xs: List[float] = [] |
|
|
|
|
|
|
|
|
|
for party, vec in party_scores.items(): |
|
|
|
|
# Ensure vec is indexable/sequence-like |
|
|
|
|
if not isinstance(vec, (list, tuple, np.ndarray)): |
|
|
|
|
# skip malformed entries |
|
|
|
|
continue |
|
|
|
|
# safe indexing |
|
|
|
|
if axis_idx >= len(vec): |
|
|
|
|
continue |
|
|
|
|
try: |
|
|
|
|
raw = vec[axis_idx] |
|
|
|
|
# Convert to float safely |
|
|
|
|
val = float(raw) |
|
|
|
|
# filter non-finite values |
|
|
|
|
if not np.isfinite(val): |
|
|
|
|
continue |
|
|
|
|
except Exception: |
|
|
|
|
# skip entries that cannot be indexed or converted |
|
|
|
|
continue |
|
|
|
|
parties.append(party) |
|
|
|
|
xs.append(val) |
|
|
|
|
|
|
|
|
|
if not xs: |
|
|
|
|
st.caption("_Partijdata niet beschikbaar_") |
|
|
|
|
st.caption("Geen bruikbare partijposities gevonden voor de gekozen SVD-as.") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
x_min = min(xs) |
|
|
|
|
x_max = max(xs) |
|
|
|
|
x_min = float(min(xs)) |
|
|
|
|
x_max = float(max(xs)) |
|
|
|
|
except Exception: |
|
|
|
|
st.caption("_Onvoldoende gegevens om asbereik te berekenen_") |
|
|
|
|
st.caption("Onvoldoende gegevens om het asbereik te berekenen.") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
# If min == max, apply symmetric padding around the value. |
|
|
|
|
# Symmetric padding around the midpoint for balanced visualisation |
|
|
|
|
if x_min == x_max: |
|
|
|
|
padding = 0.5 if x_min == 0 else abs(x_min) * 0.1 |
|
|
|
|
if padding <= 0: |
|
|
|
|
padding = 0.5 |
|
|
|
|
x_min = x_min - padding |
|
|
|
|
x_max = x_max + padding |
|
|
|
|
center = x_min |
|
|
|
|
half = padding |
|
|
|
|
else: |
|
|
|
|
# Expand range slightly for visual padding |
|
|
|
|
x_min = x_min * 1.15 |
|
|
|
|
x_max = x_max * 1.15 |
|
|
|
|
center = (x_min + x_max) / 2.0 |
|
|
|
|
half = max(abs(x_max - center), abs(center - x_min)) |
|
|
|
|
# add slight visual padding |
|
|
|
|
half = half * 1.15 |
|
|
|
|
|
|
|
|
|
x_min = center - half |
|
|
|
|
x_max = center + half |
|
|
|
|
|
|
|
|
|
# Build horizontal scatter: y is constant (0) but offset for label placement |
|
|
|
|
ys = [0 for _ in xs] |
|
|
|
|
|