diff --git a/.gitignore b/.gitignore
index f3e4e31..d0f811a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
gpx_files/*
!gpx_files/.keep
+fit_files/*
+!fit_files/.keep
diff --git a/fit_app.py b/fit_app.py
new file mode 100644
index 0000000..d5526b3
--- /dev/null
+++ b/fit_app.py
@@ -0,0 +1,827 @@
+#!/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)
+#
diff --git a/fit_files/.keep b/fit_files/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app.py b/gpx_app.py
similarity index 77%
rename from app.py
rename to gpx_app.py
index 9d6732f..7b5bd52 100644
--- a/app.py
+++ b/gpx_app.py
@@ -21,6 +21,7 @@ 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
@@ -214,43 +215,82 @@ def create_map_plot(df):
)
)
return fig
-
-
-def create_elevation_plot(df):
+
+
+
+
+######################
+# NEUE VERSION:
+def create_elevation_plot(df, smooth_points=500):
+ # Originale Daten
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,
+ # 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='lime', width=2),
+ line=dict(color='#1CAF50'), # Fill between color!
+ fill='tozeroy',
+ #fillcolor='rgba(226, 241, 248)',
+ hoverinfo='skip',
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
- ))
+ # 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
+ ))
- fig = go.Figure(data=traces)
+ # 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)),
+ 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',
@@ -258,11 +298,13 @@ def create_elevation_plot(df):
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
- height=500
+ 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]
@@ -282,7 +324,7 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
template='plotly_dark',
)
fig.update_layout(
- title=dict(text='Abweichung von integrierter Durchschnittsgeschwindigkeit', font=dict(size=16)),
+ title=dict(text='Abweichung von integriertem Durchschnittstempo', font=dict(size=16)),
yaxis_title='Abweichung (km)',
xaxis_title='Zeit',
height=400,
@@ -299,7 +341,7 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
y0=0,
y1=0,
line=dict(color='gray', width=1, dash='dash'),
- name='Durchschnittsgeschwindigkeit'
+ name='Durchschnittstempo'
)
return fig
@@ -316,7 +358,7 @@ def create_speed_plot(df):
line=dict(color='royalblue')
))
fig.update_layout(
- title=dict(text=f'Durchschnittsgeschwindigkeit über die Zeit (geglättet): {mean_speed_kmh:.2f} km/h', font=dict(size=16)),
+ 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',
@@ -325,6 +367,16 @@ def create_speed_plot(df):
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
@@ -381,8 +433,49 @@ def create_pace_bars_plot(df):
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='Pace (min/km) je Kilometer', font=dict(size=16)),
+ title=dict(text='Tempo (min/km) je Kilometer', font=dict(size=16)),
xaxis_title='Distanz (km)',
yaxis_title='Minuten pro km',
barmode='overlay',
diff --git a/requirements.txt b/requirements.txt
index 2874af7..c8f38f5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,6 @@ dash-bootstrap-components
plotly
pandas
numpy
+scipy
gpxpy
+fitparse