#!/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]}
" + "Distance (km): %{customdata[1]:.2f}
" + "Elapsed Time: %{customdata[2]}" ), 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='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 === # 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) #