diff --git a/.gitignore b/.gitignore
index 36c4bae..9c58235 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,5 @@ gpx_files/*
!gpx_files/.keep
fit_files/*
!fit_files/.keep
-fit_app_build-exe-gui.py
-fit_app_build_EXE_gui_file.txt
+__pycache__/*
+!__pycache__/.keep
diff --git a/__pycache__/.keep b/__pycache__/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/gpx_app.py b/gpx_app.py
deleted file mode 100644
index dfe4c4f..0000000
--- a/gpx_app.py
+++ /dev/null
@@ -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]}
" +
- "Distance (km): %{customdata[1]:.2f}
" +
- "Elapsed Time: %{customdata[2]}"
- ),
- 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]}
" +
- # "Distance (km): %{customdata[0]:.2f}
" +
- # "Elevation: %{customdata[1]}" +
- # "Elapsed Time: %{customdata[2]}"
- # ),
- # 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)
-#
diff --git a/fit_app.py b/jogging_dashboard_browser_app.py
similarity index 80%
rename from fit_app.py
rename to jogging_dashboard_browser_app.py
index 5a18cc7..8a9f2f2 100644
--- a/fit_app.py
+++ b/jogging_dashboard_browser_app.py
@@ -26,69 +26,107 @@ import gpxpy
from fitparse import FitFile
# === 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'
- # Prüfe ob Ordner existiert
- if not os.path.exists(folder):
- print(f"Ordner {folder} existiert nicht!")
- return [{'label': 'Ordner nicht gefunden', 'value': 'NO_FOLDER'}]
+ # Definiere Ordner und Dateierweiterungen
+ folders_config = [
+ {'folder': './fit_files', 'extensions': ['.fit'], 'type': 'FIT'},
+ {'folder': './gpx_files', 'extensions': ['.gpx'], 'type': 'GPX'}
+ ]
- # Hole alle .fit Files
- try:
- all_files = os.listdir(folder)
- files = [f for f in all_files if f.lower().endswith('.fit')]
- except Exception as e:
- print(f"Fehler beim Lesen des Ordners: {e}")
- return [{'label': 'Fehler beim Lesen', 'value': 'ERROR'}]
+ all_file_options = []
- def extract_date(filename):
- """Extrahiert Datum aus Filename für Sortierung"""
+ for config in folders_config:
+ folder = config['folder']
+ extensions = config['extensions']
+ file_type = config['type']
+
+ # Prüfe ob Ordner existiert
+ if not os.path.exists(folder):
+ print(f"Ordner {folder} existiert nicht!")
+ continue
+
+ # Hole alle Files mit den entsprechenden Erweiterungen
try:
- # Versuche verschiedene Datumsformate
- return datetime.datetime.strptime(filename[:10], '%d.%m.%Y')
- except ValueError:
- try:
- return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
- except ValueError:
- try:
- # Versuche auch andere Formate
- return datetime.datetime.strptime(filename[:8], '%Y%m%d')
- except ValueError:
- # Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum
- try:
- file_path = os.path.join(folder, filename)
- return datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
- except:
- return datetime.datetime.min
+ all_files = os.listdir(folder)
+ files = [f for f in all_files
+ if any(f.lower().endswith(ext) for ext in extensions)]
+ except Exception as e:
+ print(f"Fehler beim Lesen des Ordners {folder}: {e}")
+ continue
- # Sortiere Files nach Datum (neueste zuerst)
- files.sort(key=extract_date, reverse=True)
-
- # Erstelle Dropdown-Optionen
- if files:
- options = []
+ # Erstelle Optionen für diesen Ordner
for f in files:
file_path = os.path.join(folder, f)
- # Zeige auch Dateigröße und Änderungsdatum an
+
+ # 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}"
- #label = f"{f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')}\n)" # For debugging purpose
+ # 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
+ #label = f"[{file_type}] {f}"
+ label = f"{f}"
- options.append({
+ all_file_options.append({
'label': label,
- 'value': file_path
+ 'value': file_path,
+ 'date': file_date,
+ 'type': file_type
})
- return options
- else:
- return [{'label': 'Keine .fit Dateien gefunden', 'value': 'NO_FILE'}]
+
+ # 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"""
+ try:
+ # Versuche verschiedene Datumsformate im Dateinamen
+ # Format: dd.mm.yyyy
+ return datetime.datetime.strptime(filename[:10], '%d.%m.%Y')
+ except ValueError:
+ try:
+ # Format: yyyy-mm-dd
+ return datetime.datetime.strptime(filename[:10], '%Y-%m-%d')
+ except ValueError:
+ try:
+ # Format: yyyymmdd
+ 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:
+ # Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum
+ try:
+ return datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
+ except:
+ return datetime.datetime.min
+
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
return 2 * R * asin(sqrt(a))
+
+########
+# FIT
+########
def process_fit(file_path):
"""
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)
-
-
-
-
# Heart rate handling (NEU!)
# ##############
# 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):
"""
@@ -553,7 +697,8 @@ def create_elevation_plot(df, smooth_points=500):
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
- height=400
+ height=400,
+ uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
@@ -672,7 +817,8 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
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
fig.add_shape(
@@ -706,7 +852,8 @@ def create_speed_plot(df):
paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111',
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
fig.add_shape(
@@ -849,7 +996,8 @@ def create_heart_rate_plot(df):
plot_bgcolor='#111111',
font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40),
- height=400
+ height=400,
+ uirevision='constant', # Avoiding not needed Re-renderings
)
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),
plot_bgcolor='#111111',
paper_bgcolor='#1e1e1e',
- font=dict(color='white')
+ font=dict(color='white'),
+ uirevision='constant', # Avoiding not needed Re-renderings
)
return fig
@@ -980,19 +1129,23 @@ def create_pace_bars_plot(df, formatted_pace=None):
# === App Setup ===
-app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE])
-app.title = "FIT Dashboard"
+app = dash.Dash(__name__,
+ suppress_callback_exceptions=True, # Weniger Validierung
+ compress=True, # Gzip-Kompression
+ external_stylesheets=[dbc.themes.SLATE],
+ title = "Jogging Dashboard"
+)
app.layout = html.Div([
- html.H1("Running Dashboard", style={'textAlign': 'center'}),
+ html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
dcc.Store(id='stored-df'),
html.Div([
- html.Label("FIT-Datei wählen:", style={'color': 'white'}),
+ html.Label("Datei wählen:", style={'color': 'white'}),
dcc.Dropdown(
- id='fit-file-dropdown',
- options=list_fit_files(),
- value=list_fit_files()[0]['value'], # immer gültig
+ id='file-dropdown',
+ options=list_files(),
+ value=list_files()[0]['value'], # immer gültig
clearable=False,
style={'width': '300px', 'color': 'black'}
)
@@ -1012,11 +1165,10 @@ app.layout = html.Div([
# Callback 1: Load GPX File and Store as JSON
@app.callback(
Output('stored-df', 'data'),
- Input('fit-file-dropdown', 'value')
+ Input('file-dropdown', 'value')
)
-def load_fit_data(path):
- df = process_fit(path)
-
+def load_data(selected_file): # Dateipfad der ausgewählten Datei
+ df = process_selected_file(selected_file) # Verarbeitet diese Datei
return df.to_json(date_format='iso', orient='split')
# Callback 2: Update All (static) Plots
@@ -1068,7 +1220,11 @@ def highlight_map(hoverData, fig_map, json_data):
# === Run Server ===
if __name__ == '__main__':
- app.run(debug=True, port=8051)
+ app.run(debug=True,
+ port=8051,
+ threaded=True,
+ processes=1
+ )
# NOTE:
diff --git a/jogging_dashboard_gui_app.py b/jogging_dashboard_gui_app.py
new file mode 100644
index 0000000..3a0174b
--- /dev/null
+++ b/jogging_dashboard_gui_app.py
@@ -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()