Files
jogging-dashboard/app.py

480 lines
15 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
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_mapbox(
# 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_mapbox(
df,
lat='lat',
lon='lon',
zoom=13,
height=800
)
fig.update_traces(
hovertemplate=(
#"Time: %{customdata[0]}<br>" +
"Distance (km): %{customdata[1]:.2f}<br>" +
"Elapsed Time: %{customdata[2]}<extra></extra>"
),
customdata=df[['time', 'cum_dist_km', 'duration_hms']]
)
#
fig.update_layout(mapbox_style="open-street-map")
fig.update_traces(line=dict(color="#f54269", width=3))
# Start / Stop marker
start = df.iloc[0]
end = df.iloc[-1]
fig.add_trace(go.Scattermapbox(
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.Scattermapbox(
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
def create_elevation_plot(df):
x = df['time']
y = df['rel_elev']
n_layers = 36
base_color = (5, 158, 5) # Greenish
max_alpha = 0.25
traces = []
# Main elevation line
traces.append(go.Scatter(
x=x, y=y,
mode='lines',
line=dict(color='lime', width=2),
showlegend=False
))
# Single gradient fill (above and below 0)
for i in range(1, n_layers + 1):
alpha = max_alpha * (1 - i / n_layers)
color = f'rgba({base_color[0]}, {base_color[1]}, {base_color[2]}, {alpha:.3f})'
y_layer = y * (i / n_layers)
traces.append(go.Scatter(
x=x,
y=y_layer,
mode='lines',
fill='tonexty',
line=dict(width=0),
fillcolor=color,
hoverinfo='skip',
showlegend=False
))
fig = go.Figure(data=traces)
fig.update_layout(
title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16)),
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=500
)
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 integrierter Durchschnittsgeschwindigkeit', 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='Durchschnittsgeschwindigkeit'
)
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'Geschwindigkeit über die Zeit (geglättet) (∅: {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)
)
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
))
fig.update_layout(
title=dict(text='Pace (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(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)
#