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