diff --git a/DashboardApp_WebVersion.png b/DashboardApp_WebVersion.png index 809941c..9d28935 100644 Binary files a/DashboardApp_WebVersion.png and b/DashboardApp_WebVersion.png differ diff --git a/DashboardApp_WebVersion_btoggled.png b/DashboardApp_WebVersion_btoggled.png new file mode 100644 index 0000000..807b39d Binary files /dev/null and b/DashboardApp_WebVersion_btoggled.png differ diff --git a/jogging_dashboard_browser_app.py b/jogging_dashboard_browser_app.py index 9584792..00639a1 100644 --- a/jogging_dashboard_browser_app.py +++ b/jogging_dashboard_browser_app.py @@ -463,6 +463,24 @@ _HEIGHT_CM = 178.0 _AGE_YEARS = 36 _IS_MALE = True _HR_REST = 64.5 # Ruhepuls in bpm – kalibriert gegen Strava 550 kcal +HR_MAX_BPM = 220 - _AGE_YEARS # z.B. 185 bei Alter 35 +HR_ZONES = [ + {"name": "Zone 1", "lower": 0, "upper": 124, "color": "#7FB3FF", "label": "Recovery"}, + {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#9DE79A", "label": "Ausdauer"}, + {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#FFF29A", "label": "Aerob"}, + {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFBE7A", "label": "Schwelle"}, + {"name": "Zone 5", "lower": 184, "upper": 999, "color": "#FF9AA2", "label": "Neuromuск."}, +] + +def get_zone_for_bpm(bpm): + """Gibt den Zonen-Index (0-4) für einen BPM-Wert zurück.""" + for i, z in enumerate(HR_ZONES): + if z["lower"] <= bpm < z["upper"]: + return i + return 4 + + + # ───────────────────────────────────────────────────────────────────────────── def calculate_elevation_gain(df): @@ -1045,7 +1063,7 @@ def create_pixel_heatmap(dataframes, fig.update_layout( paper_bgcolor='#1e1e1e', font=dict(color='white'), height=img_height, - title=dict(text='Keine Daten verfügbar', font=dict(color='white')) + title=dict(text='No Data avaiable', font=dict(color='white')) ) return fig @@ -1160,7 +1178,7 @@ def create_pixel_heatmap(dataframes, img_b64 = _fig_to_base64(fig_mpl) plotly_title = ( - f'Pixel-Heatmap · {city_code} · {n_city_runs} runs (region)' + f'PIXEL-Heatmap · {city_code} · {n_city_runs} runs (region)' if mode == 'city' and city_code else 'Pixel-Heatmap · Einzellauf' ) fig = go.Figure() @@ -1188,21 +1206,15 @@ def create_pixel_heatmap(dataframes, # ----------------------------------------------------------------------------- # 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 - +def create_route_elevation_map(df, height=500): + """ + Interaktive Elevation-Map als go.Scattergl. + Farbe: grün (bergab) → grau (flach) → rot (bergauf). + Horizontale Colorbar unten: links min (grün) → mitte flach (grau) → rechts max (rot). + """ if df.empty or 'lat' not in df.columns or 'lon' not in df.columns: fig = go.Figure() - fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height, + fig.update_layout(paper_bgcolor='#0d0d0d', height=height, title=dict(text='Keine GPS-Daten', font=dict(color='white'))) return fig @@ -1212,146 +1224,142 @@ def create_pixel_elevation_map(df, img_width=900, img_height=900, 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) + elevs = np.convolve(elevs_raw, np.ones(n_smooth) / n_smooth, mode='same') else: elevs = np.zeros(len(lats)) n = min(len(lats), len(lons), len(elevs)) lats, lons, elevs = lats[:n], lons[:n], elevs[:n] + delta = np.diff(elevs, prepend=elevs[0]) - delta_elev = np.diff(elevs, prepend=elevs[0]) + abs_d = np.abs(delta) + nz = abs_d[abs_d > 0] + flat_thresh = np.percentile(nz, 20) if len(nz) > 0 else 0.05 + max_d = max(np.percentile(abs_d, 80), flat_thresh * 2) - # 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) + dist = df['cum_dist_km'].values[:n] if 'cum_dist_km' in df.columns else np.zeros(n) - # Statistik - total_up = delta_elev[delta_elev > FLAT_THRESHOLD].sum() - total_down = abs(delta_elev[delta_elev < -FLAT_THRESHOLD].sum()) + total_up = delta[delta > flat_thresh].sum() + total_down = abs(delta[delta < -flat_thresh].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) + # Farbe pro Punkt: normalisiert -max_d → 0 → +max_d + # Colorscale: grün (0.0) → grau (0.5) → rot (1.0) + norm_vals = np.clip((delta + max_d) / (2 * max_d), 0, 1) - # ------------------------------------------------------------------------- - # 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) + colorscale = [ + [0.00, '#00aa00'], + [0.35, '#227722'], + [0.50, '#666666'], + [0.65, '#772222'], + [1.00, '#ff2200'], ] - 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' + + # Haupttrace: Route eingefärbt + fig.add_trace(go.Scattergl( + x=lons, y=lats, + mode='markers', + marker=dict( + color=norm_vals, + colorscale=colorscale, + cmin=0, cmax=1, + size=3, + opacity=0.9, + showscale=False, # Colorbar kommt als separater Dummy-Trace unten + ), + hovertemplate=( + '⛰ %{customdata[0]:.0f} m
' + '📏 %{customdata[1]:.2f} km' + ), + customdata=np.column_stack([elevs, dist]), + showlegend=False, )) + + # Start / Ziel + fig.add_trace(go.Scattergl( + x=[lons[0]], y=[lats[0]], mode='markers', + marker=dict(color='#b9fc62', size=12, symbol='circle'), + showlegend=False, hovertemplate='Start', + )) + fig.add_trace(go.Scattergl( + x=[lons[-1]], y=[lats[-1]], mode='markers', + marker=dict(color='#fca062', size=11, symbol='square'), + showlegend=False, hovertemplate='Ziel', + )) + + # ------------------------------------------------------------------------- + # Horizontale Colorbar unten als Dummy-Scatter + # links: grün (bergab/-max_d) | mitte: grau (flach/0) | rechts: rot (bergauf/+max_d) + # ------------------------------------------------------------------------- + fig.add_trace(go.Scatter( + x=[None], y=[None], + mode='markers', + marker=dict( + colorscale=colorscale, + cmin=-max_d, + cmax=max_d, + color=[0], + colorbar=dict( + orientation='h', + x=0.5, xanchor='center', + y=-0.08, yanchor='top', + len=0.85, thickness=10, + title=dict( + #text='Steigung (m/Punkt)', + side='bottom', + font=dict(color='white', size=11), + ), + tickfont=dict(color='white', size=10), + tickvals=[-max_d, 0, max_d], + ticktext=[ + f'↓ min ({-max_d:.2f}m)', + 'flach', + f'↑ max (+{max_d:.2f}m)', + ], + tickcolor='white', + ), + showscale=True, + ), + showlegend=False, + hoverinfo='skip', + )) + fig.update_layout( - title=dict(text='Pixel-Elevation-Map (grün = bergab · grau = flach · rot = bergauf)', - font=dict(size=13, color='white')), - paper_bgcolor=bg_color, - plot_bgcolor=bg_color, + title=dict( + text=f'Elevation-Map · ↑ {total_up:.0f} m ↓ {total_down:.0f} m ' + f' [green=downhill · gray=flat · red=uphill]', + font=dict(size=12, color='white') + ), + xaxis=dict(visible=False, scaleanchor='y', scaleratio=1), + yaxis=dict(visible=False), + paper_bgcolor='#0d0d0d', + plot_bgcolor='#0d0d0d', font=dict(color='white'), - margin=dict(l=0, r=0, t=30, b=0), - height=img_height, - xaxis=dict(visible=False, range=[0, 1]), - yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'), + margin=dict(l=0, r=0, t=40, b=70), + height=height, uirevision='elevation_map', + dragmode='zoom', ) 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'): +def create_route_pace_map(df, height=500): """ - 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. + Interaktive Pace-Map als go.Scattergl. + Horizontale Colorbar unten: + links: rot (langsam/höchste Pace) → mitte: gelb → rechts: blau (schnell/niedrigste Pace). """ - 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, + fig.update_layout(paper_bgcolor='#0d0d0d', height=height, title=dict(text='Keine GPS-Daten', font=dict(color='white'))) return fig @@ -1360,284 +1368,307 @@ def create_pixel_pace_map(df, img_width=900, img_height=900, 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) + pace = np.where(speed > 0.5, 60.0 / speed, np.nan) elif 'vel_kmps' in df.columns: - vel = df['vel_kmps'].fillna(0).values - pace_per_km = np.where(vel * 3600 > 0.5, 60.0 / (vel * 3600), np.nan) + vel = df['vel_kmps'].fillna(0).values + pace = np.where(vel * 3600 > 0.5, 60.0 / (vel * 3600), np.nan) else: - pace_per_km = np.full(len(lats), np.nan) + pace = 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] + n = min(len(lats), len(lons), len(pace)) + lats, lons, pace = lats[:n], lons[:n], pace[:n] + dist = df['cum_dist_km'].values[:n] if 'cum_dist_km' in df.columns else np.zeros(n) - valid_pace = pace[(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) + valid = pace[(pace >= 2) & (pace <= 15) & ~np.isnan(pace)] + p5 = np.percentile(valid, 5) if len(valid) > 0 else 4.0 + p95 = np.percentile(valid, 95) if len(valid) > 0 else 8.0 + avg = valid.mean() if len(valid) > 0 else 6.0 - xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height, padding=50) - xs = xs.astype(int) - ys = ys.astype(int) + # norm: 0 = schnell (blau), 1 = langsam (rot) + # Colorscale links → rechts: rot (langsam) → gelb → grün → blau (schnell) + # Daher invertieren: norm_inv = 1 - norm → 0 = langsam (rot links), 1 = schnell (blau rechts) + norm = np.clip((pace - p5) / (p95 - p5), 0, 1) + # 1 - norm: schnell (niedrige Pace) → 1 → blau (rechts in Colorscale) + # langsam (hohe Pace) → 0 → rot (links in Colorscale) + norm_inv = np.where(np.isnan(norm), 0.5, 1 - norm) - cmap_colors = [ - (0.0, '#0033cc'), - (0.2, '#0099ff'), - (0.4, '#00cc88'), - (0.6, '#ffcc00'), - (0.8, '#ff6600'), - (1.0, '#cc0000'), + # Colorscale: rot (0/links/langsam) → orange → gelb → grün → blau (1/rechts/schnell) + colorscale = [ + [0.00, '#cc0000'], + [0.25, '#ff6600'], + [0.50, '#ffcc00'], + [0.75, '#00cc88'], + [1.00, '#0033cc'], ] - 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') + def fmt_pace(p): + if np.isnan(p): return '–' + m = int(p); s = int(round((p - m) * 60)) + return f'{m}:{s:02d} min/km' - 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'Slow: {p95:.1f} Fast: {p5:.1f} min/km', - color='white', fontsize=10, ha='center', va='top', - transform=fig_mpl.transFigure) - - img_b64 = _fig_to_base64(fig_mpl) + m_avg = int(avg); s_avg = int(round((avg - m_avg) * 60)) 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.add_trace(go.Scattergl( + x=lons, y=lats, + mode='markers', + marker=dict( + color=norm_inv, + colorscale=colorscale, + cmin=0, cmax=1, + size=3, + opacity=0.9, + showscale=False, + ), + hovertemplate=( + '🏃 %{customdata[0]}
' + '📏 %{customdata[1]:.2f} km' + ), + customdata=np.column_stack([ + [fmt_pace(p) for p in pace], + dist, + ]), + showlegend=False, )) + + fig.add_trace(go.Scattergl( + x=[lons[0]], y=[lats[0]], mode='markers', + marker=dict(color='#b9fc62', size=12, symbol='circle'), + showlegend=False, hovertemplate='Start', + )) + fig.add_trace(go.Scattergl( + x=[lons[-1]], y=[lats[-1]], mode='markers', + marker=dict(color='#fca062', size=11, symbol='square'), + showlegend=False, hovertemplate='Ziel', + )) + + # ------------------------------------------------------------------------- + # Horizontale Colorbar unten + # Angezeigter Bereich: p95 (rot/langsam) links → p5 (blau/schnell) rechts + # ------------------------------------------------------------------------- + fig.add_trace(go.Scatter( + x=[None], y=[None], + mode='markers', + marker=dict( + colorscale=colorscale, + cmin=0, # 0 = rot = langsam + cmax=1, # 1 = blau = schnell + color=[0], + colorbar=dict( + orientation='h', + x=0.5, xanchor='center', + y=-0.08, yanchor='top', + len=0.85, thickness=10, + title=dict( + #text='Pace (min/km) — links: langsam · rechts: schnell', + side='bottom', + font=dict(color='white', size=11), + ), + tickfont=dict(color='white', size=10), + tickvals=[0, 0.5, 1], # im cmin/cmax Bereich → werden angezeigt + ticktext=[ + fmt_pace(p95), # 0 = langsam = rot = links + fmt_pace((p5 + p95) / 2), # 0.5 = mittel + fmt_pace(p5), # 1 = schnell = blau = rechts + ], + tickcolor='white', + ), + showscale=True, + ), + showlegend=False, + hoverinfo='skip', + )) + fig.update_layout( - title=dict(text='Pixel-Pace-Map (blau = schnell · rot = langsam)', - font=dict(size=13, color='white')), - paper_bgcolor=bg_color, - plot_bgcolor=bg_color, + title=dict( + text=f'Pace-Map · Ø {m_avg}:{s_avg:02d} min/km [red=slow · blue=fast]', + font=dict(size=12, color='white') + ), + xaxis=dict(visible=False, scaleanchor='y', scaleratio=1), + yaxis=dict(visible=False), + paper_bgcolor='#0d0d0d', + plot_bgcolor='#0d0d0d', font=dict(color='white'), - margin=dict(l=0, r=0, t=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'), + margin=dict(l=0, r=0, t=40, b=70), + height=height, uirevision='pace_map', + dragmode='zoom', ) 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'): +def create_route_hr_map(df, mode='bpm', height=500): """ - 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. + Interaktive HR-Map als go.Scattergl. + mode='bpm' → Farbe: dunkelrot (niedrig BPM) → gelb/weiß (max BPM) + Horizontale Colorbar unten: links dunkelrot → rechts hellgelb + mode='zone' → Farbe: je HR-Zone (blau/grün/gelb/orange/rot) + Legende rechts mit Zonen-Namen und Prozentanteilen """ - 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'))) + fig.update_layout(paper_bgcolor='#0d0d0d', height=height, + title=dict(text='No GPS-Data', font=dict(color='white'))) return fig - lats = df['lat'].dropna().values - lons = df['lon'].dropna().values + lats = df['lat'].dropna().values + lons = df['lon'].dropna().values + has_hr = ('heart_rate' in df.columns and df['heart_rate'].notna().sum() > 10) + hr_raw = df['heart_rate'].ffill().bfill().values if has_hr else np.full(len(lats), np.nan) - # 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 + n = min(len(lats), len(lons), len(hr_raw)) + lats, lons, hr = lats[:n], lons[:n], hr_raw[:n] + dist = df['cum_dist_km'].values[:n] if 'cum_dist_km' in df.columns else np.zeros(n) - has_hr = hr_values is not None + valid_hr = hr[(hr > 40) & (hr < 220) & ~np.isnan(hr)] + hr_min = np.percentile(valid_hr, 2) if len(valid_hr) > 0 else 100 + hr_max = np.percentile(valid_hr, 98) if len(valid_hr) > 0 else 180 + hr_avg = valid_hr.mean() if len(valid_hr) > 0 else 0 - 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] + fig = go.Figure() - # 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) + if mode == 'bpm': + # Colorscale: dunkelrot (niedrig) → rot → orange → gelb → hellgelb (max) + colorscale_bpm = [ + [0.00, '#3d0000'], + [0.25, '#8b0000'], + [0.50, '#cc2200'], + [0.70, '#ff6600'], + [0.85, '#ffcc00'], + [1.00, '#ffff99'], + ] - # ------------------------------------------------------------------------- - # 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) + fig.add_trace(go.Scattergl( + x=lons, y=lats, + mode='markers', + marker=dict( + color=hr, + colorscale=colorscale_bpm, + cmin=hr_min, + cmax=hr_max, + size=3, + opacity=0.9, + showscale=False, + ), + hovertemplate=( + '❤️ %{customdata[0]:.0f} bpm
' + '📏 %{customdata[1]:.2f} km' + ), + customdata=np.column_stack([hr, dist]), + showlegend=False, + )) + + # ------------------------------------------------------------------------- + # Horizontale Colorbar unten: links dunkelrot (min BPM) → rechts hellgelb (max BPM) + # ------------------------------------------------------------------------- + fig.add_trace(go.Scatter( + x=[None], y=[None], + mode='markers', + marker=dict( + colorscale=colorscale_bpm, + cmin=hr_min, + cmax=hr_max, + color=[0], + colorbar=dict( + orientation='h', + x=0.5, xanchor='center', + y=-0.08, yanchor='top', + len=0.85, thickness=10, + title=dict( + #text='Heart Rate (bpm) — links: niedrig · rechts: max', + side='bottom', + font=dict(color='white', size=11), + ), + tickfont=dict(color='white', size=10), + tickvals=[hr_min, (hr_min + hr_max) / 2, hr_max], + ticktext=[ + f'{hr_min:.0f} bpm', + f'{(hr_min + hr_max) / 2:.0f} bpm', + f'{hr_max:.0f} bpm', + ], + tickcolor='white', + ), + showscale=True, + ), + showlegend=False, + hoverinfo='skip', + )) - # 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 + # Zone-Modus: Farbe nach HR-Zone + zone_colors = [get_zone_for_bpm(h) for h in hr] + colors = [ + HR_ZONES[z]['color'] if not np.isnan(h) and h > 40 else '#1a1a1a' + for h, z in zip(hr, zone_colors) + ] - norm_hr = Normalize(vmin=hr_min, vmax=hr_max) + fig.add_trace(go.Scattergl( + x=lons, y=lats, + mode='markers', + marker=dict(color=colors, size=3, opacity=0.9), + hovertemplate=( + '❤️ %{customdata[0]:.0f} bpm · Zone %{customdata[1]}
' + '📏 %{customdata[2]:.2f} km' + ), + customdata=np.column_stack([ + hr, + [get_zone_for_bpm(h) + 1 if not np.isnan(h) and h > 40 else 0 for h in hr], + dist, + ]), + showlegend=False, + )) - # ------------------------------------------------------------------------- - # 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) + # Zonen-Legende + for z in HR_ZONES: + cnt = np.sum((hr >= z['lower']) & (hr < z['upper']) & ~np.isnan(hr)) + pct = 100 * cnt / len(valid_hr) if len(valid_hr) > 0 else 0 + fig.add_trace(go.Scatter( + x=[None], y=[None], mode='markers', + marker=dict(color=z['color'], size=10), + name=f'{z["name"]} {z["label"]} {pct:.1f}%', + showlegend=True, + )) # Start / Ziel - 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.add_trace(go.Scattergl( + x=[lons[0]], y=[lats[0]], mode='markers', + marker=dict(color='#b9fc62', size=12, symbol='circle'), + showlegend=False, hovertemplate='Start', )) + fig.add_trace(go.Scattergl( + x=[lons[-1]], y=[lats[-1]], mode='markers', + marker=dict(color='#fca062', size=11, symbol='square'), + showlegend=False, hovertemplate='Ziel', + )) + + no_hr = '' if has_hr else ' · No HR-Data' + mode_label = 'BPM-intensity [red=low · white=high]' if mode == 'bpm' else 'Zones' + fig.update_layout( title=dict( - text='Pixel-HR-Map (dunkelrot = niedrige BPM · weiß = maximale BPM)', - font=dict(size=13, color='white') + text=f'Heart Rate ({mode_label}) · Ø {hr_avg:.0f} bpm{no_hr}', + font=dict(size=12, color='white') ), - paper_bgcolor=bg_color, - plot_bgcolor=bg_color, + xaxis=dict(visible=False, scaleanchor='y', scaleratio=1), + yaxis=dict(visible=False), + paper_bgcolor='#0d0d0d', + plot_bgcolor='#0d0d0d', font=dict(color='white'), - margin=dict(l=0, r=0, t=30, b=0), - height=img_height, - xaxis=dict(visible=False, range=[0, 1]), - yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'), + margin=dict(l=0, r=0 if mode == 'bpm' else 130, t=40, b=70), + height=height, + legend=dict( + x=1.01, y=0.5, xanchor='left', + font=dict(color='white', size=9), + bgcolor='rgba(0,0,0,0.4)', + ) if mode == 'zone' else dict(visible=False), uirevision='hr_map', + dragmode='zoom', ) return fig @@ -1645,6 +1676,8 @@ def create_pixel_hr_map(df, img_width=900, img_height=900, + + # ----------------------------------------------------------------------------- # ELEVATION-PLOT: # ----------------------------------------------------------------------------- @@ -2025,14 +2058,15 @@ def create_heart_rate_plot(df): # {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFDAB9"}, # Schwelle (Threshold) (Anaerob) # {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#FFB6C1"}, # Neuromuskulär (Neuromuskulär) #] - zones = [ - {"name": "Zone 0", "lower": 0, "upper": 40, "color": "#2b2b2b"}, # unrealistisch / very dark neutral - {"name": "Zone 1", "lower": 40, "upper": 124, "color": "#7FB3FF"}, # Pastellblau (Recovery) - {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#9DE79A"}, # Pastellgrün (Endurance - Grundlagenausdauer) - {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#FFF29A"}, # Pastellgelb (Aerob - Tempo) - {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFBE7A"}, # Pastellorange (Anaerob - Threshold - Schwelle) - {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#FF9AA2"}, # Pastellrot (Neuromuskulär) - ] + #zones = [ + # {"name": "Zone 0", "lower": 0, "upper": 40, "color": "#2b2b2b"}, # unrealistisch / very dark neutral + # {"name": "Zone 1", "lower": 40, "upper": 124, "color": "#7FB3FF"}, # Pastellblau (Recovery) + # {"name": "Zone 2", "lower": 124, "upper": 154, "color": "#9DE79A"}, # Pastellgrün (Endurance - Grundlagenausdauer) + # {"name": "Zone 3", "lower": 154, "upper": 169, "color": "#FFF29A"}, # Pastellgelb (Aerob - Tempo) + # {"name": "Zone 4", "lower": 169, "upper": 184, "color": "#FFBE7A"}, # Pastellorange (Anaerob - Threshold - Schwelle) + # {"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#FF9AA2"}, # Pastellrot (Neuromuskulär) + #] + zones = HR_ZONES # Berechne die Anzahl der Werte in jeder Zone total_count = len(valid_hr) @@ -2425,17 +2459,17 @@ app.layout = html.Div([ # START !!!!!!!!!!!!!!! # Pixel-Map Überschrift - html.Hr(style={'borderColor': '#111111', 'margin': '10px 20px'}), #'#333' - html.Div([ - html.H3("Pixel-Maps", style={ - 'color': '#aaaaaa', 'margin': '10px 0 0 0', 'fontSize': '16px' - }), - html.P( - "Plot 1) Heatmap: Toggle between single run or all runs in the region.\n" - "Plot 2), 3) & 4) Elevation, Pace & HR maps: of the currently selected run.", - style={'color': '#aaaaaa', 'margin': '2px 0 8px 0', 'fontSize': '12px'} - ), - ], style={'padding': '0 20px'}), + #html.Hr(style={'borderColor': '#111111', 'margin': '10px 20px'}), #'#333' + #html.Div([ + # html.H3("Pixel-Maps", style={ + # 'color': '#aaaaaa', 'margin': '10px 0 0 0', 'fontSize': '16px' + # }), + # html.P( + # "Plot 1) Heatmap: Toggle between single run or all runs in the region.\n" + # "Plot 2), 3) & 4) Elevation, Pace & HR maps: of the currently selected run.", + # style={'color': '#aaaaaa', 'margin': '2px 0 8px 0', 'fontSize': '12px'} + # ), + #], style={'padding': '0 20px'}), # Drei Plots nebeneinander – Heatmap links, Elevation Mitte, Pace rechts html.Div([ @@ -2484,17 +2518,43 @@ app.layout = html.Div([ # --- Elevation-Map --- html.Div([ - dcc.Graph(id='fig-pixel-elevation'), + dcc.Graph(id='fig-route-elevation'), ], style={'flex': '1', 'minWidth': '300px'}), # --- Pace-Map --- html.Div([ - dcc.Graph(id='fig-pixel-pace'), + dcc.Graph(id='fig-route-pace'), ], style={'flex': '1', 'minWidth': '300px'}), - # --- HR-Map --- + # --- HR-Map (mit BPM/Zone Toggle) --- html.Div([ - dcc.Graph(id='fig-pixel-hr'), + dcc.Graph(id='fig-route-hr'), + html.Div([ + html.Span("BPM", style={ + 'color': '#aaa', 'fontSize': '12px', + 'marginRight': '8px', 'verticalAlign': 'middle' + }), + dcc.Checklist( + id='hr-map-mode-toggle', + options=[{'label': ' HR-Zonen', 'value': 'zone'}], + value=[], + inputStyle={ + 'cursor': 'pointer', 'width': '36px', 'height': '18px', + 'accentColor': '#fc4e00', 'verticalAlign': 'middle', + 'marginRight': '6px', + }, + labelStyle={ + 'color': '#cccccc', 'fontSize': '12px', + 'verticalAlign': 'middle', 'cursor': 'pointer' + }, + ), + ], style={ + 'display': 'flex', 'alignItems': 'center', + 'padding': '8px 12px', + 'backgroundColor': '#1a1a1a', + 'borderRadius': '0 0 6px 6px', + 'borderTop': '1px solid #333', + }), ], style={'flex': '1', 'minWidth': '300px'}), ], style={ @@ -2505,7 +2565,7 @@ app.layout = html.Div([ 'backgroundColor': '#111111' }), - html.Hr(style={'borderColor': '#111111', 'margin': '10px 20px'}), + #html.Hr(style={'borderColor': '#111111', 'margin': '10px 20px'}), # ENDE !!!!!!!!!!!!!!!! dcc.Graph(id='fig-elevation'), @@ -2528,7 +2588,11 @@ def load_data(selected_file): # Dateipfad der ausgewählten Da print(f"DEBUG load_data: rows={len(df)}") return df.to_json(date_format='iso', orient='split') -# Callback 2: Update All (static) Plots + + +# ============================================================================= +# Callback 2: Update All Standard Plots +# ============================================================================= @app.callback( Output('info-banner', 'children'), Output('fig-map', 'figure', allow_duplicate=True), @@ -2537,70 +2601,123 @@ def load_data(selected_file): # Dateipfad der ausgewählten Da Output('fig_deviation', '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_speed = create_speed_plot(df) - fig_dev = create_deviation_plot(df) - fig_hr = create_heart_rate_plot(df) - fig_pace = create_pace_bars_plot(df) + info = create_info_banner(df) + fig_map = create_map_plot(df) + fig_elev = create_elevation_plot(df) + fig_speed = create_speed_plot(df) + fig_dev = create_deviation_plot(df) + fig_hr = create_heart_rate_plot(df) + fig_pace = create_pace_bars_plot(df) - # 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_speed, fig_dev, fig_hr, fig_pace, - fig_pixel_elevation, fig_pixel_pace, fig_pixel_hr) + return (info, fig_map, fig_elev, fig_speed, fig_dev, fig_hr, fig_pace) +# ============================================================================= +# Callback 2b: Update Route Pixel-Maps (Elevation + Pace) +# Separater Callback → blockiert nicht die schnellen Standard-Plots oben +# ============================================================================= +@app.callback( + Output('fig-route-elevation', 'figure'), + Output('fig-route-pace', 'figure'), + Input('stored-df', 'data'), + prevent_initial_call=True +) +def update_route_plots(json_data): + df = pd.read_json(io.StringIO(json_data), orient='split') + return ( + create_route_elevation_map(df, height=500), + create_route_pace_map(df, height=500), + ) +# ============================================================================= +# Callback 2c: Update HR Route-Map (mit BPM/Zone Toggle) +# ============================================================================= +@app.callback( + Output('fig-route-hr', 'figure'), + Input('stored-df', 'data'), + Input('hr-map-mode-toggle', 'value'), + prevent_initial_call=True +) +def update_hr_route_map(json_data, toggle_value): + if not json_data: + empty = go.Figure() + empty.update_layout( + paper_bgcolor='#0d0d0d', + font=dict(color='white'), + title=dict(text='Datei wählen...', font=dict(color='white')) + ) + return empty + + df = pd.read_json(io.StringIO(json_data), orient='split') + mode = 'zone' if toggle_value and 'zone' in toggle_value else 'bpm' + return create_route_hr_map(df, mode=mode, height=500) + + +# ============================================================================= +# Callback 3: Hover auf Elevation-Plot → Marker auf Map aktualisieren +# ============================================================================= +@app.callback( + Output('fig-map', 'figure'), + Input('fig-elevation', 'hoverData'), + State('fig-map', 'figure'), + State('stored-df', 'data'), + prevent_initial_call=True +) +def highlight_map(hoverData, fig_map, json_data): + df = pd.read_json(io.StringIO(json_data), orient='split') + + if hoverData is not None: + point_index = hoverData['points'][0]['pointIndex'] + lat, lon = df.iloc[point_index][['lat', 'lon']] + fig_map['data'][-1]['lat'] = [lat] + fig_map['data'][-1]['lon'] = [lon] + + return fig_map + + +# ============================================================================= +# Callback 4: Pixel-Heatmap (Matplotlib PNG) mit Stadt-Toggle +# ============================================================================= @app.callback( Output('fig-pixel-heatmap', 'figure'), Output('heatmap-city-info', 'children'), Input('file-dropdown', 'value'), Input('heatmap-mode-toggle', 'value'), - #prevent_initial_call=True # Sonst beim Start der App kein Renderprozess + # prevent_initial_call=True ← bewusst deaktiviert: initiales Laden beim App-Start ) def update_pixel_heatmap(selected_file, toggle_value): """ Rendert die Pixel-Heatmap abhängig vom Toggle-Switch. - - toggle_value == [] → Einzellauf (nur die gewählte Datei) + toggle_value == [] → Einzellauf (nur gewählte Datei, count aus Region) 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'))) + 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_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) + # Immer alle Stadt-Läufe laden (für korrekten Count-Grid-Kontext) all_options = list_files() if city_code: city_dfs, _ = load_runs_for_city(city_code, all_options) else: city_dfs = [] - # Fallback falls keine Stadt-Läufe gefunden + # Fallback: keine Stadt-Läufe → nur aktuelle Datei if not city_dfs: try: city_dfs = [process_selected_file(selected_file)] @@ -2608,38 +2725,40 @@ def update_pixel_heatmap(selected_file, toggle_value): 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 --- + # --- Einzellauf-Modus --- + # count_grid aus allen Läufen der Region, aber nur dieser Lauf gezeichnet try: df_single = process_selected_file(selected_file) except Exception: df_single = pd.DataFrame() fig = create_pixel_heatmap( - dataframes=city_dfs, # count_grid aus allen Läufen - highlight_df=df_single, # nur dieser Lauf wird gezeichnet + dataframes=city_dfs, + highlight_df=df_single, mode='single', city_code=city_code, n_city_runs=len(city_dfs), - img_width=800, img_height=500, # ← NEU + img_width=800, img_height=500, ) - city_info_text = f'Region {city_code} · single run · Count of {len(city_dfs)} runs' + city_info_text = f'Region {city_code} · Einzellauf · Count aus {len(city_dfs)} Läufen' + else: - # --- Region-Modus: alle Läufe anzeigen --- + # --- Region-Modus: alle Läufe der Stadt anzeigen --- fig = create_pixel_heatmap( dataframes=city_dfs, mode='city', city_code=city_code, n_city_runs=len(city_dfs), - img_width=800, img_height=500, # ← NEU + img_width=800, img_height=500, ) - city_info_text = f'Region {city_code} · {len(city_dfs)} runs loaded' + city_info_text = f'Region {city_code} · {len(city_dfs)} Läufe geladen' return fig, city_info_text -# Callback 3: Export SVG + +# Callback 5: Export SVG @app.callback( Output('export-status', 'children'), Input('export-button', 'n_clicks'), @@ -2701,28 +2820,6 @@ def export_summary_image(n_clicks, json_data, selected_file): 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,