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.
797 lines
23 KiB
797 lines
23 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
|
|
flip = theme.get("flip", False)
|
|
|
|
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])
|
|
if flip:
|
|
score = -score
|
|
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"]
|
|
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
|
|
st.markdown(
|
|
f"**{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")
|
|
|