Files
jogging-dashboard/jogging_dashboard_browser_app.py

1515 lines
52 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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)
]
# Berechne die Anzahl der Werte in jeder Zone
total_count = len(valid_hr)
for zone in zones:
# Filter für die Zone
zone_count = valid_hr[(valid_hr >= zone["lower"]) & (valid_hr < zone["upper"])].count()
zone_percentage = (zone_count / total_count) * 100 if total_count > 0 else 0
# Zeichne die Zone als Hintergrund
fig.add_hrect(
y0=zone["lower"], y1=zone["upper"],
fillcolor=zone["color"],
opacity=0.15,
line_width=0,
)
# Annotation für die Zone (Name und Prozentsatz)
fig.add_annotation(
x=df['time_loc'].iloc[-1], # Rechts am Plot
y=zone["upper"] -6 , # Oben in der Zone
#y=(zone["lower"] + zone["upper"]) / 2, # Falls Pos. mittig je Zone gewünscht
text=f"{zone['name']}<br>{zone_percentage:.1f}%",
showarrow=False,
font=dict(color="white", size=10),
align="left",
bgcolor="rgba(0,0,0,0.5)",
bordercolor="gray",
)
# Layout
title_text = f'Herzfrequenz über die Zeit (geglättete)'
if mean_hr > 0:
title_text += f' - Ø {mean_hr:.0f} bpm (Range: {min_hr:.0f}-{max_hr:.0f})'
fig.update_layout(
title=dict(text=title_text, font=dict(size=16, color='white')),
xaxis=dict(
title='Zeit',
tickformat='%H:%M',
type='date'
),
yaxis=dict(
title='Herzfrequenz (bpm)',
range=[70, 200] # instead of: rangemode='tozero'
),
template='plotly_dark',
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
height=400,
uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
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 (Install: python-flask-compress)
external_stylesheets=[dbc.themes.SLATE],
title = "Jogging Dashboard"
)
app.layout = html.Div([
html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
dcc.Store(id='stored-df'),
# Horizontales Layout für Dropdown und Button
html.Div([
# Linke Seite: Datei-Dropdown
html.Div([
html.Label("Datei wählen:", style={'color': '#aaaaaa', 'marginBottom': '5px'}),
dcc.Dropdown(
id='file-dropdown',
options=list_files(),
value=list_files()[0]['value'],
clearable=False,
style={'width': '300px', 'color': 'black'}
)
], style={'display': 'flex', 'flexDirection': 'column'}),
# Rechte Seite: Export Button
html.Div([
html.Label("Export SVG:",
style={'color': '#aaaaaa', 'marginBottom': '8px'}),
html.Button(
[
html.I(className="fas fa-download"),
"Summary Image"
],
id='export-button',
style={
'backgroundColor': '#007bff',
'border': 'none',
'color': 'white',
'padding': '10px 12px',
'borderRadius': '5px',
'fontSize': '14px',
'cursor': 'pointer',
'display': 'flex',
'alignItems': 'center',
'justifyContent': 'center',
'gap': '5px'
}
)
], style={'display': 'flex', 'flexDirection': 'column', 'alignItems': 'flex-end'})
], style={
'padding': '20px',
'backgroundColor': '#1e1e1e',
'display': 'flex',
'justifyContent': 'space-between', # Dropdown links, Button rechts
'alignItems': 'flex-end', # Beide Elemente unten ausrichten
'minHeight': '80px' # Mindesthöhe für konsistentes Layout
}),
# Export Status
html.Div(id='export-status', children="", style={'padding': '0 20px'}),
# Rest deines Layouts
html.Div(id='info-banner'),
dcc.Graph(id='fig-map'),
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)
#