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.
 
 
motief/thoughts/shared/designs/2026-03-28-compass-ui-impro...

6.0 KiB

date topic status
2026-03-28 Compass UI Improvements 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.