"""Rendering helpers for explorer tabs. This module contains all Plotly/Streamlit rendering functions extracted from explorer.py. It is import-safe: plotly and streamlit are optional. """ from __future__ import annotations import json import logging from typing import Dict, List, Optional, Tuple try: import plotly.express as px import plotly.graph_objects as go except Exception: px = None import types class _DummyTrace: def __init__(self, **kwargs): self.name = kwargs.get("name") self.x = kwargs.get("x") self.y = kwargs.get("y") self.text = kwargs.get("text") self.customdata = kwargs.get("customdata") class _DummyFigure: def __init__(self): self.data = [] def add_trace(self, trace): if isinstance(trace, _DummyTrace): self.data.append(trace) else: try: name = getattr(trace, "name", None) x = getattr(trace, "x", None) y = getattr(trace, "y", None) text = getattr(trace, "text", None) customdata = getattr(trace, "customdata", None) except Exception: name = trace.get("name") if hasattr(trace, "get") else None x = trace.get("x") if hasattr(trace, "get") else None y = trace.get("y") if hasattr(trace, "get") else None text = trace.get("text") if hasattr(trace, "get") else None customdata = ( trace.get("customdata") if hasattr(trace, "get") else None ) self.data.append( _DummyTrace(name=name, x=x, y=y, text=text, customdata=customdata) ) def add_annotation(self, *args, **kwargs): return None def update_layout(self, **kwargs): return None def update_traces(self, **kwargs): return None def add_hline(self, **kwargs): return None go = types.SimpleNamespace( Figure=_DummyFigure, Scatter=lambda **kwargs: _DummyTrace(**kwargs), Bar=lambda **kwargs: _DummyTrace(**kwargs), ) try: import streamlit as st except Exception: class _DummySt: def cache_data(self, *args, **kwargs): def _decorator(func): return func return _decorator def markdown(self, *args, **kwargs): return None def subheader(self, *args, **kwargs): return None def plotly_chart(self, *args, **kwargs): return None def caption(self, *args, **kwargs): return None def text_area(self, *args, **kwargs): return None def json(self, *args, **kwargs): return None def checkbox(self, *args, **kwargs): return kwargs.get("value", False) def warning(self, *args, **kwargs): return None def info(self, *args, **kwargs): return None def error(self, *args, **kwargs): return None def success(self, *args, **kwargs): return None def selectbox(self, *args, **kwargs): opts = ( kwargs.get("options") if kwargs.get("options") is not None else (args[1] if len(args) > 1 else []) ) return opts[0] if opts else None def multiselect(self, *args, **kwargs): opts = ( kwargs.get("options") if kwargs.get("options") is not None else (args[1] if len(args) > 1 else []) ) default = kwargs.get("default") if default is not None: return default return opts[:6] if opts else [] def number_input(self, *args, **kwargs): return kwargs.get("value") if "value" in kwargs else 1 def slider(self, *args, **kwargs): return kwargs.get("value") if "value" in kwargs else 0.35 def select_slider(self, *args, **kwargs): return kwargs.get("value") if "value" in kwargs else (None, None) def expander(self, *args, **kwargs): class _Ctx: def __enter__(self_inner): return self_inner def __exit__(self_inner, exc_type, exc, tb): return False return _Ctx() def columns(self, *args, **kwargs): class _Col: def markdown(self, *a, **k): return None def metric(self, *a, **k): return None def dataframe(self, *a, **k): return None def write(self, *a, **k): return None def text_input(self, *a, **k): return None n = len(args[0]) if args else 1 return tuple(_Col() for _ in range(n)) def form(self, *args, **kwargs): class _Ctx: def __enter__(self_inner): return self_inner def __exit__(self_inner, exc_type, exc, tb): return False return _Ctx() def form_submit_button(self, *args, **kwargs): return False def button(self, *args, **kwargs): return False def rerun(self, *args, **kwargs): return None def divider(self, *args, **kwargs): return None def spinner(self, *args, **kwargs): class _Ctx: def __enter__(self_inner): return self_inner def __exit__(self_inner, exc_type, exc, tb): return False return _Ctx() def write(self, *args, **kwargs): return None def dataframe(self, *args, **kwargs): return None def set_page_config(self, *args, **kwargs): return None def title(self, *args, **kwargs): return None def sidebar(self, *args, **kwargs): return self def radio(self, *args, **kwargs): return kwargs.get("value") if "value" in kwargs else None def text_input(self, *args, **kwargs): return kwargs.get("value", "") def tabs(self, *args, **kwargs): n = len(args[0]) if args else 1 return [self for _ in range(n)] @property def session_state(self): if not hasattr(self, "_session_state"): self._session_state = {} return self._session_state st = _DummySt() from analysis.config import PARTY_COLOURS logger = logging.getLogger(__name__) def _render_scree_plot(importances: List[float], n_show: int = 15) -> None: """Render a scree plot showing relative SVD component importance. Highlighted bars for the top-2 components (used in the compass); muted bars for the rest. A cumulative-variance dashed line on the same y-axis helps spot the elbow. A 50 % cumulative threshold line is drawn for reference. Args: importances: List of importance values sorted descending (from load_scree_data). n_show: How many components to display (default: first 15). """ if not importances: return data = list(importances[:n_show]) ranks = list(range(1, len(data) + 1)) cumsum = [] running = 0.0 for v in data: running += v cumsum.append(running) n_highlight = 2 bar_colours = [ "#1565C0" if i < n_highlight else "#90CAF9" for i in range(len(data)) ] fig = go.Figure() fig.add_trace( go.Bar( x=ranks, y=data, marker_color=bar_colours, hovertemplate="As %{x}
%{y:.1f}% verklaarde variantie", showlegend=False, ) ) fig.add_trace( go.Scatter( x=ranks, y=cumsum, mode="lines+markers", line={"color": "#F57C00", "width": 2, "dash": "dot"}, marker={"size": 5, "color": "#F57C00"}, hovertemplate="As %{x}
Cumulatief: %{y:.1f}%", name="Cumulatief", showlegend=True, ) ) fig.add_hline( y=50, line_dash="dash", line_color="#BDBDBD", line_width=1, annotation_text="50%", annotation_position="right", annotation_font_color="#9E9E9E", annotation_font_size=11, ) for i in range(min(n_highlight, len(data))): fig.add_annotation( x=ranks[i], y=data[i] + 0.3, text=f"{data[i]:.1f}%", showarrow=False, font={"size": 11, "color": "#1565C0"}, yanchor="bottom", ) fig.update_layout( height=280, margin={"l": 10, "r": 50, "t": 30, "b": 40}, title={ "text": "Belang per SVD-as", "font": {"size": 13, "color": "#555555"}, "x": 0.02, "xanchor": "left", }, legend={ "orientation": "h", "x": 0.5, "xanchor": "center", "y": 1.08, "font": {"size": 11}, }, xaxis={ "title": {"text": "As (rang)", "font": {"size": 11}}, "tickmode": "linear", "tick0": 1, "dtick": 1, "showline": False, "showgrid": False, }, yaxis={ "title": {"text": "% van totale variantie", "font": {"size": 11}}, "showline": False, "showgrid": True, "gridcolor": "#eeeeee", "ticksuffix": "%", "range": [0, max(cumsum) * 1.08], }, plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", bargap=0.25, ) st.plotly_chart(fig, use_container_width=True) def _build_party_axis_figure( party_coords: Dict[str, Tuple[float, float]], comp_sel: int, theme: dict, bootstrap_data: Optional[Dict[str, Dict]] = None, ) -> Optional[go.Figure]: """Build a 1D horizontal Plotly scatter of party positions on SVD axis `comp_sel`. Accepts explicit per-party 2D coordinates (x,y) and uses the component selection to pick the value (comp_sel==1 -> x, comp_sel==2 -> y). This makes the API explicit and avoids indexing into long SVD vectors. Returns go.Figure or None if no data available. """ if not party_coords: return None if comp_sel not in (1, 2): raise ValueError( "_build_party_axis_figure only supports comp_sel 1 or 2 when using explicit coords" ) axis_idx = comp_sel - 1 flip = theme.get("flip", False) parties = [] scores = [] colours = [] for party, val in party_coords.items(): try: if hasattr(val, "__len__") and len(val) == 2: x, y = val score = float(x if axis_idx == 0 else y) else: score = float(val[axis_idx]) if flip: score = -score except Exception: continue parties.append(party) scores.append(score) colours.append(PARTY_COLOURS.get(party, "#9E9E9E")) if not scores: return None hover = [] symbols = [] if bootstrap_data: for p, s in zip(parties, scores): bd = bootstrap_data.get(p) if bd: n_mps = bd.get("n_mps", "?") ci_low = None ci_high = None try: ci_low = float(bd["ci_lower"][axis_idx]) ci_high = float(bd["ci_upper"][axis_idx]) except Exception: pass if ci_low is not None and ci_high is not None: hover.append( f"{p}: {s:.3f} (N={n_mps}, 95%-BI: [{ci_low:.3f}, {ci_high:.3f}])" ) else: hover.append(f"{p}: {s:.3f} (N={n_mps})") symbols.append("diamond" if n_mps == 1 else "circle") else: hover.append(f"{p}: {s:.3f}") symbols.append("circle") marker_kwargs = {"size": 14, "color": colours, "symbol": symbols} else: hover = [f"{p}: {s:.3f}" for p, s in zip(parties, scores)] marker_kwargs = {"size": 14, "color": colours} fig = go.Figure() x_min, x_max = min(scores) * 1.15, max(scores) * 1.15 if x_min == x_max: x_min, x_max = x_min - 1, x_max + 1 fig.add_trace( go.Scatter( x=[x_min, x_max], y=[0, 0], mode="lines", line={"color": "#cccccc", "width": 1}, hoverinfo="skip", showlegend=False, ) ) scatter_kwargs = { "x": scores, "y": [0] * len(scores), "mode": "markers+text", "text": parties, "textposition": "top center", "marker": marker_kwargs, "hovertext": hover, "hoverinfo": "text", "showlegend": False, } fig.add_trace(go.Scatter(**scatter_kwargs)) pos_pole = theme.get("positive_pole", "") neg_pole = theme.get("negative_pole", "") left_label = neg_pole right_label = pos_pole fig.update_layout( height=160, margin={"l": 10, "r": 10, "t": 10, "b": 30}, xaxis={ "title": f"← {left_label} | {right_label} →", "showticklabels": False, "showline": False, "showgrid": False, "zeroline": False, }, yaxis={"visible": False, "range": [-1, 2]}, plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", ) return fig def _render_party_axis_chart( party_coords: Dict[str, Tuple[float, float]], comp_sel: int, theme: dict, bootstrap_data: Optional[Dict[str, Dict]] = None, ) -> None: """Render a 1D horizontal Plotly scatter of party positions on SVD axis `comp_sel`. Expects explicit per-party coords mapping (party -> (x,y)) for components 1 & 2. """ fig = _build_party_axis_figure(party_coords, comp_sel, theme, bootstrap_data) if fig is None: st.caption("_Partijdata niet beschikbaar voor deze as._") return st.plotly_chart(fig, use_container_width=True) def _render_party_axis_chart_1d( party_coords: Dict[str, Tuple[float, ...]], comp_sel: int, theme: dict, ) -> None: """Render a 1D horizontal scatter of party positions on SVD component `comp_sel`. Uses the same format as components 1-2: parties as markers on a horizontal line with axis title showing poles with arrows. Args: party_coords: Dict mapping party name to tuple of scores (score_for_comp,) comp_sel: SVD component number (1-indexed) theme: Dict with label, positive_pole, negative_pole, flip """ if not party_coords: st.caption("_Partijdata niet beschikbaar voor deze as._") return parties = [] scores = [] colours = [] for party, coords in party_coords.items(): try: score = float(coords[0]) parties.append(party) scores.append(score) colours.append(PARTY_COLOURS.get(party, "#9E9E9E")) except Exception: continue if not scores: st.caption("_Partijdata niet beschikbaar voor deze as._") return flip = theme.get("flip", False) if flip: scores = [-s for s in scores] hover = [f"{p}: {s:.3f}" for p, s in zip(parties, scores)] fig = go.Figure() x_min, x_max = min(scores) * 1.15, max(scores) * 1.15 if x_min == x_max: x_min, x_max = x_min - 1, x_max + 1 fig.add_trace( go.Scatter( x=[x_min, x_max], y=[0, 0], mode="lines", line={"color": "#cccccc", "width": 1}, hoverinfo="skip", showlegend=False, ) ) fig.add_trace( go.Scatter( x=scores, y=[0] * len(scores), mode="markers+text", text=parties, textposition="top center", marker={"size": 14, "color": colours}, hovertext=hover, hoverinfo="text", showlegend=False, ) ) pos_pole = theme.get("positive_pole", "") neg_pole = theme.get("negative_pole", "") left_label = neg_pole right_label = pos_pole fig.update_layout( height=160, margin={"l": 10, "r": 10, "t": 10, "b": 30}, xaxis={ "title": f"← {left_label} | {right_label} →", "showticklabels": False, "showline": False, "showgrid": False, "zeroline": False, }, yaxis={"visible": False, "range": [-1, 2]}, plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", ) st.plotly_chart(fig, use_container_width=True) def _render_svd_time_trajectory( party_scores_by_window: Dict[str, Dict[str, List[float]]], comp_sel: int, theme: dict, selected_parties: List[str], ) -> None: """Render a time trajectory plot showing party positions over time on an SVD component. Args: party_scores_by_window: {window_id: {party_name: [scores]}} comp_sel: SVD component number (1-indexed) theme: Theme dict with label, positive_pole, negative_pole, flip selected_parties: List of party names to display """ if not party_scores_by_window or not selected_parties: st.caption("_Geen data beschikbaar voor tijdtraject._") return idx = comp_sel - 1 party_trajectories: Dict[str, List[Tuple[str, float]]] = {} all_windows = list(party_scores_by_window.keys()) sorted_windows = [] if "current_parliament" in all_windows: sorted_windows.append("current_parliament") other_windows = sorted( [w for w in all_windows if w != "current_parliament"], reverse=True ) sorted_windows.extend(other_windows) for window in sorted_windows: scores_by_party = party_scores_by_window.get(window, {}) for party in selected_parties: scores = scores_by_party.get(party, []) if scores and len(scores) > idx: try: score = float(scores[idx]) party_trajectories.setdefault(party, []).append((window, score)) except (ValueError, TypeError): continue if not party_trajectories: st.caption("_Geen data beschikbaar voor geselecteerde partijen._") return fig = go.Figure() all_scores = [] for traj in party_trajectories.values(): all_scores.extend([s for _, s in traj]) if not all_scores: st.caption("_Geen scores beschikbaar._") return x_min, x_max = min(all_scores) * 1.15, max(all_scores) * 1.15 if x_min == x_max: x_min, x_max = x_min - 1, x_max + 1 window_to_y = {w: i for i, w in enumerate(sorted_windows)} for window in sorted_windows: y_pos = window_to_y[window] fig.add_trace( go.Scatter( x=[x_min, x_max], y=[y_pos, y_pos], mode="lines", line={"color": "#cccccc", "width": 1}, hoverinfo="skip", showlegend=False, ) ) for party in selected_parties: if party not in party_trajectories: continue traj = party_trajectories[party] if len(traj) < 1: continue x_vals = [score for _, score in traj] y_vals = [window_to_y[window] for window, _ in traj] color = PARTY_COLOURS.get(party, "#9E9E9E") fig.add_trace( go.Scatter( x=x_vals, y=y_vals, mode="lines", line={"color": color, "width": 2}, hoverinfo="skip", showlegend=False, ) ) hover_texts = [f"{party}
{window}: {score:.3f}" for window, score in traj] fig.add_trace( go.Scatter( x=x_vals, y=y_vals, mode="markers+text", text=[party] * len(traj), textposition="top center", marker={"size": 12, "color": color}, hovertext=hover_texts, hoverinfo="text", showlegend=False, ) ) pos_pole = theme.get("positive_pole", "") neg_pole = theme.get("negative_pole", "") left_label = neg_pole right_label = pos_pole y_labels = {} for window in sorted_windows: if window == "current_parliament": y_labels[window_to_y[window]] = "Huidig" else: y_labels[window_to_y[window]] = window fig.update_layout( height=max(400, len(sorted_windows) * 60 + 100), margin={"l": 80, "r": 10, "t": 10, "b": 30}, xaxis={ "title": f"← {left_label} | {right_label} →", "range": [x_min, x_max], "showticklabels": False, "showline": False, "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "zeroline": True, "zerolinecolor": "rgba(0,0,0,0.2)", }, yaxis={ "tickvals": list(y_labels.keys()), "ticktext": list(y_labels.values()), "tickmode": "array", "autorange": "reversed", "showgrid": False, }, plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", ) st.plotly_chart(fig, use_container_width=True) def _render_voting_results(voting_results_json) -> None: """Render a voting_results JSON blob as a grouped voor/tegen/onthouden table. The JSON is stored as {party_or_mp: vote} where vote is one of 'voor', 'tegen', 'onthouden', 'afwezig'. We group by vote for readability. """ if not voting_results_json: return try: vdata = ( json.loads(voting_results_json) if isinstance(voting_results_json, str) else voting_results_json ) if not isinstance(vdata, dict) or not vdata: return by_vote: Dict[str, List[str]] = {} for actor, vote in vdata.items(): vote_str = str(vote).lower().strip() by_vote.setdefault(vote_str, []).append(str(actor)) vote_order = ["voor", "tegen", "onthouden", "afwezig"] vote_emoji = {"voor": "✅", "tegen": "❌", "onthouden": "🟡", "afwezig": "⬜"} rows_shown = False for v in vote_order + [k for k in by_vote if k not in vote_order]: actors = by_vote.get(v) if not actors: continue emoji = vote_emoji.get(v, "▪️") st.markdown( f"**{emoji} {v.capitalize()}** ({len(actors)}): {', '.join(sorted(actors))}" ) rows_shown = True if not rows_shown: st.caption("_Geen stemuitslag beschikbaar_") except Exception: pass def _add_y_direction_annotations(fig: go.Figure) -> None: """Add ▲ Progressief / ▼ Conservatief labels above and below the Y axis.""" common = dict( xref="paper", yref="paper", x=-0.07, showarrow=False, font=dict(size=11, color="#666666"), ) fig.add_annotation(**common, y=1.02, text="▲ Progressief", xanchor="center") fig.add_annotation(**common, y=-0.06, text="▼ Conservatief", xanchor="center")