#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Thu Jul 30th 2025
@author: Marcel Weschke
@email: marcel.weschke@directbox.de
"""
# %% Load libraries
import os
import base64
import io
import datetime
from math import radians, sin, cos, sqrt, asin
import dash
from dash import dcc, html, Input, Output, Dash
import dash_bootstrap_components as dbc
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from scipy.interpolate import interp1d
import gpxpy
# === Helper Functions ===
def list_gpx_files():
folder = './gpx_files'
#return [{'label': f, 'value': os.path.join(folder, f)} for f in os.listdir(folder) if f.endswith('.gpx')]
files = [f for f in os.listdir(folder) if f.endswith('.gpx')]
# Extract date from the start of the filename and sort descending
def extract_date(filename):
try:
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
except ValueError:
return datetime.datetime.min # Put files without a valid date at the end
files.sort(key=extract_date, reverse=True)
return [{'label': f, 'value': os.path.join(folder, f)} for f in files]
def haversine(lon1, lat1, lon2, lat2):
R = 6371
dlon = radians(lon2 - lon1)
dlat = radians(lat2 - lat1)
a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
return 2 * R * asin(sqrt(a))
def process_gpx(file_path):
with open(file_path, 'r') as gpx_file:
gpx = gpxpy.parse(gpx_file)
points = gpx.tracks[0].segments[0].points
df = pd.DataFrame([{
'lat': p.latitude,
'lon': p.longitude,
'elev': p.elevation,
'time': p.time
} for p in points])
# Basic cleanup
df['time'] = pd.to_datetime(df['time'])
df['time_loc'] = df['time'].dt.tz_localize(None)
df['time_diff'] = df['time'] - df['time'][0]
df['time_diff_sec'] = df['time_diff'].dt.total_seconds()
df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0])
# Cumulative distance (km)
distances = [0]
for i in range(1, len(df)):
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat'])
distances.append(distances[-1] + d)
df['cum_dist_km'] = distances
# Elevation and elevation change
df['elev'] = df['elev'].bfill()
df['delta_elev'] = df['elev'].diff().fillna(0)
df['rel_elev'] = df['elev'] - df['elev'].iloc[0]
# Velocity (used in pace and speed)
df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec'])
# Speed calculation (km/h) via distance and time diffs
df['delta_t'] = df['time'].diff().dt.total_seconds()
df['delta_d'] = df['cum_dist_km'].diff()
df['speed_kmh'] = (df['delta_d'] / df['delta_t']) * 3600
df['speed_kmh'] = df['speed_kmh'].replace([np.inf, -np.inf], np.nan)
# Smoothed speed (Gaussian rolling)
df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
return df
# =============================================================================
# INFO BANNER
# =============================================================================
def create_info_banner(df):
# Total distance in km
total_distance_km = df['cum_dist_km'].iloc[-1]
# Total time as timedelta
total_seconds = df['time_diff_sec'].iloc[-1]
hours, remainder = divmod(int(total_seconds), 3600)
minutes, seconds = divmod(remainder, 60)
formatted_total_time = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
# Average pace (min/km)
if total_distance_km > 0:
pace_sec_per_km = total_seconds / total_distance_km
pace_min = int(pace_sec_per_km // 60)
pace_sec = int(pace_sec_per_km % 60)
formatted_pace = f"{pace_min}:{pace_sec:02d} min/km"
else:
formatted_pace = "N/A"
# Build the info banner layout
info_banner = html.Div([
html.Div([
html.H4("Total Distance", style={'margin-bottom': '5px'}),
html.H2(f"{total_distance_km:.2f} km")
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
html.Div([
html.H4("Total Time", style={'margin-bottom': '5px'}),
html.H2(formatted_total_time)
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
html.Div([
html.H4("Average Pace", style={'margin-bottom': '5px'}),
html.H2(formatted_pace)
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
], style={
'display': 'flex',
'justifyContent': 'space-around',
'backgroundColor': '#1e1e1e',
'color': 'white',
'padding': '20px',
'marginBottom': '5px',
'borderRadius': '10px',
'width': '100%',
#'maxWidth': '1200px',
'margin': 'auto'
})
return info_banner
# =============================================================================
# START OF THE PLOTS
# =============================================================================
def create_map_plot(df):
# fig = px.line_map(
# df,
# lat='lat',
# lon='lon',
# hover_name='time',
# hover_data={
# 'cum_dist_km': ':.2f',
# 'duration_hms': True,
# 'lat': False,
# 'lon': False,
# 'time': False
# },
# labels={
# 'cum_dist_km': 'Distance (km) ',
# 'duration_hms': 'Elapsed Time '
# },
# zoom=13,
# height=800
# )
fig = px.line_map(
df,
lat='lat',
lon='lon',
zoom=13,
height=800
)
fig.update_traces(
hovertemplate=(
#"Time: %{customdata[0]}
" +
"Distance (km): %{customdata[1]:.2f}
" +
"Elapsed Time: %{customdata[2]}"
),
customdata=df[['time', 'cum_dist_km', 'duration_hms']]
)
# Define map style and the line ontop
fig.update_layout(map_style="open-street-map")
# The built-in plotly.js styles are: carto-darkmatter, carto-positron, open-street-map, stamen-terrain, stamen-toner, stamen-watercolor, white-bg
# The built-in Mapbox styles are: basic, streets, outdoors, light, dark, satellite, satellite-streets
fig.update_traces(line=dict(color="#f54269", width=3))
# Start / Stop marker
start = df.iloc[0]
end = df.iloc[-1]
fig.add_trace(go.Scattermap(
lat=[start['lat']], lon=[start['lon']], mode='markers+text',
marker=dict(size=12, color='#fca062'), text=['Start'], name='Start', textposition='bottom left'
))
fig.add_trace(go.Scattermap(
lat=[end['lat']], lon=[end['lon']], mode='markers+text',
marker=dict(size=12, color='#b9fc62'), text=['Stop'], name='Stop', textposition='bottom left'
))
fig.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'))
fig.update_layout(
legend=dict(
orientation='h', # horizontal layout
yanchor='top',
y=-0.01, # move legend below the map
xanchor='center',
x=0.5,
font=dict(color='white')
)
)
return fig
######################
# NEUE VERSION:
def create_elevation_plot(df, smooth_points=500):
# Originale Daten
x = df['time']
y = df['rel_elev']
# Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
if len(y) >= 4: # Genug Punkte für cubic interpolation
y_numeric = y.to_numpy()
# Nur gültige Y-Punkte für Interpolation
mask = ~np.isnan(y_numeric)
if np.sum(mask) >= 4: # Genug gültige Punkte
# Index-basierte Interpolation für Y-Werte
valid_indices = np.where(mask)[0]
valid_y = y_numeric[mask]
# Interpolation über die Indizes
f = interp1d(valid_indices, valid_y, kind='cubic',
bounds_error=False, fill_value='extrapolate')
# Neue Y-Werte für alle ursprünglichen X-Positionen
all_indices = np.arange(len(y))
y_smooth = f(all_indices)
# Originale X-Werte beibehalten
x_smooth = x
else:
# Fallback: originale Daten
x_smooth, y_smooth = x, y
else:
# Zu wenige Punkte: originale Daten verwenden
x_smooth, y_smooth = x, y
fig = go.Figure()
# Fläche unter der Kurve (mit geglätteten Daten)
fig.add_trace(go.Scatter(
x=x_smooth, y=y_smooth,
mode='lines',
line=dict(color='#1CAF50'), # Fill between color!
fill='tozeroy',
#fillcolor='rgba(226, 241, 248)',
hoverinfo='skip',
showlegend=False
))
# Hauptlinie (geglättet)
fig.add_trace(go.Scatter(
x=x_smooth, y=y_smooth,
mode='lines',
line=dict(color='#084C20', width=2), # Line color!
name='Elevation',
showlegend=False
))
# SUPDER IDEE, ABER GEHT NICHT WEGE NEUEN smoothed POINTS! GEHT NUR BEI X
#fig.update_traces(
# hovertemplate=(
# #"Time: %{customdata[0]}
" +
# "Distance (km): %{customdata[0]:.2f}
" +
# "Elevation: %{customdata[1]}" +
# "Elapsed Time: %{customdata[2]}"
# ),
# customdata=df[['cum_dist_km','elev', 'time']]
#
# Layout im Dark Theme
fig.update_layout(
title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16, color='white')),
xaxis_title='Zeit',
yaxis_title='Höhe relativ zum Start (m)',
template='plotly_dark',
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
height=400
)
return fig
def create_deviation_plot(df): #Distanz-Zeit-Diagramm
# Compute mean velocity in km/s
vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1]
# Expected cumulative distance assuming constant mean velocity
df['cum_dist_km_qmean'] = df['time_diff_sec'] * vel_kmps_mean
# Deviation from mean velocity distance
df['del_dist_km_qmean'] = df['cum_dist_km'] - df['cum_dist_km_qmean']
# Plot the deviation
fig = px.line(
df,
x='time_loc',
y='del_dist_km_qmean',
labels={
'time_loc': 'Zeit',
'del_dist_km_qmean': 'Δ Strecke (km)'
},
template='plotly_dark',
)
fig.update_layout(
title=dict(text='Abweichung von integriertem Durchschnittstempo', font=dict(size=16)),
yaxis_title='Abweichung (km)',
xaxis_title='Zeit',
height=400,
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
font=dict(color='white', size=14),
margin=dict(l=40, r=40, t=50, b=40)
)
# Add horizontal reference line at y=0
fig.add_shape(
type='line',
x0=df['time_loc'].iloc[0],
x1=df['time_loc'].iloc[-1],
y0=0,
y1=0,
line=dict(color='gray', width=1, dash='dash'),
name='Durchschnittstempo'
)
return fig
def create_speed_plot(df):
mask = df['speed_kmh_smooth'].isna()
mean_speed_kmh = df['speed_kmh'].mean()
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df['time'][~mask],
y=df['speed_kmh_smooth'][~mask],
mode='lines',
name='Geglättete Geschwindigkeit',
line=dict(color='royalblue')
))
fig.update_layout(
title=dict(text=f'Tempo über die Zeit (geglättet) - Durchschnittstempo: {mean_speed_kmh:.2f} km/h', font=dict(size=16)),
xaxis=dict(title='Zeit', tickformat='%H:%M', type='date'),
yaxis=dict(title='Geschwindigkeit (km/h)', rangemode='tozero'),
template='plotly_dark',
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=40, b=40)
)
# Add horizontal reference line at y=mean_speed_kmh
fig.add_shape(
type='line',
x0=df['time_loc'].iloc[0],
x1=df['time_loc'].iloc[-1],
y0=mean_speed_kmh,
y1=mean_speed_kmh,
line=dict(color='gray', width=1, dash='dash'),
name='Durchschnittstempo'
)
return fig
def create_pace_bars_plot(df):
# Ensure time column is datetime
if not pd.api.types.is_datetime64_any_dtype(df['time']):
df['time'] = pd.to_datetime(df['time'], errors='coerce')
# Assign km segments
df['km'] = df['cum_dist_km'].astype(int)
# Time in seconds from start
df['time_sec'] = (df['time'] - df['time'].iloc[0]).dt.total_seconds()
# Step 3: Compute pace manually per km group
df['km_start'] = np.nan
df['segment_len'] = np.nan
df['pace_min_per_km'] = np.nan
for km_val, group in df.groupby('km'):
dist_start = group['cum_dist_km'].iloc[0]
dist_end = group['cum_dist_km'].iloc[-1]
segment_len = dist_end - dist_start
time_start = group['time_sec'].iloc[0]
time_end = group['time_sec'].iloc[-1]
elapsed_time_sec = time_end - time_start
if segment_len > 0:
pace_min_per_km = (elapsed_time_sec / 60) / segment_len
else:
pace_min_per_km = np.nan
df.loc[group.index, 'km_start'] = km_val
df.loc[group.index, 'segment_len'] = segment_len
df.loc[group.index, 'pace_min_per_km'] = pace_min_per_km
# Clean types
df['km_start'] = df['km_start'].astype(int)
df['segment_len'] = df['segment_len'].astype(float)
df['pace_min_per_km'] = pd.to_numeric(df['pace_min_per_km'], errors='coerce')
# Step 4: Create Plotly bar chart
fig = go.Figure()
fig.add_trace(go.Bar(
x=df['km_start'],
y=df['pace_min_per_km'],
width=df['segment_len'],
text=[f"{v:.1f} min/km" if pd.notnull(v) else "" for v in df['pace_min_per_km']],
textposition='outside',
marker_color='dodgerblue',
name='Pace pro km',
offset=0
))
# #########
# Add horizontal reference line - X-Werte für gesamte Breite
# Calculate average pace
total_distance_km = df['cum_dist_km'].iloc[-1]
total_seconds = df['time_diff_sec'].iloc[-1]
# Average pace (min/km) - KORRIGIERT
if total_distance_km > 0:
pace_sec_per_km = total_seconds / total_distance_km
pace_min_per_km = pace_sec_per_km / 60 # Konvertiere zu Minuten pro km
else:
pace_min_per_km = 0
fig.add_shape(
type='line',
x0=0, # Start bei 0
x1=total_distance_km, # Ende bei maximaler Distanz
y0=pace_min_per_km,
y1=pace_min_per_km,
line=dict(color='gray', width=1, dash='dash'),
)
## Optional: Text-Annotation für die durchschnittliche Pace
#fig.add_annotation(
# x=total_distance_km * 0.8, # Position bei 80% der Distanz
# y=pace_min_per_km,
# text=f"Ø {pace_min_per_km:.1f} min/km",
# showarrow=True,
# arrowhead=2,
# arrowcolor="gray",
# bgcolor="rgba(255,0,0,0.1)",
# bordercolor="gray",
# font=dict(color="white")
#)
fig.update_layout(
title=dict(text='Tempo (min/km) je Kilometer', font=dict(size=16)),
xaxis_title='Distanz (km)',
yaxis_title='Minuten pro km',
barmode='overlay',
bargap=0,
bargroupgap=0,
xaxis=dict(
type='linear',
range=[0, df['cum_dist_km'].iloc[-1]],
tickmode='linear',
dtick=1,
showgrid=True
),
template='plotly_dark',
height=400,
margin=dict(l=40, r=40, t=30, b=40),
plot_bgcolor='#111111',
paper_bgcolor='#1e1e1e',
font=dict(color='white')
)
return fig
# === App Setup ===
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE])
app.title = "GPX Dashboard"
app.layout = html.Div([
html.H1("Running Dashboard", style={'textAlign': 'center'}),
dcc.Store(id='stored-df'),
html.Div([
html.Label("GPX-Datei wählen:", style={'color': 'white'}),
dcc.Dropdown(
id='gpx-file-dropdown',
options=list_gpx_files(),
value=list_gpx_files()[0]['value'],
clearable=False,
style={'width': '300px', 'color': 'black'}
)
], style={'padding': '20px', 'backgroundColor': '#1e1e1e'}),
html.Div(id='info-banner'),
dcc.Graph(id='fig-map'),
dcc.Graph(id='fig-elevation'),
dcc.Graph(id='fig_deviation'),
dcc.Graph(id='fig_speed'),
dcc.Graph(id='fig_pace_bars')
])
# === Callbacks ===
# Callback 1: Load GPX File and Store as JSON
@app.callback(
Output('stored-df', 'data'),
Input('gpx-file-dropdown', 'value')
)
def load_gpx_data(path):
df = process_gpx(path)
return df.to_json(date_format='iso', orient='split')
# Callback 2: Update All Plots
@app.callback(
Output('info-banner', 'children'),
Output('fig-map', 'figure'),
Output('fig-elevation', 'figure'),
Output('fig_deviation', 'figure'),
Output('fig_speed', 'figure'),
Output('fig_pace_bars', 'figure'),
Input('stored-df', 'data')
)
def update_all_plots(json_data):
df = pd.read_json(io.StringIO(json_data), orient='split')
info = create_info_banner(df)
fig_map = create_map_plot(df)
fig_elev = create_elevation_plot(df)
fig_dev = create_deviation_plot(df)
fig_speed = create_speed_plot(df)
fig_pace = create_pace_bars_plot(df)
return info, fig_map, fig_elev, fig_dev, fig_speed, fig_pace
# === Run Server ===
if __name__ == '__main__':
app.run(debug=True, port=8051)
# NOTE:
# Zusammenhang zwischen Pace und Geschwindigkeit
# - Pace = Minuten pro Kilometer (z. B. 5:40/km)
# - Geschwindigkeit = Kilometer pro Stunde (z. B. 10.71 km/h)
#