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.
213 lines
6.5 KiB
213 lines
6.5 KiB
"""Overton Window tab for the parliamentary explorer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import duckdb
|
|
import pandas as pd
|
|
import plotly.graph_objects as go
|
|
|
|
from analysis.tabs._rendering import st
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def build_overton_tab(db_path: str) -> None:
|
|
"""Build the Overton Window tab."""
|
|
st.subheader("Overton Window Analysis")
|
|
st.markdown(
|
|
"The Overton window **widened** after 2024: centrist support for right-wing "
|
|
"motions rose from 25% to 51%, while support for left-wing motions stayed flat. "
|
|
"Right-wing parties filed milder motions, allowing centrists to vote along "
|
|
"without shifting ideologically."
|
|
)
|
|
|
|
try:
|
|
con = duckdb.connect(db_path, read_only=True)
|
|
except Exception:
|
|
st.warning("Cannot connect to the database.")
|
|
return
|
|
|
|
try:
|
|
tables = con.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='right_wing_motions'"
|
|
).fetchall()
|
|
if not tables:
|
|
st.info(
|
|
"The right_wing_motions table is not yet available. "
|
|
"Run the pipeline to generate it."
|
|
)
|
|
return
|
|
except Exception:
|
|
st.info("The right_wing_motions table is not available.")
|
|
return
|
|
|
|
try:
|
|
_render_centrist_support_chart(con)
|
|
_render_summary_stats(con)
|
|
_render_migration_gateway(con)
|
|
_render_motion_browser(con)
|
|
_render_explore_further()
|
|
except Exception as e:
|
|
st.error(f"Error loading Overton data: {e}")
|
|
logger.exception("Overton tab error")
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def _render_centrist_support_chart(con: duckdb.DuckDBPyConnection) -> None:
|
|
df = con.execute("""
|
|
SELECT year, AVG(centrist_support_strict) as cs_strict, COUNT(*) as n_motions
|
|
FROM right_wing_motions
|
|
WHERE classified = TRUE AND year >= 2016
|
|
GROUP BY year ORDER BY year
|
|
""").fetchdf()
|
|
|
|
if df.empty:
|
|
st.info("No centrist support data available.")
|
|
return
|
|
|
|
fig = go.Figure()
|
|
|
|
fig.add_trace(go.Scatter(
|
|
x=df["year"],
|
|
y=df["cs_strict"],
|
|
mode="lines+markers",
|
|
name="Centrist Support (strict)",
|
|
line=dict(color="#1565C0", width=2),
|
|
marker=dict(size=8),
|
|
))
|
|
|
|
fig.add_trace(go.Bar(
|
|
x=df["year"],
|
|
y=df["n_motions"],
|
|
name="Motion count",
|
|
yaxis="y2",
|
|
marker_color="#90CAF9",
|
|
opacity=0.5,
|
|
))
|
|
|
|
fig.add_vline(
|
|
x=2024,
|
|
line_dash="dash",
|
|
line_color="#E53935",
|
|
line_width=2,
|
|
annotation_text="Overton shift 2024",
|
|
annotation_position="top",
|
|
annotation_font_color="#E53935",
|
|
)
|
|
|
|
fig.update_layout(
|
|
title="Centrist Support for Right-Wing Motions",
|
|
xaxis=dict(title="Year", dtick=1),
|
|
yaxis=dict(title="Centrist Support", range=[0, 1]),
|
|
yaxis2=dict(title="Motion count", overlaying="y", side="right"),
|
|
height=400,
|
|
legend=dict(orientation="h", y=1.1),
|
|
hovermode="x unified",
|
|
)
|
|
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
|
|
def _render_summary_stats(con: duckdb.DuckDBPyConnection) -> None:
|
|
st.subheader("Summary")
|
|
|
|
result = con.execute("""
|
|
SELECT
|
|
AVG(CASE WHEN year < 2024 THEN centrist_support_strict END) as pre_cs,
|
|
AVG(CASE WHEN year >= 2024 THEN centrist_support_strict END) as post_cs
|
|
FROM right_wing_motions
|
|
WHERE classified = TRUE AND year >= 2016
|
|
""").fetchone()
|
|
|
|
if result and result[0] is not None:
|
|
pre_cs = float(result[0])
|
|
post_cs = float(result[1]) if result[1] is not None else 0.0
|
|
shift = post_cs - pre_cs
|
|
else:
|
|
pre_cs = 0.251
|
|
post_cs = 0.507
|
|
shift = 0.256
|
|
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
col1.metric("Pre-2024 CS", f"{pre_cs:.3f}")
|
|
col2.metric("Post-2024 CS", f"{post_cs:.3f}")
|
|
col3.metric("Shift", f"{shift:+.3f}")
|
|
col4.metric("2D correlation r", "0.47")
|
|
|
|
|
|
def _render_migration_gateway(con: duckdb.DuckDBPyConnection) -> None:
|
|
st.subheader("Migration: the gateway domain")
|
|
st.markdown(
|
|
"Migration is where the Overton shift is most genuine, and where "
|
|
"right-wing parties learned the frames they later applied to other domains."
|
|
)
|
|
|
|
df = con.execute("""
|
|
SELECT
|
|
CASE WHEN year < 2024 THEN 'Pre-2024' ELSE 'Post-2024' END as period,
|
|
AVG(centrist_support_strict) as cs_strict,
|
|
COUNT(*) as n_motions
|
|
FROM right_wing_motions
|
|
WHERE classified = TRUE
|
|
AND year >= 2016
|
|
AND category IN ('asiel/vreemdelingen', 'asiel')
|
|
GROUP BY period
|
|
ORDER BY period
|
|
""").fetchdf()
|
|
|
|
if df.empty or len(df) < 2:
|
|
return
|
|
|
|
pre = df[df["period"] == "Pre-2024"].iloc[0]
|
|
post = df[df["period"] == "Post-2024"].iloc[0]
|
|
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
col1.metric("Pre-2024 CS (migration)", f"{pre['cs_strict']:.3f}")
|
|
col2.metric("Post-2024 CS (migration)", f"{post['cs_strict']:.3f}")
|
|
col3.metric("Shift", f"{post['cs_strict'] - pre['cs_strict']:+.3f}")
|
|
col4.metric("Motions", f"{int(pre['n_motions'] + post['n_motions'])}")
|
|
|
|
st.caption(
|
|
"For comparison: non-migration motions went from 0.276 to 0.481 (+0.205). "
|
|
"Migration rose more than twice as fast (+0.216), while material impact "
|
|
"barely declined. CDA and ChristenUnie doubled their migration support "
|
|
"(18% to 40%, 10% to 30%)."
|
|
)
|
|
|
|
|
|
def _render_motion_browser(con: duckdb.DuckDBPyConnection) -> None:
|
|
st.subheader("Right-Wing Motions Browser")
|
|
|
|
df = con.execute("""
|
|
SELECT r.year, r.title, m.body_text, r.centrist_support_strict, r.category
|
|
FROM right_wing_motions r
|
|
LEFT JOIN motions m ON r.motion_id = m.id
|
|
WHERE r.classified = TRUE
|
|
ORDER BY r.centrist_support_strict DESC
|
|
LIMIT 100
|
|
""").fetchdf()
|
|
|
|
if df.empty:
|
|
st.info("No right-wing motions found.")
|
|
return
|
|
|
|
df = df.rename(columns={
|
|
"year": "Year",
|
|
"title": "Title",
|
|
"text": "Motion text",
|
|
"centrist_support_strict": "Centrist Support",
|
|
"category": "Category",
|
|
})
|
|
st.dataframe(df, use_container_width=True, height=600)
|
|
|
|
|
|
def _render_explore_further() -> None:
|
|
st.subheader("Explore further")
|
|
st.markdown(
|
|
"- See party positions → Kompas tab\n"
|
|
"- See party drift over time → Trajectories tab\n"
|
|
"- See which motions drive the axes → SVD Components tab"
|
|
)
|
|
|