2843 lines
104 KiB
Python
2843 lines
104 KiB
Python
#!/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,
|
||
padding_pct=0.12):
|
||
"""
|
||
Erstellt ein STRAVA-style SVG mit transparentem Hintergrund
|
||
"""
|
||
|
||
lats = df['lat'].values
|
||
lons = df['lon'].values
|
||
|
||
lat_min, lat_max = lats.min(), lats.max()
|
||
lon_min, lon_max = lons.min(), 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
|
||
|
||
# Ziel: Route-Bereich soll in etwa 500×500px passen (quadratisch)
|
||
# Skalierung: Route maximal einpassen, Seitenverhältnis erhalten
|
||
target_size = 500
|
||
scale_x = target_size / lon_range
|
||
scale_y = target_size / lat_range
|
||
scale = min(scale_x, scale_y)
|
||
|
||
# Tatsächliche Routengröße in Pixel
|
||
route_px_w = lon_range * scale
|
||
route_px_h = lat_range * scale
|
||
|
||
# Padding in Pixel (padding_pct × Routengröße)
|
||
pad = max(route_px_w, route_px_h) * padding_pct
|
||
|
||
# Canvas für Route (tight + padding)
|
||
canvas_w = route_px_w + 2 * pad
|
||
canvas_h = route_px_h + 2 * pad
|
||
|
||
# Stats-Bereich rechts: feste Breite 220px
|
||
stats_w = 220
|
||
total_w = canvas_w + stats_w
|
||
total_h = canvas_h
|
||
|
||
# Offset: Route zentriert im Canvas
|
||
offset_x = pad
|
||
offset_y = pad
|
||
|
||
def to_svg(lat, lon):
|
||
x = offset_x + (lon - lon_min) * scale
|
||
y = offset_y + (lat_max - lat) * scale
|
||
return x, y
|
||
|
||
# SVG Root
|
||
svg = Element('svg')
|
||
svg.set('width', f'{total_w:.0f}')
|
||
svg.set('height', f'{total_h:.0f}')
|
||
svg.set('xmlns', 'http://www.w3.org/2000/svg')
|
||
svg.set('style', 'background: transparent;')
|
||
|
||
# Route-Pfad
|
||
path_data = []
|
||
for i, (lat, lon) in enumerate(zip(lats, lons)):
|
||
x, y = to_svg(lat, lon)
|
||
path_data.append(f"{'M' if i == 0 else 'L'} {x:.2f} {y:.2f}")
|
||
|
||
route_path = SubElement(svg, 'path')
|
||
route_path.set('d', ' '.join(path_data))
|
||
route_path.set('stroke', '#ff6909')
|
||
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)
|
||
sx, sy = to_svg(lats[0], lons[0])
|
||
sc = SubElement(svg, 'circle')
|
||
sc.set('cx', f'{sx:.2f}'); sc.set('cy', f'{sy:.2f}')
|
||
sc.set('r', '8'); sc.set('fill', '#4CAF50')
|
||
sc.set('stroke', 'white'); sc.set('stroke-width', '2')
|
||
|
||
# End-Punkt (rot)
|
||
ex, ey = to_svg(lats[-1], lons[-1])
|
||
ec = SubElement(svg, 'circle')
|
||
ec.set('cx', f'{ex:.2f}'); ec.set('cy', f'{ey:.2f}')
|
||
ec.set('r', '8'); ec.set('fill', '#f44336')
|
||
ec.set('stroke', 'white'); ec.set('stroke-width', '2')
|
||
|
||
# Stats-Block: vertikal zentriert im rechten Bereich
|
||
stats = [
|
||
("Distance", f"{total_distance_km:.1f} km" if total_distance_km else "N/A"),
|
||
("Pace", avg_pace or "N/A"),
|
||
("Time", total_time or "N/A"),
|
||
]
|
||
|
||
n_stats = len(stats)
|
||
entry_h = 90
|
||
block_h = n_stats * entry_h
|
||
stats_start_y = (total_h - block_h) / 2
|
||
stats_cx = canvas_w + stats_w / 2 # Mitte des Stats-Bereichs
|
||
|
||
for i, (label, value) in enumerate(stats):
|
||
y_pos = stats_start_y + i * entry_h
|
||
|
||
lbl = SubElement(svg, 'text')
|
||
lbl.set('x', f'{stats_cx:.1f}')
|
||
lbl.set('y', f'{y_pos:.1f}')
|
||
lbl.set('font-family', 'Arial, sans-serif')
|
||
lbl.set('font-size', '18px')
|
||
lbl.set('font-weight', 'bold')
|
||
lbl.set('fill', 'white')
|
||
lbl.set('text-anchor', 'middle')
|
||
lbl.text = label
|
||
|
||
val = SubElement(svg, 'text')
|
||
val.set('x', f'{stats_cx:.1f}')
|
||
val.set('y', f'{y_pos + 40:.1f}')
|
||
val.set('font-family', 'Arial, sans-serif')
|
||
val.set('font-size', '34px')
|
||
val.set('font-weight', 'bold')
|
||
val.set('fill', 'white')
|
||
val.set('text-anchor', 'middle')
|
||
val.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: 2–6 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_raw = df['duration_hms'].iloc[-1] if 'duration_hms' in df.columns else "00:00:00"
|
||
total_time_str = str(total_time_raw).split(' ')[-1] if 'days' in str(total_time_raw) else str(total_time_raw)
|
||
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,
|
||
padding_pct=0.12
|
||
)
|
||
|
||
# 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)
|
||
#
|