parent
064cd059d4
commit
c5b39ced5f
@ -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. |
||||
Loading…
Reference in new issue