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.
172 lines
5.0 KiB
172 lines
5.0 KiB
"""Generate additional blog charts: controversy trend + party alignment heatmap."""
|
|
|
|
from __future__ import annotations
|
|
import os, sys
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
if ROOT not in sys.path:
|
|
sys.path.insert(0, ROOT)
|
|
|
|
import duckdb
|
|
import plotly.graph_objects as go
|
|
import plotly.express as px
|
|
import numpy as np
|
|
|
|
DB = "data/motions.db"
|
|
OUT = "outputs/blog-charts"
|
|
os.makedirs(OUT, exist_ok=True)
|
|
|
|
con = duckdb.connect(DB, read_only=True)
|
|
|
|
# ─── 1. Controversy trend (bar chart, 2019-2026, quarterly) ──────────────────
|
|
rows = con.execute("""
|
|
SELECT
|
|
YEAR(date) || '-Q' || QUARTER(date) as wid,
|
|
YEAR(date) as yr,
|
|
QUARTER(date) as q,
|
|
COUNT(*) as n,
|
|
ROUND(AVG(controversy_score), 3) as avg_c,
|
|
COUNT(*) FILTER (WHERE controversy_score >= 0.7) as high_c
|
|
FROM motions
|
|
WHERE controversy_score IS NOT NULL
|
|
AND date >= '2019-01-01' AND date < '2026-04-01'
|
|
GROUP BY wid, yr, q
|
|
ORDER BY yr, q
|
|
""").fetchall()
|
|
|
|
windows = [r[0] for r in rows]
|
|
avg_c = [r[4] for r in rows]
|
|
high_pct = [round(100.0 * r[5] / r[3], 1) if r[3] else 0 for r in rows]
|
|
|
|
fig = go.Figure()
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=windows,
|
|
y=high_pct,
|
|
name="% highly contested (score ≥ 0.7)",
|
|
marker_color="#00d9a3",
|
|
opacity=0.85,
|
|
)
|
|
)
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=windows,
|
|
y=[v * 100 for v in avg_c],
|
|
name="avg controversy × 100",
|
|
mode="lines+markers",
|
|
line=dict(color="#e6edf3", width=2),
|
|
marker=dict(size=4),
|
|
)
|
|
)
|
|
fig.update_layout(
|
|
title="Political controversy per quarter (Tweede Kamer, 2019–2026)",
|
|
xaxis_title="Quarter",
|
|
yaxis_title="% of motions",
|
|
plot_bgcolor="#161b22",
|
|
paper_bgcolor="#0d1117",
|
|
font=dict(color="#e6edf3", family="Inter, system-ui"),
|
|
legend=dict(bgcolor="rgba(0,0,0,0)", bordercolor="#30363d", borderwidth=1),
|
|
xaxis=dict(tickangle=-45, gridcolor="#30363d"),
|
|
yaxis=dict(gridcolor="#30363d", range=[0, 55]),
|
|
bargap=0.15,
|
|
)
|
|
out1 = os.path.join(OUT, "controversy_trend.html")
|
|
fig.write_html(out1, include_plotlyjs="cdn", full_html=True)
|
|
print(f"Wrote {out1}")
|
|
|
|
# ─── 2. Party alignment heatmap ──────────────────────────────────────────────
|
|
# Only include major parties with sufficient data
|
|
MAJOR = [
|
|
"VVD",
|
|
"PVV",
|
|
"D66",
|
|
"CDA",
|
|
"PvdA",
|
|
"GroenLinks",
|
|
"SP",
|
|
"ChristenUnie",
|
|
"SGP",
|
|
"FVD",
|
|
"BBB",
|
|
"PvdD",
|
|
"Volt",
|
|
"GroenLinks-PvdA",
|
|
"Nieuw Sociaal Contract",
|
|
"DENK",
|
|
"JA21",
|
|
]
|
|
|
|
rows = con.execute("""
|
|
WITH pv AS (
|
|
SELECT motion_id, party,
|
|
CASE
|
|
WHEN SUM(CASE WHEN vote='voor' THEN 1 ELSE 0 END) > SUM(CASE WHEN vote='tegen' THEN 1 ELSE 0 END) THEN 'voor'
|
|
WHEN SUM(CASE WHEN vote='tegen' THEN 1 ELSE 0 END) > SUM(CASE WHEN vote='voor' THEN 1 ELSE 0 END) THEN 'tegen'
|
|
ELSE 'split'
|
|
END as pv
|
|
FROM mp_votes WHERE party IS NOT NULL AND vote IN ('voor','tegen')
|
|
GROUP BY motion_id, party
|
|
),
|
|
d AS (SELECT * FROM pv WHERE pv != 'split')
|
|
SELECT a.party, b.party,
|
|
COUNT(*) as shared,
|
|
ROUND(100.0 * SUM(CASE WHEN a.pv = b.pv THEN 1 ELSE 0 END) / COUNT(*), 1) as pct
|
|
FROM d a JOIN d b ON a.motion_id = b.motion_id AND a.party != b.party
|
|
GROUP BY a.party, b.party
|
|
HAVING COUNT(*) >= 100
|
|
""").fetchall()
|
|
|
|
# Build matrix
|
|
agree = {}
|
|
for a, b, _, pct in rows:
|
|
agree[(a, b)] = pct
|
|
|
|
# Filter to parties that have data
|
|
present = set()
|
|
for a, b in agree:
|
|
if a in MAJOR:
|
|
present.add(a)
|
|
if b in MAJOR:
|
|
present.add(b)
|
|
parties = [p for p in MAJOR if p in present]
|
|
|
|
n = len(parties)
|
|
matrix = np.full((n, n), np.nan)
|
|
for i, a in enumerate(parties):
|
|
matrix[i, i] = 100.0
|
|
for j, b in enumerate(parties):
|
|
if i != j and (a, b) in agree:
|
|
matrix[i, j] = agree[(a, b)]
|
|
|
|
fig2 = go.Figure(
|
|
data=go.Heatmap(
|
|
z=matrix,
|
|
x=parties,
|
|
y=parties,
|
|
colorscale=[[0, "#6e40c9"], [0.5, "#30363d"], [1, "#00d9a3"]],
|
|
zmid=70,
|
|
zmin=35,
|
|
zmax=100,
|
|
text=[[f"{v:.0f}%" if not np.isnan(v) else "" for v in row] for row in matrix],
|
|
texttemplate="%{text}",
|
|
textfont=dict(size=9),
|
|
hoverongaps=False,
|
|
showscale=True,
|
|
colorbar=dict(title="Agreement %", tickfont=dict(color="#e6edf3")),
|
|
)
|
|
)
|
|
fig2.update_layout(
|
|
title="Cross-party vote alignment (all years combined)",
|
|
plot_bgcolor="#161b22",
|
|
paper_bgcolor="#0d1117",
|
|
font=dict(color="#e6edf3", family="Inter, system-ui", size=11),
|
|
xaxis=dict(tickangle=-45, side="bottom", gridcolor="#30363d"),
|
|
yaxis=dict(autorange="reversed", gridcolor="#30363d"),
|
|
height=600,
|
|
)
|
|
out2 = os.path.join(OUT, "party_alignment.html")
|
|
fig2.write_html(out2, include_plotlyjs="cdn", full_html=True)
|
|
print(f"Wrote {out2}")
|
|
|
|
con.close()
|
|
print("Done.")
|
|
|