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

View File

@@ -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}<br>'
'Pace: %{text}<br>'
'<extra></extra>'
),
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"<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(
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