--- date: 2026-03-28 topic: "Compass UI Improvements" status: validated --- # Compass UI Improvements ## Problem Statement Three separate issues degrade the political compass UI: 1. **SVD axis descriptions (axes 3–5) are outdated.** The current `label`, `explanation`, `positive_pole`, and `negative_pole` strings in `SVD_THEMES` were written for an earlier dataset and no longer match the structural patterns the axes actually capture. Using single-year (2024) centroid snapshots to verify this was misleading — the fix must be grounded in multi-year averages and motion-level content. 2. **Y-axis direction indicators are broken.** The current `"Progressief ↑ / Conservatief ↓"` string is passed as the Plotly Y-axis title. Plotly rotates axis titles 90° counter-clockwise, so the arrows end up pointing sideways instead of up/down. This appears in the two compass scatter plots and in the trajectories tab. 3. **No voting discipline context.** The compass shows where parties sit spatially but gives no sense of whether a party votes as a bloc. This context would make the compass more interpretable. ## Constraints - No new DB tables or schema changes. - `compute_party_discipline` reads `mp_votes` where `mp_name LIKE '%,%'` (individual MP rows only — party-aggregate rows are excluded). - Skip discipline section if fewer than 5 roll-call motions in the selected window. - Follow existing patterns in `explorer.py` and `database.py`. - Tests use `uv run pytest`. ## Approach **Change 1 — Axis descriptions:** Derive corrected descriptions from multi-year party centroid averages (all annual windows, not just 2024) and from the motion-level content that loads high/low on each axis. Update only `label`, `explanation`, `positive_pole`, `negative_pole` in `SVD_THEMES` entries for axes 3, 4, and 5. The `flip` boolean is not changed. **Change 2 — Y-axis arrows:** Replace the ↑/↓ characters from the axis title string (set to plain `"Progressief / Conservatief"`). Add two `fig.add_annotation` calls per chart: top-center `"▲ Progressief"` and bottom-center `"▼ Conservatief"`, using `xref="paper", yref="paper"`, `showarrow=False`, styled to be subtle (small font, muted color). Apply to both compass scatter plots and the trajectories chart. **Change 3 — Voting discipline:** Add a `compute_party_discipline(db_path, start_date, end_date)` function in `explorer.py` that queries individual MP votes, computes per-party Rice index (fraction voting with party majority), and returns a DataFrame with columns `[party, n_motions, discipline]`. In `build_compass_tab`, after rendering the compass chart, call this function with the window's date range, and render: (a) a horizontal bar chart sorted ascending (least disciplined at top), and (b) a small table showing the three most and three least disciplined parties. If fewer than 5 motions, show a brief explanatory message instead. ## Architecture All changes are confined to `explorer.py`. No changes to `analysis/`, `database.py`, or test files (the discipline function is a read-only helper, not shared infrastructure). ## Components **`SVD_THEMES` dict (explorer.py ~line 1156)** - Entries for axes 3, 4, 5 updated in-place. - New text is based on multi-year patterns (see Data Flow below). **Y-axis annotation helper (explorer.py)** - Small inline helper or inline code block that adds the two direction annotations to any given `fig`. - Called once after each `px.scatter(...)` and once after the trajectories `fig` is built. **`compute_party_discipline(db_path, start_date, end_date)` (explorer.py)** - Connects to DuckDB read-only. - Queries `mp_votes` filtered to individual MPs (`mp_name LIKE '%,%'`) and date range. - Groups by `(motion_id, party)`, counts votes per token, determines majority token, computes Rice index per motion per party. - Averages Rice index across motions per party. - Returns `pd.DataFrame(columns=["party", "n_motions", "discipline"])` or empty DataFrame. **`build_compass_tab` additions (explorer.py ~line 841+)** - After `st.plotly_chart(fig, ...)`, map the current `window_idx` to a `(start_date, end_date)` range. - Call `compute_party_discipline(...)`. - If result has ≥ 5 motions: render bar chart + extremes table under a `st.subheader("Stemgedrag cohesie")`. - If not: `st.caption("Te weinig hoofdelijke stemmingen voor cohesieanalyse.")`. ## Data Flow **Axis description research (prior to implementation):** Multi-year centroid averages are computed by averaging each party's SVD vector across all annual windows in which it appears. The axis 3/4/5 descriptions are updated to reflect these stable patterns rather than any single year's snapshot. **Discipline computation:** ``` mp_votes (individual MPs, date range) → GROUP BY (motion_id, party, vote) → vote counts → determine majority_vote per (motion_id, party) → Rice index = (count voting with majority) / (total voting) per motion per party → average Rice index across motions → per-party score → return DataFrame ``` ## Error Handling - `compute_party_discipline` returns an empty DataFrame on any DB exception (logged, not raised), following the pattern of other read helpers in `explorer.py`. - Empty DataFrame → show the "too few motions" caption (same path as < 5 motions). - The Y-axis annotation is purely visual — no error paths needed. - Axis description changes are static strings — no runtime risk. ## Testing Strategy - The `compute_party_discipline` function is tested with a small in-memory DuckDB fixture in `tests/test_political_compass.py`: - Construct a fixture with 6 motions, 2 parties, each with varying vote splits. - Assert returned DataFrame has correct columns and that discipline scores are in [0, 1]. - Assert empty DataFrame is returned when date range has 0 motions. - Y-axis annotation: no unit test needed (visual only, trivially correct). - Axis description changes: no unit test needed (static strings). - Run all tests with `uv run pytest tests/test_political_compass.py -v` after each change. ## Open Questions None. All design decisions are resolved.