#!/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 from fitparse import FitFile # === Helper Functions === def list_fit_files(): folder = './fit_files' # Ordnerpfad anpassen if not os.path.exists(folder): os.makedirs(folder) files = [f for f in os.listdir(folder) if f.lower().endswith('.fit')] # Datum extrahieren für Sortierung def extract_date(filename): try: return datetime.datetime.strptime(filename[:10], '%d.%m.%Y') # Format DD.MM.YYYY except ValueError: try: return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') # Format YYYY-MM-DD except ValueError: return datetime.datetime.min # Ungültige -> ans Ende files.sort(key=extract_date, reverse=True) # Dropdown-Einträge bauen if files: return [{'label': f, 'value': os.path.join(folder, f)} for f in files] else: # Dummy-Eintrag, damit es nie crasht return [{ 'label': 'Keine FIT-Datei gefunden', 'value': 'NO_FILE' }] 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_fit(file_path): fit_file = FitFile(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) # Erstelle DataFrame df = pd.DataFrame(records) # 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['heart_rate'] = heart_rate[:len(df)] # ############## # 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 # ============================================================================= # 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[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") # 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 # 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='#E43D70', width=2), 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.8)], # Bei 80% 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 (220 - Alter, hier als Beispiel 190) max_hr_estimated = 190 # Du kannst das anpassen # Zone 1: Sehr leicht (50-60% HRmax) zone1_lower = max_hr_estimated * 0.5 zone1_upper = max_hr_estimated * 0.6 # Zone 2: Leicht (60-70% HRmax) zone2_upper = max_hr_estimated * 0.7 # Zone 3: Moderat (70-80% HRmax) zone3_upper = max_hr_estimated * 0.8 # Zone 4: Hart (80-90% HRmax) #update: bis 100% zone4_upper = max_hr_estimated * 1.0 # Füge Zonen-Bereiche als Hintergrundbereiche hinzu fig.add_hrect(y0=zone1_lower, y1=zone1_upper, fillcolor="green", opacity=0.1, line_width=0) fig.add_hrect(y0=zone1_upper, y1=zone2_upper, fillcolor="yellow", opacity=0.1, line_width=0) fig.add_hrect(y0=zone2_upper, y1=zone3_upper, fillcolor="orange", opacity=0.1, line_width=0) fig.add_hrect(y0=zone3_upper, y1=zone4_upper, fillcolor="red", opacity=0.1, line_width=0) # 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)', 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'], 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 = "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 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_hr', '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_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 # === 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) #