---
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.
