Created new Pixel Map Plots (3) - below the main Map Plot - to highlighting further/main Information insights. Also updated the Dashboard_App_WebVersion image

This commit is contained in:
2026-05-05 14:46:15 +02:00
parent bbea4fe8be
commit b4c6d409a7
2 changed files with 823 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -25,6 +25,12 @@ from scipy.interpolate import interp1d
import gpxpy
from fitparse import FitFile
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
from xml.etree.ElementTree import Element, SubElement, tostring
import xml.etree.ElementTree as ET
@@ -781,12 +787,13 @@ def create_map_plot(df):
end = df.iloc[-1]
fig.add_trace(go.Scattermap(
lat=[start['lat']], lon=[start['lon']], mode='markers+text',
marker=dict(size=12, color='#fca062'), text=['Start'], name='Start', textposition='bottom left'
marker=dict(size=12, color='#b9fc62', symbol='circle'), text=['Start'], name='Start', textposition='bottom left' # Starting point !
))
fig.add_trace(go.Scattermap(
lat=[end['lat']], lon=[end['lon']], mode='markers+text',
marker=dict(size=12, color='#b9fc62'), text=['Stop'], name='Stop', textposition='bottom left'
marker=dict(size=12, color='#fca062', symbol='circle'), text=['Stop'], name='Stop', textposition='bottom left' # Finishing point !
))
# THIS IS MY ELEVATION-PLOT SHOW POSITION-MARKER IN MAP-PLOT:
fig.add_trace(go.Scattermap(
lat=[],
@@ -1041,6 +1048,627 @@ def create_elevation_plot(df, smooth_points=500):
# -----------------------------------------------------------------------------
# HILFSFUNKTION: GPS → Pixel-Koordinaten (Mercator-Projektion)
# -----------------------------------------------------------------------------
def _gps_to_pixel(lats, lons, img_width=800, img_height=600,
padding=None,
pad_top=40, pad_bottom=60, pad_left=10, pad_right=10):
"""
Konvertiert GPS-Koordinaten (lat/lon) in Pixel-Koordinaten.
Unterstützt symmetrisches padding (alter Aufruf bleibt kompatibel)
oder asymmetrisches padding pro Seite.
Aufruf alt (kompatibel): _gps_to_pixel(lats, lons, padding=50)
Aufruf neu: _gps_to_pixel(lats, lons, pad_top=40, pad_bottom=60, ...)
"""
lats = np.array(lats, dtype=float)
lons = np.array(lons, dtype=float)
# Symmetrisches padding überschreibt alle vier Seiten (Rückwärtskompatibilität)
if padding is not None:
pad_top = pad_bottom = pad_left = pad_right = padding
lat_min, lat_max = lats.min(), lats.max()
lon_min, lon_max = lons.min(), lons.max()
lat_range = lat_max - lat_min
lon_range = lon_max - lon_min
draw_w = img_width - pad_left - pad_right
draw_h = img_height - pad_top - pad_bottom
if lon_range == 0 or lat_range == 0:
xs = np.full(len(lats), img_width / 2)
ys = np.full(len(lats), img_height / 2)
else:
scale = min(draw_w / lon_range, draw_h / lat_range)
offset_x = pad_left + (draw_w - lon_range * scale) / 2
offset_y = pad_top + (draw_h - lat_range * scale) / 2
xs = offset_x + (lons - lon_min) * scale
ys = offset_y + (lat_max - lats) * scale # Y-Achse umkehren
meta = {
'lat_min': lat_min, 'lat_max': lat_max,
'lon_min': lon_min, 'lon_max': lon_max,
'img_width': img_width, 'img_height': img_height
}
return xs, ys, meta
# -----------------------------------------------------------------------------
# HILFSFUNKTION: Matplotlib-Figure → base64-PNG (für Dash dcc.Graph)
# -----------------------------------------------------------------------------
def _fig_to_base64(fig):
"""
Konvertiert eine Matplotlib-Figure zu einem base64-PNG.
KEIN bbox_inches='tight' → Figure-Größe bleibt exakt wie gesetzt.
"""
import io, base64
import matplotlib.pyplot as plt
buf = io.BytesIO()
fig.savefig(
buf,
format='png',
dpi=fig.get_dpi(), # Nutze den dpi-Wert der Figure
bbox_inches=None, # KEIN tight feste Größe beibehalten
facecolor=fig.get_facecolor(),
edgecolor='none',
pad_inches=0,
)
buf.seek(0)
img_b64 = base64.b64encode(buf.read()).decode('utf-8')
plt.close(fig)
return f"data:image/png;base64,{img_b64}"
# =============================================================================
# HILFSFUNKTION: Stadtcode aus Dateiname extrahieren
# =============================================================================
def extract_city_code(file_path):
"""
Extrahiert den Stadtcode aus dem Dateinamen.
Format erwartet: "DATUM_STADTCODE_*.fit" oder "DATUM_STADTCODE_*.gpx"
Beispiele:
"2025-07-30_FRA_Run_6.68Km.fit""FRA"
"2025-09-10_HH_Run_10.27Km.fit""HH"
"UnbekanntesDateiformat.fit" → None
Args:
file_path (str): Vollständiger Pfad oder nur Dateiname.
Returns:
str | None: Stadtcode in Großbuchstaben oder None wenn nicht erkennbar.
"""
import os
filename = os.path.basename(file_path) # Nur Dateiname, ohne Pfad
name_without_ext = os.path.splitext(filename)[0] # Ohne .fit/.gpx
parts = name_without_ext.split('_')
# Mindestens 2 Teile nötig: ["2025-07-30", "FRA", ...]
if len(parts) >= 2:
city_code = parts[1].upper().strip()
# Plausibilitätsprüfung: 26 Zeichen, nur Buchstaben
if 2 <= len(city_code) <= 6 and city_code.isalpha():
return city_code
return None # Stadtcode nicht erkennbar
# =============================================================================
# HILFSFUNKTION: Alle Läufe einer Stadt laden
# =============================================================================
def load_runs_for_city(city_code, all_file_options):
"""
Lädt alle Läufe, deren Dateiname den angegebenen Stadtcode enthält.
Args:
city_code (str): Stadtcode z.B. "FRA" oder "HH".
all_file_options (list): Rückgabe von list_files()
Liste von dicts mit 'value' (Dateipfad).
Returns:
list[pd.DataFrame]: Liste der erfolgreich geladenen DataFrames.
list[str]: Liste der geladenen Dateipfade (für Debug/Titel).
"""
loaded_dfs = []
loaded_paths = []
for opt in all_file_options:
path = opt['value']
# Überspringe Platzhalter-Einträge
if path in ['NO_FILES', 'NO_FOLDER', 'ERROR']:
continue
# Prüfe ob diese Datei zum gewünschten Stadtcode gehört
if extract_city_code(path) == city_code.upper():
try:
df_run = process_selected_file(path)
if not df_run.empty:
loaded_dfs.append(df_run)
loaded_paths.append(path)
print(f" [Heatmap/{city_code}] Geladen: {path} "
f"({len(df_run)} Punkte)")
except Exception as e:
print(f" [Heatmap/{city_code}] Fehler bei {path}: {e}")
print(f" [Heatmap/{city_code}] Insgesamt {len(loaded_dfs)} Läufe geladen.")
return loaded_dfs, loaded_paths
# -----------------------------------------------------------------------------
# PLOT 1: HEATMAP (count) mehrere Läufe, Linienstärke = Häufigkeit
# -----------------------------------------------------------------------------
def create_pixel_heatmap(dataframes,
img_width=900, img_height=900,
line_width=2, bg_color='#0d0d0d',
mode='single',
city_code=None,
n_city_runs=0,
highlight_df=None):
"""
Sam-Style Heatmap: Zeichnet einen oder mehrere Läufe pixelweise.
Je öfter ein Pixel überquert wurde, desto heller/wärmer die Farbe.
Args:
dataframes (list[pd.DataFrame] | pd.DataFrame):
Einzelner DataFrame ODER Liste von DataFrames.
img_width, img_height (int): Canvas-Größe in Pixel.
line_width (int): Breite der gezeichneten Linien.
bg_color (str): Hintergrundfarbe.
mode (str): 'single' → ein Lauf | 'city' → alle Läufe der Stadt.
city_code (str | None): Erkannter Stadtcode (z.B. "FRA").
n_city_runs (int): Anzahl der geladenen Stadt-Läufe (nur für Titel).
Returns:
plotly.graph_objects.Figure
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
from mpl_toolkits.axes_grid1 import make_axes_locatable
import pandas as pd
import plotly.graph_objects as go
DPI = 100 # Fester DPI-Wert NICHT ändern
if isinstance(dataframes, pd.DataFrame):
dataframes = [dataframes]
dataframes = [
df for df in dataframes
if not df.empty and 'lat' in df.columns and 'lon' in df.columns
]
if not dataframes:
fig = go.Figure()
fig.update_layout(
paper_bgcolor='#1e1e1e', font=dict(color='white'),
height=img_height,
title=dict(text='Keine Daten verfügbar', font=dict(color='white'))
)
return fig
# Bounding-Box
all_lats = np.concatenate([df['lat'].dropna().values for df in dataframes])
all_lons = np.concatenate([df['lon'].dropna().values for df in dataframes])
lat_min, lat_max = all_lats.min(), all_lats.max()
lon_min, lon_max = all_lons.min(), all_lons.max()
lat_range = lat_max - lat_min if lat_max != lat_min else 1e-6
lon_range = lon_max - lon_min if lon_max != lon_min else 1e-6
# Skalierung (Seitenverhältnis erhalten)
padding = 50
draw_w = img_width - 2 * padding
draw_h = img_height - 2 * padding
scale = min(draw_w / lon_range, draw_h / lat_range)
offset_x = padding + (draw_w - lon_range * scale) / 2
offset_y = padding + (draw_h - lat_range * scale) / 2
def to_px(lats_arr, lons_arr):
xs = np.clip((offset_x + (lons_arr - lon_min) * scale).astype(int), 0, img_width - 1)
ys = np.clip((offset_y + (lat_max - lats_arr) * scale).astype(int), 0, img_height - 1)
return xs, ys
# Count-Grid
count_grid = np.zeros((img_height, img_width), dtype=np.float32)
for df in dataframes:
lats_r = df['lat'].dropna().values
lons_r = df['lon'].dropna().values
if len(lats_r) < 2:
continue
xs, ys = to_px(lats_r, lons_r)
for x, y in zip(xs, ys):
count_grid[y, x] += 1
max_count = max(count_grid.max(), 1)
log_max = np.log1p(max_count)
# ---------------------------------------------------------------
# Matplotlib-Canvas: EXAKT img_width × img_height Pixel
# figsize in Inch = Pixel / DPI
# ---------------------------------------------------------------
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
# Haupt-Axes: füllt die gesamte Figure (keine Ränder)
ax = fig_mpl.add_axes([0, 0, 1, 1]) # [left, bottom, width, height] in Figure-Koordinaten
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
# Colormap
cmap_colors = [
(0.00, '#1a0a00'),
(0.20, '#7a2800'),
(0.45, '#fc4e00'),
(0.70, '#fcaa00'),
(0.90, '#fde68a'),
(1.00, '#ffffff'),
]
cmap = LinearSegmentedColormap.from_list('heatmap', cmap_colors, N=256)
# Linien zeichnen
# Welche DataFrames werden GEZEICHNET?
# - Region-Modus: alle
# - Einzellauf-Modus: nur highlight_df, aber count_grid kam von allen
draw_frames = dataframes if (mode == 'city' or highlight_df is None) else [highlight_df]
for df in draw_frames: # ← draw_frames statt dataframes
lats_r = df['lat'].dropna().values
lons_r = df['lon'].dropna().values
if len(lats_r) < 2:
continue
xs, ys = to_px(lats_r, lons_r)
for i in range(len(xs) - 1):
mid_x = np.clip((xs[i] + xs[i+1]) // 2, 0, img_width - 1)
mid_y = np.clip((ys[i] + ys[i+1]) // 2, 0, img_height - 1)
norm_val = np.log1p(count_grid[mid_y, mid_x]) / log_max if log_max > 0 else 0
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=cmap(norm_val), linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
# Colorbar als Inset-Axes (verändert NICHT die Figure-Größe)
#cbar_ax = fig_mpl.add_axes([0.88, 0.10, 0.025, 0.70]) # # verticale Position: rechts
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020]) # [left, bottom, w, h], horizontale Position: unten !!
sm = cm.ScalarMappable(cmap=cmap, norm=Normalize(vmin=1, vmax=int(max_count)))
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar.set_label('Anzahl', color='white', fontsize=8)
#cbar.ax.yaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
# Titel als Text direkt in Figure-Koordinaten
if mode == 'city' and city_code:
title_str = f'Heatmap {city_code} · {n_city_runs} Läufe · max {int(max_count)}×'
else:
title_str = f'Heatmap (Einzellauf) · max {int(max_count)}× durchquert'
fig_mpl.text(0.5, 0.97, title_str, color='white', fontsize=10,
ha='center', va='top', transform=fig_mpl.transFigure)
img_b64 = _fig_to_base64(fig_mpl)
plotly_title = (
f'Pixel-Heatmap · {city_code} · {n_city_runs} Läufe (Region)'
if mode == 'city' and city_code else 'Pixel-Heatmap · Einzellauf'
)
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(text=plotly_title, font=dict(size=13, color='white')),
paper_bgcolor=bg_color, # Gleiche Farbe wie Plot → kein grauer Rand
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height,
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='heatmap',
)
return fig
# -----------------------------------------------------------------------------
# PLOT 2: ELEVATION-MAP Farbe zeigt Steigung/Gefälle je Segment
# -----------------------------------------------------------------------------
def create_pixel_elevation_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'):
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
import plotly.graph_objects as go
DPI = 100
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height,
title=dict(text='Keine GPS-Daten', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
if 'elev' in df.columns and df['elev'].notna().sum() > 10:
elevs_raw = df['elev'].ffill().bfill().values
n_smooth = max(5, min(50, len(elevs_raw) // 100))
kernel = np.ones(n_smooth) / n_smooth
elevs = np.convolve(elevs_raw, kernel, mode='same')
elif 'delta_elev' in df.columns:
delta_elev = df['delta_elev'].fillna(0).values
elevs = np.cumsum(delta_elev)
else:
elevs = np.zeros(len(lats))
n = min(len(lats), len(lons), len(elevs))
lats, lons, elevs = lats[:n], lons[:n], elevs[:n]
delta_elev = np.diff(elevs, prepend=elevs[0])
# Adaptiver Threshold
abs_deltas = np.abs(delta_elev)
nonzero_deltas = abs_deltas[abs_deltas > 0]
FLAT_THRESHOLD = np.percentile(nonzero_deltas, 20) if len(nonzero_deltas) > 0 else 0.05
max_delta = max(np.percentile(abs_deltas, 80), FLAT_THRESHOLD * 2)
# Statistik
total_up = delta_elev[delta_elev > FLAT_THRESHOLD].sum()
total_down = abs(delta_elev[delta_elev < -FLAT_THRESHOLD].sum())
# Pixel-Koordinaten
xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height,
pad_top=40, pad_bottom=60, pad_left=10, pad_right=10)
xs = xs.astype(int)
ys = ys.astype(int)
# -------------------------------------------------------------------------
# Colormap: grün (min/bergab) → grau (flach) → rot (max/bergauf)
# -------------------------------------------------------------------------
cmap_colors = [
(0.00, '#00aa00'), # grün (stärkster Abstieg)
(0.35, '#227722'), # dunkelgrün
(0.50, '#666666'), # grau (flach)
(0.65, '#772222'), # dunkelrot
(1.00, '#ff2200'), # rot (stärkster Anstieg)
]
cmap_elev = LinearSegmentedColormap.from_list('elevation', cmap_colors, N=256)
# Normalisierung: -max_delta → 0 → +max_delta
norm_elev = Normalize(vmin=-max_delta, vmax=max_delta)
# Canvas
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
ax = fig_mpl.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
# Linien zeichnen Farbe direkt aus cmap+norm
for i in range(n - 1):
d = delta_elev[i]
color = cmap_elev(norm_elev(d))
# Flache Segmente etwas transparenter
alpha = 0.45 if abs(d) <= FLAT_THRESHOLD else 0.55 + min(abs(d) / max_delta, 1.0) * 0.45
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=color, linewidth=line_width, alpha=alpha,
solid_capstyle='round', solid_joinstyle='round')
# Start / Ziel
ax.plot(xs[0], ys[0], 'o', color='#b9fc62', markersize=10, zorder=5)
ax.plot(xs[-1], ys[-1], 's', color='#fca062', markersize=9, zorder=5)
# Titel
fig_mpl.text(0.5, 0.97,
f'Elevation-Map · ↑ {total_up:.0f} m ↓ {total_down:.0f} m',
color='white', fontsize=10, ha='center', va='top',
transform=fig_mpl.transFigure)
# -------------------------------------------------------------------------
# Colorbar horizontal unten grün links (bergab) → rot rechts (bergauf)
# -------------------------------------------------------------------------
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020])
sm = cm.ScalarMappable(cmap=cmap_elev, norm=norm_elev)
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar.set_label('Steigung (m/Punkt)', color='white', fontsize=8)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
# Ticks: min (bergab), 0 (flach), max (bergauf)
cbar.set_ticks([-max_delta, 0, max_delta])
cbar.set_ticklabels([
f'↓ -{max_delta:.2f}m',
'flach',
f'↑ +{max_delta:.2f}m'
])
img_b64 = _fig_to_base64(fig_mpl)
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(text='Pixel-Elevation-Map (grün = bergab · grau = flach · rot = bergauf)',
font=dict(size=13, color='white')),
paper_bgcolor=bg_color,
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height,
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='elevation_map',
)
return fig
# -----------------------------------------------------------------------------
# PLOT 3: PACE-MAP Farbe zeigt Tempo je Segment (schnell = blau, langsam = rot)
# -----------------------------------------------------------------------------
def create_pixel_pace_map(df, img_width=900, img_height=900,
line_width=3, bg_color='#0d0d0d'):
"""
Sam-Style Pace-Map: Die Route wird pixelweise gezeichnet, wobei die
Farbe anzeigt, wie schnell du in diesem Bereich gelaufen bist.
Blau = schnell (niedriger min/km-Wert), Rot = langsam (hoher min/km-Wert).
Ausreißer (Pausen, GPS-Sprünge) werden automatisch herausgefiltert.
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import LinearSegmentedColormap, Normalize
import plotly.graph_objects as go
DPI = 100
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', height=img_height,
title=dict(text='Keine GPS-Daten', font=dict(color='white')))
return fig
lats = df['lat'].dropna().values
lons = df['lon'].dropna().values
if 'speed_kmh' in df.columns and df['speed_kmh'].notna().sum() > 10:
speed = df['speed_kmh'].ffill().fillna(0).values
pace_per_km = np.where(speed > 0.5, 60.0 / speed, np.nan)
elif 'vel_kmps' in df.columns:
vel = df['vel_kmps'].fillna(0).values
pace_per_km = np.where(vel * 3600 > 0.5, 60.0 / (vel * 3600), np.nan)
else:
pace_per_km = np.full(len(lats), np.nan)
n = min(len(lats), len(lons), len(pace_per_km))
lats, lons, pace = lats[:n], lons[:n], pace_per_km[:n]
valid_pace = pace[(pace >= 2) & (pace <= 15) & ~np.isnan(pace)]
if len(valid_pace) == 0:
valid_pace = np.array([5.0, 8.0])
p5 = np.percentile(valid_pace, 5)
p95 = np.percentile(valid_pace, 95)
xs, ys, _ = _gps_to_pixel(lats, lons, img_width, img_height, padding=50)
xs = xs.astype(int)
ys = ys.astype(int)
cmap_colors = [
(0.0, '#0033cc'),
(0.2, '#0099ff'),
(0.4, '#00cc88'),
(0.6, '#ffcc00'),
(0.8, '#ff6600'),
(1.0, '#cc0000'),
]
cmap = LinearSegmentedColormap.from_list('pace', cmap_colors, N=256)
# ---------------------------------------------------------------
# Exakt img_width × img_height Pixel Canvas
# ---------------------------------------------------------------
fig_mpl = plt.figure(figsize=(img_width / DPI, img_height / DPI), dpi=DPI)
fig_mpl.patch.set_facecolor(bg_color)
ax = fig_mpl.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
ax.set_xlim(0, img_width)
ax.set_ylim(img_height, 0)
ax.axis('off')
for i in range(n - 1):
p = pace[i]
if np.isnan(p) or p < 2 or p > 15:
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color='#222222', linewidth=line_width * 0.5,
solid_capstyle='round')
continue
norm_val = np.clip((p - p5) / (p95 - p5), 0, 1)
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=cmap(norm_val), linewidth=line_width,
solid_capstyle='round', solid_joinstyle='round')
# Start/Ziel
ax.plot(xs[0], ys[0], 'o', color='#b9fc62', markersize=10, zorder=5) # Starting point !
ax.plot(xs[-1], ys[-1], 's', color='#fca062', markersize=9, zorder=5) # Finishing point !
# Colorbar als Inset-Axes (verändert Figure-Größe nicht)
#cbar_ax = fig_mpl.add_axes([0.88, 0.10, 0.025, 0.70]) # rechts
cbar_ax = fig_mpl.add_axes([0.15, 0.04, 0.70, 0.020]) # unten
#cmap_reversed = cmap.reversed()
sm = cm.ScalarMappable(cmap=cmap, norm=Normalize(vmin=p5, vmax=p95))
sm.set_array([])
cbar = fig_mpl.colorbar(sm, cax=cbar_ax, orientation='horizontal')
cbar_ax.invert_xaxis() # ← Colorbar-Balken spiegeln: blau rechts, rot links
cbar.set_label('Pace (min/km)', color='white', fontsize=8)
#cbar.ax.yaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.ax.xaxis.set_tick_params(color='white', labelcolor='white', labelsize=7)
cbar.set_ticks([p5, (p5 + p95) / 2, p95])
cbar.set_ticklabels([f'{p5:.1f}', f'{(p5+p95)/2:.1f}', f'{p95:.1f}'])
#cbar.set_ticklabels([f'{p95:.1f}', f'{(p5+p95)/2:.1f}', f'{p5:.1f}']) # reversed the display order !!!
# Titel
valid_mean = valid_pace.mean()
min_v = int(valid_mean)
sec_v = int((valid_mean - min_v) * 60)
fig_mpl.text(0.5, 0.97,
f'Pace-Map · Ø {min_v}:{sec_v:02d} min/km | '
f'schnell: {p5:.1f} langsam: {p95:.1f} min/km',
color='white', fontsize=10, ha='center', va='top',
transform=fig_mpl.transFigure)
img_b64 = _fig_to_base64(fig_mpl)
fig = go.Figure()
fig.add_layout_image(dict(
source=img_b64, xref='paper', yref='paper',
x=0, y=1, sizex=1, sizey=1,
xanchor='left', yanchor='top', layer='below'
))
fig.update_layout(
title=dict(text='Pixel-Pace-Map (blau = schnell · rot = langsam)',
font=dict(size=13, color='white')),
paper_bgcolor=bg_color,
plot_bgcolor=bg_color,
font=dict(color='white'),
margin=dict(l=0, r=0, t=30, b=0),
height=img_height + 30, # Vorher jeweils: height=img_height (900)
xaxis=dict(visible=False, range=[0, 1]),
yaxis=dict(visible=False, range=[0, 1], scaleanchor='x'),
uirevision='pace_map',
)
return fig
def create_deviation_plot(df): #Distanz-Zeit-Diagramm
# Compute mean velocity in km/s
vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1]
@@ -1464,6 +2092,85 @@ app.layout = html.Div([
# Rest deines Layouts
html.Div(id='info-banner'),
dcc.Graph(id='fig-map'),
# START !!!!!!!!!!!!!!!
# Pixel-Map Überschrift
html.Hr(style={'borderColor': '#333', 'margin': '10px 20px'}),
html.Div([
html.H3("Pixel-Maps", style={
'color': '#aaaaaa', 'margin': '10px 0 0 0', 'fontSize': '16px'
}),
html.P(
"Plot 1) Heatmap: Einzellauf oder alle Läufe der Region umschalten.\n"
"Plot 2) & 3) Elevation- & Pace-Map: des aktuell gewählten Laufs. ",
style={'color': '#666', 'margin': '2px 0 8px 0', 'fontSize': '12px'}
),
], style={'padding': '0 20px'}),
# Drei Plots nebeneinander Heatmap links, Elevation Mitte, Pace rechts
html.Div([
# --- Heatmap-Spalte (mit Toggle darunter) ---
html.Div([
dcc.Graph(id='fig-pixel-heatmap'),
# Toggle: Einzellauf ↔ Region
html.Div([
html.Span("Einzellauf", style={
'color': '#aaa', 'fontSize': '12px',
'marginRight': '8px', 'verticalAlign': 'middle'
}),
# dcc.Checklist als Toggle-Switch (ein Checkbox = ON/OFF)
dcc.Checklist(
id='heatmap-mode-toggle',
options=[{'label': ' Alle Läufe (Region)', 'value': 'city'}],
#value=[], # Standard: leer = Einzellauf
value=['city'], # Standard: Region aktiv - Jezt ist immer der Harken gesetzt!
inputStyle={
'cursor': 'pointer',
'width': '36px', 'height': '18px',
'accentColor': '#fc4e00', # Strava-Orange
'verticalAlign': 'middle',
'marginRight': '6px',
},
labelStyle={
'color': '#cccccc', 'fontSize': '12px',
'verticalAlign': 'middle', 'cursor': 'pointer'
},
),
# Infotext: aktuell erkannter Stadtcode
html.Span(id='heatmap-city-info', style={
'color': '#fc4e00', 'fontSize': '11px',
'marginLeft': '12px', 'verticalAlign': 'middle'
}),
], style={
'display': 'flex', 'alignItems': 'center',
'padding': '8px 12px',
'backgroundColor': '#1a1a1a',
'borderRadius': '0 0 6px 6px',
'borderTop': '1px solid #333',
}),
], style={'flex': '1', 'minWidth': '300px'}),
# --- Elevation-Map ---
html.Div([
dcc.Graph(id='fig-pixel-elevation'),
], style={'flex': '1', 'minWidth': '300px'}),
# --- Pace-Map ---
html.Div([
dcc.Graph(id='fig-pixel-pace'),
], style={'flex': '1', 'minWidth': '300px'}),
], style={
'display': 'flex', 'flexWrap': 'wrap',
'gap': '5px', 'padding': '0 20px',
'backgroundColor': '#111111'
}),
html.Hr(style={'borderColor': '#333', 'margin': '10px 20px'}),
# ENDE !!!!!!!!!!!!!!!!
dcc.Graph(id='fig-elevation'),
dcc.Graph(id='fig_deviation'),
dcc.Graph(id='fig_speed'),
@@ -1493,20 +2200,126 @@ def load_data(selected_file): # Dateipfad der ausgewählten Da
Output('fig_speed', 'figure'),
Output('fig_hr', 'figure'),
Output('fig_pace_bars', 'figure'),
# NEU: drei Pixel-Maps
#Output('fig-pixel-heatmap', 'figure'),
Output('fig-pixel-elevation', 'figure'), # ← aus pixel_map_extension.py
Output('fig-pixel-pace', 'figure'), # ← aus pixel_map_extension.py
Input('stored-df', 'data'),
prevent_initial_call=True
)
def update_all_plots(json_data):
df = pd.read_json(io.StringIO(json_data), orient='split')
info = create_info_banner(df)
fig_map = create_map_plot(df)
fig_elev = create_elevation_plot(df)
fig_dev = create_deviation_plot(df)
fig_speed = create_speed_plot(df)
fig_hr = create_heart_rate_plot(df)
fig_pace = create_pace_bars_plot(df)
return info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace
# Bestehende Plots (unverändert)
info = create_info_banner(df)
fig_map = create_map_plot(df)
fig_elev = create_elevation_plot(df)
fig_dev = create_deviation_plot(df)
fig_speed = create_speed_plot(df)
fig_hr = create_heart_rate_plot(df)
fig_pace = create_pace_bars_plot(df)
# Pixel-Maps (Elevation + Pace bleiben hier; Heatmap hat eigenen Callback)
fig_pixel_elevation = create_pixel_elevation_map(df, img_width=800, img_height=500)
fig_pixel_pace = create_pixel_pace_map(df, img_width=800, img_height=500)
return (info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace,
fig_pixel_elevation, fig_pixel_pace)
@app.callback(
Output('fig-pixel-heatmap', 'figure'),
Output('heatmap-city-info', 'children'),
Input('file-dropdown', 'value'),
Input('heatmap-mode-toggle', 'value'),
prevent_initial_call=True
)
def update_pixel_heatmap(selected_file, toggle_value):
"""
Rendert die Pixel-Heatmap abhängig vom Toggle-Switch.
toggle_value == [] → Einzellauf (nur die gewählte Datei)
toggle_value == ['city'] → Region (alle Läufe mit gleichem Stadtcode)
"""
import os
if not selected_file or selected_file in ['NO_FILES', 'NO_FOLDER', 'ERROR']:
fig = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'),
title=dict(text='Keine Datei ausgewählt'))
return fig, ''
city_code = extract_city_code(selected_file)
city_info_text = f'Erkannte Region: {city_code}' if city_code else 'Kein Stadtcode erkannt'
# Immer alle Stadt-Läufe laden (für korrekten Count-Kontext)
all_options = list_files()
if city_code:
city_dfs, _ = load_runs_for_city(city_code, all_options)
else:
city_dfs = []
# Fallback falls keine Stadt-Läufe gefunden
if not city_dfs:
try:
city_dfs = [process_selected_file(selected_file)]
except Exception:
city_dfs = []
if not toggle_value or 'city' not in toggle_value:
# --- Einzellauf-Modus: nur aktuellen Lauf ANZEIGEN,
# aber count_grid aus allen Läufen berechnen ---
try:
df_single = process_selected_file(selected_file)
except Exception:
df_single = pd.DataFrame()
fig = create_pixel_heatmap(
dataframes=city_dfs, # count_grid aus allen Läufen
highlight_df=df_single, # nur dieser Lauf wird gezeichnet
mode='single',
city_code=city_code,
n_city_runs=len(city_dfs),
img_width=800, img_height=500, # ← NEU
)
city_info_text = f'Region {city_code} · Einzellauf · Count aus {len(city_dfs)} Läufen'
else:
# --- Region-Modus: alle Läufe anzeigen ---
fig = create_pixel_heatmap(
dataframes=city_dfs,
mode='city',
city_code=city_code,
n_city_runs=len(city_dfs),
img_width=800, img_height=500, # ← NEU
)
city_info_text = f'Region {city_code} · {len(city_dfs)} Läufe geladen'
return fig, city_info_text
# Lade alle Läufe dieser Stadt
all_options = list_files()
city_dfs, city_paths = load_runs_for_city(city_code, all_options)
if not city_dfs:
# Fallback: nur aktuelle Datei
try:
df_single = process_selected_file(selected_file)
city_dfs = [df_single]
except Exception:
city_dfs = []
fig = create_pixel_heatmap(
dataframes=city_dfs,
mode='city',
city_code=city_code,
n_city_runs=len(city_dfs),
)
city_info_text = f'Region {city_code} · {len(city_dfs)} Läufe geladen'
return fig, city_info_text
# Callback 3: Export SVG
@app.callback(