Created a new Heart-Rate-Pixel Map Plot, updated the Plot arangement, added elevation info in the hover-info on the main map course.

This commit is contained in:
2026-05-06 12:59:13 +02:00
parent fc78b0c5b0
commit f5a49a218d
3 changed files with 444 additions and 237 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -32,11 +32,21 @@ This SVG can then be overlaid on another image (Stravainspired).
### Pixel Maps
Three additional pixel-accurate route maps rendered below the main map, inspired by [Sam's approach](https://youtu.be/PA8d4u5T4BM?t=240) of drawing GPS routes pixel by pixel:
- **Heatmap** — draws the route pixelwise and counts how often each pixel was crossed. Segments you run frequently glow brighter (dark → orange → white). Supports two modes toggled via a switch below the plot:
- *Single run* — shows only the currently selected run, but the color intensity is still derived from the full regional count grid so you can see how often you have been at each spot across all your runs
- *All runs (Region)* — overlays all runs from the same city/region (detected automatically from the filename, e.g. `2025-07-30_FRA_Run_6.68Km.fit` → region `FRA`). This is the default view.
- **Elevation Map** — the route is color-coded segment by segment based on the gradient between consecutive GPS points: red = climbing, grey = flat, green = descending. Intensity scales with steepness. GPS noise is removed via smoothing before the gradient is calculated, giving accurate up/down statistics in the title.
- **Pace Map** — each segment is colored by your running pace at that location: blue = fast, red = slow. A horizontal colorbar below the plot shows the pace scale. Outliers (stops, GPS jumps) are filtered automatically.
- **Heatmap** — draws the route pixelwise and counts how often each pixel was crossed.
Segments you run frequently glow brighter (dark → orange → white). Toggle below the plot:
- *Single run* — shows only the selected run, color intensity derived from the full regional count grid
- *All runs (Region)* — overlays all runs from the same city/region (default). Region is detected
automatically from the filename, e.g. `2025-07-30_FRA_Run_6.68Km.fit` → region `FRA`
- **Elevation Map** — route color-coded per segment by gradient: red = climbing, grey = flat,
green = descending. GPS noise removed via smoothing before gradient calculation.
Horizontal colorbar: green (max descent) → grey (flat) → red (max climb)
- **Pace Map** — each segment colored by running pace: blue = fast, red = slow.
Outliers (stops, GPS jumps) filtered automatically.
Horizontal colorbar: red (slow) → blue (fast)
- **Heart Rate Map** — each segment colored by heart rate at that location:
dark red = low BPM (easy) → white = max BPM (full effort).
Horizontal colorbar: dark red (min BPM) → white (max BPM).
Falls back gracefully to a grey route with info text for GPX files without HR data.
#### City/Region detection from filename
The heatmap automatically groups runs by city code extracted from the filename at position `[1]` after splitting by `_`:
@@ -48,6 +58,13 @@ The heatmap automatically groups runs by city code extracted from the filename a
Use any consistent 26 letter code in your filenames to group runs by region.
#### Responsive layout
The four Pixel Maps are arranged in a 2×2 grid on wide screens (desktop/tablet).
On screens narrower than 768 px (smartphones) the grid collapses to a single column
so each plot uses the full screen width and remains readable.
Scroll zoom is enabled on all four plots (`scrollZoom: True`) — use the mouse wheel
on desktop or two-finger drag on mobile to pan after zooming in.
---
## Project Structure
@@ -167,11 +184,13 @@ YYYY-MM-DD_CITYCODE_Run_DISTANCEKm.fit
- [X] Pixel Heatmap — frequency map across multiple runs per region
- [X] Pixel Elevation Map — per-segment gradient coloring on the route
- [X] Pixel Pace Map — per-segment pace coloring on the route
- [X] Pixel Heart Rate Map — per-segment BPM coloring on the route
- [X] Elevation gain calculation calibrated against Strava
- [X] Calories burned estimation via HR-based Karvonen formula
- [X] GPX file support in addition to FIT
- [X] Responsive 2×2 grid layout for Pixel Maps (collapses to single column on mobile)
- [ ] Export as PDF report
- [ ] Multi-run comparison overlay
- [ ] Pinch-to-zoom on Pixel Maps for mobile devices
---

View File

@@ -765,16 +765,18 @@ def create_map_plot(df):
height=800
)
# Info: Frankfurt liegt ca. 112 m ü.NN, Hamburg ca. 6 m ü.NN.
fig.update_traces(
hovertemplate=(
#"Time: %{customdata[0]}<br>" +
"Distance (Km): %{customdata[0]:.2f}<br>" +
"Speed (Km/h): %{customdata[1]:.2f}<br>" +
"Heart Rate (bpm): %{customdata[2]}<br>" +
"Elapsed Time: %{customdata[3]}<extra></extra>"
"Time: %{customdata[5]}<br>" +
"Distance: %{customdata[0]:.2f} km<br>" +
"Elevation: %{customdata[1]:.0f} m ü.NN (%{customdata[2]:+.0f} m zum Start)<br>" + #„m ü.NN" bedeutet Meter über Normal-Null
"Speed: %{customdata[3]:.1f} km/h<br>" +
"Heart Rate: %{customdata[4]:.0f} bpm<extra></extra>"
),
#customdata=df[['time', 'cum_dist_km', 'duration_hms']]
customdata=df[['cum_dist_km', 'speed_kmh', 'heart_rate', 'duration_hms']]
#customdata=df[['cum_dist_km', 'speed_kmh', 'heart_rate', 'duration_hms']]
customdata=df[['cum_dist_km', 'elev', 'rel_elev', 'speed_kmh', 'heart_rate', 'duration_hms']]
)
# Define map style and the line ontop
fig.update_layout(map_style="open-street-map") #My-Fav: open-street-map, satellite-streets, dark, white-bg
@@ -826,226 +828,6 @@ def create_map_plot(df):
# #####################
def create_elevation_plot(df, smooth_points=500):
# Originale Daten
x = df['time']
y = df['rel_elev']
# Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
if len(y) >= 4: # Genug Punkte für cubic interpolation
y_numeric = y.to_numpy()
# Nur gültige Y-Punkte für Interpolation
mask = ~np.isnan(y_numeric)
if np.sum(mask) >= 4: # Genug gültige Punkte
# Index-basierte Interpolation für Y-Werte
valid_indices = np.where(mask)[0]
valid_y = y_numeric[mask]
# Interpolation über die Indizes
f = interp1d(valid_indices, valid_y, kind='cubic', bounds_error=False, fill_value='extrapolate')
# Neue Y-Werte für alle ursprünglichen X-Positionen
all_indices = np.arange(len(y))
y_smooth = f(all_indices)
# Originale X-Werte beibehalten
x_smooth = x
else:
# Fallback: originale Daten
x_smooth, y_smooth = x, y
else:
# Zu wenige Punkte: originale Daten verwenden
x_smooth, y_smooth = x, y
fig = go.Figure()
# Separate Behandlung für positive und negative Bereiche
y_array = np.array(y_smooth)
x_array = np.array(x_smooth)
# Positive Bereiche (Anstiege) - Gradient von transparent unten zu grün oben
positive_mask = y_array >= 0
if np.any(positive_mask):
# Nulllinie für positive Bereiche
fig.add_trace(go.Scatter(
x=x_array,
y=np.zeros_like(y_array),
mode='lines',
line=dict(width=0),
hoverinfo='skip',
showlegend=False
))
# Positive Bereiche mit Gradient nach oben
fig.add_trace(go.Scatter(
x=x_array,
y=np.where(y_array >= 0, y_array, 0), # Nur positive Werte
fill='tonexty', # Fill zur vorherigen Trace (Nulllinie)
mode='lines',
line=dict(width=0),
fillgradient=dict(
type="vertical",
colorscale=[
(0.0, "rgba(17, 17, 17, 0.0)"), # Transparent unten (bei y=0)
(1.0, "rgba(43, 82, 144, 0.8)") # Blau oben (bei maximaler Höhe) Grün: 73, 189, 115
]
),
hoverinfo='skip',
showlegend=False
))
# Negative Bereiche (Abstiege) - Gradient von grün unten zu transparent oben
negative_mask = y_array < 0
if np.any(negative_mask):
# Nulllinie für negative Bereiche
fig.add_trace(go.Scatter(
x=x_array,
y=np.where(y_array < 0, y_array, 0), # Nur negative Werte
mode='lines',
line=dict(width=0),
hoverinfo='skip',
showlegend=False
))
# Negative Bereiche mit Gradient nach unten
fig.add_trace(go.Scatter(
x=x_array,
y=np.zeros_like(y_array),
fill='tonexty', # Fill zur vorherigen Trace (negative Werte)
mode='lines',
line=dict(width=0),
fillgradient=dict(
type="vertical",
colorscale=[
(0.0, "rgba(43, 82, 144, 0.8)"), # Blau unten (bei minimaler Tiefe)
(1.0, "rgba(17, 17, 17, 0.0)") # Transparent oben (bei y=0)
]
),
hoverinfo='skip',
showlegend=False
))
# Hauptlinie (geglättet) - über allem
fig.add_trace(go.Scatter(
x=x_smooth,
y=y_smooth,
mode='lines',
line=dict(color='#2b5290', width=2), # Weiße Linie für bessere Sichtbarkeit
name='Elevation',
showlegend=False
))
# Add horizontal reference line at y=0
fig.add_shape(
type='line',
x0=df['time_loc'].iloc[0],
x1=df['time_loc'].iloc[-1],
y0=0,
y1=0,
line=dict(color='gray', width=1, dash='dash'),
name='Durchschnittstempo'
)
# Layout im Dark Theme
fig.update_layout(
title=dict(text='Höhenprofil (relativ zum Ausgangswert: 0m)', font=dict(size=16, color='white')),
xaxis_title='Zeit',
yaxis_title='Höhe relativ zum Start (m)',
template='plotly_dark',
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
height=400,
uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
# Alte Version - normaler fill between:
# def create_elevation_plot(df, smooth_points=500):
# # Originale Daten
# x = df['time']
# y = df['rel_elev']
#
# # Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
# if len(y) >= 4: # Genug Punkte für cubic interpolation
# y_numeric = y.to_numpy()
#
# # Nur gültige Y-Punkte für Interpolation
# mask = ~np.isnan(y_numeric)
#
# if np.sum(mask) >= 4: # Genug gültige Punkte
# # Index-basierte Interpolation für Y-Werte
# valid_indices = np.where(mask)[0]
# valid_y = y_numeric[mask]
#
# # Interpolation über die Indizes
# f = interp1d(valid_indices, valid_y, kind='cubic',
# bounds_error=False, fill_value='extrapolate')
#
# # Neue Y-Werte für alle ursprünglichen X-Positionen
# all_indices = np.arange(len(y))
# y_smooth = f(all_indices)
#
# # Originale X-Werte beibehalten
# x_smooth = x
# else:
# # Fallback: originale Daten
# x_smooth, y_smooth = x, y
# else:
# # Zu wenige Punkte: originale Daten verwenden
# x_smooth, y_smooth = x, y
#
# fig = go.Figure()
#
# # Fläche unter der Kurve (mit geglätteten Daten)
# fig.add_trace(go.Scatter(
# x=x_smooth, y=y_smooth,
# mode='lines',
# line=dict(color='#1CAF50'), # Fill between color!
# fill='tozeroy',
# #fillcolor='rgba(226, 241, 248)',
# hoverinfo='skip',
# showlegend=False
# ))
#
# # Hauptlinie (geglättet)
# fig.add_trace(go.Scatter(
# x=x_smooth, y=y_smooth,
# mode='lines',
# line=dict(color='#084C20', width=2), # Line color!
# name='Elevation',
# showlegend=False
# ))
#
# # SUPDER IDEE, ABER GEHT NICHT WEGE NEUEN smoothed POINTS! GEHT NUR BEI X
# #fig.update_traces(
# # hovertemplate=(
# # #"Time: %{customdata[0]}<br>" +
# # "Distance (km): %{customdata[0]:.2f}<br>" +
# # "Elevation: %{customdata[1]}<extra></extra>" +
# # "Elapsed Time: %{customdata[2]}<extra></extra>"
# # ),
# # customdata=df[['cum_dist_km','elev', 'time']]
# #
#
# # Layout im Dark Theme
# fig.update_layout(
# title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16, color='white')),
# xaxis_title='Zeit',
# yaxis_title='Höhe relativ zum Start (m)',
# template='plotly_dark',
# paper_bgcolor='#1e1e1e',
# plot_bgcolor='#111111',
# font=dict(color='white'),
# margin=dict(l=40, r=40, t=50, b=40),
# height=400
# )
#
# return fig
# #####################
# -----------------------------------------------------------------------------
@@ -1664,7 +1446,403 @@ def create_pixel_pace_map(df, img_width=900, img_height=900,
return fig
# -----------------------------------------------------------------------------
# PLOT 4: HEART-RATE-MAP
# -----------------------------------------------------------------------------
def create_pixel_hr_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'):
"""
Pixel-Map der Heart Rate je Streckenabschnitt.
Farbe: dunkelrot (min BPM, niedrige Belastung) → weiß (max BPM, Vollgas).
Fehlende HR-Daten (GPX-Dateien) werden als dunkle Linie gezeichnet.
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
import plotly.graph_objects as go
DPI = 100
# Leerer Plot wenn keine GPS-Daten
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height,
title=dict(text='Keine GPS-Daten', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
# Heart Rate laden prüfe beide möglichen Spaltennamen
hr_values = None
for col in ['heart_rate', 'hr_smooth']:
if col in df.columns and df[col].notna().sum() > 10:
hr_values = df[col].ffill().bfill().values
break
has_hr = hr_values is not None
n = min(len(lats), len(lons), len(hr_values) if has_hr else len(lats))
lats = lats[:n]
lons = lons[:n]
if has_hr:
hr_values = hr_values[:n]
# Pixel-Koordinaten
xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height,
pad_top=40, pad_bottom=60, pad_left=10, pad_right=10)
xs = xs.astype(int)
ys = ys.astype(int)
# -------------------------------------------------------------------------
# Colormap: dunkelrot (niedrige BPM) → rot → orange → gelb → weiß (max BPM)
# -------------------------------------------------------------------------
cmap_colors = [
(0.00, '#3d0000'), # sehr dunkelrot (minimale HR)
(0.25, '#8b0000'), # dunkelrot
(0.50, '#cc2200'), # rot
(0.70, '#ff6600'), # orange
(0.85, '#ffcc00'), # gelb
(1.00, '#ffffff'), # weiß (maximale HR = Vollgas)
]
cmap_hr = LinearSegmentedColormap.from_list('heartrate', cmap_colors, N=256)
# HR-Grenzen: Perzentile statt absolutes Min/Max → Ausreißer ignorieren
if has_hr:
valid_hr = hr_values[~np.isnan(hr_values)]
valid_hr = valid_hr[(valid_hr > 40) & (valid_hr < 220)] # Plausibilitätsfilter
if len(valid_hr) > 0:
hr_min = np.percentile(valid_hr, 2)
hr_max = np.percentile(valid_hr, 98)
else:
hr_min, hr_max = 100, 180
has_hr = False
else:
hr_min, hr_max = 100, 180
norm_hr = Normalize(vmin=hr_min, vmax=hr_max)
# -------------------------------------------------------------------------
# Canvas: exakt img_width × img_height Pixel
# -------------------------------------------------------------------------
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
ax = fig_mpl.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
# -------------------------------------------------------------------------
# Linien zeichnen
# -------------------------------------------------------------------------
if has_hr:
for i in range(n - 1):
hr = hr_values[i]
if np.isnan(hr) or hr < 40 or hr > 220:
# Ungültige HR → sehr dunkel
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color='#1a1a1a', linewidth=line_width * 0.6,
solid_capstyle='round')
continue
color = cmap_hr(norm_hr(hr))
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=color, linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
else:
# Keine HR-Daten → Route grau zeichnen mit Hinweis
for i in range(n - 1):
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color='#444444', linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
fig_mpl.text(0.5, 0.50, 'Keine Heart-Rate-Daten\n(nur in .fit Dateien verfügbar)',
color='#888888', fontsize=11, ha='center', va='center',
transform=fig_mpl.transFigure)
# Start / Ziel
ax.plot(xs[0], ys[0], 'o', color='#b9fc62', markersize=10, zorder=5)
ax.plot(xs[-1], ys[-1], 's', color='#fca062', markersize=9, zorder=5)
# Titel
if has_hr:
avg_hr = float(np.nanmean(valid_hr))
title_str = (f'HR-Map · Ø {avg_hr:.0f} bpm | '
f'min {hr_min:.0f} max {hr_max:.0f} bpm')
else:
title_str = 'HR-Map · Keine Heart-Rate-Daten'
fig_mpl.text(0.5, 0.97, title_str,
color='white', fontsize=10, ha='center', va='top',
transform=fig_mpl.transFigure)
# -------------------------------------------------------------------------
# Colorbar horizontal unten
# dunkelrot links (min BPM) → weiß rechts (max BPM)
# -------------------------------------------------------------------------
if has_hr:
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020])
sm = cm.ScalarMappable(cmap=cmap_hr, norm=norm_hr)
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar.set_label('Heart Rate (bpm)', color='white', fontsize=8)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.set_ticks([hr_min, (hr_min + hr_max) / 2, hr_max])
cbar.set_ticklabels([
f'{hr_min:.0f} bpm',
f'{(hr_min + hr_max) / 2:.0f} bpm',
f'{hr_max:.0f} bpm'
])
img_b64 = _fig_to_base64(fig_mpl)
# Plotly-Figure
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(
text='Pixel-HR-Map (dunkelrot = niedrige BPM · weiß = maximale BPM)',
font=dict(size=13, color='white')
),
paper_bgcolor=bg_color,
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height,
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='hr_map',
)
return fig
# #####################
def create_elevation_plot(df, smooth_points=500):
# Originale Daten
x = df['time']
y = df['rel_elev']
# Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
if len(y) >= 4: # Genug Punkte für cubic interpolation
y_numeric = y.to_numpy()
# Nur gültige Y-Punkte für Interpolation
mask = ~np.isnan(y_numeric)
if np.sum(mask) >= 4: # Genug gültige Punkte
# Index-basierte Interpolation für Y-Werte
valid_indices = np.where(mask)[0]
valid_y = y_numeric[mask]
# Interpolation über die Indizes
f = interp1d(valid_indices, valid_y, kind='cubic', bounds_error=False, fill_value='extrapolate')
# Neue Y-Werte für alle ursprünglichen X-Positionen
all_indices = np.arange(len(y))
y_smooth = f(all_indices)
# Originale X-Werte beibehalten
x_smooth = x
else:
# Fallback: originale Daten
x_smooth, y_smooth = x, y
else:
# Zu wenige Punkte: originale Daten verwenden
x_smooth, y_smooth = x, y
fig = go.Figure()
# Separate Behandlung für positive und negative Bereiche
y_array = np.array(y_smooth)
x_array = np.array(x_smooth)
# Positive Bereiche (Anstiege) - Gradient von transparent unten zu grün oben
positive_mask = y_array >= 0
if np.any(positive_mask):
# Nulllinie für positive Bereiche
fig.add_trace(go.Scatter(
x=x_array,
y=np.zeros_like(y_array),
mode='lines',
line=dict(width=0),
hoverinfo='skip',
showlegend=False
))
# Positive Bereiche mit Gradient nach oben
fig.add_trace(go.Scatter(
x=x_array,
y=np.where(y_array >= 0, y_array, 0), # Nur positive Werte
fill='tonexty', # Fill zur vorherigen Trace (Nulllinie)
mode='lines',
line=dict(width=0),
fillgradient=dict(
type="vertical",
colorscale=[
(0.0, "rgba(17, 17, 17, 0.0)"), # Transparent unten (bei y=0)
(1.0, "rgba(43, 82, 144, 0.8)") # Blau oben (bei maximaler Höhe) Grün: 73, 189, 115
]
),
hoverinfo='skip',
showlegend=False
))
# Negative Bereiche (Abstiege) - Gradient von grün unten zu transparent oben
negative_mask = y_array < 0
if np.any(negative_mask):
# Nulllinie für negative Bereiche
fig.add_trace(go.Scatter(
x=x_array,
y=np.where(y_array < 0, y_array, 0), # Nur negative Werte
mode='lines',
line=dict(width=0),
hoverinfo='skip',
showlegend=False
))
# Negative Bereiche mit Gradient nach unten
fig.add_trace(go.Scatter(
x=x_array,
y=np.zeros_like(y_array),
fill='tonexty', # Fill zur vorherigen Trace (negative Werte)
mode='lines',
line=dict(width=0),
fillgradient=dict(
type="vertical",
colorscale=[
(0.0, "rgba(43, 82, 144, 0.8)"), # Blau unten (bei minimaler Tiefe)
(1.0, "rgba(17, 17, 17, 0.0)") # Transparent oben (bei y=0)
]
),
hoverinfo='skip',
showlegend=False
))
# Hauptlinie (geglättet) - über allem
fig.add_trace(go.Scatter(
x=x_smooth,
y=y_smooth,
mode='lines',
line=dict(color='#2b5290', width=2), # Weiße Linie für bessere Sichtbarkeit
name='Elevation',
showlegend=False
))
# Add horizontal reference line at y=0
fig.add_shape(
type='line',
x0=df['time_loc'].iloc[0],
x1=df['time_loc'].iloc[-1],
y0=0,
y1=0,
line=dict(color='gray', width=1, dash='dash'),
name='Durchschnittstempo'
)
# Layout im Dark Theme
fig.update_layout(
title=dict(text='Höhenprofil (relativ zum Ausgangswert: 0m)', font=dict(size=16, color='white')),
xaxis_title='Zeit',
yaxis_title='Höhe relativ zum Start (m)',
template='plotly_dark',
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
height=400,
uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
# Alte Version - normaler fill between:
# def create_elevation_plot(df, smooth_points=500):
# # Originale Daten
# x = df['time']
# y = df['rel_elev']
#
# # Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
# if len(y) >= 4: # Genug Punkte für cubic interpolation
# y_numeric = y.to_numpy()
#
# # Nur gültige Y-Punkte für Interpolation
# mask = ~np.isnan(y_numeric)
#
# if np.sum(mask) >= 4: # Genug gültige Punkte
# # Index-basierte Interpolation für Y-Werte
# valid_indices = np.where(mask)[0]
# valid_y = y_numeric[mask]
#
# # Interpolation über die Indizes
# f = interp1d(valid_indices, valid_y, kind='cubic',
# bounds_error=False, fill_value='extrapolate')
#
# # Neue Y-Werte für alle ursprünglichen X-Positionen
# all_indices = np.arange(len(y))
# y_smooth = f(all_indices)
#
# # Originale X-Werte beibehalten
# x_smooth = x
# else:
# # Fallback: originale Daten
# x_smooth, y_smooth = x, y
# else:
# # Zu wenige Punkte: originale Daten verwenden
# x_smooth, y_smooth = x, y
#
# fig = go.Figure()
#
# # Fläche unter der Kurve (mit geglätteten Daten)
# fig.add_trace(go.Scatter(
# x=x_smooth, y=y_smooth,
# mode='lines',
# line=dict(color='#1CAF50'), # Fill between color!
# fill='tozeroy',
# #fillcolor='rgba(226, 241, 248)',
# hoverinfo='skip',
# showlegend=False
# ))
#
# # Hauptlinie (geglättet)
# fig.add_trace(go.Scatter(
# x=x_smooth, y=y_smooth,
# mode='lines',
# line=dict(color='#084C20', width=2), # Line color!
# name='Elevation',
# showlegend=False
# ))
#
# # SUPDER IDEE, ABER GEHT NICHT WEGE NEUEN smoothed POINTS! GEHT NUR BEI X
# #fig.update_traces(
# # hovertemplate=(
# # #"Time: %{customdata[0]}<br>" +
# # "Distance (km): %{customdata[0]:.2f}<br>" +
# # "Elevation: %{customdata[1]}<extra></extra>" +
# # "Elapsed Time: %{customdata[2]}<extra></extra>"
# # ),
# # customdata=df[['cum_dist_km','elev', 'time']]
# #
#
# # Layout im Dark Theme
# fig.update_layout(
# title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16, color='white')),
# xaxis_title='Zeit',
# yaxis_title='Höhe relativ zum Start (m)',
# template='plotly_dark',
# paper_bgcolor='#1e1e1e',
# plot_bgcolor='#111111',
# font=dict(color='white'),
# margin=dict(l=40, r=40, t=50, b=40),
# height=400
# )
#
# return fig
# #####################
@@ -2102,7 +2280,7 @@ app.layout = html.Div([
}),
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. ",
"Plot 2), 3) & 4) Elevation-, Pace- & HR-Map: des aktuell gewählten Laufs.",
style={'color': '#666', 'margin': '2px 0 8px 0', 'fontSize': '12px'}
),
], style={'padding': '0 20px'}),
@@ -2162,10 +2340,17 @@ app.layout = html.Div([
dcc.Graph(id='fig-pixel-pace'),
], style={'flex': '1', 'minWidth': '300px'}),
# --- HR-Map ---
html.Div([
dcc.Graph(id='fig-pixel-hr'),
], style={'flex': '1', 'minWidth': '300px'}),
], style={
'display': 'flex', 'flexWrap': 'wrap',
'gap': '5px', 'padding': '0 20px',
'backgroundColor': '#111111'
'display': 'grid',
'gridTemplateColumns': 'repeat(2, 1fr)', # immer 2 Spalten
'gap': '8px',
'padding': '0 20px',
'backgroundColor': '#111111'
}),
html.Hr(style={'borderColor': '#333', 'margin': '10px 20px'}),
@@ -2204,6 +2389,7 @@ def load_data(selected_file): # Dateipfad der ausgewählten Da
#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
)
@@ -2222,9 +2408,11 @@ def update_all_plots(json_data):
# Pixel-Maps (Elevation + Pace bleiben hier; Heatmap hat eigenen Callback)
fig_pixel_elevation = create_pixel_elevation_map(df, img_width=800, img_height=500)
fig_pixel_pace = create_pixel_pace_map(df, img_width=800, img_height=500)
fig_pixel_hr = create_pixel_hr_map(df, img_width=800, img_height=500) # ← NEU
return (info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace,
fig_pixel_elevation, fig_pixel_pace)
fig_pixel_elevation, fig_pixel_pace, fig_pixel_hr)