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,