#!/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)
#