diff --git a/DashboardApp_WebVersion.png b/DashboardApp_WebVersion.png index 5e2e4fc..6f307b9 100644 Binary files a/DashboardApp_WebVersion.png and b/DashboardApp_WebVersion.png differ diff --git a/jogging_dashboard_browser_app.py b/jogging_dashboard_browser_app.py index 9749436..778e1bf 100644 --- a/jogging_dashboard_browser_app.py +++ b/jogging_dashboard_browser_app.py @@ -25,6 +25,12 @@ 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 @@ -781,12 +787,13 @@ def create_map_plot(df): 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' + 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='#b9fc62'), text=['Stop'], name='Stop', textposition='bottom left' + 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=[], @@ -1041,6 +1048,627 @@ def create_elevation_plot(df, smooth_points=500): +# ----------------------------------------------------------------------------- +# 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 + + + + +# ----------------------------------------------------------------------------- +# 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): + """ + Sam-Style 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) + for x, y in zip(xs, ys): + count_grid[y, x] += 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] + + for df in draw_frames: # ← draw_frames statt 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) + + for i in range(len(xs) - 1): + mid_x = np.clip((xs[i] + xs[i+1]) // 2, 0, img_width - 1) + mid_y = np.clip((ys[i] + ys[i+1]) // 2, 0, img_height - 1) + norm_val = np.log1p(count_grid[mid_y, mid_x]) / log_max if log_max > 0 else 0 + 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') + + # 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 + + + + +# ----------------------------------------------------------------------------- +# 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 + + + + +# ----------------------------------------------------------------------------- +# 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'): + """ + Sam-Style 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 + + + + + + + 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] @@ -1464,6 +2092,85 @@ app.layout = html.Div([ # 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) Elevation- & Pace-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'}), + + ], style={ + 'display': 'flex', 'flexWrap': 'wrap', + 'gap': '5px', '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'), @@ -1493,20 +2200,126 @@ def load_data(selected_file): # Dateipfad der ausgewählten Da 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 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 + # 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) + + return (info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace, + fig_pixel_elevation, fig_pixel_pace) + + + +@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 +) +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']: + fig = go.Figure() + fig.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'), + title=dict(text='Keine Datei ausgewählt')) + return fig, '' + + 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 + + # Lade alle Läufe dieser Stadt + all_options = list_files() + city_dfs, city_paths = load_runs_for_city(city_code, all_options) + + if not city_dfs: + # Fallback: nur aktuelle Datei + try: + df_single = process_selected_file(selected_file) + city_dfs = [df_single] + except Exception: + city_dfs = [] + + fig = create_pixel_heatmap( + dataframes=city_dfs, + mode='city', + city_code=city_code, + n_city_runs=len(city_dfs), + ) + + city_info_text = f'Region {city_code} · {len(city_dfs)} Läufe geladen' + return fig, city_info_text + + # Callback 3: Export SVG @app.callback(