feat(explorer): finalise SVD tab helper robustness and constants
Include plan: docs/superpowers/plans/2026-03-24-svd-tab-redesign.md
This commit is contained in:
+92
-19
@@ -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
|
# Cached loaders
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -204,10 +224,41 @@ def load_party_axis_scores(db_path: str) -> Dict[str, List[float]]:
|
|||||||
f"AND entity_id IN ({placeholders})",
|
f"AND entity_id IN ({placeholders})",
|
||||||
party_list,
|
party_list,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {
|
|
||||||
row[0]: json.loads(row[1]) if isinstance(row[1], str) else list(row[1])
|
out: Dict[str, List[float]] = {}
|
||||||
for row in rows
|
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:
|
except Exception:
|
||||||
logger.exception("Failed to load party axis scores")
|
logger.exception("Failed to load party axis scores")
|
||||||
return {}
|
return {}
|
||||||
@@ -249,55 +300,77 @@ def _render_party_axis_chart(
|
|||||||
"""
|
"""
|
||||||
# Validate component selection
|
# Validate component selection
|
||||||
if not isinstance(comp_sel, int) or comp_sel < 1:
|
if not isinstance(comp_sel, int) or comp_sel < 1:
|
||||||
st.caption("_Ongeldige SVD-as geselecteerd._")
|
st.caption("Ongeldige SVD-as geselecteerd.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not party_scores:
|
if not party_scores:
|
||||||
st.caption("_Partijdata niet beschikbaar_")
|
st.caption("Partijdata zijn niet beschikbaar.")
|
||||||
return
|
return
|
||||||
|
|
||||||
axis_idx = comp_sel - 1
|
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] = []
|
parties: List[str] = []
|
||||||
xs: List[float] = []
|
xs: List[float] = []
|
||||||
|
|
||||||
for party, vec in party_scores.items():
|
for party, vec in party_scores.items():
|
||||||
# Ensure vec is indexable/sequence-like
|
# Ensure vec is indexable/sequence-like
|
||||||
if not isinstance(vec, (list, tuple, np.ndarray)):
|
if not isinstance(vec, (list, tuple, np.ndarray)):
|
||||||
# skip malformed entries
|
continue
|
||||||
|
# safe indexing
|
||||||
|
if axis_idx >= len(vec):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
raw = vec[axis_idx]
|
raw = vec[axis_idx]
|
||||||
# Convert to float safely
|
|
||||||
val = float(raw)
|
val = float(raw)
|
||||||
|
# filter non-finite values
|
||||||
|
if not np.isfinite(val):
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
# skip entries that cannot be indexed or converted
|
|
||||||
continue
|
continue
|
||||||
parties.append(party)
|
parties.append(party)
|
||||||
xs.append(val)
|
xs.append(val)
|
||||||
|
|
||||||
if not xs:
|
if not xs:
|
||||||
st.caption("_Partijdata niet beschikbaar_")
|
st.caption("Geen bruikbare partijposities gevonden voor de gekozen SVD-as.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
x_min = min(xs)
|
x_min = float(min(xs))
|
||||||
x_max = max(xs)
|
x_max = float(max(xs))
|
||||||
except Exception:
|
except Exception:
|
||||||
st.caption("_Onvoldoende gegevens om asbereik te berekenen_")
|
st.caption("Onvoldoende gegevens om het asbereik te berekenen.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# If min == max, apply symmetric padding around the value.
|
# Symmetric padding around the midpoint for balanced visualisation
|
||||||
if x_min == x_max:
|
if x_min == x_max:
|
||||||
padding = 0.5 if x_min == 0 else abs(x_min) * 0.1
|
padding = 0.5 if x_min == 0 else abs(x_min) * 0.1
|
||||||
if padding <= 0:
|
if padding <= 0:
|
||||||
padding = 0.5
|
padding = 0.5
|
||||||
x_min = x_min - padding
|
center = x_min
|
||||||
x_max = x_max + padding
|
half = padding
|
||||||
else:
|
else:
|
||||||
# Expand range slightly for visual padding
|
center = (x_min + x_max) / 2.0
|
||||||
x_min = x_min * 1.15
|
half = max(abs(x_max - center), abs(center - x_min))
|
||||||
x_max = x_max * 1.15
|
# 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
|
# Build horizontal scatter: y is constant (0) but offset for label placement
|
||||||
ys = [0 for _ in xs]
|
ys = [0 for _ in xs]
|
||||||
|
|||||||
Reference in New Issue
Block a user