You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
motief/analysis/tabs/_rendering.py

796 lines
24 KiB

"""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}<br><b>%{y:.1f}%</b> verklaarde variantie<extra></extra>",
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}<br>Cumulatief: <b>%{y:.1f}%</b><extra></extra>",
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}<br>{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")