feat(explorer): finalise SVD tab helper robustness and constants

Include plan: docs/superpowers/plans/2026-03-24-svd-tab-redesign.md
main
Sven Geboers 1 month ago
parent 32fe3aed18
commit 9caaa8baca
  1. 111
      explorer.py

@ -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]

Loading…
Cancel
Save