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