diff --git a/explorer.py b/explorer.py index f312222..d0cbdcd 100644 --- a/explorer.py +++ b/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]