Updated the create_pace_bars_plot design and uploaded a new version of the Dashboard preview image.

This commit is contained in:
2026-05-06 17:48:07 +02:00
parent f5a49a218d
commit 9b329232c6
2 changed files with 259 additions and 113 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -34,7 +34,9 @@ from matplotlib.colors import LinearSegmentedColormap, Normalize
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
# === Helper Functions === # =============================================================================
# Helper Functions
# =============================================================================
def list_files(): def list_files():
""" """
Listet alle .fit Files aus fit_files/ und .gpx Files aus gpx_files/ auf 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)) return 2 * R * asin(sqrt(a))
######## # -----------------------------------------------------------------------------
# FIT # FIT-FILE-FUNCTION
######## # -----------------------------------------------------------------------------
def process_fit(file_path): def process_fit(file_path):
""" """
Verarbeitet eine FIT-Datei und erstellt einen DataFrame 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): def process_gpx(file_path):
""" """
Verarbeitet GPX-Dateien und gibt DataFrame im gleichen Format wie process_fit() zurück 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)) return int(round(cumulative))
# ============================================================================= # -----------------------------------------------------------------------------
# INFO BANNER # INFO BANNER
# ============================================================================= # -----------------------------------------------------------------------------
def create_info_banner(df): def create_info_banner(df):
# Total distance in km # Total distance in km
total_distance_km = df['cum_dist_km'].iloc[-1] total_distance_km = df['cum_dist_km'].iloc[-1]
@@ -590,9 +592,9 @@ def create_info_banner(df):
return info_banner return info_banner
# ============================================================================= # -----------------------------------------------------------------------------
# EXPORT SUMMARY IMAGE (SVG) # EXPORT SUMMARY IMAGE (SVG)
# ============================================================================= # -----------------------------------------------------------------------------
def create_strava_style_svg(df, total_distance_km=None, total_time=None, avg_pace=None, def create_strava_style_svg(df, total_distance_km=None, total_time=None, avg_pace=None,
width=800, height=600, padding=50): width=800, height=600, padding=50):
""" """
@@ -753,9 +755,10 @@ def calculate_pace(distance_km, total_seconds):
# ============================================================================= # -----------------------------------------------------------------------------
# START OF THE PLOTS # START OF THE PLOTS
# ============================================================================= # MAP-PLOT:
# -----------------------------------------------------------------------------
def create_map_plot(df): def create_map_plot(df):
fig = px.line_map( fig = px.line_map(
df, df,
@@ -830,9 +833,9 @@ def create_map_plot(df):
# ----------------------------------------------------------------------------- # =============================================================================
# HILFSFUNKTION: GPS → Pixel-Koordinaten (Mercator-Projektion) # HILFSFUNKTION: GPS → Pixel-Koordinaten (Mercator-Projektion)
# ----------------------------------------------------------------------------- # =============================================================================
def _gps_to_pixel(lats, lons, img_width=800, img_height=600, def _gps_to_pixel(lats, lons, img_width=800, img_height=600,
padding=None, padding=None,
pad_top=40, pad_bottom=60, pad_left=10, pad_right=10): 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 return xs, ys, meta
# ----------------------------------------------------------------------------- # =============================================================================
# HILFSFUNKTION: Matplotlib-Figure → base64-PNG (für Dash dcc.Graph) # HILFSFUNKTION: Matplotlib-Figure → base64-PNG (für Dash dcc.Graph)
# ----------------------------------------------------------------------------- # =============================================================================
def _fig_to_base64(fig): def _fig_to_base64(fig):
""" """
Konvertiert eine Matplotlib-Figure zu einem base64-PNG. 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, def create_pixel_heatmap(dataframes,
img_width=900, img_height=900, img_width=900, img_height=900,
@@ -995,7 +998,7 @@ def create_pixel_heatmap(dataframes,
n_city_runs=0, n_city_runs=0,
highlight_df=None): 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. Je öfter ein Pixel überquert wurde, desto heller/wärmer die Farbe.
Args: 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, def create_pixel_elevation_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'): 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, def create_pixel_pace_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'): 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. Farbe anzeigt, wie schnell du in diesem Bereich gelaufen bist.
Blau = schnell (niedriger min/km-Wert), Rot = langsam (hoher min/km-Wert). 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, def create_pixel_hr_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'): 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): def create_elevation_plot(df, smooth_points=500):
# Originale Daten # Originale Daten
x = df['time'] 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 # Compute mean velocity in km/s
vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1] vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1]
# Expected cumulative distance assuming constant mean velocity # Expected cumulative distance assuming constant mean velocity
@@ -1889,6 +1896,9 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
return fig return fig
# -----------------------------------------------------------------------------
# SPEED-PLOT:
# -----------------------------------------------------------------------------
def create_speed_plot(df): def create_speed_plot(df):
mask = df['speed_kmh_smooth'].isna() mask = df['speed_kmh_smooth'].isna()
mean_speed_kmh = df['speed_kmh'].mean() mean_speed_kmh = df['speed_kmh'].mean()
@@ -1926,10 +1936,9 @@ def create_speed_plot(df):
# -----------------------------------------------------------------------------
# HEART-RATE-PLOT:
# -----------------------------------------------------------------------------
# heart_rate Plot NEW !!!
def create_heart_rate_plot(df): def create_heart_rate_plot(df):
# Maske für gültige Heart Rate Daten # Maske für gültige Heart Rate Daten
mask = df['hr_smooth'].isna() 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): 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']): if not pd.api.types.is_datetime64_any_dtype(df['time']):
df = df.copy()
df['time'] = pd.to_datetime(df['time'], errors='coerce') df['time'] = pd.to_datetime(df['time'], errors='coerce')
# Assign km segments df = df.copy()
df['km'] = df['cum_dist_km'].astype(int) 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() 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 # Pace, Elevation-Delta, HR je km-Segment berechnen
df['segment_len'] = np.nan # -------------------------------------------------------------------------
df['pace_min_per_km'] = np.nan segments = []
for km_val, group in df.groupby('km'): for km_val, group in df.groupby('km'):
dist_start = group['cum_dist_km'].iloc[0] dist_start = group['cum_dist_km'].iloc[0]
dist_end = group['cum_dist_km'].iloc[-1] dist_end = group['cum_dist_km'].iloc[-1]
segment_len = dist_end - dist_start segment_len = dist_end - dist_start
time_start = group['time_sec'].iloc[0] time_start = group['time_sec'].iloc[0]
time_end = group['time_sec'].iloc[-1] time_end = group['time_sec'].iloc[-1]
elapsed_time_sec = time_end - time_start elapsed_time_sec = time_end - time_start
if segment_len > 0: if segment_len > 0 and elapsed_time_sec > 0:
pace_min_per_km = (elapsed_time_sec / 60) / segment_len pace_min = (elapsed_time_sec / 60) / segment_len
else: else:
pace_min_per_km = np.nan pace_min = np.nan
df.loc[group.index, 'km_start'] = km_val # Elevation-Delta für dieses Segment
df.loc[group.index, 'segment_len'] = segment_len elev_delta = np.nan
df.loc[group.index, 'pace_min_per_km'] = pace_min_per_km 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 # Durchschnittliche HR für dieses Segment
df['km_start'] = df['km_start'].astype(int) hr_mean = np.nan
df['segment_len'] = df['segment_len'].astype(float) if 'heart_rate' in group.columns:
df['pace_min_per_km'] = pd.to_numeric(df['pace_min_per_km'], errors='coerce') 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() fig = go.Figure()
# -------------------------------------------------------------------------
# Balken (horizontal)
# -------------------------------------------------------------------------
fig.add_trace(go.Bar( fig.add_trace(go.Bar(
x=df['km_start'], # Mittig unter jeder Bar x=pace_vals,
y=df['pace_min_per_km'], y=y_labels,
width=df['segment_len'], orientation='h',
text=[f"{v:.1f} min/km" if pd.notnull(v) else "" for v in df['pace_min_per_km']], marker=dict(
#textposition='outside', color=bar_colors,
line=dict(width=0),
),
opacity=0.92,
width=0.72,
text=[fmt_pace(p) for p in pace_vals],
textposition='inside', textposition='inside',
marker_color='#125595', textfont=dict(color='white', size=11),
opacity=0.9, # Transparenz hovertemplate=(
name='Pace pro km', 'km %{y}<br>'
offset=0 'Pace: %{text}<br>'
'<extra></extra>'
),
name='',
showlegend=False,
)) ))
# -------------------------------------------------------------------------
# Durchschnitts-Linie (vertikal, gestrichelt)
# -------------------------------------------------------------------------
# #########
# 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)
fig.add_shape( fig.add_shape(
type='line', type='line',
x0=0, # Start bei 0 x0=avg_pace_numeric, x1=avg_pace_numeric,
x1=total_distance_km, # Ende bei maximaler Distanz y0=-0.5, y1=len(y_labels) - 0.5,
y0=avg_pace_numeric, line=dict(color='rgba(180,180,180,0.7)', width=1.5, dash='dash'),
y1=avg_pace_numeric, layer='above',
line=dict(color='gray', width=1, dash='dash'), )
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' for i in range(len(seg_df)):
title_text += f' - Ø {formatted_pace}' 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"<span style='color:#aaaaaa'>{arrow}{abs(elev):.0f}m</span>"
if has_hr and not np.isnan(hr):
if right_text:
right_text += ' '
right_text += f"<span style='color:#ff6b6b'>♥ {hr:.0f}</span>"
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( fig.update_layout(
title=dict(text=title_text, font=dict(size=16, color='white')), title=dict(
xaxis_title='Distanz (km)', text=f'Tempo je Kilometer · Ø {formatted_pace}',
yaxis_title='Minuten pro km', font=dict(size=15, color='white')
barmode='overlay', ),
bargap=0,
bargroupgap=0,
xaxis=dict( xaxis=dict(
type='linear', title='Pace (min/km)',
range=[0, df['cum_dist_km'].iloc[-1]], range=[0, x_max],
tickmode='linear', tickmode='array',
dtick=1, tickvals=[i * 0.5 for i in range(int(x_max / 0.5) + 2)],
showgrid=True 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', template='plotly_dark',
height=400, height=plot_height,
margin=dict(l=40, r=40, t=30, b=40), margin=dict(l=50, r=80, t=45, b=45),
plot_bgcolor='#111111', plot_bgcolor='#111111',
paper_bgcolor='#1e1e1e', paper_bgcolor='#1e1e1e',
font=dict(color='white'), font=dict(color='white'),
uirevision='constant', # Avoiding not needed Re-renderings bargap=0.15,
uirevision='constant',
) )
return fig return fig