Die Bundestagswahl 2025 markierte einen politisch hochgradig kompetitiven Urnengang, dessen Endergebnis durch marginale Stimmenunterschiede geprägt wurde.
Zwei Parteien verfehlten die Fünf-Prozent-Hürde nur knapp:
Die Freie Demokratische Partei (FDP), Mitglied der vorangegangenen Regierungskoalition, blieb mit rund 333.000 Stimmen (≈0,67 % der 49,6 Mio. gültigen Zweitstimmen) unterhalb der Sperrklausel. Dem Bündnis Sahra Wagenknecht (BSW),
seit 2024 Teil der Landesregierungen in Thüringen und Brandenburg, fehlten weniger als 10.000 Stimmen (≈0,02 %) zum Einzug in den Deutschen Bundestag. Mit dem Einzug einer oder beider Parteien hätte die gegenwärtige
Regierungskoalition ihre absoluten Mehrheit verfehlt.1
Vor diesem Hintergrund ist das vorliegende Analyseprojekt entstanden. Das Ziel war es, auf Basis der amtlichen Wahldaten der Bundeswahlleiterin die Diskrepanz zwischen Stimmenanteilen und Sitzanteilen systematisch zu untersuchen.
Neben der direkten Gegenüberstellung wurden folgende Metriken untersucht:
Alle Grafiken auf dieser Seite wurden mit Plotly erstellt und sind interaktiv. Hier ist eine Übersicht ihrer Funktionen:
Die Übersichtsgrafik stellt für jede Bundestagswahl seit 1949 die Stimmenanteile (unterer Balken) und Sitzanteile (oberer Balken) gewählter Parteien gegenüber. Eine vertikale Referenzlinie markiert die 50-Prozent-Schwelle,
die benötigt wird, um eine mehrheitsfähige Regierung zu bilden.
Die Grafik visualisiert die Entwicklung von der dominanten Stellung der Volksparteien (CDU/CSU und SPD) in den frühen Jahrzehnten hin zu einem zunehmend
fragmentierten Mehrparteiensystem seit den 1990er Jahren. Die FDP, die seit 1949 regelmäßig den Volksparteien bei der Erreichung einer absoluten Mehrheit half, verpasste in zwei der letzten vier Bundestagswahlen den
Einzug ins Parlament. Gleichzeitig traten ab 2013 neue Akteure (AfD, BSW, weitere Kleinparteien) deutlicher in Erscheinung.
Die Grafik stellt die Asymmetrie zwischen Stimmen- und Sitzanteilen direkt gegenüber und
verdeutlicht den Effekt der Fünf-Prozent-Hürde. In Wahlen mit knappen Mehrheitsverhältnissen, wie 2025, verstärkt sich dieser Effekt erheblich. Stimmen für Parteien unterhalb der Fünf-Prozent-Hürde (z. B. FDP und BSW)
werden bei der Mandatsverteilung unter erfolgreichen Parteien aufgeteilt und erlauben Regierungskoalitionen mit engen Mehrheiten.
Die zweite Grafik visualisiert die zeitliche Entwicklung der Differenz zwischen Sitzanteil und Stimmenanteil für ausgewählte Parteien (CDU/CSU, SPD, FDP, GRÜNE, AfD). Positive Werte signalisieren eine Überrepräsentation
im Parlament, negative Werte eine Unterrepräsentation.
Historisch zeigen insbesondere CDU/CSU und SPD regelmäßig positive Abweichungen, während kleinere Parteien geringere oder schwankende Differenzen aufweisen. Auffällig
ist die starke negative Abweichung der Grünen im Jahr 1990 sowie deutliche positive Ausschläge bei CDU/CSU in einzelnen Wahlperioden (z. B. 2013).
Die Differenzanalyse offenbart zwei zentrale Mechanismen: Erstens führt
die Sperrklausel zu systematischen Überrepräsentationen der etablierten Parteien, je mehr Parteien knapp unter fünf Prozent verbleiben. Zweitens steigt die Varianz der Differenzen mit zunehmender Fragmentierung des
Parteiensystems. Besonders 2013 zeigt, wie stark der Ausschluss der FDP die Sitzverteilung zugunsten der größeren Parteien verschob. Das äußerst knappe Scheitern von FDP und BSW im Wahljahr 2025 erzeugte eine
disproportionale Mandatsverteilung zugunsten der fünf im Parlament vertretenen Parteien (mit Ausnahme des SSW). Die gezeigten Differenzen sind das direkte Resultat der Beschränkungen des Wahlsystems.
Der Gallagher-Index misst die Disproportionalität zwischen Stimm- und Sitzanteilen auf einer Skala von 0 (kaum Disproportionalität, z.B. 0,64 Schweden 2022) bis 20+ (sehr hohe Disproportionalität, z.B. 23,64 Vereinigtes
Königreich 2024).2
Für deutsche Bundestagswahlen zeigt die Zeitreihe vergleichsweise niedrige Werte in den 1970er und frühen 1980er Jahren. Alle anderen Wahljahre erreichen mittlere bis relativ leicht erhöhte Werte. Die
eingefärbten Schwellenbereiche verdeutlichen unterschiedliche Intensitätsstufen der Disproportionalität.
Quadratische Abweichungsmaße gewichten größere Differenzen überproportional stark. Entsprechend spiegeln hohe
Gallagher-Werte Wahljahre wider, in denen strukturelle Verzerrungen besonders ausgeprägt sind. Schon 1949, vor Einführung der Fünf-Prozent-Hürde auf Bundesebene, wurde der zweithöchste Indexwert gemessen. 2013 markiert eine
signifikante Steigung infolge des FDP-Scheiterns. 2025 lag erneut in einem erhöhten Bereich, bleibt jedoch zwei Prozentpunkte hinter dem Höchstwert zurück. Damit untermalt der Index die Ergebnisse der Differenzanalyse und
zeigt, dass je mehr Stimmen unterhalb der Sperrklausel verbleiben, desto stärker die Sitzverteilung vom Stimmenverhältnis abweicht. Für 2025 bedeutet der Nichteinzug von FDP und BSW, die zusammen mit den sonstigen Parteien
13 % der Wählerstimmen ausmachten eine erneut erhöhte Disproportionalität im Gallagher Index.
Der Boxplot zeigt die Konvertierungsrate von Stimmen zu Bundestagssitzen je Partei über mehrere Wahlperioden. Werte oberhalb von 100 % signalisieren eine überproportionale Mandatsumsetzung, Werte darunter eine
Unterkonvertierung. Wahljahre, in denen Parteien an der Sperrklausel scheiterten, wurden in der Grafik nicht berücksichtigt.
Die Grafik visualisiert, dass die Volksparteien eine konstante Konvertierungsrate von über 100%
aufweisen jedoch 120% nicht überschreiten. Dagegen zeigen kleinere Parteien stärkere Streuungen und im Durchschnitt etwas niedrigere Raten. Extreme Ausreißer und durchschnittlich niedrigere Konvertierungsraten sind insbesondere
bei Parteien mit zeitweisem Scheitern an der Sperrklausel sichtbar.
Der Südschleswigsche Wählerverband (SSW) ist als Vertretung der dänischen Minderheit von der Fünf-Prozent-Hürde befreit.3 Dies ermöglichte ihm 1949
(vor Einführung der Hürde), 2021 und 2025 jeweils einen Sitz im Bundestag zu erreichen. Das einzige Mandate führt aufgrund des sehr kleinen
Stimmenanteils im Vergleich zur Gesamtheit des Bundestages zu statistischen Ausreißern.
Die kombinierte Betrachtung aller vier Grafiken ergibt ein konsistentes Bild:
All operations were performed in a Google Colab environment
An up-to-date version of Plotly is required. The following libraries are used for data processing and visualization:
!pip install --upgrade plotly
#--upgrade is required for specific hover menu functions
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
List of all election years, used for columns, y-axis labels and sorting:
#used for columns, y-axis labels and sorting
electionYears = [1949, 1953, 1957, 1961, 1965, 1969, 1972, 1976, 1980, 1983, 1987, 1990, 1994, 1998, 2002, 2005, 2009, 2013, 2017, 2021, 2025]
Color codes for graphing, keyed by party name:
partyColor = { "CDU/CSU" : "#2d3c4b", "SPD" : "#E3000F", "FDP" : "#ffed00", "GRÜNE" : "#46962b",
"AfD" : "#0099ff", "DIE LINKE" : "#FF0000", "SSW" : "#003c8f",
"NPD" : "#8b4726", "PIRATEN" : "#FF8800", "BSW" : "#792351", "REP" : "#0075be",
"KPD/DKP":"#FF355E", "DRP" : "#8b4726", "GB/BHE" : "#A04B90", "DP" : "#EFBF04",
"BP" : "#3158b0", "Zentrum" : "#3389CC", "Sonstige" : "#878787"
}
Dictionaries mapping chancellors and coalition parties to election years:
#name of chancellor
chancellorNames = {"Adenauer" : [1949, 1953, 1957, 1961],"Erhard" : [1965], "Brandt" : [1969, 1972],
"Schmidt" : [1976, 1980], "Kohl" : [1983, 1987, 1990, 1994], "Schröder" : [1998, 2002],
"Merkel" : [2005, 2009, 2013, 2017], "Scholz" : [2021], "Merz" : [2025]}
coalitionYears = {1949 : ["CDU/CSU", "FDP","DP"], 1953 : ["CDU/CSU", "FDP","GB/BHE","DP"],
1957 : ["CDU/CSU", "DP"], 1961 : ["CDU/CSU", "FDP"], 1965 : ["CDU/CSU", "FDP"],
1969 : ["SPD", "FDP"], 1972 : ["SPD", "FDP"], 1976 : ["SPD", "FDP"], 1980 : ["SPD", "FDP"],
1983 : ["CDU/CSU", "FDP"], 1987 : ["CDU/CSU", "FDP"], 1990 : ["CDU/CSU", "FDP"],
1994 : ["CDU/CSU", "FDP"], 1998 : ["SPD", "GRÜNE"], 2002 : ["SPD", "GRÜNE"],
2005 : ["CDU/CSU", "SPD"], 2009 : ["CDU/CSU", "FDP"], 2013 : ["CDU/CSU", "SPD"],
2017 : ["CDU/CSU", "SPD"], 2021 : ["SPD", "GRÜNE", "FDP"], 2025 : ["CDU/CSU", "SPD"]}
#parties relevant for discrepancy chart
discParties = ["CDU/CSU", "SPD", "FDP", "GRÜNE", "AfD"]
Data is scraped directly from the wahlrecht.de results table:
url = 'https://www.wahlrecht.de/ergebnisse/bundestag.htm'
tables = pd.read_html(url)
print (f'Total tables: {len(tables)}')
Verify import and transfer to DataFrame:
tables[1].head(3)
electionResults = tables[1]
electionResults.head(3)
The raw HTML table contains annotated text values and ambiguous separators that need to be resolved before analysis:
#replace annotated text values
electionResults.replace('5,1¹',51,inplace=True, regex=True)
electionResults.replace('DIE LINKE²','DIE LINKE',inplace=True, regex=True)
electionResults.replace('KPD/DKP³','KPD/DKP',inplace=True, regex=True)
#set first col as index
electionResults = electionResults.set_index(('Unnamed: 0_level_0', 'Unnamed: 0_level_1')).copy()
electionResults.index.name = 'Partei'
#drop irrelevant row
electionResults = electionResults.drop('Wahlbeteiligung')
#drop last, redundant, column
electionResults = electionResults.drop(electionResults.columns[-1], axis=1)
#proper NaNs
electionResults = electionResults.replace(['–', '-'], np.nan)
electionResults.head(3)
Separate DataFrames are created for seat counts and vote percentages, then normalized and transposed for plotting:
#select seat columns
seats = electionResults.loc[:, (slice(None), 'Sitze')]
#drop multi-index level
seats.columns = seats.columns.droplevel(1)
#assign years as column names
seats.columns = electionYears
seats.head(3) #verify dataframe creation
#select percentage columns
percentage = electionResults.loc[:, (slice(None), '%')]
percentage.columns = percentage.columns.droplevel(1)
percentage.columns = electionYears
#decimal ',' was not recognized during import. replace values
percentage = percentage.astype(float)
percentage = percentage / 10
percentage.loc['BSW',2025] = percentage.loc['BSW',2025]/100 #BSW value was imported incorrectly
percentage.head(3)
percentagePlot = percentage.transpose()
#convert all values in the DataFrame to numeric, coercing errors to NaN
seatShare = seats.apply(pd.to_numeric, errors='coerce')
#calculate the sum of seats for each year, then calculate percentage of total
totalSeats = seatShare.sum(axis=0)
seatShare = seatShare.div(totalSeats, axis=1) * 100
#transpose, set index and sort by year
seatShare = seatShare.transpose()
seatShare.index = seatShare.index.astype(int)
seatShare = seatShare.sort_index(ascending=True)
#drop columns containing only NaN values to limit legend entries
seatShare = seatShare.dropna(axis=1, how='all')
Combined and derived DataFrames are prepared for use across all four charts:
#combine percentage and seat dataframes for comparison
percSeatComp = pd.concat([percentagePlot, seatShare])
percSeatComp = percSeatComp.sort_index(level=0) #sort years in index
#create df to analyze discrepancy between votes and seats
discrepancy = percentagePlot - seatShare
discrepancyRate = (seatShare / percentagePlot) *100
#needed for boxplot
seatConv = discrepancyRate.dropna(axis=1, how='all')
Each election year contributes three rows to the plot (a spacer, a votes row, and a seats row) with interactive dropdown menus for filtering by chancellor and coalition:
rows = []
meta = [] #keep metadata for y-axis labels and hover menus
#loop through every election year, adding from vote and seat dfs
for y in electionYears:
#1st spacer row
spacer = pd.Series(np.nan, index=percentagePlot.columns)
rows.append(spacer)
meta.append({"year": y, "row_type": "spacer"})
#2nd votes row
votePerc = percentagePlot.loc[y].copy()
rows.append(votePerc)
meta.append({"year": y, "row_type": "Stimmanteil"})
#3rd seats row
seatPerc = seatShare.loc[y].copy()
rows.append(seatPerc)
meta.append({"year": y, "row_type": "Sitzanteil"})
compPlot = pd.DataFrame(rows).reset_index(drop=True)
meta = pd.DataFrame(meta)
#build y categories
y = np.arange(len(compPlot))
#create tick labels only on every 3rd row (your "seats" rows)
#2 instead of 0 or 1 so year label is on top of row entry
tick_vals = np.arange(2, len(compPlot), 3)
tick_text = [str(y) for y in electionYears]
fig = go.Figure() #create plotly figure element
Create primary filtering arrays for votes/seats rows:
#meta["rowType"] should be one of: "spacer", "votes", "seats"
row_type = meta["row_type"].to_numpy()
votesMask = (row_type == "Stimmanteil")
seatsMask = (row_type == "Sitzanteil")
bothMask = votesMask | seatsMask # spacer rows remain hidden anyway
#precompute masked x arrays per party
xAll = [compPlot[c].where(bothMask, None).to_numpy() for c in compPlot.columns]
xVotes = [compPlot[c].where(votesMask, None).to_numpy() for c in compPlot.columns]
xSeats = [compPlot[c].where(seatsMask, None).to_numpy() for c in compPlot.columns]
Create coalition-based filtering system:
xCoalition = [] #create dropdown menu options for coalitions
for party in compPlot.columns:
partyCoalitionMask = np.zeros(len(compPlot), dtype=bool)
#for each row, check if the party was in coalition for that year
for idx, row_meta in meta.iterrows():
year = row_meta["year"]
if year in coalitionYears and party in coalitionYears[year]:
partyCoalitionMask[idx] = bothMask[idx]
xCoalition.append(compPlot[party].where(partyCoalitionMask, None).to_numpy())
Refine chancellor dropdown — this fixes an issue arising from selecting "coalition only" after having selected a chancellor:
n_traces = len(compPlot.columns)
#helper: indices (rows) belonging to certain election years
def RowsForYears(years):
idxs = []
for i, yr in enumerate(electionYears):
if yr in years:
base = i * 3
idxs.extend([base, base+1, base+2]) # spacer, votes, seats
return idxs
#precompute chancellor-specific arrays for all menus
chancellorArrays = {}
for chancellor, years in chancellorNames.items():
yearMask = meta["year"].isin(years).to_numpy()
#create masks for proper button functions
bothChancMask = bothMask & yearMask
votesChancMask = votesMask & yearMask
seatsChancMask = seatsMask & yearMask
#coalition mask (full length)
coalition_ch = np.zeros(len(compPlot), dtype=bool)
for idx, row_meta in meta.iterrows():
yr = row_meta["year"]
if yr in coalitionYears:
#only mark rows that belong to this chancellor and are votes/seats
if (yr in years) and (row_meta["row_type"] in ("Stimmanteil", "Sitzanteil")):
coalition_ch[idx] = True
#build x arrays per trace
xAll_ch = [compPlot[c].where(bothChancMask, None).to_numpy() for c in compPlot.columns]
xvotesChancMask = [compPlot[c].where(votesChancMask, None).to_numpy() for c in compPlot.columns]
xseatsChancMask = [compPlot[c].where(seatsChancMask, None).to_numpy() for c in compPlot.columns]
#coalition per trace must be party-aware per year
xCoalition_ch = []
for party in compPlot.columns:
partyMask = np.zeros(len(compPlot), dtype=bool)
for idx, row_meta in meta.iterrows():
yr = row_meta["year"]
if yr in years and yr in coalitionYears and party in coalitionYears[yr]:
partyMask[idx] = bothMask[idx] # votes/seats rows only
xCoalition_ch.append(compPlot[party].where(partyMask, None).to_numpy())
#y-axis zoom + ticks for those years (use the seats row of each year: base+2)
idxs = RowsForYears(years)
lo, hi = min(idxs), max(idxs)
tick_vals_ch = [i*3 + 2 for i, yr in enumerate(electionYears) if yr in years]
tick_text_ch = [str(yr) for yr in electionYears if yr in years]
chancellorArrays[chancellor] = dict(
xAll=xAll_ch, xVotes=xvotesChancMask, xSeats=xseatsChancMask, xCoalition=xCoalition_ch,
tickvals=tick_vals_ch, ticktext=tick_text_ch,
yrange=[lo - 0.5, hi + 0.5],
height= 1000
)
Add dropdown for each chancellor:
chancellorDropdown = []
#default menu including all chancellors
chancellorDropdown.append(
dict(
label="Alle Kanzler",
method="update",
args=[
{"x": xAll, "y": [y] * n_traces},
{
"yaxis.tickvals": tick_vals,
"yaxis.ticktext": tick_text,
"yaxis.range": [-0.5, len(compPlot) - 0.5],
"title.text": "<b>Bundestagswahlen: Sitzanteil vs. Stimmanteil - Alle Jahre</b>",
"height": 1000,
#set menu button args back to global arrays
"updatemenus[0].buttons[0].args": [{"x": xAll}],
"updatemenus[0].buttons[1].args": [{"x": xVotes}],
"updatemenus[0].buttons[2].args": [{"x": xSeats}],
"updatemenus[2].buttons[0].args": [{"x": xAll}],
"updatemenus[2].buttons[1].args": [{"x": xCoalition}],
}
]
)
)
#add one option per chancellor
for chancellor in chancellorNames.keys():
A = chancellorArrays[chancellor]
chancellorDropdown.append(
dict(
label=chancellor,
method="update",
args=[
{"x": A["xAll"], "y": [y] * n_traces},
{
"yaxis.tickvals": A["tickvals"],
"yaxis.ticktext": A["ticktext"],
"yaxis.range": A["yrange"],
"title.text": f"<b>Bundestagswahlen: Sitzanteil vs. Stimmanteil - {chancellor}</b>",
"height": 1000,
#rewrite other menus so subsequent clicks use the same chancellor subset
"updatemenus[0].buttons[0].args": [{"x": A["xAll"]}],
"updatemenus[0].buttons[1].args": [{"x": A["xVotes"]}],
"updatemenus[0].buttons[2].args": [{"x": A["xSeats"]}],
"updatemenus[2].buttons[0].args": [{"x": A["xAll"]}],
"updatemenus[2].buttons[1].args": [{"x": A["xCoalition"]}],
}
]
)
)
Create all dropdown and button menus:
fig.update_layout(
updatemenus=[
#data type selector (Alle/Stimmanteile/Sitzanteile)
dict(
type="buttons",
direction="right",
x=0.0, xanchor="left",
y=1.07, yanchor="bottom",
pad = {"t":10,"b":0},
buttons=[
dict(
label="Alle",
method="restyle",
args=[{"x": xAll}],
),
dict(
label="Nur Stimmanteile",
method="restyle",
args=[{"x": xVotes}],
),
dict(
label="Nur Sitzanteile",
method="restyle",
args=[{"x": xSeats}],
),
],
),
#chancellor selector dropdown
dict(
type="dropdown",
direction="right",
x=0, xanchor="left",
y=1, yanchor="bottom",
pad = {"t":0,"b":10},
buttons=chancellorDropdown,
active=0,
),
#coalition filter button
dict(
type="buttons",
direction="right",
x=0.41, xanchor="left",
y=1.07, yanchor="bottom",
pad = {"t":10,"b":0},
active=0,
buttons=[
dict(
label="Alle Parteien",
method="restyle",
args=[{"x": xAll}],
),
dict(
label="Nur Koalitionsparteien",
method="restyle",
args=[{"x": xCoalition}],
),
],
)
],
)
Add one stacked horizontal bar trace per party:
#add one stacked trace per party (column)
for i, party in enumerate(compPlot.columns):
fig.add_trace(
go.Bar(
y=y,
x=xAll[i],
orientation="h",
name=party,
visible=True,
marker=dict(color=partyColor.get(party, "#CCCCCC")),
#richer hover: year + whether it's votes or seats
customdata=np.stack([meta["year"], meta["row_type"]], axis=-1),
hovertemplate=(
"%{customdata[1]}<br>"
f"{party}<br>"
"Anteil: %{x:.2f}%<extra></extra>"
),
)
)
Add final styling elements, axis labels and display the chart:
#absolute majority line at 50%
fig.add_vline(x=50, line_width=1, line_color="black")
fig.update_layout(
barmode="stack",
title="<b>Bundestagswahlen: Sitzanteil vs. Stimmanteil</b>",
xaxis_title="<b>Anteil (%)</b>",
yaxis_title="",
xaxis=dict(range=[0, 100]),
yaxis=dict(
tickmode="array",
tickvals=tick_vals,
ticktext=tick_text,
range=[-0.5, len(compPlot) - 0.5],
),
legend_title="Partei",
bargap=0,
plot_bgcolor="#FfFffF",
hovermode="closest",
height=1000,
margin=dict(t=200),
)
fig.update_layout(legend_traceorder="normal")
fig.show()
Export chart:
# fig.write_html("bundestagswahlen_chart.html")
The discrepancy DataFrame is plotted as a line chart for the major parties. The values are inverted to show the seats gained relative to vote share:
discrepancy.sample(3)
discFig = px.line(-discrepancy[discParties]) #inverse to show seats gained
#discParties is limited to (former) governing or high share parties
for trace in discFig.data: #loop through plot columns
if trace.name in partyColor:
#match column/party name and get color coding from dict
trace.line.color = partyColor[trace.name]
discFig.update_traces(line=dict(width=3),
hovertemplate =
"<b>%{y:.2f}%</b>")
discFig.update_yaxes(
zeroline=True,
zerolinewidth=2,
zerolinecolor='rgba(0, 0, 0, 0.5)')
discFig.update_layout(
hovermode="x unified",
title="<b>Diskrepanz zwischen Stimm- und Sitzanteil</b>",
yaxis_title="<b>Diskrepanz (%)</b>",
xaxis_title="<b>Wahljahr</b>",
xaxis=dict(range=[1948, 2026], unifiedhovertitle=dict(text="<b>Diskrepanz im Wahljahr %{x}</b>")),
yaxis=dict(range=[-4, 8]),
legend_title="<b>Partei</b>",
plot_bgcolor="#ffffff",
height=700,
margin=dict(t=100),
)
discFig.show()
Export chart:
discFig.write_html("diskrepanzen_chart.html")
The Gallagher Index is computed from the squared discrepancy values and plotted with color-coded background bands indicating proportionality levels:
discrepancyGallagher = discrepancy**2
discrepancyGallagher['Gallagher Score'] = np.sqrt((discrepancyGallagher.sum(axis=1))*.5)
discrepancyGallagher.head(3)
gallFig = px.line(discrepancyGallagher["Gallagher Score"])
#prevent stacking of hrect shapes from rerunning graph
gallFig.layout.shapes = ()
gallFig.update_traces(line=dict(width=3),
hovertemplate = '<b>Wahljahr %{x} | %{y:.2f}%</b><extra></extra>',
line_color = 'white')
gallFig.update_yaxes(tickmode = 'linear',dtick = 2)
#hex values taken from Wikipedia shading for Gallgher Index
gallFig.add_hrect(y0=0, y1=1, fillcolor="#0c3091",
opacity=1, layer="below", line_width=0)
gallFig.add_hrect(y0=1, y1=3, fillcolor="#2f5cd5",
opacity=1, layer="below", line_width=0)
gallFig.add_hrect(y0=3, y1=5, fillcolor="#6bd2df",
opacity=1, layer="below", line_width=0)
gallFig.add_hrect(y0=5, y1=7, fillcolor="#c3eded",
opacity=1, layer="below", line_width=0)
gallFig.update_layout(
hovermode="closest",
title="<b>Gallagher Index nach Wahljahr</b>",
yaxis_title="<b>Index (%)</b>",
xaxis_title="<b>Wahljahr</b>",
xaxis=dict(range=[1948, 2026], showgrid=False),
yaxis=dict(range=[0, 7]),
height=600,
showlegend=False,
)
gallFig.show()
Export chart:
discFig.write_html("gallgher_chart.html")
The seat conversion rate (seat share / vote share × 100) is displayed as a boxplot per party, showing the distribution across all elections in which a party was represented:
seatConv.sample(3)
orderedCols = [col for col in partyColor.keys() if col in seatConv.columns and col not in ["Sonstige"]]
seatConv = seatConv[orderedCols]
boxFig = go.Figure()
for col in orderedCols:
color = partyColor[col]
boxFig.add_trace(go.Box(
y=seatConv[col].dropna(),
name=col,
marker_color=color,
line_color=color,
boxpoints="all",
pointpos=0,
text=seatConv[col].dropna().index.astype(str),
hovertemplate='<b>%{text} | %{y:.2f}</b><extra></extra>'
))
boxFig.update_traces(
yhoverformat=".2f"
)
boxFig.update_layout(
hovermode="closest",
title="<b>Sitzkonvertierung von Stimmanteil zu Sitzanteil</b>",
yaxis_title="<b>Konvertierungsrate (%)</b>",
xaxis_title="<b>Partei</b>",
yaxis=dict(range=[20, 140]),
plot_bgcolor="#ffffff",
height=600,
showlegend=False,
)
boxFig.show()
Export chart:
boxFig.write_html("boxplot_chart.html")