431 lines
13 KiB
Python
431 lines
13 KiB
Python
#!/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')]
|
||
|
||
|
||
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()
|
||
# 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',
|
||
zoom=13, height=800)
|
||
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='Höhenprofil relativ zum Startwert',
|
||
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=20, 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',
|
||
title='Abweichung von integrierter Durchschnittsgeschwindigkeit',
|
||
labels={
|
||
'time_loc': 'Zeit',
|
||
'del_dist_km_qmean': 'Δ Strecke (km)'
|
||
},
|
||
template='plotly_dark',
|
||
)
|
||
fig.update_layout(
|
||
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=f'Geschwindigkeit über die Zeit (geglättet) (∅: {mean_speed_kmh:.2f} km/h)',
|
||
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='Pace (min/km) je Kilometer',
|
||
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=20, t=20, 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 ===
|
||
@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')
|
||
|
||
|
||
@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)
|
||
#
|