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:
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.8 MiB |
@@ -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: 2–6 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,11 +2200,17 @@ 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')
|
||||
|
||||
# Bestehende Plots (unverändert)
|
||||
info = create_info_banner(df)
|
||||
fig_map = create_map_plot(df)
|
||||
fig_elev = create_elevation_plot(df)
|
||||
@@ -1506,7 +2219,107 @@ def update_all_plots(json_data):
|
||||
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
|
||||
# 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(
|
||||
|
||||
Reference in New Issue
Block a user