Major updates: analyse .fit and .gpx files now with jogging_dashboard_***_app.py. Additionally, created a WEB and a GUI version of the tool.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,5 +2,5 @@ gpx_files/*
|
|||||||
!gpx_files/.keep
|
!gpx_files/.keep
|
||||||
fit_files/*
|
fit_files/*
|
||||||
!fit_files/.keep
|
!fit_files/.keep
|
||||||
fit_app_build-exe-gui.py
|
__pycache__/*
|
||||||
fit_app_build_EXE_gui_file.txt
|
!__pycache__/.keep
|
||||||
|
|||||||
0
__pycache__/.keep
Normal file
0
__pycache__/.keep
Normal file
575
gpx_app.py
575
gpx_app.py
@@ -1,575 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Created on Thu Jul 30th 2025
|
|
||||||
|
|
||||||
@author: Marcel Weschke
|
|
||||||
@email: marcel.weschke@directbox.de
|
|
||||||
"""
|
|
||||||
# %% Load libraries
|
|
||||||
import os
|
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import datetime
|
|
||||||
from math import radians, sin, cos, sqrt, asin
|
|
||||||
|
|
||||||
import dash
|
|
||||||
from dash import dcc, html, Input, Output, Dash
|
|
||||||
import dash_bootstrap_components as dbc
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
import plotly.express as px
|
|
||||||
import plotly.graph_objects as go
|
|
||||||
from scipy.interpolate import interp1d
|
|
||||||
import gpxpy
|
|
||||||
|
|
||||||
|
|
||||||
# === Helper Functions ===
|
|
||||||
def list_gpx_files():
|
|
||||||
folder = './gpx_files'
|
|
||||||
#return [{'label': f, 'value': os.path.join(folder, f)} for f in os.listdir(folder) if f.endswith('.gpx')]
|
|
||||||
files = [f for f in os.listdir(folder) if f.endswith('.gpx')]
|
|
||||||
|
|
||||||
# Extract date from the start of the filename and sort descending
|
|
||||||
def extract_date(filename):
|
|
||||||
try:
|
|
||||||
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
|
|
||||||
except ValueError:
|
|
||||||
return datetime.datetime.min # Put files without a valid date at the end
|
|
||||||
|
|
||||||
files.sort(key=extract_date, reverse=True)
|
|
||||||
|
|
||||||
return [{'label': f, 'value': os.path.join(folder, f)} for f in files]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def haversine(lon1, lat1, lon2, lat2):
|
|
||||||
R = 6371
|
|
||||||
dlon = radians(lon2 - lon1)
|
|
||||||
dlat = radians(lat2 - lat1)
|
|
||||||
a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
|
|
||||||
return 2 * R * asin(sqrt(a))
|
|
||||||
|
|
||||||
|
|
||||||
def process_gpx(file_path):
|
|
||||||
with open(file_path, 'r') as gpx_file:
|
|
||||||
gpx = gpxpy.parse(gpx_file)
|
|
||||||
|
|
||||||
points = gpx.tracks[0].segments[0].points
|
|
||||||
df = pd.DataFrame([{
|
|
||||||
'lat': p.latitude,
|
|
||||||
'lon': p.longitude,
|
|
||||||
'elev': p.elevation,
|
|
||||||
'time': p.time
|
|
||||||
} for p in points])
|
|
||||||
|
|
||||||
# Basic cleanup
|
|
||||||
df['time'] = pd.to_datetime(df['time'])
|
|
||||||
df['time_loc'] = df['time'].dt.tz_localize(None)
|
|
||||||
df['time_diff'] = df['time'] - df['time'][0]
|
|
||||||
df['time_diff_sec'] = df['time_diff'].dt.total_seconds()
|
|
||||||
df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0])
|
|
||||||
# Cumulative distance (km)
|
|
||||||
distances = [0]
|
|
||||||
for i in range(1, len(df)):
|
|
||||||
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat'])
|
|
||||||
distances.append(distances[-1] + d)
|
|
||||||
df['cum_dist_km'] = distances
|
|
||||||
# Elevation and elevation change
|
|
||||||
df['elev'] = df['elev'].bfill()
|
|
||||||
df['delta_elev'] = df['elev'].diff().fillna(0)
|
|
||||||
df['rel_elev'] = df['elev'] - df['elev'].iloc[0]
|
|
||||||
# Velocity (used in pace and speed)
|
|
||||||
df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec'])
|
|
||||||
# Speed calculation (km/h) via distance and time diffs
|
|
||||||
df['delta_t'] = df['time'].diff().dt.total_seconds()
|
|
||||||
df['delta_d'] = df['cum_dist_km'].diff()
|
|
||||||
df['speed_kmh'] = (df['delta_d'] / df['delta_t']) * 3600
|
|
||||||
df['speed_kmh'] = df['speed_kmh'].replace([np.inf, -np.inf], np.nan)
|
|
||||||
# Smoothed speed (Gaussian rolling)
|
|
||||||
df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
|
|
||||||
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# INFO BANNER
|
|
||||||
# =============================================================================
|
|
||||||
def create_info_banner(df):
|
|
||||||
# Total distance in km
|
|
||||||
total_distance_km = df['cum_dist_km'].iloc[-1]
|
|
||||||
|
|
||||||
# Total time as timedelta
|
|
||||||
total_seconds = df['time_diff_sec'].iloc[-1]
|
|
||||||
hours, remainder = divmod(int(total_seconds), 3600)
|
|
||||||
minutes, seconds = divmod(remainder, 60)
|
|
||||||
formatted_total_time = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
||||||
|
|
||||||
# Average pace (min/km)
|
|
||||||
if total_distance_km > 0:
|
|
||||||
pace_sec_per_km = total_seconds / total_distance_km
|
|
||||||
pace_min = int(pace_sec_per_km // 60)
|
|
||||||
pace_sec = int(pace_sec_per_km % 60)
|
|
||||||
formatted_pace = f"{pace_min}:{pace_sec:02d} min/km"
|
|
||||||
else:
|
|
||||||
formatted_pace = "N/A"
|
|
||||||
|
|
||||||
# Build the info banner layout
|
|
||||||
info_banner = html.Div([
|
|
||||||
html.Div([
|
|
||||||
html.H4("Total Distance", style={'margin-bottom': '5px'}),
|
|
||||||
html.H2(f"{total_distance_km:.2f} km")
|
|
||||||
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
|
|
||||||
|
|
||||||
html.Div([
|
|
||||||
html.H4("Total Time", style={'margin-bottom': '5px'}),
|
|
||||||
html.H2(formatted_total_time)
|
|
||||||
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
|
|
||||||
|
|
||||||
html.Div([
|
|
||||||
html.H4("Average Pace", style={'margin-bottom': '5px'}),
|
|
||||||
html.H2(formatted_pace)
|
|
||||||
], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}),
|
|
||||||
], style={
|
|
||||||
'display': 'flex',
|
|
||||||
'justifyContent': 'space-around',
|
|
||||||
'backgroundColor': '#1e1e1e',
|
|
||||||
'color': 'white',
|
|
||||||
'padding': '20px',
|
|
||||||
'marginBottom': '5px',
|
|
||||||
'borderRadius': '10px',
|
|
||||||
'width': '100%',
|
|
||||||
#'maxWidth': '1200px',
|
|
||||||
'margin': 'auto'
|
|
||||||
})
|
|
||||||
|
|
||||||
return info_banner
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# START OF THE PLOTS
|
|
||||||
# =============================================================================
|
|
||||||
def create_map_plot(df):
|
|
||||||
# fig = px.line_map(
|
|
||||||
# df,
|
|
||||||
# lat='lat',
|
|
||||||
# lon='lon',
|
|
||||||
# hover_name='time',
|
|
||||||
# hover_data={
|
|
||||||
# 'cum_dist_km': ':.2f',
|
|
||||||
# 'duration_hms': True,
|
|
||||||
# 'lat': False,
|
|
||||||
# 'lon': False,
|
|
||||||
# 'time': False
|
|
||||||
# },
|
|
||||||
# labels={
|
|
||||||
# 'cum_dist_km': 'Distance (km) ',
|
|
||||||
# 'duration_hms': 'Elapsed Time '
|
|
||||||
# },
|
|
||||||
# zoom=13,
|
|
||||||
# height=800
|
|
||||||
# )
|
|
||||||
fig = px.line_map(
|
|
||||||
df,
|
|
||||||
lat='lat',
|
|
||||||
lon='lon',
|
|
||||||
zoom=13,
|
|
||||||
height=800
|
|
||||||
)
|
|
||||||
|
|
||||||
fig.update_traces(
|
|
||||||
hovertemplate=(
|
|
||||||
#"Time: %{customdata[0]}<br>" +
|
|
||||||
"Distance (km): %{customdata[1]:.2f}<br>" +
|
|
||||||
"Elapsed Time: %{customdata[2]}<extra></extra>"
|
|
||||||
),
|
|
||||||
customdata=df[['time', 'cum_dist_km', 'duration_hms']]
|
|
||||||
)
|
|
||||||
# Define map style and the line ontop
|
|
||||||
fig.update_layout(map_style="open-street-map")
|
|
||||||
# The built-in plotly.js styles are: carto-darkmatter, carto-positron, open-street-map, stamen-terrain, stamen-toner, stamen-watercolor, white-bg
|
|
||||||
# The built-in Mapbox styles are: basic, streets, outdoors, light, dark, satellite, satellite-streets
|
|
||||||
fig.update_traces(line=dict(color="#f54269", width=3))
|
|
||||||
|
|
||||||
# Start / Stop marker
|
|
||||||
start = df.iloc[0]
|
|
||||||
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'
|
|
||||||
))
|
|
||||||
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'
|
|
||||||
))
|
|
||||||
fig.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'))
|
|
||||||
fig.update_layout(
|
|
||||||
legend=dict(
|
|
||||||
orientation='h', # horizontal layout
|
|
||||||
yanchor='top',
|
|
||||||
y=-0.01, # move legend below the map
|
|
||||||
xanchor='center',
|
|
||||||
x=0.5,
|
|
||||||
font=dict(color='white')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return fig
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
######################
|
|
||||||
# NEUE VERSION:
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
|
||||||
# Expected cumulative distance assuming constant mean velocity
|
|
||||||
df['cum_dist_km_qmean'] = df['time_diff_sec'] * vel_kmps_mean
|
|
||||||
# Deviation from mean velocity distance
|
|
||||||
df['del_dist_km_qmean'] = df['cum_dist_km'] - df['cum_dist_km_qmean']
|
|
||||||
# Plot the deviation
|
|
||||||
fig = px.line(
|
|
||||||
df,
|
|
||||||
x='time_loc',
|
|
||||||
y='del_dist_km_qmean',
|
|
||||||
labels={
|
|
||||||
'time_loc': 'Zeit',
|
|
||||||
'del_dist_km_qmean': 'Δ Strecke (km)'
|
|
||||||
},
|
|
||||||
template='plotly_dark',
|
|
||||||
)
|
|
||||||
fig.update_layout(
|
|
||||||
title=dict(text='Abweichung von integriertem Durchschnittstempo', font=dict(size=16)),
|
|
||||||
yaxis_title='Abweichung (km)',
|
|
||||||
xaxis_title='Zeit',
|
|
||||||
height=400,
|
|
||||||
paper_bgcolor='#1e1e1e',
|
|
||||||
plot_bgcolor='#111111',
|
|
||||||
font=dict(color='white', size=14),
|
|
||||||
margin=dict(l=40, r=40, t=50, b=40)
|
|
||||||
)
|
|
||||||
# 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'
|
|
||||||
)
|
|
||||||
return fig
|
|
||||||
|
|
||||||
|
|
||||||
def create_speed_plot(df):
|
|
||||||
mask = df['speed_kmh_smooth'].isna()
|
|
||||||
mean_speed_kmh = df['speed_kmh'].mean()
|
|
||||||
fig = go.Figure()
|
|
||||||
fig.add_trace(go.Scatter(
|
|
||||||
x=df['time'][~mask],
|
|
||||||
y=df['speed_kmh_smooth'][~mask],
|
|
||||||
mode='lines',
|
|
||||||
name='Geglättete Geschwindigkeit',
|
|
||||||
line=dict(color='royalblue')
|
|
||||||
))
|
|
||||||
fig.update_layout(
|
|
||||||
title=dict(text=f'Tempo über die Zeit (geglättet) - Durchschnittstempo: {mean_speed_kmh:.2f} km/h', font=dict(size=16)),
|
|
||||||
xaxis=dict(title='Zeit', tickformat='%H:%M', type='date'),
|
|
||||||
yaxis=dict(title='Geschwindigkeit (km/h)', rangemode='tozero'),
|
|
||||||
template='plotly_dark',
|
|
||||||
paper_bgcolor='#1e1e1e',
|
|
||||||
plot_bgcolor='#111111',
|
|
||||||
font=dict(color='white'),
|
|
||||||
margin=dict(l=40, r=40, t=40, b=40)
|
|
||||||
)
|
|
||||||
# Add horizontal reference line at y=mean_speed_kmh
|
|
||||||
fig.add_shape(
|
|
||||||
type='line',
|
|
||||||
x0=df['time_loc'].iloc[0],
|
|
||||||
x1=df['time_loc'].iloc[-1],
|
|
||||||
y0=mean_speed_kmh,
|
|
||||||
y1=mean_speed_kmh,
|
|
||||||
line=dict(color='gray', width=1, dash='dash'),
|
|
||||||
name='Durchschnittstempo'
|
|
||||||
)
|
|
||||||
return fig
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_pace_bars_plot(df):
|
|
||||||
# Ensure time column is datetime
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(df['time']):
|
|
||||||
df['time'] = pd.to_datetime(df['time'], errors='coerce')
|
|
||||||
|
|
||||||
# Assign km segments
|
|
||||||
df['km'] = df['cum_dist_km'].astype(int)
|
|
||||||
|
|
||||||
# Time in seconds from start
|
|
||||||
df['time_sec'] = (df['time'] - df['time'].iloc[0]).dt.total_seconds()
|
|
||||||
|
|
||||||
# Step 3: Compute pace manually per km group
|
|
||||||
df['km_start'] = np.nan
|
|
||||||
df['segment_len'] = np.nan
|
|
||||||
df['pace_min_per_km'] = np.nan
|
|
||||||
|
|
||||||
for km_val, group in df.groupby('km'):
|
|
||||||
dist_start = group['cum_dist_km'].iloc[0]
|
|
||||||
dist_end = group['cum_dist_km'].iloc[-1]
|
|
||||||
segment_len = dist_end - dist_start
|
|
||||||
|
|
||||||
time_start = group['time_sec'].iloc[0]
|
|
||||||
time_end = group['time_sec'].iloc[-1]
|
|
||||||
elapsed_time_sec = time_end - time_start
|
|
||||||
|
|
||||||
if segment_len > 0:
|
|
||||||
pace_min_per_km = (elapsed_time_sec / 60) / segment_len
|
|
||||||
else:
|
|
||||||
pace_min_per_km = np.nan
|
|
||||||
|
|
||||||
df.loc[group.index, 'km_start'] = km_val
|
|
||||||
df.loc[group.index, 'segment_len'] = segment_len
|
|
||||||
df.loc[group.index, 'pace_min_per_km'] = pace_min_per_km
|
|
||||||
|
|
||||||
# Clean types
|
|
||||||
df['km_start'] = df['km_start'].astype(int)
|
|
||||||
df['segment_len'] = df['segment_len'].astype(float)
|
|
||||||
df['pace_min_per_km'] = pd.to_numeric(df['pace_min_per_km'], errors='coerce')
|
|
||||||
|
|
||||||
# Step 4: Create Plotly bar chart
|
|
||||||
fig = go.Figure()
|
|
||||||
fig.add_trace(go.Bar(
|
|
||||||
x=df['km_start'],
|
|
||||||
y=df['pace_min_per_km'],
|
|
||||||
width=df['segment_len'],
|
|
||||||
text=[f"{v:.1f} min/km" if pd.notnull(v) else "" for v in df['pace_min_per_km']],
|
|
||||||
#textposition='outside',
|
|
||||||
textposition='inside',
|
|
||||||
marker_color='#125595',
|
|
||||||
name='Pace pro km',
|
|
||||||
offset=0
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# #########
|
|
||||||
# Add horizontal reference line - X-Werte für gesamte Breite
|
|
||||||
# Calculate average pace
|
|
||||||
total_distance_km = df['cum_dist_km'].iloc[-1]
|
|
||||||
total_seconds = df['time_diff_sec'].iloc[-1]
|
|
||||||
|
|
||||||
# Average pace (min/km) - KORRIGIERT
|
|
||||||
if total_distance_km > 0:
|
|
||||||
pace_sec_per_km = total_seconds / total_distance_km
|
|
||||||
pace_min_per_km = pace_sec_per_km / 60 # Konvertiere zu Minuten pro km
|
|
||||||
else:
|
|
||||||
pace_min_per_km = 0
|
|
||||||
|
|
||||||
fig.add_shape(
|
|
||||||
type='line',
|
|
||||||
x0=0, # Start bei 0
|
|
||||||
x1=total_distance_km, # Ende bei maximaler Distanz
|
|
||||||
y0=pace_min_per_km,
|
|
||||||
y1=pace_min_per_km,
|
|
||||||
line=dict(color='gray', width=1, dash='dash'),
|
|
||||||
)
|
|
||||||
|
|
||||||
## Optional: Text-Annotation für die durchschnittliche Pace
|
|
||||||
#fig.add_annotation(
|
|
||||||
# x=total_distance_km * 0.8, # Position bei 80% der Distanz
|
|
||||||
# y=pace_min_per_km,
|
|
||||||
# text=f"Ø {pace_min_per_km:.1f} min/km",
|
|
||||||
# showarrow=True,
|
|
||||||
# arrowhead=2,
|
|
||||||
# arrowcolor="gray",
|
|
||||||
# bgcolor="rgba(255,0,0,0.1)",
|
|
||||||
# bordercolor="gray",
|
|
||||||
# font=dict(color="white")
|
|
||||||
#)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fig.update_layout(
|
|
||||||
title=dict(text='Tempo (min/km) je Kilometer', font=dict(size=16)),
|
|
||||||
xaxis_title='Distanz (km)',
|
|
||||||
yaxis_title='Minuten pro km',
|
|
||||||
barmode='overlay',
|
|
||||||
bargap=0,
|
|
||||||
bargroupgap=0,
|
|
||||||
xaxis=dict(
|
|
||||||
type='linear',
|
|
||||||
range=[0, df['cum_dist_km'].iloc[-1]],
|
|
||||||
tickmode='linear',
|
|
||||||
dtick=1,
|
|
||||||
showgrid=True
|
|
||||||
),
|
|
||||||
template='plotly_dark',
|
|
||||||
height=400,
|
|
||||||
margin=dict(l=40, r=40, t=30, b=40),
|
|
||||||
plot_bgcolor='#111111',
|
|
||||||
paper_bgcolor='#1e1e1e',
|
|
||||||
font=dict(color='white')
|
|
||||||
)
|
|
||||||
return fig
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# === App Setup ===
|
|
||||||
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE])
|
|
||||||
app.title = "GPX Dashboard"
|
|
||||||
|
|
||||||
app.layout = html.Div([
|
|
||||||
html.H1("Running Dashboard", style={'textAlign': 'center'}),
|
|
||||||
dcc.Store(id='stored-df'),
|
|
||||||
|
|
||||||
html.Div([
|
|
||||||
html.Label("GPX-Datei wählen:", style={'color': 'white'}),
|
|
||||||
dcc.Dropdown(
|
|
||||||
id='gpx-file-dropdown',
|
|
||||||
options=list_gpx_files(),
|
|
||||||
value=list_gpx_files()[0]['value'],
|
|
||||||
clearable=False,
|
|
||||||
style={'width': '300px', 'color': 'black'}
|
|
||||||
)
|
|
||||||
], style={'padding': '20px', 'backgroundColor': '#1e1e1e'}),
|
|
||||||
|
|
||||||
html.Div(id='info-banner'),
|
|
||||||
dcc.Graph(id='fig-map'),
|
|
||||||
dcc.Graph(id='fig-elevation'),
|
|
||||||
dcc.Graph(id='fig_deviation'),
|
|
||||||
dcc.Graph(id='fig_speed'),
|
|
||||||
dcc.Graph(id='fig_pace_bars')
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
# === Callbacks ===
|
|
||||||
# Callback 1: Load GPX File and Store as JSON
|
|
||||||
@app.callback(
|
|
||||||
Output('stored-df', 'data'),
|
|
||||||
Input('gpx-file-dropdown', 'value')
|
|
||||||
)
|
|
||||||
def load_gpx_data(path):
|
|
||||||
df = process_gpx(path)
|
|
||||||
return df.to_json(date_format='iso', orient='split')
|
|
||||||
|
|
||||||
# Callback 2: Update All Plots
|
|
||||||
@app.callback(
|
|
||||||
Output('info-banner', 'children'),
|
|
||||||
Output('fig-map', 'figure'),
|
|
||||||
Output('fig-elevation', 'figure'),
|
|
||||||
Output('fig_deviation', 'figure'),
|
|
||||||
Output('fig_speed', 'figure'),
|
|
||||||
Output('fig_pace_bars', 'figure'),
|
|
||||||
Input('stored-df', 'data')
|
|
||||||
)
|
|
||||||
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_pace = create_pace_bars_plot(df)
|
|
||||||
|
|
||||||
return info, fig_map, fig_elev, fig_dev, fig_speed, fig_pace
|
|
||||||
|
|
||||||
|
|
||||||
# === Run Server ===
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app.run(debug=True, port=8051)
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE:
|
|
||||||
# Zusammenhang zwischen Pace und Geschwindigkeit
|
|
||||||
# - Pace = Minuten pro Kilometer (z. B. 5:40/km)
|
|
||||||
# - Geschwindigkeit = Kilometer pro Stunde (z. B. 10.71 km/h)
|
|
||||||
#
|
|
||||||
@@ -26,69 +26,107 @@ import gpxpy
|
|||||||
from fitparse import FitFile
|
from fitparse import FitFile
|
||||||
|
|
||||||
# === Helper Functions ===
|
# === Helper Functions ===
|
||||||
def list_fit_files():
|
def list_files():
|
||||||
"""
|
"""
|
||||||
Listet alle .fit Files im Verzeichnis auf und sortiert sie nach Datum
|
Listet alle .fit Files aus fit_files/ und .gpx Files aus gpx_files/ auf
|
||||||
|
und sortiert sie nach Datum (neueste zuerst)
|
||||||
"""
|
"""
|
||||||
folder = './fit_files'
|
|
||||||
|
# Definiere Ordner und Dateierweiterungen
|
||||||
|
folders_config = [
|
||||||
|
{'folder': './fit_files', 'extensions': ['.fit'], 'type': 'FIT'},
|
||||||
|
{'folder': './gpx_files', 'extensions': ['.gpx'], 'type': 'GPX'}
|
||||||
|
]
|
||||||
|
|
||||||
|
all_file_options = []
|
||||||
|
|
||||||
|
for config in folders_config:
|
||||||
|
folder = config['folder']
|
||||||
|
extensions = config['extensions']
|
||||||
|
file_type = config['type']
|
||||||
|
|
||||||
# Prüfe ob Ordner existiert
|
# Prüfe ob Ordner existiert
|
||||||
if not os.path.exists(folder):
|
if not os.path.exists(folder):
|
||||||
print(f"Ordner {folder} existiert nicht!")
|
print(f"Ordner {folder} existiert nicht!")
|
||||||
return [{'label': 'Ordner nicht gefunden', 'value': 'NO_FOLDER'}]
|
continue
|
||||||
|
|
||||||
# Hole alle .fit Files
|
# Hole alle Files mit den entsprechenden Erweiterungen
|
||||||
try:
|
try:
|
||||||
all_files = os.listdir(folder)
|
all_files = os.listdir(folder)
|
||||||
files = [f for f in all_files if f.lower().endswith('.fit')]
|
files = [f for f in all_files
|
||||||
|
if any(f.lower().endswith(ext) for ext in extensions)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Fehler beim Lesen des Ordners: {e}")
|
print(f"Fehler beim Lesen des Ordners {folder}: {e}")
|
||||||
return [{'label': 'Fehler beim Lesen', 'value': 'ERROR'}]
|
continue
|
||||||
|
|
||||||
def extract_date(filename):
|
# Erstelle Optionen für diesen Ordner
|
||||||
|
for f in files:
|
||||||
|
file_path = os.path.join(folder, f)
|
||||||
|
|
||||||
|
# Extrahiere Datum für Sortierung
|
||||||
|
file_date = extract_date_from_file(f, file_path)
|
||||||
|
|
||||||
|
# Erstelle Label mit Dateityp-Info
|
||||||
|
try:
|
||||||
|
size_mb = os.path.getsize(file_path) / (1024 * 1024)
|
||||||
|
mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||||
|
#label = f"[{file_type}] {f}"
|
||||||
|
label = f"{f}"
|
||||||
|
# Optional: Erweiterte Info (auskommentiert für sauberere Ansicht)
|
||||||
|
# label = f"[{file_type}] {f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')})"
|
||||||
|
except:
|
||||||
|
#label = f"[{file_type}] {f}"
|
||||||
|
label = f"{f}"
|
||||||
|
|
||||||
|
all_file_options.append({
|
||||||
|
'label': label,
|
||||||
|
'value': file_path,
|
||||||
|
'date': file_date,
|
||||||
|
'type': file_type
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sortiere alle Files nach Datum (neueste zuerst)
|
||||||
|
all_file_options.sort(key=lambda x: x['date'], reverse=True)
|
||||||
|
|
||||||
|
# Entferne 'date' und 'type' aus den finalen Optionen (nur für Sortierung gebraucht)
|
||||||
|
final_options = [{'label': opt['label'], 'value': opt['value']}
|
||||||
|
for opt in all_file_options]
|
||||||
|
|
||||||
|
# Fallback wenn keine Files gefunden
|
||||||
|
if not final_options:
|
||||||
|
return [{'label': 'Keine .fit oder .gpx Dateien gefunden', 'value': 'NO_FILES'}]
|
||||||
|
|
||||||
|
return final_options
|
||||||
|
|
||||||
|
def extract_date_from_file(filename, file_path):
|
||||||
"""Extrahiert Datum aus Filename für Sortierung"""
|
"""Extrahiert Datum aus Filename für Sortierung"""
|
||||||
try:
|
try:
|
||||||
# Versuche verschiedene Datumsformate
|
# Versuche verschiedene Datumsformate im Dateinamen
|
||||||
|
# Format: dd.mm.yyyy
|
||||||
return datetime.datetime.strptime(filename[:10], '%d.%m.%Y')
|
return datetime.datetime.strptime(filename[:10], '%d.%m.%Y')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
try:
|
try:
|
||||||
|
# Format: yyyy-mm-dd
|
||||||
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
|
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
try:
|
try:
|
||||||
# Versuche auch andere Formate
|
# Format: yyyymmdd
|
||||||
return datetime.datetime.strptime(filename[:8], '%Y%m%d')
|
return datetime.datetime.strptime(filename[:8], '%Y%m%d')
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
# Format: yyyy_mm_dd
|
||||||
|
return datetime.datetime.strptime(filename[:10], '%Y_%m_%d')
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
# Format: dd-mm-yyyy
|
||||||
|
return datetime.datetime.strptime(filename[:10], '%d-%m-%Y')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum
|
# Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum
|
||||||
try:
|
try:
|
||||||
file_path = os.path.join(folder, filename)
|
|
||||||
return datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
|
return datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||||
except:
|
except:
|
||||||
return datetime.datetime.min
|
return datetime.datetime.min
|
||||||
|
|
||||||
# Sortiere Files nach Datum (neueste zuerst)
|
|
||||||
files.sort(key=extract_date, reverse=True)
|
|
||||||
|
|
||||||
# Erstelle Dropdown-Optionen
|
|
||||||
if files:
|
|
||||||
options = []
|
|
||||||
for f in files:
|
|
||||||
file_path = os.path.join(folder, f)
|
|
||||||
# Zeige auch Dateigröße und Änderungsdatum an
|
|
||||||
try:
|
|
||||||
size_mb = os.path.getsize(file_path) / (1024 * 1024)
|
|
||||||
mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
|
|
||||||
label = f"{f}"
|
|
||||||
#label = f"{f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')}\n)" # For debugging purpose
|
|
||||||
except:
|
|
||||||
label = f
|
|
||||||
|
|
||||||
options.append({
|
|
||||||
'label': label,
|
|
||||||
'value': file_path
|
|
||||||
})
|
|
||||||
return options
|
|
||||||
else:
|
|
||||||
return [{'label': 'Keine .fit Dateien gefunden', 'value': 'NO_FILE'}]
|
|
||||||
|
|
||||||
def haversine(lon1, lat1, lon2, lat2):
|
def haversine(lon1, lat1, lon2, lat2):
|
||||||
"""
|
"""
|
||||||
@@ -100,6 +138,10 @@ def haversine(lon1, lat1, lon2, lat2):
|
|||||||
a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
|
a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
|
||||||
return 2 * R * asin(sqrt(a))
|
return 2 * R * asin(sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
########
|
||||||
|
# FIT
|
||||||
|
########
|
||||||
def process_fit(file_path):
|
def process_fit(file_path):
|
||||||
"""
|
"""
|
||||||
Verarbeitet eine FIT-Datei und erstellt einen DataFrame
|
Verarbeitet eine FIT-Datei und erstellt einen DataFrame
|
||||||
@@ -222,10 +264,6 @@ def process_fit(file_path):
|
|||||||
df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
|
df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Heart rate handling (NEU!)
|
# Heart rate handling (NEU!)
|
||||||
# ##############
|
# ##############
|
||||||
# UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben:
|
# UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben:
|
||||||
@@ -266,6 +304,112 @@ def process_fit(file_path):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
########
|
||||||
|
# GPX
|
||||||
|
########
|
||||||
|
def process_gpx(file_path):
|
||||||
|
"""
|
||||||
|
Verarbeitet GPX-Dateien und gibt DataFrame im gleichen Format wie process_fit() zurück
|
||||||
|
"""
|
||||||
|
import gpxpy
|
||||||
|
import gpxpy.gpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as gpx_file:
|
||||||
|
gpx = gpxpy.parse(gpx_file)
|
||||||
|
|
||||||
|
print(f"Verarbeite GPX-Datei: {file_path}")
|
||||||
|
|
||||||
|
# Sammle GPS-Punkte aus allen Tracks/Segments
|
||||||
|
points_data = []
|
||||||
|
for track in gpx.tracks:
|
||||||
|
for segment in track.segments:
|
||||||
|
for point in segment.points:
|
||||||
|
points_data.append({
|
||||||
|
'time': point.time,
|
||||||
|
'lat': point.latitude,
|
||||||
|
'lon': point.longitude,
|
||||||
|
'elev': point.elevation if point.elevation else 0,
|
||||||
|
'heart_rate': None # GPX hat normalerweise keine HR-Daten
|
||||||
|
})
|
||||||
|
|
||||||
|
if not points_data:
|
||||||
|
print("Keine GPS-Daten in GPX-Datei gefunden")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Erstelle DataFrame
|
||||||
|
df = pd.DataFrame(points_data)
|
||||||
|
print(f"GPX DataFrame erstellt mit {len(df)} Zeilen")
|
||||||
|
|
||||||
|
# Sortiere nach Zeit
|
||||||
|
df = df.sort_values('time').reset_index(drop=True)
|
||||||
|
|
||||||
|
# Zeit-Verarbeitung (wie in deiner FIT-Funktion)
|
||||||
|
df['time'] = pd.to_datetime(df['time'])
|
||||||
|
df['time_loc'] = df['time'].dt.tz_localize(None)
|
||||||
|
df['time_diff'] = df['time'] - df['time'].iloc[0]
|
||||||
|
df['time_diff_sec'] = df['time_diff'].dt.total_seconds()
|
||||||
|
df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0])
|
||||||
|
|
||||||
|
# Kumulative Distanz (gleiche Logik wie in deiner FIT-Funktion)
|
||||||
|
distances = [0]
|
||||||
|
for i in range(1, len(df)):
|
||||||
|
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat'])
|
||||||
|
distances.append(distances[-1] + d)
|
||||||
|
df['cum_dist_km'] = distances
|
||||||
|
|
||||||
|
# Elevation (gleiche Logik wie in deiner FIT-Funktion)
|
||||||
|
df['elev'] = df['elev'].bfill()
|
||||||
|
df['delta_elev'] = df['elev'].diff().fillna(0)
|
||||||
|
df['rel_elev'] = df['elev'] - df['elev'].iloc[0]
|
||||||
|
|
||||||
|
# Speed-Berechnung (gleiche Logik wie dein Fallback)
|
||||||
|
df['delta_t'] = df['time'].diff().dt.total_seconds()
|
||||||
|
df['delta_d'] = df['cum_dist_km'].diff()
|
||||||
|
df['speed_kmh'] = (df['delta_d'] / df['delta_t']) * 3600
|
||||||
|
df['speed_kmh'] = df['speed_kmh'].replace([np.inf, -np.inf], np.nan)
|
||||||
|
|
||||||
|
# Velocity (wie in deiner FIT-Funktion)
|
||||||
|
df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec'])
|
||||||
|
|
||||||
|
# Smoothed speed (wie in deiner FIT-Funktion)
|
||||||
|
df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
|
||||||
|
|
||||||
|
# Heart rate (GPX hat keine, also NaN wie dein Fallback)
|
||||||
|
df['heart_rate'] = np.nan
|
||||||
|
df['hr_smooth'] = np.nan
|
||||||
|
|
||||||
|
print(f"Verarbeitete GPX-Datei: {len(df)} Datenpunkte")
|
||||||
|
print(f"Distanz: {df['cum_dist_km'].iloc[-1]:.2f} km")
|
||||||
|
print(f"Dauer: {df['duration_hms'].iloc[-1]}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fehler beim Verarbeiten der GPX-Datei {file_path}: {str(e)}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# NEUE UNIVERSELLE WRAPPER-FUNKTION (nutzt deine bestehenden Funktionen!)
|
||||||
|
def process_selected_file(file_path):
|
||||||
|
"""
|
||||||
|
Universelle Funktion die automatisch FIT oder GPX verarbeitet
|
||||||
|
"""
|
||||||
|
if not file_path or file_path in ['NO_FILES', 'NO_FOLDER', 'ERROR']:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Bestimme Dateityp
|
||||||
|
if file_path.lower().endswith('.fit'):
|
||||||
|
# NUTZT DEINE ORIGINALE FUNKTION!
|
||||||
|
return process_fit(file_path)
|
||||||
|
elif file_path.lower().endswith('.gpx'):
|
||||||
|
# Nutzt die neue GPX-Funktion
|
||||||
|
return process_gpx(file_path)
|
||||||
|
else:
|
||||||
|
print(f"Unbekannter Dateityp: {file_path}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def safe_add_column_to_dataframe(df, column_name, values):
|
def safe_add_column_to_dataframe(df, column_name, values):
|
||||||
"""
|
"""
|
||||||
@@ -553,7 +697,8 @@ def create_elevation_plot(df, smooth_points=500):
|
|||||||
plot_bgcolor='#111111',
|
plot_bgcolor='#111111',
|
||||||
font=dict(color='white'),
|
font=dict(color='white'),
|
||||||
margin=dict(l=40, r=40, t=50, b=40),
|
margin=dict(l=40, r=40, t=50, b=40),
|
||||||
height=400
|
height=400,
|
||||||
|
uirevision='constant', # Avoiding not needed Re-renderings
|
||||||
)
|
)
|
||||||
|
|
||||||
return fig
|
return fig
|
||||||
@@ -672,7 +817,8 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
|
|||||||
paper_bgcolor='#1e1e1e',
|
paper_bgcolor='#1e1e1e',
|
||||||
plot_bgcolor='#111111',
|
plot_bgcolor='#111111',
|
||||||
font=dict(color='white', size=14),
|
font=dict(color='white', size=14),
|
||||||
margin=dict(l=40, r=40, t=50, b=40)
|
margin=dict(l=40, r=40, t=50, b=40),
|
||||||
|
uirevision='constant', # Avoiding not needed Re-renderings
|
||||||
)
|
)
|
||||||
# Add horizontal reference line at y=0
|
# Add horizontal reference line at y=0
|
||||||
fig.add_shape(
|
fig.add_shape(
|
||||||
@@ -706,7 +852,8 @@ def create_speed_plot(df):
|
|||||||
paper_bgcolor='#1e1e1e',
|
paper_bgcolor='#1e1e1e',
|
||||||
plot_bgcolor='#111111',
|
plot_bgcolor='#111111',
|
||||||
font=dict(color='white'),
|
font=dict(color='white'),
|
||||||
margin=dict(l=40, r=40, t=40, b=40)
|
margin=dict(l=40, r=40, t=40, b=40),
|
||||||
|
uirevision='constant', # Avoiding not needed Re-renderings
|
||||||
)
|
)
|
||||||
# Add horizontal reference line at y=mean_speed_kmh
|
# Add horizontal reference line at y=mean_speed_kmh
|
||||||
fig.add_shape(
|
fig.add_shape(
|
||||||
@@ -849,7 +996,8 @@ def create_heart_rate_plot(df):
|
|||||||
plot_bgcolor='#111111',
|
plot_bgcolor='#111111',
|
||||||
font=dict(color='white'),
|
font=dict(color='white'),
|
||||||
margin=dict(l=40, r=40, t=50, b=40),
|
margin=dict(l=40, r=40, t=50, b=40),
|
||||||
height=400
|
height=400,
|
||||||
|
uirevision='constant', # Avoiding not needed Re-renderings
|
||||||
)
|
)
|
||||||
|
|
||||||
return fig
|
return fig
|
||||||
@@ -971,7 +1119,8 @@ def create_pace_bars_plot(df, formatted_pace=None):
|
|||||||
margin=dict(l=40, r=40, t=30, b=40),
|
margin=dict(l=40, r=40, t=30, b=40),
|
||||||
plot_bgcolor='#111111',
|
plot_bgcolor='#111111',
|
||||||
paper_bgcolor='#1e1e1e',
|
paper_bgcolor='#1e1e1e',
|
||||||
font=dict(color='white')
|
font=dict(color='white'),
|
||||||
|
uirevision='constant', # Avoiding not needed Re-renderings
|
||||||
)
|
)
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
@@ -980,19 +1129,23 @@ def create_pace_bars_plot(df, formatted_pace=None):
|
|||||||
|
|
||||||
|
|
||||||
# === App Setup ===
|
# === App Setup ===
|
||||||
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE])
|
app = dash.Dash(__name__,
|
||||||
app.title = "FIT Dashboard"
|
suppress_callback_exceptions=True, # Weniger Validierung
|
||||||
|
compress=True, # Gzip-Kompression
|
||||||
|
external_stylesheets=[dbc.themes.SLATE],
|
||||||
|
title = "Jogging Dashboard"
|
||||||
|
)
|
||||||
|
|
||||||
app.layout = html.Div([
|
app.layout = html.Div([
|
||||||
html.H1("Running Dashboard", style={'textAlign': 'center'}),
|
html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
|
||||||
dcc.Store(id='stored-df'),
|
dcc.Store(id='stored-df'),
|
||||||
|
|
||||||
html.Div([
|
html.Div([
|
||||||
html.Label("FIT-Datei wählen:", style={'color': 'white'}),
|
html.Label("Datei wählen:", style={'color': 'white'}),
|
||||||
dcc.Dropdown(
|
dcc.Dropdown(
|
||||||
id='fit-file-dropdown',
|
id='file-dropdown',
|
||||||
options=list_fit_files(),
|
options=list_files(),
|
||||||
value=list_fit_files()[0]['value'], # immer gültig
|
value=list_files()[0]['value'], # immer gültig
|
||||||
clearable=False,
|
clearable=False,
|
||||||
style={'width': '300px', 'color': 'black'}
|
style={'width': '300px', 'color': 'black'}
|
||||||
)
|
)
|
||||||
@@ -1012,11 +1165,10 @@ app.layout = html.Div([
|
|||||||
# Callback 1: Load GPX File and Store as JSON
|
# Callback 1: Load GPX File and Store as JSON
|
||||||
@app.callback(
|
@app.callback(
|
||||||
Output('stored-df', 'data'),
|
Output('stored-df', 'data'),
|
||||||
Input('fit-file-dropdown', 'value')
|
Input('file-dropdown', 'value')
|
||||||
)
|
)
|
||||||
def load_fit_data(path):
|
def load_data(selected_file): # Dateipfad der ausgewählten Datei
|
||||||
df = process_fit(path)
|
df = process_selected_file(selected_file) # Verarbeitet diese Datei
|
||||||
|
|
||||||
return df.to_json(date_format='iso', orient='split')
|
return df.to_json(date_format='iso', orient='split')
|
||||||
|
|
||||||
# Callback 2: Update All (static) Plots
|
# Callback 2: Update All (static) Plots
|
||||||
@@ -1068,7 +1220,11 @@ def highlight_map(hoverData, fig_map, json_data):
|
|||||||
|
|
||||||
# === Run Server ===
|
# === Run Server ===
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True, port=8051)
|
app.run(debug=True,
|
||||||
|
port=8051,
|
||||||
|
threaded=True,
|
||||||
|
processes=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# NOTE:
|
# NOTE:
|
||||||
213
jogging_dashboard_gui_app.py
Normal file
213
jogging_dashboard_gui_app.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
|
||||||
|
@author: Marcel Weschke
|
||||||
|
@email: marcel.weschke@directbox.de
|
||||||
|
"""
|
||||||
|
# %% Load libraries
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QSplashScreen, QLabel
|
||||||
|
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||||
|
from PyQt6.QtCore import QUrl, QTimer, Qt
|
||||||
|
from PyQt6.QtGui import QPixmap
|
||||||
|
|
||||||
|
# Performance-Optimierungen für Qt WebEngine
|
||||||
|
os.environ.update({
|
||||||
|
# Hardware-Beschleunigung forcieren
|
||||||
|
"QTWEBENGINE_CHROMIUM_FLAGS": (
|
||||||
|
"--ignore-gpu-blocklist "
|
||||||
|
"--enable-gpu-rasterization "
|
||||||
|
"--enable-zero-copy "
|
||||||
|
"--disable-logging "
|
||||||
|
"--no-sandbox "
|
||||||
|
"--disable-dev-shm-usage "
|
||||||
|
"--disable-extensions "
|
||||||
|
"--disable-plugins "
|
||||||
|
"--disable-background-timer-throttling "
|
||||||
|
"--disable-backgrounding-occluded-windows "
|
||||||
|
"--disable-renderer-backgrounding "
|
||||||
|
"--disable-features=TranslateUI "
|
||||||
|
"--aggressive-cache-discard "
|
||||||
|
"--memory-pressure-off"
|
||||||
|
),
|
||||||
|
# Logging reduzieren
|
||||||
|
"QT_LOGGING_RULES": "qt.webenginecontext.debug=false",
|
||||||
|
"QTWEBENGINE_DISABLE_SANDBOX": "1",
|
||||||
|
# Cache-Optimierungen
|
||||||
|
"QTWEBENGINE_DISABLE_GPU_THREAD": "0"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Importiere deine Dash-App
|
||||||
|
from jogging_dashboard_browser_app import app
|
||||||
|
|
||||||
|
class DashThread(threading.Thread):
|
||||||
|
"""Optimierter Dash-Thread mit besserer Kontrolle"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
self.dash_ready = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
# Dash mit Performance-Optimierungen starten
|
||||||
|
app.run(
|
||||||
|
debug=False,
|
||||||
|
port=8051,
|
||||||
|
use_reloader=False,
|
||||||
|
host='127.0.0.1',
|
||||||
|
threaded=True, # Threading für bessere Performance
|
||||||
|
processes=1 # Single process für Desktop
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Dash-Server Fehler: {e}")
|
||||||
|
|
||||||
|
def wait_for_dash(self, timeout=10):
|
||||||
|
"""Warte bis Dash-Server bereit ist"""
|
||||||
|
import requests
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
try:
|
||||||
|
response = requests.get('http://127.0.0.1:8051/', timeout=2)
|
||||||
|
if response.status_code == 200:
|
||||||
|
self.dash_ready = True
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("Jogging Dashboard - Desktop")
|
||||||
|
self.setGeometry(100, 100, 1400, 900) # Größere Standardgröße
|
||||||
|
|
||||||
|
# Performance: Lade Seite erst wenn Dash bereit ist
|
||||||
|
self.browser = None
|
||||||
|
self.setup_ui()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
"""UI-Setup ohne sofortiges Laden der Seite"""
|
||||||
|
central_widget = QWidget()
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Loading-Label während Dash startet
|
||||||
|
self.loading_label = QLabel("🚀 Dashboard wird geladen...")
|
||||||
|
self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.loading_label.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout.addWidget(self.loading_label)
|
||||||
|
central_widget.setLayout(layout)
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
|
||||||
|
def load_dashboard(self):
|
||||||
|
"""Lade Dashboard nachdem Dash bereit ist"""
|
||||||
|
# Browser-Widget erstellen
|
||||||
|
self.browser = QWebEngineView()
|
||||||
|
|
||||||
|
# Performance-Einstellungen für WebEngineView
|
||||||
|
settings = self.browser.settings()
|
||||||
|
settings.setAttribute(settings.WebAttribute.PluginsEnabled, False)
|
||||||
|
settings.setAttribute(settings.WebAttribute.JavascriptEnabled, True)
|
||||||
|
settings.setAttribute(settings.WebAttribute.LocalStorageEnabled, True)
|
||||||
|
|
||||||
|
# Seite laden
|
||||||
|
self.browser.setUrl(QUrl("http://127.0.0.1:8051"))
|
||||||
|
|
||||||
|
# Layout aktualisieren
|
||||||
|
central_widget = QWidget()
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0) # Kein Rand für maximale Größe
|
||||||
|
layout.addWidget(self.browser)
|
||||||
|
central_widget.setLayout(layout)
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
|
||||||
|
print("✅ Dashboard geladen!")
|
||||||
|
|
||||||
|
class SplashScreen(QSplashScreen):
|
||||||
|
"""Splash Screen für bessere UX während des Startens"""
|
||||||
|
def __init__(self):
|
||||||
|
# Einfacher Text-Splash (du kannst ein Logo hinzufügen)
|
||||||
|
pixmap = QPixmap(400, 200)
|
||||||
|
pixmap.fill(Qt.GlobalColor.white)
|
||||||
|
super().__init__(pixmap)
|
||||||
|
|
||||||
|
# Text hinzufügen
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QSplashScreen {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def showMessage(self, message):
|
||||||
|
super().showMessage(message, Qt.AlignmentFlag.AlignCenter, Qt.GlobalColor.white)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("🚀 Starte Jogging Dashboard...")
|
||||||
|
|
||||||
|
# Qt Application mit Performance-Flags
|
||||||
|
app_qt = QApplication(sys.argv)
|
||||||
|
#app_qt.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) # Not working yet
|
||||||
|
#app_qt.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) # Not working yet
|
||||||
|
|
||||||
|
# Splash Screen anzeigen
|
||||||
|
splash = SplashScreen()
|
||||||
|
splash.show()
|
||||||
|
splash.showMessage("Initialisiere Dashboard...")
|
||||||
|
app_qt.processEvents()
|
||||||
|
|
||||||
|
# Dash-Server starten
|
||||||
|
dash_thread = DashThread()
|
||||||
|
dash_thread.start()
|
||||||
|
|
||||||
|
splash.showMessage("Starte Web-Server...")
|
||||||
|
app_qt.processEvents()
|
||||||
|
|
||||||
|
# Auf Dash warten
|
||||||
|
if dash_thread.wait_for_dash(timeout=15):
|
||||||
|
splash.showMessage("Dashboard bereit!")
|
||||||
|
app_qt.processEvents()
|
||||||
|
|
||||||
|
# Hauptfenster erstellen und laden
|
||||||
|
window = MainWindow()
|
||||||
|
|
||||||
|
# Kurz warten für bessere UX
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Dashboard laden
|
||||||
|
window.load_dashboard()
|
||||||
|
|
||||||
|
# Splash schließen und Hauptfenster anzeigen
|
||||||
|
splash.close()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
print("✅ Dashboard erfolgreich gestartet!")
|
||||||
|
|
||||||
|
else:
|
||||||
|
splash.showMessage("❌ Fehler beim Starten!")
|
||||||
|
app_qt.processEvents()
|
||||||
|
time.sleep(2)
|
||||||
|
splash.close()
|
||||||
|
print("❌ Dashboard konnte nicht gestartet werden!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Event-Loop starten
|
||||||
|
sys.exit(app_qt.exec())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user