Files
jogging-dashboard/jogging_dashboard_browser_app.py

2723 lines
100 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, State
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
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
from xml.etree.ElementTree import Element, SubElement, tostring
import xml.etree.ElementTree as ET
# =============================================================================
# Helper Functions
# =============================================================================
def list_files():
"""
Listet alle .fit Files aus fit_files/ und .gpx Files aus gpx_files/ auf
und sortiert sie nach Datum (neueste zuerst)
"""
# Definiere Ordner und Dateierweiterungen
folders_config = [
{'folder': './fit_files', 'extensions': ['.fit'], 'type': 'FIT'},
{'folder': './gpx_files', 'extensions': ['.gpx'], 'type': 'GPX'}
]
all_file_options = []
for config in folders_config:
folder = config['folder']
extensions = config['extensions']
file_type = config['type']
# Prüfe ob Ordner existiert
if not os.path.exists(folder):
print(f"Ordner {folder} existiert nicht!")
continue
# Hole alle Files mit den entsprechenden Erweiterungen
try:
all_files = os.listdir(folder)
files = [f for f in all_files
if any(f.lower().endswith(ext) for ext in extensions)]
except Exception as e:
print(f"Fehler beim Lesen des Ordners {folder}: {e}")
continue
# Erstelle Optionen für diesen Ordner
for f in files:
file_path = os.path.join(folder, f)
# Extrahiere Datum für Sortierung
file_date = extract_date_from_file(f, file_path)
# Erstelle Label mit Dateityp-Info
try:
size_mb = os.path.getsize(file_path) / (1024 * 1024)
mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
#label = f"[{file_type}] {f}"
label = f"{f}"
# Optional: Erweiterte Info (auskommentiert für sauberere Ansicht)
# label = f"[{file_type}] {f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')})"
except:
#label = f"[{file_type}] {f}"
label = f"{f}"
all_file_options.append({
'label': label,
'value': file_path,
'date': file_date,
'type': file_type
})
# Sortiere alle Files nach Datum (neueste zuerst)
all_file_options.sort(key=lambda x: x['date'], reverse=True)
# Entferne 'date' und 'type' aus den finalen Optionen (nur für Sortierung gebraucht)
final_options = [{'label': opt['label'], 'value': opt['value']}
for opt in all_file_options]
# Fallback wenn keine Files gefunden
if not final_options:
return [{'label': 'Keine .fit oder .gpx Dateien gefunden', 'value': 'NO_FILES'}]
return final_options
def extract_date_from_file(filename, file_path):
"""Extrahiert Datum aus Filename für Sortierung"""
try:
# Versuche verschiedene Datumsformate im Dateinamen
# Format: dd.mm.yyyy
return datetime.datetime.strptime(filename[:10], '%d.%m.%Y')
except ValueError:
try:
# Format: yyyy-mm-dd
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
except ValueError:
try:
# Format: yyyymmdd
return datetime.datetime.strptime(filename[:8], '%Y%m%d')
except ValueError:
try:
# Format: yyyy_mm_dd
return datetime.datetime.strptime(filename[:10], '%Y_%m_%d')
except ValueError:
try:
# Format: dd-mm-yyyy
return datetime.datetime.strptime(filename[:10], '%d-%m-%Y')
except ValueError:
# Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum
try:
return datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
except:
return datetime.datetime.min
def haversine(lon1, lat1, lon2, lat2):
"""
Berechnet die Entfernung zwischen zwei GPS-Koordinaten in km
"""
R = 6371 # Erdradius in km
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))
# -----------------------------------------------------------------------------
# FIT-FILE-FUNCTION
# -----------------------------------------------------------------------------
def process_fit(file_path):
"""
Verarbeitet eine FIT-Datei und erstellt einen DataFrame
"""
if file_path in ['NO_FILE', 'NO_FOLDER', 'ERROR']:
print(f"Ungültiger Dateipfad: {file_path}")
return pd.DataFrame()
if not os.path.exists(file_path):
print(f"Datei nicht gefunden: {file_path}")
return pd.DataFrame()
try:
fit_file = FitFile(file_path)
print(f"\nVerarbeite FIT-Datei: {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)
if not records:
print("Keine Aufzeichnungsdaten in der FIT-Datei gefunden")
return pd.DataFrame()
# Erstelle DataFrame
df = pd.DataFrame(records)
print(f"DataFrame erstellt mit {len(df)} Zeilen und Spalten: {list(df.columns)}")
# Debugging: Schaue welche Spalten verfügbar sind
# print(f"Verfügbare Spalten: {df.columns.tolist()}") # Uncomment if needed - DEBUG purpose!
# 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}") # Uncomment if needed - DEBUG purpose!
# 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) (vektorisiert)
lat_r = np.radians(df['lat'].values)
lon_r = np.radians(df['lon'].values)
dlat = np.diff(lat_r, prepend=lat_r[0])
dlon = np.diff(lon_r, prepend=lon_r[0])
a = np.sin(dlat/2)**2 + np.cos(lat_r) * np.cos(np.roll(lat_r,1)) * np.sin(dlon/2)**2
a[0] = 0
step_dist = 2 * 6371 * np.arcsin(np.sqrt(np.clip(a, 0, 1)))
df['cum_dist_km'] = np.cumsum(step_dist)
# 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
# Zweiter Durchlauf nötig: nach dropna() hat df weniger Zeilen als records,
# safe_add_column_to_dataframe behandelt den Längenunterschied korrekt.
heart_rate = []
for record in fit_file.get_messages("record"):
for data in record:
if data.name == 'heart_rate':
heart_rate.append(data.value)
df = safe_add_column_to_dataframe(df, 'heart_rate', 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()
df['hr_smooth'] = df['heart_rate'].rolling(window=2, center=True, min_periods=1).mean()
else:
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
except Exception as e:
print(f"Fehler beim Verarbeiten der FIT-Datei {file_path}: {str(e)}")
return pd.DataFrame()
# -----------------------------------------------------------------------------
# GPX-FILE-FUNCTION
# -----------------------------------------------------------------------------
def process_gpx(file_path):
"""
Verarbeitet GPX-Dateien und gibt DataFrame im gleichen Format wie process_fit() zurück
"""
import gpxpy
import gpxpy.gpx
try:
with open(file_path, 'r', encoding='utf-8') as gpx_file:
gpx = gpxpy.parse(gpx_file)
print(f"Verarbeite GPX-Datei: {file_path}")
# Sammle GPS-Punkte aus allen Tracks/Segments
points_data = []
for track in gpx.tracks:
for segment in track.segments:
for point in segment.points:
points_data.append({
'time': point.time,
'lat': point.latitude,
'lon': point.longitude,
'elev': point.elevation if point.elevation else 0,
'heart_rate': None # GPX hat normalerweise keine HR-Daten
})
if not points_data:
print("Keine GPS-Daten in GPX-Datei gefunden")
return pd.DataFrame()
# Erstelle DataFrame
df = pd.DataFrame(points_data)
print(f"GPX DataFrame erstellt mit {len(df)} Zeilen")
# Sortiere nach Zeit
df = df.sort_values('time').reset_index(drop=True)
# Zeit-Verarbeitung (wie in deiner FIT-Funktion)
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])
secs = df['time_diff'].dt.total_seconds().astype(int)
df['duration_hms'] = (secs // 3600).astype(int).astype(str).str.zfill(2) + ':' + \
((secs % 3600) // 60).astype(int).astype(str).str.zfill(2) + ':' + \
(secs % 60).astype(int).astype(str).str.zfill(2)
# Cumulative distance (km) (vektorisiert)
lat_r = np.radians(df['lat'].values)
lon_r = np.radians(df['lon'].values)
dlat = np.diff(lat_r, prepend=lat_r[0])
dlon = np.diff(lon_r, prepend=lon_r[0])
a = np.sin(dlat/2)**2 + np.cos(lat_r) * np.cos(np.roll(lat_r,1)) * np.sin(dlon/2)**2
a[0] = 0
step_dist = 2 * 6371 * np.arcsin(np.sqrt(np.clip(a, 0, 1)))
df['cum_dist_km'] = np.cumsum(step_dist)
# Elevation (gleiche Logik wie in deiner FIT-Funktion)
df['elev'] = df['elev'].bfill()
df['delta_elev'] = df['elev'].diff().fillna(0)
df['rel_elev'] = df['elev'] - df['elev'].iloc[0]
# Speed-Berechnung (gleiche Logik wie dein Fallback)
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 (wie in deiner FIT-Funktion)
df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec'])
# Smoothed speed (wie in deiner FIT-Funktion)
df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
# Heart rate (GPX hat keine, also NaN wie dein Fallback)
df['heart_rate'] = np.nan
df['hr_smooth'] = np.nan
print(f"Verarbeitete GPX-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
except Exception as e:
print(f"Fehler beim Verarbeiten der GPX-Datei {file_path}: {str(e)}")
return pd.DataFrame()
# NEUE UNIVERSELLE WRAPPER-FUNKTION (nutzt deine bestehenden Funktionen!)
def process_selected_file(file_path):
"""
Universelle Funktion die automatisch FIT oder GPX verarbeitet
"""
if not file_path or file_path in ['NO_FILES', 'NO_FOLDER', 'ERROR']:
return pd.DataFrame()
# Bestimme Dateityp
if file_path.lower().endswith('.fit'):
# NUTZT DEINE ORIGINALE FUNKTION!
return process_fit(file_path)
elif file_path.lower().endswith('.gpx'):
# Nutzt die neue GPX-Funktion
return process_gpx(file_path)
else:
print(f"Unbekannter Dateityp: {file_path}")
return pd.DataFrame()
def safe_add_column_to_dataframe(df, column_name, values):
"""
Fügt eine Spalte sicher zu einem DataFrame hinzu, auch wenn die Längen nicht übereinstimmen
"""
if df.empty:
return df
df_len = len(df)
values_len = len(values) if hasattr(values, '__len__') else 0
if values_len == df_len:
# Perfekt - gleiche Länge
df[column_name] = values
elif values_len > df_len:
# Zu viele Werte - kürze sie
print(f"WARNUNG: {column_name} hat {values_len} Werte, DataFrame hat {df_len} Zeilen. Kürze Werte.")
df[column_name] = values[:df_len]
elif values_len < df_len:
# Zu wenige Werte - fülle mit NaN auf
print(f"WARNUNG: {column_name} hat {values_len} Werte, DataFrame hat {df_len} Zeilen. Fülle mit NaN auf.")
extended_values = list(values) + [None] * (df_len - values_len)
df[column_name] = extended_values
else:
# Keine Werte - fülle mit NaN
print(f"WARNUNG: Keine Werte für {column_name}. Fülle mit NaN.")
df[column_name] = [None] * df_len
return df
# =============================================================================
# NEU: Elevation Gain & Calories Hilfsfunktionen
# =============================================================================
# ── Biometrics (kalibriert gegen Strava) ─────────────────────────────────────
_WEIGHT_KG = 75.0
_HEIGHT_CM = 178.0
_AGE_YEARS = 35
_IS_MALE = True
_HR_REST = 64.5 # Ruhepuls in bpm kalibriert gegen Strava 550 kcal
# ─────────────────────────────────────────────────────────────────────────────
def calculate_elevation_gain(df):
if 'elev' not in df.columns or df['elev'].isna().all():
return 0
elev_smooth = df['elev'].rolling(
window=30, win_type='gaussian', center=True, min_periods=1
).mean(std=5)
delta = elev_smooth.diff().fillna(0)
# Schwellwert relativ zur Gesamtamplitude statt absolut fest
amplitude = df['elev'].max() - df['elev'].min()
threshold = amplitude * 0.00095 # ~0.4% der Gesamtamplitude
gain = delta[delta > threshold].sum()
print(f"DEBUG elev: min={df['elev'].min():.1f}, max={df['elev'].max():.1f}, delta>0.03={delta[delta>0.03].sum():.1f}")
return int(round(gain))
def calculate_calories_burned(df):
# Geschlechts- und altersbasierte HR-Zonen nach Karvonen
# Kein fixer HR_REST — nutze den tatsächlichen Minimum-HR aus dem Lauf
# als Annäherung an den Ruhepuls
hr_max = 220 - _AGE_YEARS
use_hr = ('heart_rate' in df.columns and
df['heart_rate'].notna().sum() > len(df) * 0.5)
if not use_hr:
return 0
hr = df['heart_rate'].ffill().fillna(df['heart_rate'].bfill()).values
ts = df['time_diff_sec'].values
# Ruhepuls aus dem Lauf selbst schätzen: 5. Perzentile der HR-Werte
hr_rest = float(df['heart_rate'].quantile(0.05))
hr_rest = max(45.0, min(hr_rest, 80.0)) # Plausibilitätsgrenze
vo2max = 15.0 * (hr_max / hr_rest)
dt = np.diff(ts, prepend=ts[0])
mask = (dt > 0) & (dt <= 10)
frac = np.clip((hr - hr_rest) / (hr_max - hr_rest), 0, None)
met = np.clip((frac * vo2max) / 3.5, 1.0, 18.0)
kcal_per_s = met * _WEIGHT_KG * 3.5 / 12000.0
cumulative = float(np.sum(kcal_per_s[mask] * dt[mask]))
print(f"DEBUG calories: rows={len(df)}, hr_valid={df['heart_rate'].notna().sum()}, duration={df['time_diff_sec'].iloc[-1]:.0f}s, hr_mean={df['heart_rate'].mean():.1f}")
return int(round(cumulative))
# -----------------------------------------------------------------------------
# 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"
# Elevation Gain
elevation_gain_m = calculate_elevation_gain(df)
# Calories
total_calories = calculate_calories_burned(df)
# 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': '18%', 'display': 'inline-block', 'textAlign': 'center'}),
html.Div([
html.H4("Total Time", style={'margin-bottom': '5px'}),
html.H2(formatted_total_time)
], style={'width': '18%', 'display': 'inline-block', 'textAlign': 'center'}),
html.Div([
html.H4("Average Pace", style={'margin-bottom': '5px'}),
html.H2(formatted_pace)
], style={'width': '18%', 'display': 'inline-block', 'textAlign': 'center'}),
html.Div([
html.H4("Elevation", style={'margin-bottom': '5px'}),
html.H2(f"{elevation_gain_m} m")
], style={'width': '18%', 'display': 'inline-block', 'textAlign': 'center'}),
html.Div([
html.H4("Calories", style={'margin-bottom': '5px'}),
html.H2(f"{total_calories} kcal")
], style={'width': '18%', 'display': 'inline-block', 'textAlign': 'center'}),
], style={
'display': 'flex',
'justifyContent': 'space-around',
'backgroundColor': '#1e1e1e',
'color': 'white',
'padding': '5px',
'marginBottom': '5px',
'borderRadius': '10px',
'width': '100%',
#'maxWidth': '1200px',
'margin': 'auto'
})
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):
"""
Erstellt ein STRAVA-style SVG mit transparentem Hintergrund
"""
# SVG Root Element
svg = Element('svg')
svg.set('width', str(width))
svg.set('height', str(height))
svg.set('xmlns', 'http://www.w3.org/2000/svg')
svg.set('style', 'background: transparent;')
# Route-Bereich (links 60% der Breite)
route_width = width * 0.6
route_height = height - 2 * padding
# Koordinaten normalisieren für den Route-Bereich
lats = df['lat'].values
lons = df['lon'].values
# Bounding Box der Route
lat_min, lat_max = lats.min(), lats.max()
lon_min, lon_max = lons.min(), lons.max()
# Aspect Ratio beibehalten
lat_range = lat_max - lat_min
lon_range = lon_max - lon_min
if lat_range == 0 or lon_range == 0:
raise ValueError("Route hat keine Variation in Koordinaten")
# Skalierung berechnen
scale_x = (route_width - 2 * padding) / lon_range
scale_y = (route_height - 2 * padding) / lat_range
# Einheitliche Skalierung für korrekte Proportionen
scale = min(scale_x, scale_y)
# Zentrieren
center_x = route_width / 2
center_y = height / 2
# Route-Pfad erstellen
path_data = []
for i, (lat, lon) in enumerate(zip(lats, lons)):
# Koordinaten transformieren (Y-Achse umkehren für SVG)
x = center_x + (lon - (lon_min + lon_max) / 2) * scale
y = center_y - (lat - (lat_min + lat_max) / 2) * scale
if i == 0:
path_data.append(f"M {x:.2f} {y:.2f}")
else:
path_data.append(f"L {x:.2f} {y:.2f}")
# Route-Pfad zum SVG hinzufügen
route_path = SubElement(svg, 'path')
route_path.set('d', ' '.join(path_data))
route_path.set('stroke', '#ff6909') # Deine Routenfarbe
route_path.set('stroke-width', '4')
route_path.set('fill', 'none')
route_path.set('stroke-linecap', 'round')
route_path.set('stroke-linejoin', 'round')
# Start-Punkt (grün)
start_x = center_x + (lons[0] - (lon_min + lon_max) / 2) * scale
start_y = center_y - (lats[0] - (lat_min + lat_max) / 2) * scale
start_circle = SubElement(svg, 'circle')
start_circle.set('cx', str(start_x))
start_circle.set('cy', str(start_y))
start_circle.set('r', '8')
start_circle.set('fill', '#4CAF50') # Grün
start_circle.set('stroke', 'white')
start_circle.set('stroke-width', '2')
# End-Punkt (rot)
end_x = center_x + (lons[-1] - (lon_min + lon_max) / 2) * scale
end_y = center_y - (lats[-1] - (lat_min + lat_max) / 2) * scale
end_circle = SubElement(svg, 'circle')
end_circle.set('cx', str(end_x))
end_circle.set('cy', str(end_y))
end_circle.set('r', '8')
end_circle.set('fill', '#f44336') # Rot
end_circle.set('stroke', 'white')
end_circle.set('stroke-width', '2')
# Stats-Bereich (rechts 40% der Breite)
stats_x = route_width + padding
stats_y_start = padding + 50
## Hintergrund für Stats (optional, semi-transparent - SCHWARZE BOX)
#stats_bg = SubElement(svg, 'rect')
#stats_bg.set('x', str(stats_x - 20))
#stats_bg.set('y', str(stats_y_start - 30))
#stats_bg.set('width', str(width * 0.35))
#stats_bg.set('height', str(250))
#stats_bg.set('fill', 'rgba(0,0,0,0.7)')
#stats_bg.set('rx', '10')
# Stats-Text hinzufügen
stats = [
("TOTAL DISTANCE", f"{total_distance_km:.1f} km" if total_distance_km else "N/A"),
("TOTAL TIME", total_time or "N/A"),
("AVERAGE PACE", avg_pace or "N/A")
]
for i, (label, value) in enumerate(stats):
y_pos = stats_y_start + i * 70
# Label (kleinere Schrift, grau)
label_text = SubElement(svg, 'text')
label_text.set('x', str(stats_x))
label_text.set('y', str(y_pos))
label_text.set('font-family', 'Arial, sans-serif')
label_text.set('font-size', '14')
label_text.set('font-weight', 'bold')
label_text.set('fill', '#000000') # TEXTFARBE #333333
label_text.text = label
# Wert (größere Schrift, weiß)
value_text = SubElement(svg, 'text')
value_text.set('x', str(stats_x))
value_text.set('y', str(y_pos + 25))
value_text.set('font-family', 'Arial, sans-serif')
value_text.set('font-size', '24')
value_text.set('font-weight', 'bold')
value_text.set('fill', 'white') # TEXTFARBE
value_text.text = value
return svg
def save_svg(svg_element, filename="run_overlay.svg"):
"""SVG als Datei speichern"""
rough_string = tostring(svg_element, 'unicode')
# Formatierung verbessern
dom = ET.fromstring(rough_string)
ET.indent(dom, space=" ", level=0)
with open(filename, 'w') as f:
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
f.write(ET.tostring(dom, encoding='unicode'))
print(f"SVG saved as {filename}")
def calculate_pace(distance_km, total_seconds):
"""
Berechnet das Durchschnittstempo in min/km Format
"""
if distance_km == 0:
return "0:00 /km"
pace_seconds_per_km = total_seconds / distance_km
pace_minutes = int(pace_seconds_per_km // 60)
pace_seconds = int(pace_seconds_per_km % 60)
return f"{pace_minutes}:{pace_seconds:02d} /km"
# -----------------------------------------------------------------------------
# START OF THE PLOTS
# MAP-PLOT:
# -----------------------------------------------------------------------------
def create_map_plot(df):
fig = px.line_map(
df,
lat='lat',
lon='lon',
zoom=13.5,
height=800
)
# Info: Frankfurt liegt ca. 112 m ü.NN, Hamburg ca. 6 m ü.NN.
fig.update_traces(
hovertemplate=(
"Time: %{customdata[5]}<br>" +
"Distance: %{customdata[0]:.2f} km<br>" +
"Elevation: %{customdata[1]:.0f} m ü.NN (%{customdata[2]:+.0f} m zum Start)<br>" + #„m ü.NN" bedeutet Meter über Normal-Null
"Speed: %{customdata[3]:.1f} km/h<br>" +
"Heart Rate: %{customdata[4]:.0f} bpm<extra></extra>"
),
#customdata=df[['time', 'cum_dist_km', 'duration_hms']]
#customdata=df[['cum_dist_km', 'speed_kmh', 'heart_rate', 'duration_hms']]
customdata=df[['cum_dist_km', 'elev', 'rel_elev', 'speed_kmh', 'heart_rate', 'duration_hms']]
)
# Define map style and the line ontop
fig.update_layout(map_style="open-street-map") #My-Fav: open-street-map, satellite-streets, dark, white-bg
# Possible Options:
# 'basic', 'carto-darkmatter', 'carto-darkmatter-nolabels', 'carto-positron', 'carto-positron-nolabels', 'carto-voyager', 'carto-voyager-nolabels', 'dark', 'light', 'open-street-map', 'outdoors', 'satellite', 'satellite-streets', 'streets', 'white-bg'.
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='#b9fc62', symbol='circle'), text=['Start'], name='Start', textposition='bottom left' # Starting point !
))
fig.add_trace(go.Scattermap(
lat=[end['lat']], lon=[end['lon']], mode='markers+text',
marker=dict(size=12, color='#fca062', symbol='circle'), text=['Stop'], name='Stop', textposition='bottom left' # Finishing point !
))
# THIS IS MY ELEVATION-PLOT SHOW POSITION-MARKER IN MAP-PLOT:
fig.add_trace(go.Scattermap(
lat=[],
lon=[],
mode="markers",
marker=dict(size=18, color="#42B1E5", symbol="circle"),
name="Hovered Point"
))
# KOMPAKTE LAYOUT-EINSTELLUNGEN
fig.update_layout(
paper_bgcolor='#1e1e1e',
font=dict(color='white'),
# Margins reduzieren für kompakteren Plot
margin=dict(l=60, r=45, t=10, b=50), # Links, Rechts, Oben, Unten
# Plotly-Toolbar konfigurieren
showlegend=True,
# Kompakte Legend
legend=dict(
orientation='h', # horizontal layout
yanchor='top',
y=-0.02, # move legend below the map
xanchor='center',
x=0.5,
font=dict(color='white', size=10) # Kleinere Schrift
)
)
return fig
# =============================================================================
# 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):
"""
Konvertiert GPS-Koordinaten (lat/lon) in Pixel-Koordinaten.
Unterstützt symmetrisches padding (alter Aufruf bleibt kompatibel)
oder asymmetrisches padding pro Seite.
Aufruf alt (kompatibel): _gps_to_pixel(lats, lons, padding=50)
Aufruf neu: _gps_to_pixel(lats, lons, pad_top=40, pad_bottom=60, ...)
"""
lats = np.array(lats, dtype=float)
lons = np.array(lons, dtype=float)
# Symmetrisches padding überschreibt alle vier Seiten (Rückwärtskompatibilität)
if padding is not None:
pad_top = pad_bottom = pad_left = pad_right = padding
lat_min, lat_max = lats.min(), lats.max()
lon_min, lon_max = lons.min(), lons.max()
lat_range = lat_max - lat_min
lon_range = lon_max - lon_min
draw_w = img_width - pad_left - pad_right
draw_h = img_height - pad_top - pad_bottom
if lon_range == 0 or lat_range == 0:
xs = np.full(len(lats), img_width / 2)
ys = np.full(len(lats), img_height / 2)
else:
scale = min(draw_w / lon_range, draw_h / lat_range)
offset_x = pad_left + (draw_w - lon_range * scale) / 2
offset_y = pad_top + (draw_h - lat_range * scale) / 2
xs = offset_x + (lons - lon_min) * scale
ys = offset_y + (lat_max - lats) * scale # Y-Achse umkehren
meta = {
'lat_min': lat_min, 'lat_max': lat_max,
'lon_min': lon_min, 'lon_max': lon_max,
'img_width': img_width, 'img_height': img_height
}
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.
KEIN bbox_inches='tight' → Figure-Größe bleibt exakt wie gesetzt.
"""
import io, base64
import matplotlib.pyplot as plt
buf = io.BytesIO()
fig.savefig(
buf,
format='png',
dpi=fig.get_dpi(), # Nutze den dpi-Wert der Figure
bbox_inches=None, # KEIN tight feste Größe beibehalten
facecolor=fig.get_facecolor(),
edgecolor='none',
pad_inches=0,
)
buf.seek(0)
img_b64 = base64.b64encode(buf.read()).decode('utf-8')
plt.close(fig)
return f"data:image/png;base64,{img_b64}"
# =============================================================================
# HILFSFUNKTION: Stadtcode aus Dateiname extrahieren
# =============================================================================
def extract_city_code(file_path):
"""
Extrahiert den Stadtcode aus dem Dateinamen.
Format erwartet: "DATUM_STADTCODE_*.fit" oder "DATUM_STADTCODE_*.gpx"
Beispiele:
"2025-07-30_FRA_Run_6.68Km.fit""FRA"
"2025-09-10_HH_Run_10.27Km.fit""HH"
"UnbekanntesDateiformat.fit" → None
Args:
file_path (str): Vollständiger Pfad oder nur Dateiname.
Returns:
str | None: Stadtcode in Großbuchstaben oder None wenn nicht erkennbar.
"""
import os
filename = os.path.basename(file_path) # Nur Dateiname, ohne Pfad
name_without_ext = os.path.splitext(filename)[0] # Ohne .fit/.gpx
parts = name_without_ext.split('_')
# Mindestens 2 Teile nötig: ["2025-07-30", "FRA", ...]
if len(parts) >= 2:
city_code = parts[1].upper().strip()
# Plausibilitätsprüfung: 26 Zeichen, nur Buchstaben
if 2 <= len(city_code) <= 6 and city_code.isalpha():
return city_code
return None # Stadtcode nicht erkennbar
# =============================================================================
# HILFSFUNKTION: Alle Läufe einer Stadt laden
# =============================================================================
def load_runs_for_city(city_code, all_file_options):
"""
Lädt alle Läufe, deren Dateiname den angegebenen Stadtcode enthält.
Args:
city_code (str): Stadtcode z.B. "FRA" oder "HH".
all_file_options (list): Rückgabe von list_files()
Liste von dicts mit 'value' (Dateipfad).
Returns:
list[pd.DataFrame]: Liste der erfolgreich geladenen DataFrames.
list[str]: Liste der geladenen Dateipfade (für Debug/Titel).
"""
loaded_dfs = []
loaded_paths = []
for opt in all_file_options:
path = opt['value']
# Überspringe Platzhalter-Einträge
if path in ['NO_FILES', 'NO_FOLDER', 'ERROR']:
continue
# Prüfe ob diese Datei zum gewünschten Stadtcode gehört
if extract_city_code(path) == city_code.upper():
try:
df_run = process_selected_file(path)
if not df_run.empty:
loaded_dfs.append(df_run)
loaded_paths.append(path)
print(f" [Heatmap/{city_code}] Geladen: {path} "
f"({len(df_run)} Punkte)")
except Exception as e:
print(f" [Heatmap/{city_code}] Fehler bei {path}: {e}")
print(f" [Heatmap/{city_code}] Insgesamt {len(loaded_dfs)} Läufe geladen.")
return loaded_dfs, loaded_paths
# -----------------------------------------------------------------------------
# PIXEL-PLOT 1: HEATMAP (count) mehrere Läufe, Linienstärke = Häufigkeit
# -----------------------------------------------------------------------------
def create_pixel_heatmap(dataframes,
img_width=900, img_height=900,
line_width=2, bg_color='#0d0d0d',
mode='single',
city_code=None,
n_city_runs=0,
highlight_df=None):
"""
Heatmap: Zeichnet einen oder mehrere Läufe pixelweise.
Je öfter ein Pixel überquert wurde, desto heller/wärmer die Farbe.
Args:
dataframes (list[pd.DataFrame] | pd.DataFrame):
Einzelner DataFrame ODER Liste von DataFrames.
img_width, img_height (int): Canvas-Größe in Pixel.
line_width (int): Breite der gezeichneten Linien.
bg_color (str): Hintergrundfarbe.
mode (str): 'single' → ein Lauf | 'city' → alle Läufe der Stadt.
city_code (str | None): Erkannter Stadtcode (z.B. "FRA").
n_city_runs (int): Anzahl der geladenen Stadt-Läufe (nur für Titel).
Returns:
plotly.graph_objects.Figure
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
from mpl_toolkits.axes_grid1 import make_axes_locatable
import pandas as pd
import plotly.graph_objects as go
DPI = 100 # Fester DPI-Wert NICHT ändern
if isinstance(dataframes, pd.DataFrame):
dataframes = [dataframes]
dataframes = [
df for df in dataframes
if not df.empty and 'lat' in df.columns and 'lon' in df.columns
]
if not dataframes:
fig = go.Figure()
fig.update_layout(
paper_bgcolor='#1e1e1e', font=dict(color='white'),
height=img_height,
title=dict(text='Keine Daten verfügbar', font=dict(color='white'))
)
return fig
# Bounding-Box
all_lats = np.concatenate([df['lat'].dropna().values for df in dataframes])
all_lons = np.concatenate([df['lon'].dropna().values for df in dataframes])
lat_min, lat_max = all_lats.min(), all_lats.max()
lon_min, lon_max = all_lons.min(), all_lons.max()
lat_range = lat_max - lat_min if lat_max != lat_min else 1e-6
lon_range = lon_max - lon_min if lon_max != lon_min else 1e-6
# Skalierung (Seitenverhältnis erhalten)
padding = 50
draw_w = img_width - 2 * padding
draw_h = img_height - 2 * padding
scale = min(draw_w / lon_range, draw_h / lat_range)
offset_x = padding + (draw_w - lon_range * scale) / 2
offset_y = padding + (draw_h - lat_range * scale) / 2
def to_px(lats_arr, lons_arr):
xs = np.clip((offset_x + (lons_arr - lon_min) * scale).astype(int), 0, img_width - 1)
ys = np.clip((offset_y + (lat_max - lats_arr) * scale).astype(int), 0, img_height - 1)
return xs, ys
# Count-Grid
count_grid = np.zeros((img_height, img_width), dtype=np.float32)
for df in dataframes:
lats_r = df['lat'].dropna().values
lons_r = df['lon'].dropna().values
if len(lats_r) < 2:
continue
xs, ys = to_px(lats_r, lons_r)
np.add.at(count_grid, (ys, xs), 1)
max_count = max(count_grid.max(), 1)
log_max = np.log1p(max_count)
# ---------------------------------------------------------------
# Matplotlib-Canvas: EXAKT img_width × img_height Pixel
# figsize in Inch = Pixel / DPI
# ---------------------------------------------------------------
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
# Haupt-Axes: füllt die gesamte Figure (keine Ränder)
ax = fig_mpl.add_axes([0, 0, 1, 1]) # [left, bottom, width, height] in Figure-Koordinaten
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
# Colormap
cmap_colors = [
(0.00, '#1a0a00'),
(0.20, '#7a2800'),
(0.45, '#fc4e00'),
(0.70, '#fcaa00'),
(0.90, '#fde68a'),
(1.00, '#ffffff'),
]
cmap = LinearSegmentedColormap.from_list('heatmap', cmap_colors, N=256)
# Linien zeichnen
# Welche DataFrames werden GEZEICHNET?
# - Region-Modus: alle
# - Einzellauf-Modus: nur highlight_df, aber count_grid kam von allen
draw_frames = dataframes if (mode == 'city' or highlight_df is None) else [highlight_df]
from matplotlib.collections import LineCollection
# (LineCollection - alle Segmente in einem Aufruf):
for df in draw_frames:
lats_r = df['lat'].dropna().values
lons_r = df['lon'].dropna().values
if len(lats_r) < 2:
continue
xs, ys = to_px(lats_r, lons_r)
# Segmente als Array: shape (N-1, 2, 2)
points = np.array([xs, ys]).T.reshape(-1, 1, 2)
segments = np.concatenate([points[:-1], points[1:]], axis=1)
# Farbe pro Segment aus count_grid
mid_xs = np.clip((xs[:-1] + xs[1:]) // 2, 0, img_width - 1)
mid_ys = np.clip((ys[:-1] + ys[1:]) // 2, 0, img_height - 1)
counts = count_grid[mid_ys, mid_xs]
norm_vals = np.log1p(counts) / log_max if log_max > 0 else np.zeros_like(counts)
colors = cmap(norm_vals)
lc = LineCollection(segments, colors=colors, linewidths=line_width,
capstyle='round', joinstyle='round')
ax.add_collection(lc)
# Colorbar als Inset-Axes (verändert NICHT die Figure-Größe)
#cbar_ax = fig_mpl.add_axes([0.88, 0.10, 0.025, 0.70]) # # verticale Position: rechts
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020]) # [left, bottom, w, h], horizontale Position: unten !!
sm = cm.ScalarMappable(cmap=cmap, norm=Normalize(vmin=1, vmax=int(max_count)))
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar.set_label('Anzahl', color='white', fontsize=8)
#cbar.ax.yaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
# Titel als Text direkt in Figure-Koordinaten
if mode == 'city' and city_code:
title_str = f'Heatmap {city_code} · {n_city_runs} Läufe · max {int(max_count)}×'
else:
title_str = f'Heatmap (Einzellauf) · max {int(max_count)}× durchquert'
fig_mpl.text(0.5, 0.97, title_str, color='white', fontsize=10,
ha='center', va='top', transform=fig_mpl.transFigure)
img_b64 = _fig_to_base64(fig_mpl)
plotly_title = (
f'Pixel-Heatmap · {city_code} · {n_city_runs} Läufe (Region)'
if mode == 'city' and city_code else 'Pixel-Heatmap · Einzellauf'
)
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(text=plotly_title, font=dict(size=13, color='white')),
paper_bgcolor=bg_color, # Gleiche Farbe wie Plot → kein grauer Rand
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height,
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='heatmap',
)
return fig
# -----------------------------------------------------------------------------
# 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'):
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
import plotly.graph_objects as go
DPI = 100
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height,
title=dict(text='Keine GPS-Daten', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
if 'elev' in df.columns and df['elev'].notna().sum() > 10:
elevs_raw = df['elev'].ffill().bfill().values
n_smooth = max(5, min(50, len(elevs_raw) // 100))
kernel = np.ones(n_smooth) / n_smooth
elevs = np.convolve(elevs_raw, kernel, mode='same')
elif 'delta_elev' in df.columns:
delta_elev = df['delta_elev'].fillna(0).values
elevs = np.cumsum(delta_elev)
else:
elevs = np.zeros(len(lats))
n = min(len(lats), len(lons), len(elevs))
lats, lons, elevs = lats[:n], lons[:n], elevs[:n]
delta_elev = np.diff(elevs, prepend=elevs[0])
# Adaptiver Threshold
abs_deltas = np.abs(delta_elev)
nonzero_deltas = abs_deltas[abs_deltas > 0]
FLAT_THRESHOLD = np.percentile(nonzero_deltas, 20) if len(nonzero_deltas) > 0 else 0.05
max_delta = max(np.percentile(abs_deltas, 80), FLAT_THRESHOLD * 2)
# Statistik
total_up = delta_elev[delta_elev > FLAT_THRESHOLD].sum()
total_down = abs(delta_elev[delta_elev < -FLAT_THRESHOLD].sum())
# Pixel-Koordinaten
xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height,
pad_top=40, pad_bottom=60, pad_left=10, pad_right=10)
xs = xs.astype(int)
ys = ys.astype(int)
# -------------------------------------------------------------------------
# Colormap: grün (min/bergab) → grau (flach) → rot (max/bergauf)
# -------------------------------------------------------------------------
cmap_colors = [
(0.00, '#00aa00'), # grün (stärkster Abstieg)
(0.35, '#227722'), # dunkelgrün
(0.50, '#666666'), # grau (flach)
(0.65, '#772222'), # dunkelrot
(1.00, '#ff2200'), # rot (stärkster Anstieg)
]
cmap_elev = LinearSegmentedColormap.from_list('elevation', cmap_colors, N=256)
# Normalisierung: -max_delta → 0 → +max_delta
norm_elev = Normalize(vmin=-max_delta, vmax=max_delta)
# Canvas
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
ax = fig_mpl.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
# Linien zeichnen Farbe direkt aus cmap+norm
for i in range(n - 1):
d = delta_elev[i]
color = cmap_elev(norm_elev(d))
# Flache Segmente etwas transparenter
alpha = 0.45 if abs(d) <= FLAT_THRESHOLD else 0.55 + min(abs(d) / max_delta, 1.0) * 0.45
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=color, linewidth=line_width, alpha=alpha,
solid_capstyle='round', solid_joinstyle='round')
# Start / Ziel
ax.plot(xs[0], ys[0], 'o', color='#b9fc62', markersize=10, zorder=5)
ax.plot(xs[-1], ys[-1], 's', color='#fca062', markersize=9, zorder=5)
# Titel
fig_mpl.text(0.5, 0.97,
f'Elevation-Map · ↑ {total_up:.0f} m ↓ {total_down:.0f} m',
color='white', fontsize=10, ha='center', va='top',
transform=fig_mpl.transFigure)
# -------------------------------------------------------------------------
# Colorbar horizontal unten grün links (bergab) → rot rechts (bergauf)
# -------------------------------------------------------------------------
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020])
sm = cm.ScalarMappable(cmap=cmap_elev, norm=norm_elev)
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar.set_label('Steigung (m/Punkt)', color='white', fontsize=8)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
# Ticks: min (bergab), 0 (flach), max (bergauf)
cbar.set_ticks([-max_delta, 0, max_delta])
cbar.set_ticklabels([
f'↓ -{max_delta:.2f}m',
'flach',
f'↑ +{max_delta:.2f}m'
])
img_b64 = _fig_to_base64(fig_mpl)
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(text='Pixel-Elevation-Map (grün = bergab · grau = flach · rot = bergauf)',
font=dict(size=13, color='white')),
paper_bgcolor=bg_color,
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height,
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='elevation_map',
)
return fig
# -----------------------------------------------------------------------------
# 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'):
"""
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).
Ausreißer (Pausen, GPS-Sprünge) werden automatisch herausgefiltert.
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
import plotly.graph_objects as go
DPI = 100
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height,
title=dict(text='Keine GPS-Daten', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
if 'speed_kmh' in df.columns and df['speed_kmh'].notna().sum() > 10:
speed = df['speed_kmh'].ffill().fillna(0).values
pace_per_km = np.where(speed > 0.5, 60.0 / speed, np.nan)
elif 'vel_kmps' in df.columns:
vel = df['vel_kmps'].fillna(0).values
pace_per_km = np.where(vel * 3600 > 0.5, 60.0 / (vel * 3600), np.nan)
else:
pace_per_km = np.full(len(lats), np.nan)
n = min(len(lats), len(lons), len(pace_per_km))
lats, lons, pace = lats[:n], lons[:n], pace_per_km[:n]
valid_pace = pace[(pace >= 2) & (pace <= 15) & ~np.isnan(pace)]
if len(valid_pace) == 0:
valid_pace = np.array([5.0, 8.0])
p5 = np.percentile(valid_pace, 5)
p95 = np.percentile(valid_pace, 95)
xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height, padding=50)
xs = xs.astype(int)
ys = ys.astype(int)
cmap_colors = [
(0.0, '#0033cc'),
(0.2, '#0099ff'),
(0.4, '#00cc88'),
(0.6, '#ffcc00'),
(0.8, '#ff6600'),
(1.0, '#cc0000'),
]
cmap = LinearSegmentedColormap.from_list('pace', cmap_colors, N=256)
# ---------------------------------------------------------------
# Exakt img_width × img_height Pixel Canvas
# ---------------------------------------------------------------
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
ax = fig_mpl.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
for i in range(n - 1):
p = pace[i]
if np.isnan(p) or p < 2 or p > 15:
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color='#222222', linewidth=line_width * 0.5,
solid_capstyle='round')
continue
norm_val = np.clip((p - p5) / (p95 - p5), 0, 1)
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=cmap(norm_val), linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
# Start/Ziel
ax.plot(xs[0], ys[0], 'o', color='#b9fc62', markersize=10, zorder=5) # Starting point !
ax.plot(xs[-1], ys[-1], 's', color='#fca062', markersize=9, zorder=5) # Finishing point !
# Colorbar als Inset-Axes (verändert Figure-Größe nicht)
#cbar_ax = fig_mpl.add_axes([0.88, 0.10, 0.025, 0.70]) # rechts
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020]) # unten
#cmap_reversed = cmap.reversed()
sm = cm.ScalarMappable(cmap=cmap, norm=Normalize(vmin=p5, vmax=p95))
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar_ax.invert_xaxis() # ← Colorbar-Balken spiegeln: blau rechts, rot links
cbar.set_label('Pace (min/km)', color='white', fontsize=8)
#cbar.ax.yaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.set_ticks([p5, (p5 + p95) / 2, p95])
cbar.set_ticklabels([f'{p5:.1f}', f'{(p5+p95)/2:.1f}', f'{p95:.1f}'])
#cbar.set_ticklabels([f'{p95:.1f}', f'{(p5+p95)/2:.1f}', f'{p5:.1f}']) # reversed the display order !!!
# Titel
valid_mean = valid_pace.mean()
min_v = int(valid_mean)
sec_v = int((valid_mean - min_v) * 60)
fig_mpl.text(0.5, 0.97,
f'Pace-Map · Ø {min_v}:{sec_v:02d} min/km | '
f'schnell: {p5:.1f} langsam: {p95:.1f} min/km',
color='white', fontsize=10, ha='center', va='top',
transform=fig_mpl.transFigure)
img_b64 = _fig_to_base64(fig_mpl)
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(text='Pixel-Pace-Map (blau = schnell · rot = langsam)',
font=dict(size=13, color='white')),
paper_bgcolor=bg_color,
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height + 30, # Vorher jeweils: height=img_height (900)
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='pace_map',
)
return fig
# -----------------------------------------------------------------------------
# PIXEL-PLOT 4: HEART-RATE-MAP
# -----------------------------------------------------------------------------
def create_pixel_hr_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'):
"""
Pixel-Map der Heart Rate je Streckenabschnitt.
Farbe: dunkelrot (min BPM, niedrige Belastung) → weiß (max BPM, Vollgas).
Fehlende HR-Daten (GPX-Dateien) werden als dunkle Linie gezeichnet.
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
import plotly.graph_objects as go
DPI = 100
# Leerer Plot wenn keine GPS-Daten
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height,
title=dict(text='Keine GPS-Daten', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
# Heart Rate laden prüfe beide möglichen Spaltennamen
hr_values = None
for col in ['heart_rate', 'hr_smooth']:
if col in df.columns and df[col].notna().sum() > 10:
hr_values = df[col].ffill().bfill().values
break
has_hr = hr_values is not None
n = min(len(lats), len(lons), len(hr_values) if has_hr else len(lats))
lats = lats[:n]
lons = lons[:n]
if has_hr:
hr_values = hr_values[:n]
# Pixel-Koordinaten
xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height,
pad_top=40, pad_bottom=60, pad_left=10, pad_right=10)
xs = xs.astype(int)
ys = ys.astype(int)
# -------------------------------------------------------------------------
# Colormap: dunkelrot (niedrige BPM) → rot → orange → gelb → weiß (max BPM)
# -------------------------------------------------------------------------
cmap_colors = [
(0.00, '#3d0000'), # sehr dunkelrot (minimale HR)
(0.25, '#8b0000'), # dunkelrot
(0.50, '#cc2200'), # rot
(0.70, '#ff6600'), # orange
(0.85, '#ffcc00'), # gelb
(1.00, '#ffffff'), # weiß (maximale HR = Vollgas)
]
cmap_hr = LinearSegmentedColormap.from_list('heartrate', cmap_colors, N=256)
# HR-Grenzen: Perzentile statt absolutes Min/Max → Ausreißer ignorieren
if has_hr:
valid_hr = hr_values[~np.isnan(hr_values)]
valid_hr = valid_hr[(valid_hr > 40) & (valid_hr < 220)] # Plausibilitätsfilter
if len(valid_hr) > 0:
hr_min = np.percentile(valid_hr, 2)
hr_max = np.percentile(valid_hr, 98)
else:
hr_min, hr_max = 100, 180
has_hr = False
else:
hr_min, hr_max = 100, 180
norm_hr = Normalize(vmin=hr_min, vmax=hr_max)
# -------------------------------------------------------------------------
# Canvas: exakt img_width × img_height Pixel
# -------------------------------------------------------------------------
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
ax = fig_mpl.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
# -------------------------------------------------------------------------
# Linien zeichnen
# -------------------------------------------------------------------------
if has_hr:
for i in range(n - 1):
hr = hr_values[i]
if np.isnan(hr) or hr < 40 or hr > 220:
# Ungültige HR → sehr dunkel
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color='#1a1a1a', linewidth=line_width * 0.6,
solid_capstyle='round')
continue
color = cmap_hr(norm_hr(hr))
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=color, linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
else:
# Keine HR-Daten → Route grau zeichnen mit Hinweis
for i in range(n - 1):
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color='#444444', linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
fig_mpl.text(0.5, 0.50, 'Keine Heart-Rate-Daten\n(nur in .fit Dateien verfügbar)',
color='#888888', fontsize=11, ha='center', va='center',
transform=fig_mpl.transFigure)
# Start / Ziel
ax.plot(xs[0], ys[0], 'o', color='#b9fc62', markersize=10, zorder=5)
ax.plot(xs[-1], ys[-1], 's', color='#fca062', markersize=9, zorder=5)
# Titel
if has_hr:
avg_hr = float(np.nanmean(valid_hr))
title_str = (f'HR-Map · Ø {avg_hr:.0f} bpm | '
f'min {hr_min:.0f} max {hr_max:.0f} bpm')
else:
title_str = 'HR-Map · Keine Heart-Rate-Daten'
fig_mpl.text(0.5, 0.97, title_str,
color='white', fontsize=10, ha='center', va='top',
transform=fig_mpl.transFigure)
# -------------------------------------------------------------------------
# Colorbar horizontal unten
# dunkelrot links (min BPM) → weiß rechts (max BPM)
# -------------------------------------------------------------------------
if has_hr:
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020])
sm = cm.ScalarMappable(cmap=cmap_hr, norm=norm_hr)
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar.set_label('Heart Rate (bpm)', color='white', fontsize=8)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.set_ticks([hr_min, (hr_min + hr_max) / 2, hr_max])
cbar.set_ticklabels([
f'{hr_min:.0f} bpm',
f'{(hr_min + hr_max) / 2:.0f} bpm',
f'{hr_max:.0f} bpm'
])
img_b64 = _fig_to_base64(fig_mpl)
# Plotly-Figure
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(
text='Pixel-HR-Map (dunkelrot = niedrige BPM · weiß = maximale BPM)',
font=dict(size=13, color='white')
),
paper_bgcolor=bg_color,
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height,
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='hr_map',
)
return fig
# -----------------------------------------------------------------------------
# ELEVATION-PLOT:
# -----------------------------------------------------------------------------
def create_elevation_plot(df, smooth_points=500):
x = df['time']
y = df['rel_elev']
y_smooth = (
pd.Series(y.values)
.interpolate(method='linear', limit=10, limit_direction='both') # kurze Lücken schließen
.rolling(window=15, center=True, min_periods=1)
.mean()
.values
)
x_smooth = x
fig = go.Figure()
# Separate Behandlung für positive und negative Bereiche
y_array = np.array(y_smooth)
x_array = np.array(x_smooth)
# Positive Bereiche (Anstiege) - Gradient von transparent unten zu grün oben
positive_mask = y_array >= 0
if np.any(positive_mask):
# Nulllinie für positive Bereiche
fig.add_trace(go.Scatter(
x=x_array,
y=np.zeros_like(y_array),
mode='lines',
line=dict(width=0),
hoverinfo='skip',
showlegend=False
))
# Positive Bereiche mit Gradient nach oben
fig.add_trace(go.Scatter(
x=x_array,
y=np.where(y_array >= 0, y_array, 0), # Nur positive Werte
fill='tonexty', # Fill zur vorherigen Trace (Nulllinie)
mode='lines',
line=dict(width=0),
fillgradient=dict(
type="vertical",
colorscale=[
(0.0, "rgba(17, 17, 17, 0.0)"), # Transparent unten (bei y=0)
(1.0, "rgba(43, 82, 144, 0.8)") # Blau oben (bei maximaler Höhe) Grün: 73, 189, 115
]
),
hoverinfo='skip',
showlegend=False
))
# Negative Bereiche (Abstiege) - Gradient von grün unten zu transparent oben
negative_mask = y_array < 0
if np.any(negative_mask):
# Nulllinie für negative Bereiche
fig.add_trace(go.Scatter(
x=x_array,
y=np.where(y_array < 0, y_array, 0), # Nur negative Werte
mode='lines',
line=dict(width=0),
hoverinfo='skip',
showlegend=False
))
# Negative Bereiche mit Gradient nach unten
fig.add_trace(go.Scatter(
x=x_array,
y=np.zeros_like(y_array),
fill='tonexty', # Fill zur vorherigen Trace (negative Werte)
mode='lines',
line=dict(width=0),
fillgradient=dict(
type="vertical",
colorscale=[
(0.0, "rgba(43, 82, 144, 0.8)"), # Blau unten (bei minimaler Tiefe)
(1.0, "rgba(17, 17, 17, 0.0)") # Transparent oben (bei y=0)
]
),
hoverinfo='skip',
showlegend=False
))
# Hauptlinie (geglättet) - über allem
fig.add_trace(go.Scatter(
x=x_smooth,
y=y_smooth,
mode='lines',
line=dict(color='#2b5290', width=2), # Weiße Linie für bessere Sichtbarkeit
name='Elevation',
showlegend=False
))
# 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'
)
# Layout im Dark Theme
fig.update_layout(
title=dict(text='Höhenprofil (relativ zum Ausgangswert: 0m)', 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,
uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
# Alte Version - normaler fill between:
# 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]}<br>" +
# # "Distance (km): %{customdata[0]:.2f}<br>" +
# # "Elevation: %{customdata[1]}<extra></extra>" +
# # "Elapsed Time: %{customdata[2]}<extra></extra>"
# # ),
# # 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
# #####################
# -----------------------------------------------------------------------------
# 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
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),
uirevision='constant', # Avoiding not needed Re-renderings
)
# 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
# -----------------------------------------------------------------------------
# SPEED-PLOT:
# -----------------------------------------------------------------------------
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) - Ø {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),
uirevision='constant', # Avoiding not needed Re-renderings
)
# 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:
# -----------------------------------------------------------------------------
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)
mask = df['heart_rate'].isna()
fig.add_trace(go.Scatter(
x=df['time'][~mask],
y=df['heart_rate'][~mask],
mode='lines',
line=dict(color='#ff2c48', width=1.5), # etwas dünner für gezackte Linie
showlegend=False,
hovertemplate=(
"Zeit: %{x}<br>" +
"Herzfrequenz: %{y:.0f} bpm<br>" +
"<extra></extra>"
)
))
# # 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.5)], # Bei 50% 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 (Beispiel: 200 bpm)
max_hr_estimated = 200 # oder z. B. 220 - alter
## Definiere feste HR-Zonen in BPM
#zones = [
# {"name": "Zone 0", "lower": 0, "upper": 40, "color": "#333333"}, # Unrealistischer Wertebereich
# {"name": "Zone 1", "lower": 40, "upper": 124, "color": "#4A4A4A"}, # Regeneration (Recovery) (#111111 Transparent)
# {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#87CEFA"}, # Grundlagenausdauer (Endurance)
# {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#90EE90"}, # Tempo (Aerob)
# {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFDAB9"}, # Schwelle (Threshold) (Anaerob)
# {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#FFB6C1"}, # Neuromuskulär (Neuromuskulär)
#]
zones = [
{"name": "Zone 0", "lower": 0, "upper": 40, "color": "#2b2b2b"}, # unrealistisch / very dark neutral
{"name": "Zone 1", "lower": 40, "upper": 124, "color": "#7FB3FF"}, # Pastellblau (Recovery)
{"name": "Zone 2", "lower": 124, "upper": 154, "color": "#9DE79A"}, # Pastellgrün (Endurance - Grundlagenausdauer)
{"name": "Zone 3", "lower": 154, "upper": 169, "color": "#FFF29A"}, # Pastellgelb (Aerob - Tempo)
{"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFBE7A"}, # Pastellorange (Anaerob - Threshold - Schwelle)
{"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#FF9AA2"}, # Pastellrot (Neuromuskulär)
]
# Berechne die Anzahl der Werte in jeder Zone
total_count = len(valid_hr)
for zone in zones:
# Filter für die Zone
zone_count = valid_hr[(valid_hr >= zone["lower"]) & (valid_hr < zone["upper"])].count()
zone_percentage = (zone_count / total_count) * 100 if total_count > 0 else 0
# Zeichne die Zone als Hintergrund
fig.add_hrect(
y0=zone["lower"], y1=zone["upper"],
fillcolor=zone["color"],
opacity=0.15,
line_width=0,
)
# Annotation für die Zone (Name und Prozentsatz)
fig.add_annotation(
x=df['time_loc'].iloc[-1], # Rechts am Plot
y=zone["upper"] -6 , # Oben in der Zone
#y=(zone["lower"] + zone["upper"]) / 2, # Falls Pos. mittig je Zone gewünscht
text=f"{zone['name']}<br>{zone_percentage:.1f}%",
showarrow=False,
font=dict(color="white", size=10),
align="left",
bgcolor="rgba(0,0,0,0.5)",
bordercolor="gray",
)
# 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)',
range=[70, 200] # instead of: 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,
uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
# -----------------------------------------------------------------------------
# PACE-BAR-PLOT:
# -----------------------------------------------------------------------------
def create_pace_bars_plot(df, formatted_pace=None):
"""
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')
df = df.copy()
df['km'] = df['cum_dist_km'].astype(int)
df['time_sec'] = (df['time'] - df['time'].iloc[0]).dt.total_seconds()
# -------------------------------------------------------------------------
# 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]
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 and elapsed_time_sec > 0:
pace_min = (elapsed_time_sec / 60) / segment_len
else:
pace_min = np.nan
# 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]
# 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})')
fig = go.Figure()
# -------------------------------------------------------------------------
# Balken (horizontal)
# -------------------------------------------------------------------------
fig.add_trace(go.Bar(
x=pace_vals,
y=list(range(len(y_labels))), # ← 0,1,2,3... statt Strings 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',
textfont=dict(color='white', size=11),
hovertemplate=(
'km %{y}<br>'
'Pace: %{text}<br>'
'<extra></extra>'
),
name='',
showlegend=False,
))
# -------------------------------------------------------------------------
# Durchschnitts-Linie (vertikal, gestrichelt)
# -------------------------------------------------------------------------
fig.add_shape(
type='line',
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)
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=i, # ← numerischer Index statt km_lbl String
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=f'Tempo je Kilometer · Ø {formatted_pace}',
font=dict(size=15, color='white')
),
xaxis=dict(
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)
tickmode='array',
tickvals=list(range(len(y_labels))), # ← 0,1,2...
ticktext=y_labels, # ← "1","2",...,"0.4"
tickfont=dict(size=11, color='white'),
showgrid=False,
zeroline=False,
),
template='plotly_dark',
height=plot_height,
margin=dict(l=40, r=40, t=45, b=45),
plot_bgcolor='#111111',
paper_bgcolor='#1e1e1e',
font=dict(color='white'),
bargap=0.15,
uirevision='constant',
)
return fig
# === App Setup ===
app = dash.Dash(__name__,
suppress_callback_exceptions=True, # Weniger Validierung
compress=True, # Gzip-Kompression (Install: python-flask-compress)
external_stylesheets=[dbc.themes.SLATE],
title = "Jogging Dashboard"
)
app.layout = html.Div([
html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
dcc.Store(id='stored-df'),
# Horizontales Layout für Dropdown und Button
html.Div([
# Linke Seite: Datei-Dropdown
html.Div([
html.Label("Datei wählen:", style={'color': '#aaaaaa', 'marginBottom': '5px'}),
dcc.Dropdown(
id='file-dropdown',
options=list_files(),
value=list_files()[0]['value'],
clearable=False,
style={'width': '300px', 'color': 'black'}
)
], style={'display': 'flex', 'flexDirection': 'column'}),
# Rechte Seite: Export Button
html.Div([
html.Label("Export SVG:",
style={'color': '#aaaaaa', 'marginBottom': '8px'}),
html.Button(
[
html.I(className="fas fa-download"),
"Summary Image"
],
id='export-button',
style={
'backgroundColor': '#007bff',
'border': 'none',
'color': 'white',
'padding': '10px 12px',
'borderRadius': '5px',
'fontSize': '14px',
'cursor': 'pointer',
'display': 'flex',
'alignItems': 'center',
'justifyContent': 'center',
'gap': '5px'
}
)
], style={'display': 'flex', 'flexDirection': 'column', 'alignItems': 'flex-end'})
], style={
'padding': '20px',
'backgroundColor': '#1e1e1e',
'display': 'flex',
'justifyContent': 'space-between', # Dropdown links, Button rechts
'alignItems': 'flex-end', # Beide Elemente unten ausrichten
'minHeight': '80px' # Mindesthöhe für konsistentes Layout
}),
# Export Status
html.Div(id='export-status', children="", style={'padding': '0 20px'}),
# Rest deines Layouts
html.Div(id='info-banner'),
dcc.Graph(id='fig-map'),
# START !!!!!!!!!!!!!!!
# Pixel-Map Überschrift
html.Hr(style={'borderColor': '#333', 'margin': '10px 20px'}),
html.Div([
html.H3("Pixel-Maps", style={
'color': '#aaaaaa', 'margin': '10px 0 0 0', 'fontSize': '16px'
}),
html.P(
"Plot 1) Heatmap: Einzellauf oder alle Läufe der Region umschalten.\n"
"Plot 2), 3) & 4) Elevation-, Pace- & HR-Map: des aktuell gewählten Laufs.",
style={'color': '#666', 'margin': '2px 0 8px 0', 'fontSize': '12px'}
),
], style={'padding': '0 20px'}),
# Drei Plots nebeneinander Heatmap links, Elevation Mitte, Pace rechts
html.Div([
# --- Heatmap-Spalte (mit Toggle darunter) ---
html.Div([
dcc.Graph(id='fig-pixel-heatmap'),
# Toggle: Einzellauf ↔ Region
html.Div([
html.Span("Einzellauf", style={
'color': '#aaa', 'fontSize': '12px',
'marginRight': '8px', 'verticalAlign': 'middle'
}),
# dcc.Checklist als Toggle-Switch (ein Checkbox = ON/OFF)
dcc.Checklist(
id='heatmap-mode-toggle',
options=[{'label': ' Alle Läufe (Region)', 'value': 'city'}],
#value=[], # Standard: leer = Einzellauf
value=['city'], # Standard: Region aktiv - Jezt ist immer der Harken gesetzt!
inputStyle={
'cursor': 'pointer',
'width': '36px', 'height': '18px',
'accentColor': '#fc4e00', # Strava-Orange
'verticalAlign': 'middle',
'marginRight': '6px',
},
labelStyle={
'color': '#cccccc', 'fontSize': '12px',
'verticalAlign': 'middle', 'cursor': 'pointer'
},
),
# Infotext: aktuell erkannter Stadtcode
html.Span(id='heatmap-city-info', style={
'color': '#fc4e00', 'fontSize': '11px',
'marginLeft': '12px', 'verticalAlign': 'middle'
}),
], style={
'display': 'flex', 'alignItems': 'center',
'padding': '8px 12px',
'backgroundColor': '#1a1a1a',
'borderRadius': '0 0 6px 6px',
'borderTop': '1px solid #333',
}),
], style={'flex': '1', 'minWidth': '300px'}),
# --- Elevation-Map ---
html.Div([
dcc.Graph(id='fig-pixel-elevation'),
], style={'flex': '1', 'minWidth': '300px'}),
# --- Pace-Map ---
html.Div([
dcc.Graph(id='fig-pixel-pace'),
], style={'flex': '1', 'minWidth': '300px'}),
# --- HR-Map ---
html.Div([
dcc.Graph(id='fig-pixel-hr'),
], style={'flex': '1', 'minWidth': '300px'}),
], style={
'display': 'grid',
'gridTemplateColumns': 'repeat(2, 1fr)', # immer 2 Spalten
'gap': '8px',
'padding': '0 20px',
'backgroundColor': '#111111'
}),
html.Hr(style={'borderColor': '#333', 'margin': '10px 20px'}),
# ENDE !!!!!!!!!!!!!!!!
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('file-dropdown', 'value')
)
def load_data(selected_file): # Dateipfad der ausgewählten Datei
print(f"DEBUG load_data: {selected_file}")
df = process_selected_file(selected_file) # Verarbeitet diese Datei
print(f"DEBUG load_data: rows={len(df)}")
return df.to_json(date_format='iso', orient='split')
# Callback 2: Update All (static) Plots
@app.callback(
Output('info-banner', 'children'),
Output('fig-map', 'figure', allow_duplicate=True),
Output('fig-elevation', 'figure'),
Output('fig_deviation', 'figure'),
Output('fig_speed', 'figure'),
Output('fig_hr', 'figure'),
Output('fig_pace_bars', 'figure'),
# NEU: drei Pixel-Maps
#Output('fig-pixel-heatmap', 'figure'),
Output('fig-pixel-elevation', 'figure'), # ← aus pixel_map_extension.py
Output('fig-pixel-pace', 'figure'), # ← aus pixel_map_extension.py
Output('fig-pixel-hr', 'figure'), # ← NEU
Input('stored-df', 'data'),
prevent_initial_call=True
)
def update_all_plots(json_data):
df = pd.read_json(io.StringIO(json_data), orient='split')
# Bestehende Plots (unverändert)
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)
# Pixel-Maps (Elevation + Pace bleiben hier; Heatmap hat eigenen Callback)
fig_pixel_elevation = create_pixel_elevation_map(df, img_width=800, img_height=500)
fig_pixel_pace = create_pixel_pace_map(df, img_width=800, img_height=500)
fig_pixel_hr = create_pixel_hr_map(df, img_width=800, img_height=500) # ← NEU
return (info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace,
fig_pixel_elevation, fig_pixel_pace, fig_pixel_hr)
@app.callback(
Output('fig-pixel-heatmap', 'figure'),
Output('heatmap-city-info', 'children'),
Input('file-dropdown', 'value'),
Input('heatmap-mode-toggle', 'value'),
#prevent_initial_call=True # Sonst beim Start der App kein Renderprozess
)
def update_pixel_heatmap(selected_file, toggle_value):
"""
Rendert die Pixel-Heatmap abhängig vom Toggle-Switch.
toggle_value == [] → Einzellauf (nur die gewählte Datei)
toggle_value == ['city'] → Region (alle Läufe mit gleichem Stadtcode)
"""
import os
if not selected_file or selected_file in ['NO_FILES', 'NO_FOLDER', 'ERROR', None]:
empty = go.Figure()
empty.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'),
title=dict(text='Datei wählen...', font=dict(color='white')))
return empty, ''
city_code = extract_city_code(selected_file)
city_info_text = f'Erkannte Region: {city_code}' if city_code else 'Kein Stadtcode erkannt'
# Immer alle Stadt-Läufe laden (für korrekten Count-Kontext)
all_options = list_files()
if city_code:
city_dfs, _ = load_runs_for_city(city_code, all_options)
else:
city_dfs = []
# Fallback falls keine Stadt-Läufe gefunden
if not city_dfs:
try:
city_dfs = [process_selected_file(selected_file)]
except Exception:
city_dfs = []
if not toggle_value or 'city' not in toggle_value:
# --- Einzellauf-Modus: nur aktuellen Lauf ANZEIGEN,
# aber count_grid aus allen Läufen berechnen ---
try:
df_single = process_selected_file(selected_file)
except Exception:
df_single = pd.DataFrame()
fig = create_pixel_heatmap(
dataframes=city_dfs, # count_grid aus allen Läufen
highlight_df=df_single, # nur dieser Lauf wird gezeichnet
mode='single',
city_code=city_code,
n_city_runs=len(city_dfs),
img_width=800, img_height=500, # ← NEU
)
city_info_text = f'Region {city_code} · Einzellauf · Count aus {len(city_dfs)} Läufen'
else:
# --- Region-Modus: alle Läufe anzeigen ---
fig = create_pixel_heatmap(
dataframes=city_dfs,
mode='city',
city_code=city_code,
n_city_runs=len(city_dfs),
img_width=800, img_height=500, # ← NEU
)
city_info_text = f'Region {city_code} · {len(city_dfs)} Läufe geladen'
return fig, city_info_text
# Callback 3: Export SVG
@app.callback(
Output('export-status', 'children'),
Input('export-button', 'n_clicks'),
State('stored-df', 'data'),
State('file-dropdown', 'value'),
prevent_initial_call=True
)
def export_summary_image(n_clicks, json_data, selected_file):
if n_clicks and json_data and selected_file:
try:
print(f"Export wurde geklickt für Datei: {selected_file}")
# DataFrame aus bereits geladenen Daten erstellen
df = pd.read_json(io.StringIO(json_data), orient='split')
if df.empty:
return html.Div("Export fehlgeschlagen: Keine Daten verfügbar",
style={'color': 'red', 'fontSize': '12px'})
# Statistiken berechnen (gleich wie im Info-Banner)
total_distance_km = df['cum_dist_km'].iloc[-1] if 'cum_dist_km' in df.columns else 0
total_time_str = df['duration_hms'].iloc[-1] if 'duration_hms' in df.columns else "00:00:00"
total_seconds = df['time_diff_sec'].iloc[-1] if 'time_diff_sec' in df.columns else 0
# Pace berechnen
avg_pace = calculate_pace(total_distance_km, total_seconds)
# Output-Dateiname
base_name = os.path.splitext(os.path.basename(selected_file))[0]
output_filename = f"{base_name}_overlay.svg"
print(f"Stats - Distance: {total_distance_km:.1f}km, Time: {total_time_str}, Pace: {avg_pace}")
# SVG erstellen
svg = create_strava_style_svg(
df=df,
total_distance_km=total_distance_km,
total_time=total_time_str,
avg_pace=avg_pace,
width=800,
height=600
)
# SVG speichern
save_svg(svg, output_filename)
return html.Div(
f"Export erfolgreich! Datei: {output_filename}",
style={'color': 'green', 'fontSize': '12px', 'marginTop': '5px'}
)
except Exception as e:
print(f"Export-Fehler: {str(e)}")
return html.Div(
f"Export fehlgeschlagen: {str(e)}",
style={'color': 'red', 'fontSize': '12px', 'marginTop': '5px'}
)
return ""
# Callback 4: Hover → update only hover (dynamic) marker
@app.callback(
Output('fig-map', 'figure'),
Input('fig-elevation', 'hoverData'),
State('fig-map', 'figure'),
State('stored-df', 'data'),
prevent_initial_call=True
)
def highlight_map(hoverData, fig_map, json_data):
df = pd.read_json(io.StringIO(json_data), orient='split')
if hoverData is not None:
point_index = hoverData['points'][0]['pointIndex']
lat, lon = df.iloc[point_index][['lat', 'lon']]
# update the last trace (the empty Hovered Point trace)
fig_map['data'][-1]['lat'] = [lat]
fig_map['data'][-1]['lon'] = [lon]
return fig_map
# === Run Server ===
if __name__ == '__main__':
app.run(debug=True,
port=8051,
threaded=True,
processes=1
)
# 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)
#