1504 lines
51 KiB
Python
1504 lines
51 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
|
||
|
||
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
|
||
########
|
||
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)
|
||
distances = [0]
|
||
for i in range(1, len(df)):
|
||
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat'])
|
||
distances.append(distances[-1] + d)
|
||
df['cum_dist_km'] = distances
|
||
|
||
# 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 (NEU!)
|
||
# ##############
|
||
# UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben:
|
||
# save heart rate data into variable
|
||
heart_rate = []
|
||
for record in fit_file.get_messages("record"):
|
||
# Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc)
|
||
for data in record:
|
||
# Print the name and value of the data (and the units if it has any)
|
||
if data.name == 'heart_rate':
|
||
heart_rate.append(data.value)
|
||
# Hier variable neu überschrieben:
|
||
df = safe_add_column_to_dataframe(df, 'heart_rate', heart_rate)
|
||
# ##############
|
||
|
||
# ######################################
|
||
# IF issues with heart_rate values, usw these DEBUG prints:
|
||
#print(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()
|
||
# print(f"Heart rate range: {df['heart_rate'].min():.0f} - {df['heart_rate'].max():.0f} bpm") # Uncomment if needed - DEBUG purpose!
|
||
else:
|
||
print("Keine Heart Rate Daten gefunden!")
|
||
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
|
||
########
|
||
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])
|
||
|
||
# Kumulative Distanz (gleiche Logik wie in deiner FIT-Funktion)
|
||
distances = [0]
|
||
for i in range(1, len(df)):
|
||
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat'])
|
||
distances.append(distances[-1] + d)
|
||
df['cum_dist_km'] = distances
|
||
|
||
# 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
|
||
|
||
|
||
|
||
|
||
# =============================================================================
|
||
# 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"
|
||
|
||
# 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': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
|
||
|
||
html.Div([
|
||
html.H4("Total Time", style={'margin-bottom': '5px'}),
|
||
html.H2(formatted_total_time)
|
||
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
|
||
|
||
html.Div([
|
||
html.H4("Average Pace", style={'margin-bottom': '5px'}),
|
||
html.H2(formatted_pace)
|
||
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
|
||
], style={
|
||
'display': 'flex',
|
||
'justifyContent': 'space-around',
|
||
'backgroundColor': '#1e1e1e',
|
||
'color': 'white',
|
||
'padding': '5px',
|
||
'marginBottom': '5px',
|
||
'borderRadius': '10px',
|
||
'width': '100%',
|
||
#'maxWidth': '1200px',
|
||
'margin': 'auto'
|
||
})
|
||
|
||
return info_banner
|
||
|
||
|
||
# =============================================================================
|
||
# EXPORT SUMMARY IMAGE (SVG)
|
||
# =============================================================================
|
||
def create_strava_style_svg(df, total_distance_km=None, total_time=None, avg_pace=None,
|
||
width=800, height=600, padding=50):
|
||
"""
|
||
Erstellt ein STRAVA-style SVG mit transparentem Hintergrund
|
||
"""
|
||
|
||
# SVG Root Element
|
||
svg = Element('svg')
|
||
svg.set('width', str(width))
|
||
svg.set('height', str(height))
|
||
svg.set('xmlns', 'http://www.w3.org/2000/svg')
|
||
svg.set('style', 'background: transparent;')
|
||
|
||
# Route-Bereich (links 60% der Breite)
|
||
route_width = width * 0.6
|
||
route_height = height - 2 * padding
|
||
|
||
# Koordinaten normalisieren für den Route-Bereich
|
||
lats = df['lat'].values
|
||
lons = df['lon'].values
|
||
|
||
# Bounding Box der Route
|
||
lat_min, lat_max = lats.min(), lats.max()
|
||
lon_min, lon_max = lons.min(), lons.max()
|
||
|
||
# Aspect Ratio beibehalten
|
||
lat_range = lat_max - lat_min
|
||
lon_range = lon_max - lon_min
|
||
|
||
if lat_range == 0 or lon_range == 0:
|
||
raise ValueError("Route hat keine Variation in Koordinaten")
|
||
|
||
# Skalierung berechnen
|
||
scale_x = (route_width - 2 * padding) / lon_range
|
||
scale_y = (route_height - 2 * padding) / lat_range
|
||
|
||
# Einheitliche Skalierung für korrekte Proportionen
|
||
scale = min(scale_x, scale_y)
|
||
|
||
# Zentrieren
|
||
center_x = route_width / 2
|
||
center_y = height / 2
|
||
|
||
# Route-Pfad erstellen
|
||
path_data = []
|
||
for i, (lat, lon) in enumerate(zip(lats, lons)):
|
||
# Koordinaten transformieren (Y-Achse umkehren für SVG)
|
||
x = center_x + (lon - (lon_min + lon_max) / 2) * scale
|
||
y = center_y - (lat - (lat_min + lat_max) / 2) * scale
|
||
|
||
if i == 0:
|
||
path_data.append(f"M {x:.2f} {y:.2f}")
|
||
else:
|
||
path_data.append(f"L {x:.2f} {y:.2f}")
|
||
|
||
# Route-Pfad zum SVG hinzufügen
|
||
route_path = SubElement(svg, 'path')
|
||
route_path.set('d', ' '.join(path_data))
|
||
route_path.set('stroke', '#ff6909') # Deine Routenfarbe
|
||
route_path.set('stroke-width', '4')
|
||
route_path.set('fill', 'none')
|
||
route_path.set('stroke-linecap', 'round')
|
||
route_path.set('stroke-linejoin', 'round')
|
||
|
||
# Start-Punkt (grün)
|
||
start_x = center_x + (lons[0] - (lon_min + lon_max) / 2) * scale
|
||
start_y = center_y - (lats[0] - (lat_min + lat_max) / 2) * scale
|
||
start_circle = SubElement(svg, 'circle')
|
||
start_circle.set('cx', str(start_x))
|
||
start_circle.set('cy', str(start_y))
|
||
start_circle.set('r', '8')
|
||
start_circle.set('fill', '#4CAF50') # Grün
|
||
start_circle.set('stroke', 'white')
|
||
start_circle.set('stroke-width', '2')
|
||
|
||
# End-Punkt (rot)
|
||
end_x = center_x + (lons[-1] - (lon_min + lon_max) / 2) * scale
|
||
end_y = center_y - (lats[-1] - (lat_min + lat_max) / 2) * scale
|
||
end_circle = SubElement(svg, 'circle')
|
||
end_circle.set('cx', str(end_x))
|
||
end_circle.set('cy', str(end_y))
|
||
end_circle.set('r', '8')
|
||
end_circle.set('fill', '#f44336') # Rot
|
||
end_circle.set('stroke', 'white')
|
||
end_circle.set('stroke-width', '2')
|
||
|
||
# Stats-Bereich (rechts 40% der Breite)
|
||
stats_x = route_width + padding
|
||
stats_y_start = padding + 50
|
||
|
||
## Hintergrund für Stats (optional, semi-transparent - SCHWARZE BOX)
|
||
#stats_bg = SubElement(svg, 'rect')
|
||
#stats_bg.set('x', str(stats_x - 20))
|
||
#stats_bg.set('y', str(stats_y_start - 30))
|
||
#stats_bg.set('width', str(width * 0.35))
|
||
#stats_bg.set('height', str(250))
|
||
#stats_bg.set('fill', 'rgba(0,0,0,0.7)')
|
||
#stats_bg.set('rx', '10')
|
||
|
||
# Stats-Text hinzufügen
|
||
stats = [
|
||
("TOTAL DISTANCE", f"{total_distance_km:.1f} km" if total_distance_km else "N/A"),
|
||
("TOTAL TIME", total_time or "N/A"),
|
||
("AVERAGE PACE", avg_pace or "N/A")
|
||
]
|
||
|
||
for i, (label, value) in enumerate(stats):
|
||
y_pos = stats_y_start + i * 70
|
||
|
||
# Label (kleinere Schrift, grau)
|
||
label_text = SubElement(svg, 'text')
|
||
label_text.set('x', str(stats_x))
|
||
label_text.set('y', str(y_pos))
|
||
label_text.set('font-family', 'Arial, sans-serif')
|
||
label_text.set('font-size', '14')
|
||
label_text.set('font-weight', 'bold')
|
||
label_text.set('fill', '#000000') # TEXTFARBE #333333
|
||
label_text.text = label
|
||
|
||
# Wert (größere Schrift, weiß)
|
||
value_text = SubElement(svg, 'text')
|
||
value_text.set('x', str(stats_x))
|
||
value_text.set('y', str(y_pos + 25))
|
||
value_text.set('font-family', 'Arial, sans-serif')
|
||
value_text.set('font-size', '24')
|
||
value_text.set('font-weight', 'bold')
|
||
value_text.set('fill', 'white') # TEXTFARBE
|
||
value_text.text = value
|
||
|
||
return svg
|
||
|
||
def save_svg(svg_element, filename="run_overlay.svg"):
|
||
"""SVG als Datei speichern"""
|
||
rough_string = tostring(svg_element, 'unicode')
|
||
|
||
# Formatierung verbessern
|
||
dom = ET.fromstring(rough_string)
|
||
ET.indent(dom, space=" ", level=0)
|
||
|
||
with open(filename, 'w') as f:
|
||
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
||
f.write(ET.tostring(dom, encoding='unicode'))
|
||
|
||
print(f"SVG saved as {filename}")
|
||
|
||
def calculate_pace(distance_km, total_seconds):
|
||
"""
|
||
Berechnet das Durchschnittstempo in min/km Format
|
||
"""
|
||
if distance_km == 0:
|
||
return "0:00 /km"
|
||
|
||
pace_seconds_per_km = total_seconds / distance_km
|
||
pace_minutes = int(pace_seconds_per_km // 60)
|
||
pace_seconds = int(pace_seconds_per_km % 60)
|
||
|
||
return f"{pace_minutes}:{pace_seconds:02d} /km"
|
||
|
||
|
||
|
||
# =============================================================================
|
||
# START OF THE PLOTS
|
||
# =============================================================================
|
||
def create_map_plot(df):
|
||
fig = px.line_map(
|
||
df,
|
||
lat='lat',
|
||
lon='lon',
|
||
zoom=13.5,
|
||
height=800
|
||
)
|
||
|
||
fig.update_traces(
|
||
hovertemplate=(
|
||
#"Time: %{customdata[0]}<br>" +
|
||
"Distance (Km): %{customdata[0]:.2f}<br>" +
|
||
"Speed (Km/h): %{customdata[1]:.2f}<br>" +
|
||
"Heart Rate (bpm): %{customdata[2]}<br>" +
|
||
"Elapsed Time: %{customdata[3]}<extra></extra>"
|
||
),
|
||
#customdata=df[['time', 'cum_dist_km', 'duration_hms']]
|
||
customdata=df[['cum_dist_km', '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='#fca062'), text=['Start'], name='Start', textposition='bottom left'
|
||
))
|
||
fig.add_trace(go.Scattermap(
|
||
lat=[end['lat']], lon=[end['lon']], mode='markers+text',
|
||
marker=dict(size=12, color='#b9fc62'), text=['Stop'], name='Stop', textposition='bottom left'
|
||
))
|
||
# 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
|
||
|
||
|
||
|
||
|
||
# #####################
|
||
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()
|
||
|
||
# Separate Behandlung für positive und negative Bereiche
|
||
y_array = np.array(y_smooth)
|
||
x_array = np.array(x_smooth)
|
||
|
||
# Positive Bereiche (Anstiege) - Gradient von transparent unten zu grün oben
|
||
positive_mask = y_array >= 0
|
||
if np.any(positive_mask):
|
||
# Nulllinie für positive Bereiche
|
||
fig.add_trace(go.Scatter(
|
||
x=x_array,
|
||
y=np.zeros_like(y_array),
|
||
mode='lines',
|
||
line=dict(width=0),
|
||
hoverinfo='skip',
|
||
showlegend=False
|
||
))
|
||
|
||
# Positive Bereiche mit Gradient nach oben
|
||
fig.add_trace(go.Scatter(
|
||
x=x_array,
|
||
y=np.where(y_array >= 0, y_array, 0), # Nur positive Werte
|
||
fill='tonexty', # Fill zur vorherigen Trace (Nulllinie)
|
||
mode='lines',
|
||
line=dict(width=0),
|
||
fillgradient=dict(
|
||
type="vertical",
|
||
colorscale=[
|
||
(0.0, "rgba(17, 17, 17, 0.0)"), # Transparent unten (bei y=0)
|
||
(1.0, "rgba(43, 82, 144, 0.8)") # Blau oben (bei maximaler Höhe) Grün: 73, 189, 115
|
||
]
|
||
),
|
||
hoverinfo='skip',
|
||
showlegend=False
|
||
))
|
||
|
||
# Negative Bereiche (Abstiege) - Gradient von grün unten zu transparent oben
|
||
negative_mask = y_array < 0
|
||
if np.any(negative_mask):
|
||
# Nulllinie für negative Bereiche
|
||
fig.add_trace(go.Scatter(
|
||
x=x_array,
|
||
y=np.where(y_array < 0, y_array, 0), # Nur negative Werte
|
||
mode='lines',
|
||
line=dict(width=0),
|
||
hoverinfo='skip',
|
||
showlegend=False
|
||
))
|
||
|
||
# Negative Bereiche mit Gradient nach unten
|
||
fig.add_trace(go.Scatter(
|
||
x=x_array,
|
||
y=np.zeros_like(y_array),
|
||
fill='tonexty', # Fill zur vorherigen Trace (negative Werte)
|
||
mode='lines',
|
||
line=dict(width=0),
|
||
fillgradient=dict(
|
||
type="vertical",
|
||
colorscale=[
|
||
(0.0, "rgba(43, 82, 144, 0.8)"), # Blau unten (bei minimaler Tiefe)
|
||
(1.0, "rgba(17, 17, 17, 0.0)") # Transparent oben (bei y=0)
|
||
]
|
||
),
|
||
hoverinfo='skip',
|
||
showlegend=False
|
||
))
|
||
|
||
# Hauptlinie (geglättet) - über allem
|
||
fig.add_trace(go.Scatter(
|
||
x=x_smooth,
|
||
y=y_smooth,
|
||
mode='lines',
|
||
line=dict(color='#2b5290', width=2), # Weiße Linie für bessere Sichtbarkeit
|
||
name='Elevation',
|
||
showlegend=False
|
||
))
|
||
|
||
# Add horizontal reference line at y=0
|
||
fig.add_shape(
|
||
type='line',
|
||
x0=df['time_loc'].iloc[0],
|
||
x1=df['time_loc'].iloc[-1],
|
||
y0=0,
|
||
y1=0,
|
||
line=dict(color='gray', width=1, dash='dash'),
|
||
name='Durchschnittstempo'
|
||
)
|
||
|
||
# Layout im Dark Theme
|
||
fig.update_layout(
|
||
title=dict(text='Höhenprofil (relativ zum Ausgangswert: 0m)', font=dict(size=16, color='white')),
|
||
xaxis_title='Zeit',
|
||
yaxis_title='Höhe relativ zum Start (m)',
|
||
template='plotly_dark',
|
||
paper_bgcolor='#1e1e1e',
|
||
plot_bgcolor='#111111',
|
||
font=dict(color='white'),
|
||
margin=dict(l=40, r=40, t=50, b=40),
|
||
height=400,
|
||
uirevision='constant', # Avoiding not needed Re-renderings
|
||
)
|
||
|
||
return fig
|
||
|
||
# Alte Version - normaler fill between:
|
||
# def create_elevation_plot(df, smooth_points=500):
|
||
# # Originale Daten
|
||
# x = df['time']
|
||
# y = df['rel_elev']
|
||
#
|
||
# # Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
|
||
# if len(y) >= 4: # Genug Punkte für cubic interpolation
|
||
# y_numeric = y.to_numpy()
|
||
#
|
||
# # Nur gültige Y-Punkte für Interpolation
|
||
# mask = ~np.isnan(y_numeric)
|
||
#
|
||
# if np.sum(mask) >= 4: # Genug gültige Punkte
|
||
# # Index-basierte Interpolation für Y-Werte
|
||
# valid_indices = np.where(mask)[0]
|
||
# valid_y = y_numeric[mask]
|
||
#
|
||
# # Interpolation über die Indizes
|
||
# f = interp1d(valid_indices, valid_y, kind='cubic',
|
||
# bounds_error=False, fill_value='extrapolate')
|
||
#
|
||
# # Neue Y-Werte für alle ursprünglichen X-Positionen
|
||
# all_indices = np.arange(len(y))
|
||
# y_smooth = f(all_indices)
|
||
#
|
||
# # Originale X-Werte beibehalten
|
||
# x_smooth = x
|
||
# else:
|
||
# # Fallback: originale Daten
|
||
# x_smooth, y_smooth = x, y
|
||
# else:
|
||
# # Zu wenige Punkte: originale Daten verwenden
|
||
# x_smooth, y_smooth = x, y
|
||
#
|
||
# fig = go.Figure()
|
||
#
|
||
# # Fläche unter der Kurve (mit geglätteten Daten)
|
||
# fig.add_trace(go.Scatter(
|
||
# x=x_smooth, y=y_smooth,
|
||
# mode='lines',
|
||
# line=dict(color='#1CAF50'), # Fill between color!
|
||
# fill='tozeroy',
|
||
# #fillcolor='rgba(226, 241, 248)',
|
||
# hoverinfo='skip',
|
||
# showlegend=False
|
||
# ))
|
||
#
|
||
# # Hauptlinie (geglättet)
|
||
# fig.add_trace(go.Scatter(
|
||
# x=x_smooth, y=y_smooth,
|
||
# mode='lines',
|
||
# line=dict(color='#084C20', width=2), # Line color!
|
||
# name='Elevation',
|
||
# showlegend=False
|
||
# ))
|
||
#
|
||
# # SUPDER IDEE, ABER GEHT NICHT WEGE NEUEN smoothed POINTS! GEHT NUR BEI X
|
||
# #fig.update_traces(
|
||
# # hovertemplate=(
|
||
# # #"Time: %{customdata[0]}<br>" +
|
||
# # "Distance (km): %{customdata[0]:.2f}<br>" +
|
||
# # "Elevation: %{customdata[1]}<extra></extra>" +
|
||
# # "Elapsed Time: %{customdata[2]}<extra></extra>"
|
||
# # ),
|
||
# # customdata=df[['cum_dist_km','elev', 'time']]
|
||
# #
|
||
#
|
||
# # Layout im Dark Theme
|
||
# fig.update_layout(
|
||
# title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16, color='white')),
|
||
# xaxis_title='Zeit',
|
||
# yaxis_title='Höhe relativ zum Start (m)',
|
||
# template='plotly_dark',
|
||
# paper_bgcolor='#1e1e1e',
|
||
# plot_bgcolor='#111111',
|
||
# font=dict(color='white'),
|
||
# margin=dict(l=40, r=40, t=50, b=40),
|
||
# height=400
|
||
# )
|
||
#
|
||
# return fig
|
||
# #####################
|
||
|
||
|
||
|
||
|
||
|
||
def create_deviation_plot(df): #Distanz-Zeit-Diagramm
|
||
# Compute mean velocity in km/s
|
||
vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1]
|
||
# Expected cumulative distance assuming constant mean velocity
|
||
df['cum_dist_km_qmean'] = df['time_diff_sec'] * vel_kmps_mean
|
||
# Deviation from mean velocity distance
|
||
df['del_dist_km_qmean'] = df['cum_dist_km'] - df['cum_dist_km_qmean']
|
||
# Plot the deviation
|
||
fig = px.line(
|
||
df,
|
||
x='time_loc',
|
||
y='del_dist_km_qmean',
|
||
labels={
|
||
'time_loc': 'Zeit',
|
||
'del_dist_km_qmean': 'Δ Strecke (km)'
|
||
},
|
||
template='plotly_dark',
|
||
)
|
||
fig.update_layout(
|
||
title=dict(text='Abweichung von integriertem Durchschnittstempo', font=dict(size=16)),
|
||
yaxis_title='Abweichung (km)',
|
||
xaxis_title='Zeit',
|
||
height=400,
|
||
paper_bgcolor='#1e1e1e',
|
||
plot_bgcolor='#111111',
|
||
font=dict(color='white', size=14),
|
||
margin=dict(l=40, r=40, t=50, b=40),
|
||
uirevision='constant', # Avoiding not needed Re-renderings
|
||
)
|
||
# Add horizontal reference line at y=0
|
||
fig.add_shape(
|
||
type='line',
|
||
x0=df['time_loc'].iloc[0],
|
||
x1=df['time_loc'].iloc[-1],
|
||
y0=0,
|
||
y1=0,
|
||
line=dict(color='gray', width=1, dash='dash'),
|
||
name='Durchschnittstempo'
|
||
)
|
||
return fig
|
||
|
||
|
||
def create_speed_plot(df):
|
||
mask = df['speed_kmh_smooth'].isna()
|
||
mean_speed_kmh = df['speed_kmh'].mean()
|
||
fig = go.Figure()
|
||
fig.add_trace(go.Scatter(
|
||
x=df['time'][~mask],
|
||
y=df['speed_kmh_smooth'][~mask],
|
||
mode='lines',
|
||
name='Geglättete Geschwindigkeit',
|
||
line=dict(color='royalblue')
|
||
))
|
||
fig.update_layout(
|
||
title=dict(text=f'Tempo über die Zeit (geglättet) - Ø {mean_speed_kmh:.2f} km/h', font=dict(size=16)),
|
||
xaxis=dict(title='Zeit', tickformat='%H:%M', type='date'),
|
||
yaxis=dict(title='Geschwindigkeit (km/h)', rangemode='tozero'),
|
||
template='plotly_dark',
|
||
paper_bgcolor='#1e1e1e',
|
||
plot_bgcolor='#111111',
|
||
font=dict(color='white'),
|
||
margin=dict(l=40, r=40, t=40, b=40),
|
||
uirevision='constant', # Avoiding not needed Re-renderings
|
||
)
|
||
# Add horizontal reference line at y=mean_speed_kmh
|
||
fig.add_shape(
|
||
type='line',
|
||
x0=df['time_loc'].iloc[0],
|
||
x1=df['time_loc'].iloc[-1],
|
||
y0=mean_speed_kmh,
|
||
y1=mean_speed_kmh,
|
||
line=dict(color='gray', width=1, dash='dash'),
|
||
name='Durchschnittstempo'
|
||
)
|
||
return fig
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# heart_rate Plot NEW !!!
|
||
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)
|
||
fig.add_trace(go.Scatter(
|
||
x=df['time'][~mask],
|
||
y=df['hr_smooth'][~mask],
|
||
mode='lines',
|
||
#name='Geglättete Herzfrequenz',
|
||
line=dict(color='#ff2c48', width=2),
|
||
showlegend=False,
|
||
hovertemplate=(
|
||
"Zeit: %{x}<br>" +
|
||
"Herzfrequenz: %{y:.0f} bpm<br>" +
|
||
"<extra></extra>"
|
||
)
|
||
))
|
||
|
||
# # Optional: Raw Heart Rate als dünnere, transparente Linie
|
||
# if not df['heart_rate'].isna().all():
|
||
# fig.add_trace(go.Scatter(
|
||
# x=df['time'],
|
||
# y=df['heart_rate'],
|
||
# mode='lines',
|
||
# name='Raw Herzfrequenz',
|
||
# line=dict(color='#E43D70', width=1, dash='dot'),
|
||
# opacity=0.3,
|
||
# showlegend=False,
|
||
# hoverinfo='skip'
|
||
# ))
|
||
|
||
# Durchschnittslinie
|
||
if mean_hr > 0:
|
||
fig.add_shape(
|
||
type='line',
|
||
x0=df['time_loc'].iloc[0],
|
||
x1=df['time_loc'].iloc[-1],
|
||
y0=mean_hr,
|
||
y1=mean_hr,
|
||
line=dict(color='gray', width=1, dash='dash'),
|
||
)
|
||
|
||
# Annotation für Durchschnittswert
|
||
fig.add_annotation(
|
||
x=df['time_loc'].iloc[int(len(df) * 0.5)], # Bei 50% der Zeit
|
||
y=mean_hr,
|
||
text=f"Ø {mean_hr:.0f} bpm",
|
||
showarrow=True,
|
||
arrowhead=2,
|
||
arrowcolor="gray",
|
||
bgcolor="rgba(128,128,128,0.1)",
|
||
bordercolor="gray",
|
||
font=dict(color="white", size=10)
|
||
)
|
||
|
||
# Heart Rate Zonen (optional)
|
||
if mean_hr > 0:
|
||
# Geschätzte maximale Herzfrequenz (Beispiel: 200 bpm)
|
||
max_hr_estimated = 200 # oder z. B. 220 - alter
|
||
|
||
## Definiere feste HR-Zonen in BPM
|
||
#zones = [
|
||
# {"name": "Zone 1", "lower": 0, "upper": 124, "color": "#F4A4A3"}, # Regeneration (Recovery)
|
||
# {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#EF7476"}, # Grundlagenausdauer (Endurance)
|
||
# {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#EA4748"}, # Tempo (Aerob)
|
||
# {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#E02628"}, # Schwelle (Threshold) (Anaerob)
|
||
# {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#B71316"}, # Neuromuskulär (Neuromuskulär)
|
||
#]
|
||
zones = [
|
||
{"name": "Zone 1", "lower": 0, "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)
|
||
]
|
||
|
||
# Zeichne Zonen als Hintergrund (horizontale Rechtecke)
|
||
for zone in zones:
|
||
fig.add_hrect(
|
||
y0=zone["lower"], y1=zone["upper"],
|
||
fillcolor=zone["color"],
|
||
opacity=0.15,
|
||
line_width=0,
|
||
annotation_text=zone["name"], # optional: Name der Zone einblenden
|
||
annotation_position="top left"
|
||
)
|
||
|
||
# Layout
|
||
title_text = f'Herzfrequenz über die Zeit (geglättete)'
|
||
if mean_hr > 0:
|
||
title_text += f' - Ø {mean_hr:.0f} bpm (Range: {min_hr:.0f}-{max_hr:.0f})'
|
||
|
||
fig.update_layout(
|
||
title=dict(text=title_text, font=dict(size=16, color='white')),
|
||
xaxis=dict(
|
||
title='Zeit',
|
||
tickformat='%H:%M',
|
||
type='date'
|
||
),
|
||
yaxis=dict(
|
||
title='Herzfrequenz (bpm)',
|
||
range=[80, 200] # Statt 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
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def create_pace_bars_plot(df, formatted_pace=None):
|
||
# Ensure time column is datetime
|
||
if not pd.api.types.is_datetime64_any_dtype(df['time']):
|
||
df['time'] = pd.to_datetime(df['time'], errors='coerce')
|
||
|
||
# Assign km segments
|
||
df['km'] = df['cum_dist_km'].astype(int)
|
||
|
||
# Time in seconds from start
|
||
df['time_sec'] = (df['time'] - df['time'].iloc[0]).dt.total_seconds()
|
||
|
||
# Step 3: Compute pace manually per km group
|
||
df['km_start'] = np.nan
|
||
df['segment_len'] = np.nan
|
||
df['pace_min_per_km'] = np.nan
|
||
|
||
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:
|
||
pace_min_per_km = (elapsed_time_sec / 60) / segment_len
|
||
else:
|
||
pace_min_per_km = np.nan
|
||
|
||
df.loc[group.index, 'km_start'] = km_val
|
||
df.loc[group.index, 'segment_len'] = segment_len
|
||
df.loc[group.index, 'pace_min_per_km'] = pace_min_per_km
|
||
|
||
# Clean types
|
||
df['km_start'] = df['km_start'].astype(int)
|
||
df['segment_len'] = df['segment_len'].astype(float)
|
||
df['pace_min_per_km'] = pd.to_numeric(df['pace_min_per_km'], errors='coerce')
|
||
|
||
# Step 4: Create Plotly bar chart
|
||
fig = go.Figure()
|
||
fig.add_trace(go.Bar(
|
||
x=df['km_start'], # Mittig unter jeder Bar
|
||
y=df['pace_min_per_km'],
|
||
width=df['segment_len'],
|
||
text=[f"{v:.1f} min/km" if pd.notnull(v) else "" for v in df['pace_min_per_km']],
|
||
#textposition='outside',
|
||
textposition='inside',
|
||
marker_color='#125595',
|
||
opacity=0.9, # Transparenz
|
||
name='Pace pro km',
|
||
offset=0
|
||
))
|
||
|
||
|
||
|
||
|
||
# #########
|
||
# Calculate average pace if not provided from Info-Banner function
|
||
total_distance_km = df['cum_dist_km'].iloc[-1]
|
||
total_seconds = df['time_diff_sec'].iloc[-1]
|
||
|
||
# Average pace (min/km) - KORRIGIERT
|
||
if total_distance_km > 0:
|
||
pace_sec_per_km = total_seconds / total_distance_km
|
||
pace_min = int(pace_sec_per_km // 60)
|
||
pace_sec = int(pace_sec_per_km % 60)
|
||
formatted_pace = f"{pace_min}:{pace_sec:02d} min/km"
|
||
# Numerischen Wert für die dash'ed Linie berechnen
|
||
avg_pace_numeric = pace_sec_per_km / 60
|
||
else:
|
||
formatted_pace = "N/A"
|
||
avg_pace_numeric = 0
|
||
|
||
# Add horizontal dash'ed reference line (avg_pace_numeric)
|
||
fig.add_shape(
|
||
type='line',
|
||
x0=0, # Start bei 0
|
||
x1=total_distance_km, # Ende bei maximaler Distanz
|
||
y0=avg_pace_numeric,
|
||
y1=avg_pace_numeric,
|
||
line=dict(color='gray', width=1, dash='dash'),
|
||
)
|
||
|
||
|
||
title_text = f'Tempo je Kilometer'
|
||
title_text += f' - Ø {formatted_pace}'
|
||
|
||
fig.update_layout(
|
||
title=dict(text=title_text, font=dict(size=16, color='white')),
|
||
xaxis_title='Distanz (km)',
|
||
yaxis_title='Minuten pro km',
|
||
barmode='overlay',
|
||
bargap=0,
|
||
bargroupgap=0,
|
||
xaxis=dict(
|
||
type='linear',
|
||
range=[0, df['cum_dist_km'].iloc[-1]],
|
||
tickmode='linear',
|
||
dtick=1,
|
||
showgrid=True
|
||
),
|
||
template='plotly_dark',
|
||
height=400,
|
||
margin=dict(l=40, r=40, t=30, b=40),
|
||
plot_bgcolor='#111111',
|
||
paper_bgcolor='#1e1e1e',
|
||
font=dict(color='white'),
|
||
uirevision='constant', # Avoiding not needed Re-renderings
|
||
)
|
||
return fig
|
||
|
||
|
||
|
||
|
||
|
||
# === App Setup ===
|
||
app = dash.Dash(__name__,
|
||
suppress_callback_exceptions=True, # Weniger Validierung
|
||
compress=True, # Gzip-Kompression
|
||
external_stylesheets=[dbc.themes.SLATE],
|
||
title = "Jogging Dashboard"
|
||
)
|
||
|
||
app.layout = html.Div([
|
||
html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
|
||
dcc.Store(id='stored-df'),
|
||
|
||
# Horizontales Layout für Dropdown und Button
|
||
html.Div([
|
||
# Linke Seite: Datei-Dropdown
|
||
html.Div([
|
||
html.Label("Datei wählen:", style={'color': '#aaaaaa', 'marginBottom': '5px'}),
|
||
dcc.Dropdown(
|
||
id='file-dropdown',
|
||
options=list_files(),
|
||
value=list_files()[0]['value'],
|
||
clearable=False,
|
||
style={'width': '300px', 'color': 'black'}
|
||
)
|
||
], style={'display': 'flex', 'flexDirection': 'column'}),
|
||
|
||
# Rechte Seite: Export Button
|
||
html.Div([
|
||
html.Label("Export SVG:",
|
||
style={'color': '#aaaaaa', 'marginBottom': '8px'}),
|
||
html.Button(
|
||
[
|
||
html.I(className="fas fa-download"),
|
||
"Summary Image"
|
||
],
|
||
id='export-button',
|
||
style={
|
||
'backgroundColor': '#007bff',
|
||
'border': 'none',
|
||
'color': 'white',
|
||
'padding': '10px 12px',
|
||
'borderRadius': '5px',
|
||
'fontSize': '14px',
|
||
'cursor': 'pointer',
|
||
'display': 'flex',
|
||
'alignItems': 'center',
|
||
'justifyContent': 'center',
|
||
'gap': '5px'
|
||
}
|
||
)
|
||
], style={'display': 'flex', 'flexDirection': 'column', 'alignItems': 'flex-end'})
|
||
|
||
], style={
|
||
'padding': '20px',
|
||
'backgroundColor': '#1e1e1e',
|
||
'display': 'flex',
|
||
'justifyContent': 'space-between', # Dropdown links, Button rechts
|
||
'alignItems': 'flex-end', # Beide Elemente unten ausrichten
|
||
'minHeight': '80px' # Mindesthöhe für konsistentes Layout
|
||
}),
|
||
|
||
# Export Status
|
||
html.Div(id='export-status', children="", style={'padding': '0 20px'}),
|
||
|
||
# Rest deines Layouts
|
||
html.Div(id='info-banner'),
|
||
dcc.Graph(id='fig-map'),
|
||
dcc.Graph(id='fig-elevation'),
|
||
dcc.Graph(id='fig_deviation'),
|
||
dcc.Graph(id='fig_speed'),
|
||
dcc.Graph(id='fig_hr'),
|
||
dcc.Graph(id='fig_pace_bars')
|
||
])
|
||
|
||
|
||
# === Callbacks ===
|
||
# Callback 1: Load GPX File and Store as JSON
|
||
@app.callback(
|
||
Output('stored-df', 'data'),
|
||
Input('file-dropdown', 'value')
|
||
)
|
||
def load_data(selected_file): # Dateipfad der ausgewählten Datei
|
||
df = process_selected_file(selected_file) # Verarbeitet diese Datei
|
||
return df.to_json(date_format='iso', orient='split')
|
||
|
||
# Callback 2: Update All (static) Plots
|
||
@app.callback(
|
||
Output('info-banner', 'children'),
|
||
Output('fig-map', 'figure', allow_duplicate=True),
|
||
Output('fig-elevation', 'figure'),
|
||
Output('fig_deviation', 'figure'),
|
||
Output('fig_speed', 'figure'),
|
||
Output('fig_hr', 'figure'),
|
||
Output('fig_pace_bars', 'figure'),
|
||
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_dev = create_deviation_plot(df)
|
||
fig_speed = create_speed_plot(df)
|
||
fig_hr = create_heart_rate_plot(df)
|
||
fig_pace = create_pace_bars_plot(df)
|
||
|
||
return info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace
|
||
|
||
# Callback 3: Export SVG
|
||
@app.callback(
|
||
Output('export-status', 'children'),
|
||
Input('export-button', 'n_clicks'),
|
||
State('stored-df', 'data'),
|
||
State('file-dropdown', 'value'),
|
||
prevent_initial_call=True
|
||
)
|
||
def export_summary_image(n_clicks, json_data, selected_file):
|
||
if n_clicks and json_data and selected_file:
|
||
try:
|
||
print(f"Export wurde geklickt für Datei: {selected_file}")
|
||
|
||
# DataFrame aus bereits geladenen Daten erstellen
|
||
df = pd.read_json(io.StringIO(json_data), orient='split')
|
||
|
||
if df.empty:
|
||
return html.Div("Export fehlgeschlagen: Keine Daten verfügbar",
|
||
style={'color': 'red', 'fontSize': '12px'})
|
||
|
||
# Statistiken berechnen (gleich wie im Info-Banner)
|
||
total_distance_km = df['cum_dist_km'].iloc[-1] if 'cum_dist_km' in df.columns else 0
|
||
total_time_str = df['duration_hms'].iloc[-1] if 'duration_hms' in df.columns else "00:00:00"
|
||
total_seconds = df['time_diff_sec'].iloc[-1] if 'time_diff_sec' in df.columns else 0
|
||
|
||
# Pace berechnen
|
||
avg_pace = calculate_pace(total_distance_km, total_seconds)
|
||
|
||
# Output-Dateiname
|
||
base_name = os.path.splitext(os.path.basename(selected_file))[0]
|
||
output_filename = f"{base_name}_overlay.svg"
|
||
|
||
print(f"Stats - Distance: {total_distance_km:.1f}km, Time: {total_time_str}, Pace: {avg_pace}")
|
||
|
||
# SVG erstellen
|
||
svg = create_strava_style_svg(
|
||
df=df,
|
||
total_distance_km=total_distance_km,
|
||
total_time=total_time_str,
|
||
avg_pace=avg_pace,
|
||
width=800,
|
||
height=600
|
||
)
|
||
|
||
# SVG speichern
|
||
save_svg(svg, output_filename)
|
||
|
||
return html.Div(
|
||
f"Export erfolgreich! Datei: {output_filename}",
|
||
style={'color': 'green', 'fontSize': '12px', 'marginTop': '5px'}
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"Export-Fehler: {str(e)}")
|
||
return html.Div(
|
||
f"Export fehlgeschlagen: {str(e)}",
|
||
style={'color': 'red', 'fontSize': '12px', 'marginTop': '5px'}
|
||
)
|
||
|
||
return ""
|
||
|
||
|
||
|
||
# Callback 4: Hover → update only hover (dynamic) marker
|
||
@app.callback(
|
||
Output('fig-map', 'figure'),
|
||
Input('fig-elevation', 'hoverData'),
|
||
State('fig-map', 'figure'),
|
||
State('stored-df', 'data'),
|
||
prevent_initial_call=True
|
||
)
|
||
def highlight_map(hoverData, fig_map, json_data):
|
||
df = pd.read_json(io.StringIO(json_data), orient='split')
|
||
|
||
if hoverData is not None:
|
||
point_index = hoverData['points'][0]['pointIndex']
|
||
lat, lon = df.iloc[point_index][['lat', 'lon']]
|
||
|
||
# update the last trace (the empty Hovered Point trace)
|
||
fig_map['data'][-1]['lat'] = [lat]
|
||
fig_map['data'][-1]['lon'] = [lon]
|
||
|
||
return fig_map
|
||
|
||
# === Run Server ===
|
||
if __name__ == '__main__':
|
||
app.run(debug=True,
|
||
port=8051,
|
||
threaded=True,
|
||
processes=1
|
||
)
|
||
|
||
|
||
# NOTE:
|
||
# Zusammenhang zwischen Pace und Geschwindigkeit
|
||
# - Pace = Minuten pro Kilometer (z. B. 5:40/km)
|
||
# - Geschwindigkeit = Kilometer pro Stunde (z. B. 10.71 km/h)
|
||
#
|