diff --git a/thoughts/shared/designs/2026-03-28-compass-ui-improvements-design.md b/thoughts/shared/designs/2026-03-28-compass-ui-improvements-design.md new file mode 100644 index 0000000..a7c9dbd --- /dev/null +++ b/thoughts/shared/designs/2026-03-28-compass-ui-improvements-design.md @@ -0,0 +1,96 @@ +--- +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.