#!/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, State 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 from fitparse import FitFile # === Helper Functions === def list_fit_files(): """ Listet alle .fit Files im Verzeichnis auf und sortiert sie nach Datum """ folder = './fit_files' # Prüfe ob Ordner existiert if not os.path.exists(folder): print(f"Ordner {folder} existiert nicht!") return [{'label': 'Ordner nicht gefunden', 'value': 'NO_FOLDER'}] # Hole alle .fit Files try: all_files = os.listdir(folder) files = [f for f in all_files if f.lower().endswith('.fit')] except Exception as e: print(f"Fehler beim Lesen des Ordners: {e}") return [{'label': 'Fehler beim Lesen', 'value': 'ERROR'}] def extract_date(filename): """Extrahiert Datum aus Filename für Sortierung""" try: # Versuche verschiedene Datumsformate return datetime.datetime.strptime(filename[:10], '%d.%m.%Y') except ValueError: try: return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') except ValueError: try: # Versuche auch andere Formate return datetime.datetime.strptime(filename[:8], '%Y%m%d') except ValueError: # Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum try: file_path = os.path.join(folder, filename) return datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) except: return datetime.datetime.min # Sortiere Files nach Datum (neueste zuerst) files.sort(key=extract_date, reverse=True) # Erstelle Dropdown-Optionen if files: options = [] for f in files: file_path = os.path.join(folder, f) # Zeige auch Dateigröße und Änderungsdatum an try: size_mb = os.path.getsize(file_path) / (1024 * 1024) mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) label = f"{f}" #label = f"{f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')}\n)" # For debugging purpose except: label = f options.append({ 'label': label, 'value': file_path }) return options else: return [{'label': 'Keine .fit Dateien gefunden', 'value': 'NO_FILE'}] def haversine(lon1, lat1, lon2, lat2): """ Berechnet die Entfernung zwischen zwei GPS-Koordinaten in km """ R = 6371 # Erdradius in km 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_fit(file_path): """ Verarbeitet eine FIT-Datei und erstellt einen DataFrame """ if file_path in ['NO_FILE', 'NO_FOLDER', 'ERROR']: print(f"Ungültiger Dateipfad: {file_path}") return pd.DataFrame() if not os.path.exists(file_path): print(f"Datei nicht gefunden: {file_path}") return pd.DataFrame() try: fit_file = FitFile(file_path) print(f"Verarbeite FIT-Datei: {file_path}") # Sammle alle record-Daten records = [] for record in fit_file.get_messages("record"): record_data = {} for data in record: # Sammle alle verfügbaren Datenfelder record_data[data.name] = data.value records.append(record_data) if not records: print("Keine Aufzeichnungsdaten in der FIT-Datei gefunden") return pd.DataFrame() # Erstelle DataFrame df = pd.DataFrame(records) print(f"DataFrame erstellt mit {len(df)} Zeilen und Spalten: {list(df.columns)}") # Debugging: Schaue welche Spalten verfügbar sind print(f"Verfügbare Spalten: {df.columns.tolist()}") # Suche nach Heart Rate in verschiedenen Formaten possible_hr_cols = [col for col in df.columns if 'heart' in col.lower() or 'hr' in col.lower()] print(f"Mögliche Heart Rate Spalten: {possible_hr_cols}") # Standard-Spaltennamen für verschiedene FIT-Formate lat_cols = ['position_lat', 'lat', 'latitude'] lon_cols = ['position_long', 'lon', 'longitude'] elev_cols = ['altitude', 'elev', 'elevation', 'enhanced_altitude'] time_cols = ['timestamp', 'time'] hr_cols = ['heart_rate', 'hr'] + possible_hr_cols speed_cols = ['speed', 'enhanced_speed'] dist_cols = ['distance', 'total_distance'] # Finde die richtigen Spaltennamen lat_col = next((col for col in lat_cols if col in df.columns), None) lon_col = next((col for col in lon_cols if col in df.columns), None) elev_col = next((col for col in elev_cols if col in df.columns), None) time_col = next((col for col in time_cols if col in df.columns), None) hr_col = next((col for col in hr_cols if col in df.columns), None) speed_col = next((col for col in speed_cols if col in df.columns), None) # Prüfe ob wichtige Daten vorhanden sind if not lat_col or not lon_col or not time_col: raise ValueError(f"Wichtige Daten fehlen! Lat: {lat_col}, Lon: {lon_col}, Time: {time_col}") # Benenne Spalten einheitlich um df = df.rename(columns={ lat_col: 'lat', lon_col: 'lon', elev_col: 'elev' if elev_col else None, time_col: 'time', hr_col: 'heart_rate' if hr_col else None, speed_col: 'speed_ms' if speed_col else None }) # FIT lat/lon sind oft in semicircles - konvertiere zu Grad if df['lat'].max() > 180: # Semicircles detection df['lat'] = df['lat'] * (180 / 2**31) df['lon'] = df['lon'] * (180 / 2**31) # Entferne Zeilen ohne GPS-Daten df = df.dropna(subset=['lat', 'lon', 'time']).reset_index(drop=True) # 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'].iloc[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 handling if 'elev' in df.columns: df['elev'] = df['elev'].bfill() df['delta_elev'] = df['elev'].diff().fillna(0) df['rel_elev'] = df['elev'] - df['elev'].iloc[0] else: # Fallback wenn keine Elevation vorhanden df['elev'] = 0 df['delta_elev'] = 0 df['rel_elev'] = 0 # Speed calculation if 'speed_ms' in df.columns: # Konvertiere m/s zu km/h df['speed_kmh'] = df['speed_ms'] * 3.6 else: # Fallback: Berechne Speed aus GPS-Daten 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) # Velocity (used in pace calculations) df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec']) # Smoothed speed (Gaussian rolling) df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2) # Heart rate handling (NEU!) # ############## # UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben: # save heart rate data into variable heart_rate = [] for record in fit_file.get_messages("record"): # Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc) for data in record: # Print the name and value of the data (and the units if it has any) if data.name == 'heart_rate': heart_rate.append(data.value) # Hier variable neu überschrieben: df = safe_add_column_to_dataframe(df, 'heart_rate', heart_rate) # ############## # MY DEBUG: #print(heart_rate) if 'heart_rate' in df.columns: df['heart_rate'] = pd.to_numeric(df['heart_rate'], errors='coerce') df['hr_smooth'] = df['heart_rate'].rolling(window=5, center=True).mean() print(f"Heart rate range: {df['heart_rate'].min():.0f} - {df['heart_rate'].max():.0f} bpm") else: print("Keine Heart Rate Daten gefunden!") df['heart_rate'] = np.nan df['hr_smooth'] = np.nan print(f"Verarbeitete FIT-Datei: {len(df)} Datenpunkte") print(f"Distanz: {df['cum_dist_km'].iloc[-1]:.2f} km") print(f"Dauer: {df['duration_hms'].iloc[-1]}") return df except Exception as e: print(f"Fehler beim Verarbeiten der FIT-Datei {file_path}: {str(e)}") return pd.DataFrame() def safe_add_column_to_dataframe(df, column_name, values): """ Fügt eine Spalte sicher zu einem DataFrame hinzu, auch wenn die Längen nicht übereinstimmen """ if df.empty: return df df_len = len(df) values_len = len(values) if hasattr(values, '__len__') else 0 if values_len == df_len: # Perfekt - gleiche Länge df[column_name] = values elif values_len > df_len: # Zu viele Werte - kürze sie print(f"WARNUNG: {column_name} hat {values_len} Werte, DataFrame hat {df_len} Zeilen. Kürze Werte.") df[column_name] = values[:df_len] elif values_len < df_len: # Zu wenige Werte - fülle mit NaN auf print(f"WARNUNG: {column_name} hat {values_len} Werte, DataFrame hat {df_len} Zeilen. Fülle mit NaN auf.") extended_values = list(values) + [None] * (df_len - values_len) df[column_name] = extended_values else: # Keine Werte - fülle mit NaN print(f"WARNUNG: Keine Werte für {column_name}. Fülle mit NaN.") df[column_name] = [None] * df_len 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': '5px', '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', zoom=13, height=800 ) fig.update_traces( hovertemplate=( #"Time: %{customdata[0]}
" + "Distance (Km): %{customdata[0]:.2f}
" + "Speed (Km/h): %{customdata[1]:.2f}
" + "Heart Rate (bpm): %{customdata[2]}
" + "Elapsed Time: %{customdata[3]}" ), #customdata=df[['time', 'cum_dist_km', 'duration_hms']] customdata=df[['cum_dist_km', 'speed_kmh', 'heart_rate', 'duration_hms']] ) # Define map style and the line ontop fig.update_layout(map_style="open-street-map") #My-Fav: open-street-map, satellite-streets, dark, white-bg # Possible Options: # 'basic', 'carto-darkmatter', 'carto-darkmatter-nolabels', 'carto-positron', 'carto-positron-nolabels', 'carto-voyager', 'carto-voyager-nolabels', 'dark', 'light', 'open-street-map', 'outdoors', 'satellite', 'satellite-streets', 'streets', 'white-bg'. 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' )) # THIS IS MY ELEVATION-PLOT SHOW POSITION-MARKER IN MAP-PLOT: fig.add_trace(go.Scattermap( lat=[], lon=[], mode="markers", marker=dict(size=18, color="#42B1E5", symbol="circle"), name="Hovered Point" )) # KOMPAKTE LAYOUT-EINSTELLUNGEN fig.update_layout( paper_bgcolor='#1e1e1e', font=dict(color='white'), # Margins reduzieren für kompakteren Plot margin=dict(l=60, r=45, t=10, b=50), # Links, Rechts, Oben, Unten # Plotly-Toolbar konfigurieren showlegend=True, # Kompakte Legend legend=dict( orientation='h', # horizontal layout yanchor='top', y=-0.02, # move legend below the map xanchor='center', x=0.5, font=dict(color='white', size=10) # Kleinere Schrift ) ) return fig # ##################### 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() # Separate Behandlung für positive und negative Bereiche y_array = np.array(y_smooth) x_array = np.array(x_smooth) # Positive Bereiche (Anstiege) - Gradient von transparent unten zu grün oben positive_mask = y_array >= 0 if np.any(positive_mask): # Nulllinie für positive Bereiche fig.add_trace(go.Scatter( x=x_array, y=np.zeros_like(y_array), mode='lines', line=dict(width=0), hoverinfo='skip', showlegend=False )) # Positive Bereiche mit Gradient nach oben fig.add_trace(go.Scatter( x=x_array, y=np.where(y_array >= 0, y_array, 0), # Nur positive Werte fill='tonexty', # Fill zur vorherigen Trace (Nulllinie) mode='lines', line=dict(width=0), fillgradient=dict( type="vertical", colorscale=[ (0.0, "rgba(17, 17, 17, 0.0)"), # Transparent unten (bei y=0) (1.0, "rgba(43, 82, 144, 0.8)") # Blau oben (bei maximaler Höhe) Grün: 73, 189, 115 ] ), hoverinfo='skip', showlegend=False )) # Negative Bereiche (Abstiege) - Gradient von grün unten zu transparent oben negative_mask = y_array < 0 if np.any(negative_mask): # Nulllinie für negative Bereiche fig.add_trace(go.Scatter( x=x_array, y=np.where(y_array < 0, y_array, 0), # Nur negative Werte mode='lines', line=dict(width=0), hoverinfo='skip', showlegend=False )) # Negative Bereiche mit Gradient nach unten fig.add_trace(go.Scatter( x=x_array, y=np.zeros_like(y_array), fill='tonexty', # Fill zur vorherigen Trace (negative Werte) mode='lines', line=dict(width=0), fillgradient=dict( type="vertical", colorscale=[ (0.0, "rgba(43, 82, 144, 0.8)"), # Blau unten (bei minimaler Tiefe) (1.0, "rgba(17, 17, 17, 0.0)") # Transparent oben (bei y=0) ] ), hoverinfo='skip', showlegend=False )) # Hauptlinie (geglättet) - über allem fig.add_trace(go.Scatter( x=x_smooth, y=y_smooth, mode='lines', line=dict(color='#2b5290', width=2), # Weiße Linie für bessere Sichtbarkeit name='Elevation', showlegend=False )) # 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' ) # Layout im Dark Theme fig.update_layout( title=dict(text='Höhenprofil (relativ zum Ausgangswert: 0m)', 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 # Alte Version - normaler gradient fill between: # 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 # heart_rate Plot NEW !!! def create_heart_rate_plot(df): # Maske für gültige Heart Rate Daten mask = df['hr_smooth'].isna() # Durchschnittliche Heart Rate berechnen (nur gültige Werte) valid_hr = df['heart_rate'].dropna() if len(valid_hr) > 0: mean_hr = valid_hr.mean() min_hr = valid_hr.min() max_hr = valid_hr.max() else: mean_hr = 0 min_hr = 0 max_hr = 0 fig = go.Figure() # Heart Rate Linie (geglättet) fig.add_trace(go.Scatter( x=df['time'][~mask], y=df['hr_smooth'][~mask], mode='lines', #name='Geglättete Herzfrequenz', line=dict(color='#ff2c48', width=2), showlegend=False, hovertemplate=( "Zeit: %{x}
" + "Herzfrequenz: %{y:.0f} bpm
" + "" ) )) # # Optional: Raw Heart Rate als dünnere, transparente Linie # if not df['heart_rate'].isna().all(): # fig.add_trace(go.Scatter( # x=df['time'], # y=df['heart_rate'], # mode='lines', # name='Raw Herzfrequenz', # line=dict(color='#E43D70', width=1, dash='dot'), # opacity=0.3, # showlegend=False, # hoverinfo='skip' # )) # Durchschnittslinie if mean_hr > 0: fig.add_shape( type='line', x0=df['time_loc'].iloc[0], x1=df['time_loc'].iloc[-1], y0=mean_hr, y1=mean_hr, line=dict(color='gray', width=1, dash='dash'), ) # Annotation für Durchschnittswert fig.add_annotation( x=df['time_loc'].iloc[int(len(df) * 0.5)], # Bei 50% der Zeit y=mean_hr, text=f"Ø {mean_hr:.0f} bpm", showarrow=True, arrowhead=2, arrowcolor="gray", bgcolor="rgba(128,128,128,0.1)", bordercolor="gray", font=dict(color="white", size=10) ) # Heart Rate Zonen (optional) if mean_hr > 0: # Geschätzte maximale Herzfrequenz (Beispiel: 200 bpm) max_hr_estimated = 200 # oder z. B. 220 - alter ## Definiere feste HR-Zonen in BPM #zones = [ # {"name": "Zone 1", "lower": 0, "upper": 124, "color": "#F4A4A3"}, # Regeneration (Recovery) # {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#EF7476"}, # Grundlagenausdauer (Endurance) # {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#EA4748"}, # Tempo (Aerob) # {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#E02628"}, # Schwelle (Threshold) (Anaerob) # {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#B71316"}, # Neuromuskulär (Neuromuskulär) #] zones = [ {"name": "Zone 1", "lower": 0, "upper": 124, "color": "#4A4A4A"}, # Regeneration (Recovery) (#111111 Transparent) {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#87CEFA"}, # Grundlagenausdauer (Endurance) {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#90EE90"}, # Tempo (Aerob) {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFDAB9"}, # Schwelle (Threshold) (Anaerob) {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#FFB6C1"}, # Neuromuskulär (Neuromuskulär) ] # Zeichne Zonen als Hintergrund (horizontale Rechtecke) for zone in zones: fig.add_hrect( y0=zone["lower"], y1=zone["upper"], fillcolor=zone["color"], opacity=0.15, line_width=0, annotation_text=zone["name"], # optional: Name der Zone einblenden annotation_position="top left" ) # Layout title_text = f'Herzfrequenz über die Zeit (geglättete)' if mean_hr > 0: title_text += f' - Ø {mean_hr:.0f} bpm (Range: {min_hr:.0f}-{max_hr:.0f})' fig.update_layout( title=dict(text=title_text, font=dict(size=16, color='white')), xaxis=dict( title='Zeit', tickformat='%H:%M', type='date' ), yaxis=dict( title='Herzfrequenz (bpm)', range=[80, 200] # Statt rangemode='tozero' ), 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_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'], # Mittig unter jeder Bar 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', textposition='inside', marker_color='#125595', opacity=0.9, # Transparenz 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 = "FIT Dashboard" app.layout = html.Div([ html.H1("Running Dashboard", style={'textAlign': 'center'}), dcc.Store(id='stored-df'), html.Div([ html.Label("FIT-Datei wählen:", style={'color': 'white'}), dcc.Dropdown( id='fit-file-dropdown', options=list_fit_files(), value=list_fit_files()[0]['value'], # immer gültig 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_hr'), dcc.Graph(id='fig_pace_bars') ]) # === Callbacks === # Callback 1: Load GPX File and Store as JSON @app.callback( Output('stored-df', 'data'), Input('fit-file-dropdown', 'value') ) def load_fit_data(path): df = process_fit(path) return df.to_json(date_format='iso', orient='split') # Callback 2: Update All (static) Plots @app.callback( Output('info-banner', 'children'), Output('fig-map', 'figure', allow_duplicate=True), Output('fig-elevation', 'figure'), Output('fig_deviation', 'figure'), Output('fig_speed', 'figure'), Output('fig_hr', 'figure'), Output('fig_pace_bars', 'figure'), Input('stored-df', 'data'), prevent_initial_call=True ) 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_hr = create_heart_rate_plot(df) fig_pace = create_pace_bars_plot(df) return info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace # Callback 3: Hover → update only hover (dynamic) marker @app.callback( Output('fig-map', 'figure'), Input('fig-elevation', 'hoverData'), State('fig-map', 'figure'), State('stored-df', 'data'), prevent_initial_call=True ) def highlight_map(hoverData, fig_map, json_data): df = pd.read_json(io.StringIO(json_data), orient='split') if hoverData is not None: point_index = hoverData['points'][0]['pointIndex'] lat, lon = df.iloc[point_index][['lat', 'lon']] # update the last trace (the empty Hovered Point trace) fig_map['data'][-1]['lat'] = [lat] fig_map['data'][-1]['lon'] = [lon] return fig_map # === 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) #