Files
jogging-dashboard/jogging_dashboard_browser_app.py

2853 lines
104 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 = 36
_IS_MALE = True
_HR_REST = 64.5 # Ruhepuls in bpm kalibriert gegen Strava 550 kcal
HR_MAX_BPM = 220 - _AGE_YEARS # z.B. 185 bei Alter 35
HR_ZONES = [
{"name": "Zone 1", "lower": 0, "upper": 124, "color": "#7FB3FF", "label": "Recovery"},
{"name": "Zone 2", "lower": 124, "upper": 154, "color": "#9DE79A", "label": "Endurance"},
{"name": "Zone 3", "lower": 154, "upper": 169, "color": "#FFF29A", "label": "Aerob/Tempo"},
{"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFBE7A", "label": "Anaerob/Threshold"},
{"name": "Zone 5", "lower": 184, "upper": 999, "color": "#FF9AA2", "label": "Max. Effort"},
]
def get_zone_for_bpm(bpm):
"""Gibt den Zonen-Index (0-4) für einen BPM-Wert zurück."""
for i, z in enumerate(HR_ZONES):
if z["lower"] <= bpm < z["upper"]:
return i
return 4
# ─────────────────────────────────────────────────────────────────────────────
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
hr_rest = _HR_REST # 64.5 bpm
# Check, ob genug Herzfrequenz-Daten vorhanden sind
use_hr = ('heart_rate' in df.columns and
df['heart_rate'].notna().sum() > len(df) * 0.5)
if not use_hr:
return 0
# Datenvorbereitung
hr = df['heart_rate'].ffill().fillna(df['heart_rate'].bfill()).values
ts = df['time_diff_sec'].values
# 2. VO2max Schätzung basierend auf deinem echten Ruhepuls
# Die Formel 15 * (HR_max / HR_rest) ist ein Standard-Approximationswert
vo2max = 15.0 * (hr_max / hr_rest)
# Zeitdifferenzen berechnen
dt = np.diff(ts, prepend=ts[0])
mask = (dt > 0) & (dt <= 10) # Filtert Pausen oder Sprünge in der Aufzeichnung
# 3. Berechnung der Intensität (Karvonen-Prinzip)
# Anteil der Herzfrequenzreserve (HRR)
frac = np.clip((hr - hr_rest) / (hr_max - hr_rest), 0, None)
# Umrechnung in MET (Metabolic Equivalent of Task)
# Ein MET = 3.5 ml VO2/kg/min
met = np.clip((frac * vo2max) / 3.5, 1.0, 18.0)
# 4. Umrechnung in kcal pro Sekunde
# Formel: (MET * 3.5 * Gewicht_kg) / (200 * 60) -> 12000
kcal_per_s = met * _WEIGHT_KG * 3.5 / 12000.0
# Summation über den gesamten Lauf
cumulative = float(np.sum(kcal_per_s[mask] * dt[mask]))
print(f"DEBUG calories: HR_REST_USED={hr_rest}, VO2MAX_EST={vo2max:.1f}, "
f"DURATION={df['time_diff_sec'].iloc[-1]/60:.1f}min, TOTAL_KCAL={cumulative:.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 ASL (Δ %{customdata[2]:+.0f} m from start)<br>" + #"ASL" = above sea level; diff. vs start value"
"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='No Data avaiable', 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} runs (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
# -----------------------------------------------------------------------------
# Map-PLOT 2: ELEVATION-MAP Farbe zeigt Steigung/Gefälle je Segment
# -----------------------------------------------------------------------------
def create_route_elevation_map(df, height=500):
"""
Interaktive Elevation-Map als go.Scattergl.
Farbe: grün (bergab) → grau (flach) → rot (bergauf).
Horizontale Colorbar unten: links min (grün) → mitte flach (grau) → rechts max (rot).
"""
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#0d0d0d', height=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))
elevs = np.convolve(elevs_raw, np.ones(n_smooth) / n_smooth, mode='same')
else:
elevs = np.zeros(len(lats))
n = min(len(lats), len(lons), len(elevs))
lats, lons, elevs = lats[:n], lons[:n], elevs[:n]
delta = np.diff(elevs, prepend=elevs[0])
abs_d = np.abs(delta)
nz = abs_d[abs_d > 0]
flat_thresh = np.percentile(nz, 20) if len(nz) > 0 else 0.05
max_d = max(np.percentile(abs_d, 80), flat_thresh * 2)
dist = df['cum_dist_km'].values[:n] if 'cum_dist_km' in df.columns else np.zeros(n)
total_up = delta[delta > flat_thresh].sum()
total_down = abs(delta[delta < -flat_thresh].sum())
# Farbe pro Punkt: normalisiert -max_d → 0 → +max_d
# Colorscale: grün (0.0) → grau (0.5) → rot (1.0)
norm_vals = np.clip((delta + max_d) / (2 * max_d), 0, 1)
colorscale = [
[0.00, '#00aa00'],
[0.35, '#227722'],
[0.50, '#666666'],
[0.65, '#772222'],
[1.00, '#ff2200'],
]
fig = go.Figure()
# Haupttrace: Route eingefärbt
fig.add_trace(go.Scattergl(
x=lons, y=lats,
mode='markers',
marker=dict(
color=norm_vals,
colorscale=colorscale,
cmin=0, cmax=1,
size=3,
opacity=0.9,
showscale=False, # Colorbar kommt als separater Dummy-Trace unten
),
hovertemplate=(
'%{customdata[0]:.0f} m<br>'
'📏 %{customdata[1]:.2f} km<extra></extra>'
),
customdata=np.column_stack([elevs, dist]),
showlegend=False,
))
# Start / Ziel
fig.add_trace(go.Scattergl(
x=[lons[0]], y=[lats[0]], mode='markers',
marker=dict(color='#b9fc62', size=12, symbol='circle'),
showlegend=False, hovertemplate='Start<extra></extra>',
))
fig.add_trace(go.Scattergl(
x=[lons[-1]], y=[lats[-1]], mode='markers',
marker=dict(color='#fca062', size=11, symbol='square'),
showlegend=False, hovertemplate='Ziel<extra></extra>',
))
# -------------------------------------------------------------------------
# Horizontale Colorbar unten als Dummy-Scatter
# links: grün (bergab/-max_d) | mitte: grau (flach/0) | rechts: rot (bergauf/+max_d)
# -------------------------------------------------------------------------
fig.add_trace(go.Scatter(
x=[None], y=[None],
mode='markers',
marker=dict(
colorscale=colorscale,
cmin=-max_d,
cmax=max_d,
color=[0],
colorbar=dict(
orientation='h',
x=0.5, xanchor='center',
y=-0.08, yanchor='top',
len=0.85, thickness=10,
title=dict(
#text='Steigung (m/Punkt)',
side='bottom',
font=dict(color='white', size=11),
),
tickfont=dict(color='white', size=10),
tickvals=[-max_d, 0, max_d],
ticktext=[
f'↓ min ({-max_d:.2f}m)',
'flach',
f'↑ max (+{max_d:.2f}m)',
],
tickcolor='white',
),
showscale=True,
),
showlegend=False,
hoverinfo='skip',
))
fig.update_layout(
title=dict(
text=f'Elevation-Map · ↑ {total_up:.0f} m ↓ {total_down:.0f} m '
f' [green=downhill · gray=flat · red=uphill]',
font=dict(size=12, color='white')
),
xaxis=dict(visible=False, scaleanchor='y', scaleratio=1),
yaxis=dict(visible=False),
paper_bgcolor='#0d0d0d',
plot_bgcolor='#0d0d0d',
font=dict(color='white'),
margin=dict(l=0, r=0, t=40, b=70),
height=height,
uirevision='elevation_map',
dragmode='zoom',
)
return fig
# -----------------------------------------------------------------------------
# Map-PLOT 3: PACE-MAP Farbe zeigt Tempo je Segment (schnell = blau, langsam = rot)
# -----------------------------------------------------------------------------
def create_route_pace_map(df, height=500):
"""
Interaktive Pace-Map als go.Scattergl.
Horizontale Colorbar unten:
links: rot (langsam/höchste Pace) → mitte: gelb → rechts: blau (schnell/niedrigste Pace).
"""
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#0d0d0d', height=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 = np.where(speed > 0.5, 60.0 / speed, np.nan)
elif 'vel_kmps' in df.columns:
vel = df['vel_kmps'].fillna(0).values
pace = np.where(vel * 3600 > 0.5, 60.0 / (vel * 3600), np.nan)
else:
pace = np.full(len(lats), np.nan)
n = min(len(lats), len(lons), len(pace))
lats, lons, pace = lats[:n], lons[:n], pace[:n]
dist = df['cum_dist_km'].values[:n] if 'cum_dist_km' in df.columns else np.zeros(n)
valid = pace[(pace >= 2) & (pace <= 15) & ~np.isnan(pace)]
p5 = np.percentile(valid, 5) if len(valid) > 0 else 4.0
p95 = np.percentile(valid, 95) if len(valid) > 0 else 8.0
avg = valid.mean() if len(valid) > 0 else 6.0
# norm: 0 = schnell (blau), 1 = langsam (rot)
# Colorscale links → rechts: rot (langsam) → gelb → grün → blau (schnell)
# Daher invertieren: norm_inv = 1 - norm → 0 = langsam (rot links), 1 = schnell (blau rechts)
norm = np.clip((pace - p5) / (p95 - p5), 0, 1)
# 1 - norm: schnell (niedrige Pace) → 1 → blau (rechts in Colorscale)
# langsam (hohe Pace) → 0 → rot (links in Colorscale)
norm_inv = np.where(np.isnan(norm), 0.5, 1 - norm)
# Colorscale: rot (0/links/langsam) → orange → gelb → grün → blau (1/rechts/schnell)
colorscale = [
[0.00, '#cc0000'],
[0.25, '#ff6600'],
[0.50, '#ffcc00'],
[0.75, '#00cc88'],
[1.00, '#0033cc'],
]
#colorscale = [
# [0.00, '#e88080'], # pastell-rot (langsam)
# [0.25, '#f0a875'], # pastell-orange
# [0.50, '#f5e090'], # pastell-gelb (mittel)
# [0.75, '#80d4a8'], # pastell-grün
# [1.00, '#7aaee8'], # pastell-blau (schnell)
#]
def fmt_pace(p):
if np.isnan(p): return ''
m = int(p); s = int(round((p - m) * 60))
return f'{m}:{s:02d} min/km'
m_avg = int(avg); s_avg = int(round((avg - m_avg) * 60))
fig = go.Figure()
fig.add_trace(go.Scattergl(
x=lons, y=lats,
mode='markers',
marker=dict(
color=norm_inv,
colorscale=colorscale,
cmin=0, cmax=1,
size=3,
opacity=0.9,
showscale=False,
),
hovertemplate=(
'🏃 %{customdata[0]}<br>'
'📏 %{customdata[1]:.2f} km<extra></extra>'
),
customdata=np.column_stack([
[fmt_pace(p) for p in pace],
dist,
]),
showlegend=False,
))
fig.add_trace(go.Scattergl(
x=[lons[0]], y=[lats[0]], mode='markers',
marker=dict(color='#b9fc62', size=12, symbol='circle'),
showlegend=False, hovertemplate='Start<extra></extra>',
))
fig.add_trace(go.Scattergl(
x=[lons[-1]], y=[lats[-1]], mode='markers',
marker=dict(color='#fca062', size=11, symbol='square'),
showlegend=False, hovertemplate='Ziel<extra></extra>',
))
# -------------------------------------------------------------------------
# Horizontale Colorbar unten
# Angezeigter Bereich: p95 (rot/langsam) links → p5 (blau/schnell) rechts
# -------------------------------------------------------------------------
fig.add_trace(go.Scatter(
x=[None], y=[None],
mode='markers',
marker=dict(
colorscale=colorscale,
cmin=0, # 0 = rot = langsam
cmax=1, # 1 = blau = schnell
color=[0],
colorbar=dict(
orientation='h',
x=0.5, xanchor='center',
y=-0.08, yanchor='top',
len=0.85, thickness=10,
title=dict(
#text='Pace (min/km) — links: langsam · rechts: schnell',
side='bottom',
font=dict(color='white', size=11),
),
tickfont=dict(color='white', size=10),
tickvals=[0, 0.5, 1], # im cmin/cmax Bereich → werden angezeigt
ticktext=[
fmt_pace(p95), # 0 = langsam = rot = links
fmt_pace((p5 + p95) / 2), # 0.5 = mittel
fmt_pace(p5), # 1 = schnell = blau = rechts
],
tickcolor='white',
),
showscale=True,
),
showlegend=False,
hoverinfo='skip',
))
fig.update_layout(
title=dict(
text=f'Pace-Map · Ø {m_avg}:{s_avg:02d} min/km [red=slow · blue=fast]',
font=dict(size=12, color='white')
),
xaxis=dict(visible=False, scaleanchor='y', scaleratio=1),
yaxis=dict(visible=False),
paper_bgcolor='#0d0d0d',
plot_bgcolor='#0d0d0d',
font=dict(color='white'),
margin=dict(l=0, r=0, t=40, b=70),
height=height,
uirevision='pace_map',
dragmode='zoom',
)
return fig
# -----------------------------------------------------------------------------
# Map-PLOT 4: HEART-RATE-MAP
# -----------------------------------------------------------------------------
def create_route_hr_map(df, mode='bpm', height=500):
"""
Interaktive HR-Map als go.Scattergl.
mode='bpm' → Farbe: dunkelrot (niedrig BPM) → gelb/weiß (max BPM)
Horizontale Colorbar unten: links dunkelrot → rechts hellgelb
mode='zone' → Farbe: je HR-Zone (blau/grün/gelb/orange/rot)
Legende rechts mit Zonen-Namen und Prozentanteilen
"""
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#0d0d0d', height=height,
title=dict(text='No GPS-Data', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
has_hr = ('heart_rate' in df.columns and df['heart_rate'].notna().sum() > 10)
hr_raw = df['heart_rate'].ffill().bfill().values if has_hr else np.full(len(lats), np.nan)
n = min(len(lats), len(lons), len(hr_raw))
lats, lons, hr = lats[:n], lons[:n], hr_raw[:n]
dist = df['cum_dist_km'].values[:n] if 'cum_dist_km' in df.columns else np.zeros(n)
valid_hr = hr[(hr > 40) & (hr < 220) & ~np.isnan(hr)]
hr_min = np.percentile(valid_hr, 2) if len(valid_hr) > 0 else 100
hr_max = np.percentile(valid_hr, 98) if len(valid_hr) > 0 else 180
hr_avg = valid_hr.mean() if len(valid_hr) > 0 else 0
fig = go.Figure()
if mode == 'bpm':
# Colorscale: dunkelrot (niedrig) → rot → orange → gelb → hellgelb (max)
colorscale_bpm = [
[0.00, '#3d0000'],
[0.25, '#8b0000'],
[0.50, '#cc2200'],
[0.70, '#ff6600'],
[0.85, '#ffcc00'],
[1.00, '#ffff99'],
]
fig.add_trace(go.Scattergl(
x=lons, y=lats,
mode='markers',
marker=dict(
color=hr,
colorscale=colorscale_bpm,
cmin=hr_min,
cmax=hr_max,
size=3,
opacity=0.9,
showscale=False,
),
hovertemplate=(
'❤️ %{customdata[0]:.0f} bpm<br>'
'📏 %{customdata[1]:.2f} km<extra></extra>'
),
customdata=np.column_stack([hr, dist]),
showlegend=False,
))
# -------------------------------------------------------------------------
# Horizontale Colorbar unten: links dunkelrot (min BPM) → rechts hellgelb (max BPM)
# -------------------------------------------------------------------------
fig.add_trace(go.Scatter(
x=[None], y=[None],
mode='markers',
marker=dict(
colorscale=colorscale_bpm,
cmin=hr_min,
cmax=hr_max,
color=[0],
colorbar=dict(
orientation='h',
x=0.5, xanchor='center',
y=-0.08, yanchor='top',
len=0.85, thickness=10,
title=dict(
#text='Heart Rate (bpm) — links: niedrig · rechts: max',
side='bottom',
font=dict(color='white', size=11),
),
tickfont=dict(color='white', size=10),
tickvals=[hr_min, (hr_min + hr_max) / 2, hr_max],
ticktext=[
f'{hr_min:.0f} bpm',
f'{(hr_min + hr_max) / 2:.0f} bpm',
f'{hr_max:.0f} bpm',
],
tickcolor='white',
),
showscale=True,
),
showlegend=False,
hoverinfo='skip',
))
else:
# Zone-Modus: Farbe nach HR-Zone
zone_colors = [get_zone_for_bpm(h) for h in hr]
colors = [
HR_ZONES[z]['color'] if not np.isnan(h) and h > 40 else '#1a1a1a'
for h, z in zip(hr, zone_colors)
]
fig.add_trace(go.Scattergl(
x=lons, y=lats,
mode='markers',
marker=dict(color=colors, size=3, opacity=0.9),
hovertemplate=(
'❤️ %{customdata[0]:.0f} bpm · Zone %{customdata[1]}<br>'
'📏 %{customdata[2]:.2f} km<extra></extra>'
),
customdata=np.column_stack([
hr,
[get_zone_for_bpm(h) + 1 if not np.isnan(h) and h > 40 else 0 for h in hr],
dist,
]),
showlegend=False,
))
# Zonen-Legende
for z in HR_ZONES:
cnt = np.sum((hr >= z['lower']) & (hr < z['upper']) & ~np.isnan(hr))
pct = 100 * cnt / len(valid_hr) if len(valid_hr) > 0 else 0
fig.add_trace(go.Scatter(
x=[None], y=[None], mode='markers',
marker=dict(color=z['color'], size=10),
name=f'{z["name"]} {z["label"]} {pct:.1f}%',
showlegend=True,
))
# Start / Ziel
fig.add_trace(go.Scattergl(
x=[lons[0]], y=[lats[0]], mode='markers',
marker=dict(color='#b9fc62', size=12, symbol='circle'),
showlegend=False, hovertemplate='Start<extra></extra>',
))
fig.add_trace(go.Scattergl(
x=[lons[-1]], y=[lats[-1]], mode='markers',
marker=dict(color='#fca062', size=11, symbol='square'),
showlegend=False, hovertemplate='Ziel<extra></extra>',
))
no_hr = '' if has_hr else ' · No HR-Data'
mode_label = 'BPM-intensity [red=low · white=high]' if mode == 'bpm' else 'Zones'
fig.update_layout(
title=dict(
text=f'Heart Rate ({mode_label}) · Ø {hr_avg:.0f} bpm{no_hr}',
font=dict(size=12, color='white')
),
xaxis=dict(visible=False, scaleanchor='y', scaleratio=1),
yaxis=dict(visible=False),
paper_bgcolor='#0d0d0d',
plot_bgcolor='#0d0d0d',
font=dict(color='white'),
#margin=dict(l=0, r=0 if mode == 'bpm' else 130, t=40, b=70),
margin=dict(l=0, r=0, t=40, b=70),
height=height,
legend=dict(
orientation='h',
x=0.5, xanchor='center',
y=-0.08, yanchor='top',
font=dict(color='white', size=11),
bgcolor='rgba(0,0,0,0)',
entrywidth=0,
tracegroupgap=0,
) if mode == 'zone' else dict(visible=False),
uirevision='hr_map',
dragmode='zoom',
)
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
hovertemplate='Time: %{x|%H:%M:%S}<br>Δ Elevation = %{y:.1f} m<extra></extra>',
#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='Elevation Profile (relative to start: 0 m)', font=dict(size=16, color='white')),
xaxis_title='Time',
yaxis_title='Relative Elevation (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
# #####################
# -----------------------------------------------------------------------------
# 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'),
hovertemplate='Time: %{x|%H:%M:%S}<br>Speed = %{y:.1f} km/h<extra></extra>',
))
fig.update_layout(
title=dict(text=f'Speed over Time (smoothed) - Ø {mean_speed_kmh:.2f} km/h', font=dict(size=16)),
xaxis=dict(title='Time', tickformat='%H:%M', type='date'),
yaxis=dict(title='Speed (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
# -----------------------------------------------------------------------------
# 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 = go.Figure()
fig.add_trace(go.Scatter(
x=df['time_loc'],
y=df['del_dist_km_qmean'],
mode='lines',
name='Δ Strecke (km)',
line=dict(color='royalblue'),
hovertemplate='Time: %{x|%H:%M:%S}<br>Δ Strecke = %{y:.2f} km<extra></extra>',
))
fig.update_layout(
title=dict(text='Distance deviation from integrated average pace', font=dict(size=16)),
yaxis_title='Δ Distance (km)',
xaxis_title='Time',
template='plotly_dark',
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
# -----------------------------------------------------------------------------
# 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='#ff1d34', width=1.5), # etwas dünner für gezackte Linie
showlegend=False,
hovertemplate=(
"Time: %{x}<br>" +
"Heart Rate: %{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)
#]
zones = HR_ZONES
# 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'Heart Rate over Time:'
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='Time',
tickformat='%H:%M',
type='date'
),
yaxis=dict(
title='Heart Rate (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'Pace per km · Ø {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='Distance (km)',
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("Select File:", 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': '#111111', 'margin': '10px 20px'}), #'#333'
#html.Div([
# html.H3("Pixel-Maps", style={
# 'color': '#aaaaaa', 'margin': '10px 0 0 0', 'fontSize': '16px'
# }),
# html.P(
# "Plot 1) Heatmap: Toggle between single run or all runs in the region.\n"
# "Plot 2), 3) & 4) Elevation, Pace & HR maps: of the currently selected run.",
# style={'color': '#aaaaaa', '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("Courses", 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': ' All runs (regional)', '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-route-elevation'),
], style={'flex': '1', 'minWidth': '300px'}),
# --- Pace-Map ---
html.Div([
dcc.Graph(id='fig-route-pace'),
], style={'flex': '1', 'minWidth': '300px'}),
# --- HR-Map (mit BPM/Zone Toggle) ---
html.Div([
dcc.Graph(id='fig-route-hr'),
html.Div([
html.Span("BPM", style={
'color': '#aaa', 'fontSize': '12px',
'marginRight': '8px', 'verticalAlign': 'middle'
}),
dcc.Checklist(
id='hr-map-mode-toggle',
#options=[{'label': ' HR-Zonen', 'value': 'zone'}], # Uncomment if you perfere the untoggled Version!
options=[{'label': ' BPM-intensity', 'value': 'bpm'}],
value=[], # Zone as Standard - If you like to load the intensity map add: 'bpm'
inputStyle={
'cursor': 'pointer', 'width': '36px', 'height': '18px',
'accentColor': '#fc4e00', 'verticalAlign': 'middle',
'marginRight': '6px',
},
labelStyle={
'color': '#cccccc', 'fontSize': '12px',
'verticalAlign': 'middle', 'cursor': 'pointer'
},
),
], style={
'display': 'flex', 'alignItems': 'center',
'padding': '8px 12px',
'backgroundColor': '#1a1a1a',
'borderRadius': '0 0 6px 6px',
'borderTop': '1px solid #333',
}),
], 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': '#111111', 'margin': '10px 20px'}),
# ENDE !!!!!!!!!!!!!!!!
dcc.Graph(id='fig-elevation'),
dcc.Graph(id='fig_speed'),
dcc.Graph(id='fig_deviation'),
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 Standard Plots
# =============================================================================
@app.callback(
Output('info-banner', 'children'),
Output('fig-map', 'figure', allow_duplicate=True),
Output('fig-elevation', 'figure'),
Output('fig_speed', 'figure'),
Output('fig_deviation', 'figure'),
Output('fig_hr', 'figure'),
Output('fig_pace_bars', 'figure'),
Input('stored-df', 'data'),
prevent_initial_call=True
)
def update_all_plots(json_data):
df = pd.read_json(io.StringIO(json_data), orient='split')
info = create_info_banner(df)
fig_map = create_map_plot(df)
fig_elev = create_elevation_plot(df)
fig_speed = create_speed_plot(df)
fig_dev = create_deviation_plot(df)
fig_hr = create_heart_rate_plot(df)
fig_pace = create_pace_bars_plot(df)
return (info, fig_map, fig_elev, fig_speed, fig_dev, fig_hr, fig_pace)
# =============================================================================
# Callback 2b: Update Route Pixel-Maps (Elevation + Pace)
# Separater Callback → blockiert nicht die schnellen Standard-Plots oben
# =============================================================================
@app.callback(
Output('fig-route-elevation', 'figure'),
Output('fig-route-pace', 'figure'),
Input('stored-df', 'data'),
prevent_initial_call=True
)
def update_route_plots(json_data):
df = pd.read_json(io.StringIO(json_data), orient='split')
return (
create_route_elevation_map(df, height=500),
create_route_pace_map(df, height=500),
)
# =============================================================================
# Callback 2c: Update HR Route-Map (mit BPM/Zone Toggle)
# =============================================================================
@app.callback(
Output('fig-route-hr', 'figure'),
Input('stored-df', 'data'),
Input('hr-map-mode-toggle', 'value'),
prevent_initial_call=True
)
def update_hr_route_map(json_data, toggle_value):
if not json_data:
empty = go.Figure()
empty.update_layout(
paper_bgcolor='#0d0d0d',
font=dict(color='white'),
title=dict(text='Datei wählen...', font=dict(color='white'))
)
return empty
df = pd.read_json(io.StringIO(json_data), orient='split')
#mode = 'zone' if toggle_value and 'zone' in toggle_value else 'bpm' # Uncomment if you prefere untoggled!
mode = 'bpm' if toggle_value and 'bpm' in toggle_value else 'zone'
return create_route_hr_map(df, mode=mode, height=500)
# =============================================================================
# Callback 3: Hover auf Elevation-Plot → Marker auf Map aktualisieren
# =============================================================================
@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']]
fig_map['data'][-1]['lat'] = [lat]
fig_map['data'][-1]['lon'] = [lon]
return fig_map
# =============================================================================
# Callback 4: Pixel-Heatmap (Matplotlib PNG) mit Stadt-Toggle
# =============================================================================
@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 ← bewusst deaktiviert: initiales Laden beim App-Start
)
def update_pixel_heatmap(selected_file, toggle_value):
"""
Rendert die Pixel-Heatmap abhängig vom Toggle-Switch.
toggle_value == [] → Einzellauf (nur gewählte Datei, count aus Region)
toggle_value == ['city'] → Region (alle Läufe mit gleichem Stadtcode)
"""
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-Grid-Kontext)
all_options = list_files()
if city_code:
city_dfs, _ = load_runs_for_city(city_code, all_options)
else:
city_dfs = []
# Fallback: keine Stadt-Läufe → nur aktuelle Datei
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 ---
# count_grid aus allen Läufen der Region, aber nur dieser Lauf gezeichnet
try:
df_single = process_selected_file(selected_file)
except Exception:
df_single = pd.DataFrame()
fig = create_pixel_heatmap(
dataframes=city_dfs,
highlight_df=df_single,
mode='single',
city_code=city_code,
n_city_runs=len(city_dfs),
img_width=800, img_height=500,
)
city_info_text = f'Region {city_code} · single run · Count of {len(city_dfs)} runs'
else:
# --- Region-Modus: alle Läufe der Stadt 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,
)
city_info_text = f'Region {city_code} · {len(city_dfs)} runs loaded'
return fig, city_info_text
# Callback 5: 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 ""
# === Run Server ===
if __name__ == '__main__':
app.run(debug=True,
#debug=False, # ← 'False' for Netzwerk-use/test http://192.168.2.162:8051 at mobilephone browser
#host='0.0.0.0', # ← For Netzwerk-use/test
port=8051,
threaded=True,
processes=1
)
# NOTE:
# Relation between Pace and Speed
# - Pace = Minutes per Kilometer (e.g. 5:40/km)
# - Speed = Kilometer per hour (z.B. 10.71 km/h)
#