diff --git a/DashboardApp_WebVersion.png b/DashboardApp_WebVersion.png index 3fefb03..137b368 100644 Binary files a/DashboardApp_WebVersion.png and b/DashboardApp_WebVersion.png differ diff --git a/jogging_dashboard_browser_app.py b/jogging_dashboard_browser_app.py index f6b7974..d8ec3c3 100644 --- a/jogging_dashboard_browser_app.py +++ b/jogging_dashboard_browser_app.py @@ -34,7 +34,9 @@ from matplotlib.colors import LinearSegmentedColormap, Normalize from xml.etree.ElementTree import Element, SubElement, tostring import xml.etree.ElementTree as ET -# === Helper Functions === +# ============================================================================= +# Helper Functions +# ============================================================================= def list_files(): """ Listet alle .fit Files aus fit_files/ und .gpx Files aus gpx_files/ auf @@ -148,9 +150,9 @@ def haversine(lon1, lat1, lon2, lat2): return 2 * R * asin(sqrt(a)) -######## -# FIT -######## +# ----------------------------------------------------------------------------- +# FIT-FILE-FUNCTION +# ----------------------------------------------------------------------------- def process_fit(file_path): """ Verarbeitet eine FIT-Datei und erstellt einen DataFrame @@ -313,9 +315,9 @@ def process_fit(file_path): -######## -# GPX -######## +# ----------------------------------------------------------------------------- +# GPX-FILE-FUNCTION +# ----------------------------------------------------------------------------- def process_gpx(file_path): """ Verarbeitet GPX-Dateien und gibt DataFrame im gleichen Format wie process_fit() zurück @@ -520,9 +522,9 @@ def calculate_calories_burned(df): return int(round(cumulative)) -# ============================================================================= +# ----------------------------------------------------------------------------- # INFO BANNER -# ============================================================================= +# ----------------------------------------------------------------------------- def create_info_banner(df): # Total distance in km total_distance_km = df['cum_dist_km'].iloc[-1] @@ -590,9 +592,9 @@ def create_info_banner(df): return info_banner -# ============================================================================= +# ----------------------------------------------------------------------------- # EXPORT SUMMARY IMAGE (SVG) -# ============================================================================= +# ----------------------------------------------------------------------------- def create_strava_style_svg(df, total_distance_km=None, total_time=None, avg_pace=None, width=800, height=600, padding=50): """ @@ -753,9 +755,10 @@ def calculate_pace(distance_km, total_seconds): -# ============================================================================= +# ----------------------------------------------------------------------------- # START OF THE PLOTS -# ============================================================================= +# MAP-PLOT: +# ----------------------------------------------------------------------------- def create_map_plot(df): fig = px.line_map( df, @@ -830,9 +833,9 @@ def create_map_plot(df): -# ----------------------------------------------------------------------------- +# ============================================================================= # HILFSFUNKTION: GPS → Pixel-Koordinaten (Mercator-Projektion) -# ----------------------------------------------------------------------------- +# ============================================================================= def _gps_to_pixel(lats, lons, img_width=800, img_height=600, padding=None, pad_top=40, pad_bottom=60, pad_left=10, pad_right=10): @@ -879,9 +882,9 @@ def _gps_to_pixel(lats, lons, img_width=800, img_height=600, return xs, ys, meta -# ----------------------------------------------------------------------------- +# ============================================================================= # HILFSFUNKTION: Matplotlib-Figure → base64-PNG (für Dash dcc.Graph) -# ----------------------------------------------------------------------------- +# ============================================================================= def _fig_to_base64(fig): """ Konvertiert eine Matplotlib-Figure zu einem base64-PNG. @@ -985,7 +988,7 @@ def load_runs_for_city(city_code, all_file_options): # ----------------------------------------------------------------------------- -# PLOT 1: HEATMAP (count) – mehrere Läufe, Linienstärke = Häufigkeit +# PIXEL-PLOT 1: HEATMAP (count) – mehrere Läufe, Linienstärke = Häufigkeit # ----------------------------------------------------------------------------- def create_pixel_heatmap(dataframes, img_width=900, img_height=900, @@ -995,7 +998,7 @@ def create_pixel_heatmap(dataframes, n_city_runs=0, highlight_df=None): """ - Sam-Style Heatmap: Zeichnet einen oder mehrere Läufe pixelweise. + Heatmap: Zeichnet einen oder mehrere Läufe pixelweise. Je öfter ein Pixel überquert wurde, desto heller/wärmer die Farbe. Args: @@ -1167,7 +1170,7 @@ def create_pixel_heatmap(dataframes, # ----------------------------------------------------------------------------- -# PLOT 2: ELEVATION-MAP – Farbe zeigt Steigung/Gefälle je Segment +# PIXEL-PLOT 2: ELEVATION-MAP – Farbe zeigt Steigung/Gefälle je Segment # ----------------------------------------------------------------------------- def create_pixel_elevation_map(df, img_width=900, img_height=900, line_width=3, bg_color='#0d0d0d'): @@ -1309,12 +1312,12 @@ def create_pixel_elevation_map(df, img_width=900, img_height=900, # ----------------------------------------------------------------------------- -# PLOT 3: PACE-MAP – Farbe zeigt Tempo je Segment (schnell = blau, langsam = rot) +# PIXEL-PLOT 3: PACE-MAP – Farbe zeigt Tempo je Segment (schnell = blau, langsam = rot) # ----------------------------------------------------------------------------- def create_pixel_pace_map(df, img_width=900, img_height=900, line_width=3, bg_color='#0d0d0d'): """ - Sam-Style Pace-Map: Die Route wird pixelweise gezeichnet, wobei die + Pace-Map: Die Route wird pixelweise gezeichnet, wobei die Farbe anzeigt, wie schnell du in diesem Bereich gelaufen bist. Blau = schnell (niedriger min/km-Wert), Rot = langsam (hoher min/km-Wert). @@ -1447,7 +1450,7 @@ def create_pixel_pace_map(df, img_width=900, img_height=900, # ----------------------------------------------------------------------------- -# PLOT 4: HEART-RATE-MAP +# PIXEL-PLOT 4: HEART-RATE-MAP # ----------------------------------------------------------------------------- def create_pixel_hr_map(df, img_width=900, img_height=900, line_width=3, bg_color='#0d0d0d'): @@ -1626,7 +1629,9 @@ def create_pixel_hr_map(df, img_width=900, img_height=900, -# ##################### +# ----------------------------------------------------------------------------- +# ELEVATION-PLOT: +# ----------------------------------------------------------------------------- def create_elevation_plot(df, smooth_points=500): # Originale Daten x = df['time'] @@ -1846,8 +1851,10 @@ def create_elevation_plot(df, smooth_points=500): - -def create_deviation_plot(df): #Distanz-Zeit-Diagramm +# ----------------------------------------------------------------------------- +# DEVIATION-PLOT: Distanz-Zeit-Diagramm +# ----------------------------------------------------------------------------- +def create_deviation_plot(df): # 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 @@ -1889,6 +1896,9 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm return fig +# ----------------------------------------------------------------------------- +# SPEED-PLOT: +# ----------------------------------------------------------------------------- def create_speed_plot(df): mask = df['speed_kmh_smooth'].isna() mean_speed_kmh = df['speed_kmh'].mean() @@ -1926,10 +1936,9 @@ def create_speed_plot(df): - - - -# heart_rate Plot NEW !!! +# ----------------------------------------------------------------------------- +# HEART-RATE-PLOT: +# ----------------------------------------------------------------------------- def create_heart_rate_plot(df): # Maske für gültige Heart Rate Daten mask = df['hr_smooth'].isna() @@ -2079,124 +2088,261 @@ def create_heart_rate_plot(df): - - - - - - - - +# ----------------------------------------------------------------------------- +# PACE-BAR-PLOT: +# ----------------------------------------------------------------------------- def create_pace_bars_plot(df, formatted_pace=None): - # Ensure time column is datetime + """ + Strava-Style Pace-Histogram: Horizontale Balken, ein Balken pro km-Segment. + Links: km-Label + Pace-Text. Mitte: Balken (Breite = Pace-Wert). + Rechts: Elevation-Delta und Heart Rate je Segment. + Vertikale gestrichelte Linie = Durchschnittspace. + """ + import pandas as pd + import numpy as np + import plotly.graph_objects as go + + # Sicherstellen dass time eine datetime-Spalte ist if not pd.api.types.is_datetime64_any_dtype(df['time']): + df = df.copy() df['time'] = pd.to_datetime(df['time'], errors='coerce') - # Assign km segments + df = df.copy() 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 - + # ------------------------------------------------------------------------- + # Pace, Elevation-Delta, HR je km-Segment berechnen + # ------------------------------------------------------------------------- + segments = [] for km_val, group in df.groupby('km'): - dist_start = group['cum_dist_km'].iloc[0] - dist_end = group['cum_dist_km'].iloc[-1] + 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] + 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 + if segment_len > 0 and elapsed_time_sec > 0: + pace_min = (elapsed_time_sec / 60) / segment_len else: - pace_min_per_km = np.nan + pace_min = 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 + # Elevation-Delta für dieses Segment + elev_delta = np.nan + if 'elev' in group.columns and group['elev'].notna().sum() >= 2: + elev_delta = group['elev'].iloc[-1] - group['elev'].iloc[0] + elif 'rel_elev' in group.columns and group['rel_elev'].notna().sum() >= 2: + elev_delta = group['rel_elev'].iloc[-1] - group['rel_elev'].iloc[0] - # 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') + # Durchschnittliche HR für dieses Segment + hr_mean = np.nan + if 'heart_rate' in group.columns: + valid = group['heart_rate'].dropna() + if len(valid) > 0: + hr_mean = valid.mean() + + # Km-Label: letzter Km erhält tatsächliche Distanz als Label + is_last = (km_val == df['km'].max()) + km_label = f"{dist_end:.1f}" if is_last else str(km_val + 1) + # Ersten Km explizit auf "1" setzen auch wenn km_val=0 + if km_val == 0 and not is_last: + km_label = "1" + + segments.append({ + 'km_val': km_val, + 'km_label': km_label, + 'segment_len': segment_len, + 'pace_min': pace_min, + 'elev_delta': elev_delta, + 'hr_mean': hr_mean, + }) + + seg_df = pd.DataFrame(segments) + seg_df = seg_df[seg_df['pace_min'] < 20] # Pausen/Ausreißer raus + seg_df = seg_df.dropna(subset=['pace_min']) + seg_df = seg_df.sort_values('km_val').reset_index(drop=True) + + if seg_df.empty: + fig = go.Figure() + fig.update_layout(paper_bgcolor='#1e1e1e', + title=dict(text='Keine Pace-Daten', font=dict(color='white'))) + return fig + + # ------------------------------------------------------------------------- + # Durchschnittspace berechnen + # ------------------------------------------------------------------------- + total_distance_km = df['cum_dist_km'].iloc[-1] + total_seconds = df['time_diff_sec'].iloc[-1] + + if total_distance_km > 0: + pace_sec_per_km = total_seconds / total_distance_km + avg_pace_numeric = pace_sec_per_km / 60 + pace_min_i = int(pace_sec_per_km // 60) + pace_sec_i = int(pace_sec_per_km % 60) + formatted_pace = f"{pace_min_i}:{pace_sec_i:02d} min/km" + else: + avg_pace_numeric = seg_df['pace_min'].mean() + formatted_pace = "N/A" + + # ------------------------------------------------------------------------- + # Pace → Formatierung als "M:SS" + # ------------------------------------------------------------------------- + def fmt_pace(p): + if pd.isna(p): + return "" + m = int(p) + s = int(round((p - m) * 60)) + return f"{m}:{s:02d}" + + # ------------------------------------------------------------------------- + # Y-Achse: Segment-Labels (von oben = km 1 nach unten = letztes km) + # Strava zeigt älteste Km oben, letzte unten → umgekehrte Reihenfolge + # ------------------------------------------------------------------------- + # Alle Listen aus demselben sortierten Index ziehen + y_labels = seg_df['km_label'].tolist() # ["1","2",...,"11","0.2"] + pace_vals = seg_df['pace_min'].tolist() + elev_vals = seg_df['elev_delta'].tolist() + hr_vals = seg_df['hr_mean'].tolist() + + # Maximale Pace für X-Achse (leicht über Max für optischen Puffer) + x_max = max(pace_vals) * 1.18 + + # ------------------------------------------------------------------------- + # Farbe der Balken: blau wie Strava, schneller = etwas heller + # ------------------------------------------------------------------------- + pace_min_val = min(pace_vals) + pace_max_val = max(pace_vals) + pace_range = max(pace_max_val - pace_min_val, 0.01) + + bar_colors = [] + for p in pace_vals: + # Schnellster Km = hellstes Blau, langsamster = dunkelstes Blau + norm = (p - pace_min_val) / pace_range # 0 = schnell, 1 = langsam + r = int(18 + norm * 10) + g = int(85 + norm * 20) + b = int(149 + norm * 30) + bar_colors.append(f'rgb({r},{g},{b})') - # Step 4: Create Plotly bar chart fig = go.Figure() + + # ------------------------------------------------------------------------- + # Balken (horizontal) + # ------------------------------------------------------------------------- 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', + x=pace_vals, + y=y_labels, + orientation='h', + marker=dict( + color=bar_colors, + line=dict(width=0), + ), + opacity=0.92, + width=0.72, + text=[fmt_pace(p) for p in pace_vals], textposition='inside', - marker_color='#125595', - opacity=0.9, # Transparenz - name='Pace pro km', - offset=0 + textfont=dict(color='white', size=11), + hovertemplate=( + 'km %{y}
' + 'Pace: %{text}
' + '' + ), + name='', + showlegend=False, )) - - - - # ######### - # Calculate average pace if not provided from Info-Banner function - 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 = int(pace_sec_per_km // 60) - pace_sec = int(pace_sec_per_km % 60) - formatted_pace = f"{pace_min}:{pace_sec:02d} min/km" - # Numerischen Wert für die dash'ed Linie berechnen - avg_pace_numeric = pace_sec_per_km / 60 - else: - formatted_pace = "N/A" - avg_pace_numeric = 0 - - # Add horizontal dash'ed reference line (avg_pace_numeric) + # ------------------------------------------------------------------------- + # Durchschnitts-Linie (vertikal, gestrichelt) + # ------------------------------------------------------------------------- fig.add_shape( type='line', - x0=0, # Start bei 0 - x1=total_distance_km, # Ende bei maximaler Distanz - y0=avg_pace_numeric, - y1=avg_pace_numeric, - line=dict(color='gray', width=1, dash='dash'), + x0=avg_pace_numeric, x1=avg_pace_numeric, + y0=-0.5, y1=len(y_labels) - 0.5, + line=dict(color='rgba(180,180,180,0.7)', width=1.5, dash='dash'), + layer='above', + ) + fig.add_annotation( + x=avg_pace_numeric, + y=len(y_labels) - 0.5, + text=f"Ø {formatted_pace}", + showarrow=False, + yanchor='bottom', + font=dict(color='rgba(180,180,180,0.9)', size=10), + bgcolor='rgba(0,0,0,0)', ) + # ------------------------------------------------------------------------- + # Elevation-Delta als Annotation rechts vom Balken + # ------------------------------------------------------------------------- + has_elev = any(not np.isnan(e) for e in elev_vals) + has_hr = any(not np.isnan(h) for h in hr_vals) - title_text = f'Tempo je Kilometer' - title_text += f' - Ø {formatted_pace}' + for i in range(len(seg_df)): + km_lbl = y_labels[i] + elev = elev_vals[i] + hr = hr_vals[i] + + right_text = '' + if has_elev and not np.isnan(elev): + arrow = '↑' if elev > 0.5 else ('↓' if elev < -0.5 else '—') + right_text += f"{arrow}{abs(elev):.0f}m" + + if has_hr and not np.isnan(hr): + if right_text: + right_text += ' ' + right_text += f"♥ {hr:.0f}" + + if right_text: + fig.add_annotation( + x=x_max, + y=km_lbl, + text=right_text, + showarrow=False, + xanchor='right', + font=dict(size=12), + bgcolor='rgba(0,0,0,0)', + ) + + # ------------------------------------------------------------------------- + # Layout + # ------------------------------------------------------------------------- + # Höhe dynamisch: ~32px pro Balken, mindestens 300px + plot_height = max(300, len(y_labels) * 36 + 80) fig.update_layout( - title=dict(text=title_text, font=dict(size=16, color='white')), - xaxis_title='Distanz (km)', - yaxis_title='Minuten pro km', - barmode='overlay', - bargap=0, - bargroupgap=0, + title=dict( + text=f'Tempo je Kilometer · Ø {formatted_pace}', + font=dict(size=15, color='white') + ), xaxis=dict( - type='linear', - range=[0, df['cum_dist_km'].iloc[-1]], - tickmode='linear', - dtick=1, - showgrid=True + title='Pace (min/km)', + range=[0, x_max], + tickmode='array', + tickvals=[i * 0.5 for i in range(int(x_max / 0.5) + 2)], + ticktext=[fmt_pace(i * 0.5) for i in range(int(x_max / 0.5) + 2)], + showgrid=True, + gridcolor='rgba(255,255,255,0.07)', + zeroline=False, + color='white', + ), + yaxis=dict( + title='', + autorange='reversed', # km 1 oben, letzter km unten (wie Strava) + tickfont=dict(size=11, color='white'), + showgrid=False, + zeroline=False, ), template='plotly_dark', - height=400, - margin=dict(l=40, r=40, t=30, b=40), + height=plot_height, + margin=dict(l=50, r=80, t=45, b=45), plot_bgcolor='#111111', paper_bgcolor='#1e1e1e', font=dict(color='white'), - uirevision='constant', # Avoiding not needed Re-renderings + bargap=0.15, + uirevision='constant', ) + return fig