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:
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.9 MiB |
31
README.md
31
README.md
@@ -32,11 +32,21 @@ This SVG can then be overlaid on another image (Strava‑inspired).
|
||||
### 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 2–6 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user