# check out https://dash.plotly.com/ for documentation
# And check out https://py.cafe/maartenbreddels for more examples
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from dash import Dash, html, dcc, callback, Input, Output, ctx, State, ALL
import numpy as np
import json
# Load and prepare data
df_original = pd.read_csv("europe_monthly_electricity.csv.zip", compression='zip')
# Process TWh data
generation_df = df_original[
(df_original['Category'] == 'Electricity generation') &
(df_original['Unit'] == 'TWh') &
(df_original['Subcategory'].isin(['Fuel', 'Aggregate fuel']))
].copy()
generation_df['Year'] = pd.to_datetime(generation_df['Date']).dt.year
df_pivot = generation_df.pivot_table(
index=['Area', 'Year'],
columns='Variable',
values='Value',
aggfunc='sum'
).reset_index()
df_pivot = df_pivot.fillna(0)
df = df_pivot
# Process percentage data
percentage_df = df_original[
(df_original['Category'] == 'Electricity generation') &
(df_original['Unit'] == '%') &
(df_original['Subcategory'].isin(['Fuel', 'Aggregate fuel']))
].copy()
percentage_df['Year'] = pd.to_datetime(percentage_df['Date']).dt.year
df_percentage_pivot = percentage_df.pivot_table(
index=['Area', 'Year'],
columns='Variable',
values='Value',
aggfunc='sum'
).reset_index()
df_percentage_pivot = df_percentage_pivot.fillna(0)
df_percentage = df_percentage_pivot
# Map column names for both datasets
column_mapping = {
'Onshore wind': 'Wind',
'Bioenergy': 'Bioenergy',
'Other renewables': 'Other renewables',
'Hard coal': 'Hard coal',
'Gas': 'Gas',
'Solar': 'Solar',
'Hydro': 'Hydro',
'Other fossil': 'Oil',
'Nuclear': 'Nuclear' if 'Nuclear' in df.columns else None
}
existing_columns = {k: v for k, v in column_mapping.items() if k in df.columns and v is not None}
df = df.rename(columns=existing_columns)
df_percentage = df_percentage.rename(columns=existing_columns)
# Consolidate wind columns for TWh data
wind_columns = [col for col in df.columns if 'wind' in col.lower()]
if len(wind_columns) > 1:
df['Wind'] = df[wind_columns].sum(axis=1)
df = df.drop(columns=[col for col in wind_columns if col != 'Wind'])
# Consolidate wind columns for percentage data
wind_columns_pct = [col for col in df_percentage.columns if 'wind' in col.lower()]
if len(wind_columns_pct) > 1:
df_percentage['Wind'] = df_percentage[wind_columns_pct].sum(axis=1)
df_percentage = df_percentage.drop(columns=[col for col in wind_columns_pct if col != 'Wind'])
# Remove duplicate columns
df = df.loc[:, ~df.columns.duplicated()]
df_percentage = df_percentage.loc[:, ~df_percentage.columns.duplicated()]
# Add missing columns
expected_columns = ['Wind', 'Solar', 'Hydro', 'Bioenergy', 'Other renewables', 'Hard coal', 'Gas', 'Oil', 'Nuclear']
for col in expected_columns:
if col not in df.columns:
df[col] = 0
if col not in df_percentage.columns:
df_percentage[col] = 0
# Energy-themed color palette
ENERGY_COLORS = {
# UI colors (keep these)
'background': '#f8f9fa',
'card_bg': '#ffffff',
'primary': '#2c5282',
'secondary': '#3182ce',
'accent': '#63b3ed',
'text': '#1a202c',
'text_light': '#4a5568',
'border': '#e2e8f0',
# Individual energy source colors
'Wind': '#87ceeb', # Sky blue
'Solar': '#ffd700', # Gold/Yellow
'Hydro': '#1e3a8a', # Dark blue
'Nuclear': '#39ff14', # Neon Green
'Hard coal': '#000000', # Black
'Lignite': '#681a1a', # Brown
'Gas': '#ff0000', # Red
'Oil': '#ffa500', # Orange
'Bioenergy': '#10b981', # Green
'Other renewables': '#8fbc8f' # Dark sea green
}
# Define the hierarchy and categories
categories = {
'All Generation': {
'Renewables': {
'Wind and Solar': ['Wind', 'Solar'],
'Hydro, Bioenergy and Other Renewables': ['Hydro', 'Bioenergy', 'Other renewables']
},
'Fossil Fuels': {
'Coal and Gas': ['Hard coal', 'Lignite', 'Gas'],
'Oil': ['Oil']
},
'Nuclear': {
'Nuclear': ['Nuclear']
}
}
}
def get_columns_for_selection(selections):
"""Get the actual dataframe columns for current selections."""
if selections.get('level3'):
return selections['level3']
elif selections.get('level2'):
columns = []
for level2_item in selections['level2']:
if level2_item == 'Wind and Solar':
columns.extend(['Wind', 'Solar'])
elif level2_item == 'Hydro, Bioenergy and Other Renewables':
columns.extend(['Hydro', 'Bioenergy', 'Other renewables'])
elif level2_item == 'Coal and Gas':
columns.extend(['Hard coal', 'Lignite', 'Gas'])
elif level2_item == 'Oil':
columns.extend(['Oil'])
elif level2_item == 'Nuclear':
columns.extend(['Nuclear'])
return columns
elif selections.get('level1'):
columns = []
for level1_item in selections['level1']:
if level1_item == 'Renewables':
columns.extend(['Wind', 'Solar', 'Hydro', 'Bioenergy', 'Other renewables'])
elif level1_item == 'Fossil Fuels':
columns.extend(['Hard coal', 'Lignite', 'Gas', 'Oil'])
elif level1_item == 'Nuclear':
columns.extend(['Nuclear'])
return columns
else:
return [col for col in df.columns if col not in ['Area', 'Year']]
def build_selection_boxes(current_selections={}):
"""Build the hierarchical selection boxes with multi-select capability."""
boxes = []
if not current_selections:
current_selections = {'level1': [], 'level2': [], 'level3': []}
all_generation_selected = (not current_selections['level1'] and
not current_selections['level2'] and
not current_selections['level3'])
boxes.append(
html.Div([
html.Button(
'All Generation',
id={'type': 'selection-box', 'level': 0, 'value': 'All Generation'},
className=f'selection-box level-0 {"selected" if all_generation_selected else ""}',
style={
'width': '300px',
'margin': '5px auto',
'display': 'block'
}
),
html.Div("(NOTE: to RESET, click 'All Generation')",
style={'fontSize': '12px', 'color': ENERGY_COLORS['text_light'],
'textAlign': 'center', 'marginTop': '5px'})
], style={'text-align': 'center'})
)
level1_options = list(categories['All Generation'].keys())
level1_buttons = []
for option in level1_options:
is_selected = option in current_selections.get('level1', [])
level1_buttons.append(
html.Button(
option,
id={'type': 'selection-box', 'level': 1, 'value': option},
className=f'selection-box level-1 {"selected" if is_selected else ""}',
style={
'width': '140px',
'margin': '5px',
'display': 'inline-block'
}
)
)
boxes.append(
html.Div([
html.Div(level1_buttons, style={'text-align': 'center', 'margin': '10px 0'}),
html.Div("Hold Ctrl/Cmd to select multiple categories",
style={'fontSize': '11px', 'color': ENERGY_COLORS['text_light'],
'textAlign': 'center', 'fontStyle': 'italic'})
])
)
if current_selections.get('level1'):
level2_buttons = []
all_level2_options = set()
for level1_item in current_selections['level1']:
if level1_item in categories['All Generation']:
level2_options = list(categories['All Generation'][level1_item].keys())
for option in level2_options:
if option not in all_level2_options:
all_level2_options.add(option)
is_selected = option in current_selections.get('level2', [])
width = max(180, len(option) * 8 + 40)
level2_buttons.append(
html.Button(
option,
id={'type': 'selection-box', 'level': 2, 'value': option},
className=f'selection-box level-2 {"selected" if is_selected else ""}',
style={
'width': f'{width}px',
'margin': '5px',
'display': 'inline-block'
}
)
)
if level2_buttons:
boxes.append(
html.Div([
html.Div(level2_buttons, style={'text-align': 'center', 'margin': '10px 0'}),
html.Div("Hold Ctrl/Cmd to select multiple subcategories",
style={'fontSize': '11px', 'color': ENERGY_COLORS['text_light'],
'textAlign': 'center', 'fontStyle': 'italic'})
])
)
if current_selections.get('level2'):
level3_buttons = []
all_level3_options = set()
for level2_item in current_selections['level2']:
for level1_key, level2_dict in categories['All Generation'].items():
if level2_item in level2_dict:
level3_options = level2_dict[level2_item]
for option in level3_options:
if option not in all_level3_options:
all_level3_options.add(option)
is_selected = option in current_selections.get('level3', [])
level3_buttons.append(
html.Button(
option,
id={'type': 'selection-box', 'level': 3, 'value': option},
className=f'selection-box level-3 {"selected" if is_selected else ""}',
style={
'width': '120px',
'margin': '5px',
'display': 'inline-block'
}
)
)
break
if level3_buttons:
boxes.append(
html.Div([
html.Div(level3_buttons, style={'text-align': 'center', 'margin': '10px 0'}),
html.Div("Hold Ctrl/Cmd to select multiple sources",
style={'fontSize': '11px', 'color': ENERGY_COLORS['text_light'],
'textAlign': 'center', 'fontStyle': 'italic'})
])
)
return boxes
def create_chart(current_selections, countries, year_range):
"""Create the TWh chart based on current selections."""
columns_to_plot = get_columns_for_selection(current_selections)
if not columns_to_plot:
return go.Figure()
filtered_df = df[
(df['Area'].isin(countries)) &
(df['Year'] >= year_range[0]) &
(df['Year'] <= year_range[1])
].copy()
title_parts = []
if current_selections.get('level3'):
title_parts.append(f"Individual Sources: {', '.join(current_selections['level3'])}")
elif current_selections.get('level2'):
title_parts.append(f"Subcategories: {', '.join(current_selections['level2'])}")
elif current_selections.get('level1'):
title_parts.append(f"Categories: {', '.join(current_selections['level1'])}")
else:
title_parts.append("All Generation Sources")
title = f"Electricity Generation (TWh) - {title_parts[0]}"
fig = go.Figure()
for i, col in enumerate(columns_to_plot):
if col in filtered_df.columns:
color = ENERGY_COLORS.get(col, ENERGY_COLORS['secondary'])
for country in countries:
country_data = filtered_df[filtered_df['Area'] == country]
if not country_data.empty:
fig.add_trace(go.Scatter(
x=country_data['Year'],
y=country_data[col],
mode='lines+markers',
name=f"{col} ({country})",
line=dict(color=color, width=2),
marker=dict(size=6)
))
fig.update_layout(
title={
'text': title,
'x': 0.5,
'xanchor': 'center',
'font': {'size': 20, 'color': ENERGY_COLORS['text']}
},
xaxis_title="Year",
yaxis_title="Generation (TWh)",
hovermode='x unified',
plot_bgcolor='white',
paper_bgcolor='white',
font={'color': ENERGY_COLORS['text']},
margin=dict(l=50, r=50, t=80, b=50),
height=500
)
fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='#e2e8f0')
fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='#e2e8f0')
return fig
def create_percentage_chart(current_selections, countries, year_range):
"""Create the percentage chart based on current selections."""
columns_to_plot = get_columns_for_selection(current_selections)
if not columns_to_plot:
return go.Figure()
filtered_df = df_percentage[
(df_percentage['Area'].isin(countries)) &
(df_percentage['Year'] >= year_range[0]) &
(df_percentage['Year'] <= year_range[1])
].copy()
title_parts = []
if current_selections.get('level3'):
title_parts.append(f"Individual Sources: {', '.join(current_selections['level3'])}")
elif current_selections.get('level2'):
title_parts.append(f"Subcategories: {', '.join(current_selections['level2'])}")
elif current_selections.get('level1'):
title_parts.append(f"Categories: {', '.join(current_selections['level1'])}")
else:
title_parts.append("All Generation Sources")
title = f"Electricity Generation Share (%) - {title_parts[0]}"
fig = go.Figure()
for i, col in enumerate(columns_to_plot):
if col in filtered_df.columns:
color = ENERGY_COLORS.get(col, ENERGY_COLORS['secondary'])
for country in countries:
country_data = filtered_df[filtered_df['Area'] == country]
if not country_data.empty:
fig.add_trace(go.Scatter(
x=country_data['Year'],
y=country_data[col],
mode='lines+markers',
name=f"{col} ({country})",
line=dict(color=color, width=2),
marker=dict(size=6)
))
fig.update_layout(
title={
'text': title,
'x': 0.5,
'xanchor': 'center',
'font': {'size': 20, 'color': ENERGY_COLORS['text']}
},
xaxis_title="Year",
yaxis_title="Share (%)",
hovermode='x unified',
plot_bgcolor='white',
paper_bgcolor='white',
font={'color': ENERGY_COLORS['text']},
margin=dict(l=50, r=50, t=80, b=50),
height=500
)
fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='#e2e8f0')
fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='#e2e8f0')
return fig
# Initialize the Dash app
app = Dash(__name__)
# Define the app layout
app.layout = html.Div([
# Header
html.Div([
html.H1(
"European Electricity Generation (TWh & %)",
style={
'textAlign': 'center',
'color': ENERGY_COLORS['text'],
'marginBottom': '30px',
'fontSize': '28px',
'fontWeight': '600'
}
),
html.H3(
"Note: this is only an 'MVP' project as a basis for further iteration",
style={
'textAlign': 'center',
'color': ENERGY_COLORS['text'],
'marginTop': '0px',
'marginBottom': '30px',
'fontSize': '18px',
'fontWeight': '400'
}
),
html.H3(
"Dataset from EMBER (ember-energy.org)",
style={
'textAlign': 'center',
'color': ENERGY_COLORS['text'],
'marginTop': '0px',
'marginBottom': '30px',
'fontSize': '18px',
'fontWeight': '400'
}
)
], style={
'backgroundColor': ENERGY_COLORS['background'],
'padding': '20px',
'borderBottom': f"3px solid {ENERGY_COLORS['primary']}"
}),
# Main container
html.Div([
# Controls section
html.Div([
# Country selection
html.Div([
html.Label(
"Select Countries:",
style={
'fontSize': '16px',
'fontWeight': '500',
'color': ENERGY_COLORS['text'],
'marginBottom': '8px',
'display': 'block',
'textAlign': 'center'
}
),
dcc.Dropdown(
id='country-dropdown',
options=[{'label': country, 'value': country} for country in sorted(df['Area'].unique())],
value=['Germany', 'France'],
multi=True,
style={
'margin': '0 auto 20px auto',
'maxWidth': '600px'
}
)
]),
# Year range
html.Div([
html.Label(
"Select Year Range:",
style={
'fontSize': '16px',
'fontWeight': '500',
'color': ENERGY_COLORS['text'],
'marginBottom': '8px',
'display': 'block',
'textAlign': 'center'
}
),
dcc.RangeSlider(
id='year-slider',
min=df['Year'].min(),
max=df['Year'].max(),
value=[2015, 2024],
marks={year: str(year) for year in range(df['Year'].min(), df['Year'].max()+1, 1)},
step=1,
tooltip={"placement": "bottom", "always_visible": True}
)
], style={'marginBottom': '30px', 'margin': '0 auto', 'maxWidth': '600px'})
], style={
'backgroundColor': ENERGY_COLORS['card_bg'],
'padding': '25px',
'margin': '20px',
'borderRadius': '8px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)',
'border': f"1px solid {ENERGY_COLORS['border']}"
}),
# Selection boxes container
html.Div(
id='selection-container',
children=build_selection_boxes(),
style={
'backgroundColor': ENERGY_COLORS['card_bg'],
'padding': '25px',
'margin': '20px',
'borderRadius': '8px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)',
'border': f"1px solid {ENERGY_COLORS['border']}"
}
),
# TWh Chart container
html.Div([
dcc.Graph(id='electricity-chart', style={'height': '500px'})
], style={
'backgroundColor': ENERGY_COLORS['card_bg'],
'padding': '25px',
'margin': '20px',
'borderRadius': '8px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)',
'border': f"1px solid {ENERGY_COLORS['border']}"
}),
# Percentage Chart container
html.Div([
dcc.Graph(id='percentage-chart', style={'height': '500px'})
], style={
'backgroundColor': ENERGY_COLORS['card_bg'],
'padding': '25px',
'margin': '20px',
'borderRadius': '8px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)',
'border': f"1px solid {ENERGY_COLORS['border']}"
}),
# Hidden div to store current selections
html.Div(id='current-selections', children=json.dumps({'level1': [], 'level2': [], 'level3': []}), style={'display': 'none'})
], style={
'backgroundColor': ENERGY_COLORS['background'],
'minHeight': '100vh',
'padding': '0 20px 40px 20px'
})
], style={
'fontFamily': 'system-ui, -apple-system, sans-serif',
'backgroundColor': ENERGY_COLORS['background']
})
# Add CSS styling
app.index_string = '''
<!DOCTYPE html>
<html>
<head>
{%metas%}
<title>{%title%}</title>
{%favicon%}
{%css%}
<style>
.selection-box {
padding: 12px 20px;
border: 2px solid ''' + ENERGY_COLORS['border'] + ''';
border-radius: 6px;
background-color: white;
color: ''' + ENERGY_COLORS['text'] + ''';
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.selection-box:hover {
background-color: ''' + ENERGY_COLORS['accent'] + ''';
border-color: ''' + ENERGY_COLORS['secondary'] + ''';
color: white;
transform: translateY(-1px);
}
.selection-box.selected {
background-color: ''' + ENERGY_COLORS['primary'] + ''';
border-color: ''' + ENERGY_COLORS['primary'] + ''';
color: white;
box-shadow: 0 2px 8px rgba(44, 82, 130, 0.3);
}
.level-0 {
font-size: 16px;
font-weight: 600;
padding: 15px 25px;
}
.level-1 {
background-color: ''' + ENERGY_COLORS['primary'] + ''';
border-color: ''' + ENERGY_COLORS['primary'] + ''';
color: white;
}
.level-1:hover {
background-color: ''' + ENERGY_COLORS['secondary'] + ''';
border-color: ''' + ENERGY_COLORS['secondary'] + ''';
}
.level-2 {
background-color: ''' + ENERGY_COLORS['secondary'] + ''';
border-color: ''' + ENERGY_COLORS['secondary'] + ''';
color: white;
}
.level-2:hover {
background-color: ''' + ENERGY_COLORS['primary'] + ''';
border-color: ''' + ENERGY_COLORS['primary'] + ''';
}
.level-3 {
background-color: ''' + ENERGY_COLORS['Solar'] + ''';
border-color: ''' + ENERGY_COLORS['Solar'] + ''';
color: ''' + ENERGY_COLORS['text'] + ''';
}
.level-3:hover {
background-color: #f59e0b;
border-color: #f59e0b;
color: white;
}
</style>
</head>
<body>
{%app_entry%}
<footer>
{%config%}
{%scripts%}
{%renderer%}
</footer>
</body>
</html>
'''
# Callbacks
@callback(
[Output('selection-container', 'children'),
Output('current-selections', 'children')],
[Input({'type': 'selection-box', 'level': ALL, 'value': ALL}, 'n_clicks')],
[State('current-selections', 'children')],
prevent_initial_call=False
)
def handle_box_clicks(n_clicks, current_selections_json):
if isinstance(current_selections_json, str):
current_selections = json.loads(current_selections_json)
else:
current_selections = current_selections_json or {'level1': [], 'level2': [], 'level3': []}
if not ctx.triggered_id:
initial_selections = {'level1': [], 'level2': [], 'level3': []}
return build_selection_boxes(initial_selections), json.dumps(initial_selections)
clicked_box = ctx.triggered_id
level = clicked_box['level']
value = clicked_box['value']
new_selections = {
'level1': current_selections.get('level1', []).copy(),
'level2': current_selections.get('level2', []).copy(),
'level3': current_selections.get('level3', []).copy()
}
if level == 0:
new_selections = {'level1': [], 'level2': [], 'level3': []}
elif level == 1:
level_key = 'level1'
if value in new_selections[level_key]:
new_selections[level_key].remove(value)
else:
new_selections[level_key].append(value)
new_selections['level2'] = []
new_selections['level3'] = []
elif level == 2:
parent_level1 = None
for l1_key, l2_dict in categories['All Generation'].items():
if value in l2_dict:
parent_level1 = l1_key
break
if parent_level1 and parent_level1 in new_selections['level1']:
level_key = 'level2'
if value in new_selections[level_key]:
new_selections[level_key].remove(value)
else:
new_selections[level_key].append(value)
new_selections['level3'] = []
elif level == 3:
parent_level2 = None
for l1_key, l2_dict in categories['All Generation'].items():
for l2_key, l3_list in l2_dict.items():
if value in l3_list:
parent_level2 = l2_key
break
if parent_level2:
break
if parent_level2 and parent_level2 in new_selections['level2']:
level_key = 'level3'
if value in new_selections[level_key]:
new_selections[level_key].remove(value)
else:
new_selections[level_key].append(value)
return build_selection_boxes(new_selections), json.dumps(new_selections)
@callback(
Output('electricity-chart', 'figure'),
[Input('current-selections', 'children'),
Input('country-dropdown', 'value'),
Input('year-slider', 'value')]
)
def update_chart(current_selections_json, countries, year_range):
if isinstance(current_selections_json, str):
current_selections = json.loads(current_selections_json)
else:
current_selections = current_selections_json or {}
return create_chart(current_selections, countries, year_range)
@callback(
Output('percentage-chart', 'figure'),
[Input('current-selections', 'children'),
Input('country-dropdown', 'value'),
Input('year-slider', 'value')]
)
def update_percentage_chart(current_selections_json, countries, year_range):
if isinstance(current_selections_json, str):
current_selections = json.loads(current_selections_json)
else:
current_selections = current_selections_json or {}
return create_percentage_chart(current_selections, countries, year_range)
if __name__ == '__main__':
app.run(debug=True)