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