diff --git a/DashboardApp_WebVersion.png b/DashboardApp_WebVersion.png index 137b368..5ff5aee 100644 Binary files a/DashboardApp_WebVersion.png and b/DashboardApp_WebVersion.png differ diff --git a/jogging_dashboard_browser_app.py b/jogging_dashboard_browser_app.py index d8ec3c3..d969e18 100644 --- a/jogging_dashboard_browser_app.py +++ b/jogging_dashboard_browser_app.py @@ -239,12 +239,15 @@ def process_fit(file_path): 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 + # Cumulative distance (km) (vektorisiert) + lat_r = np.radians(df['lat'].values) + lon_r = np.radians(df['lon'].values) + dlat = np.diff(lat_r, prepend=lat_r[0]) + dlon = np.diff(lon_r, prepend=lon_r[0]) + a = np.sin(dlat/2)**2 + np.cos(lat_r) * np.cos(np.roll(lat_r,1)) * np.sin(dlon/2)**2 + a[0] = 0 + step_dist = 2 * 6371 * np.arcsin(np.sqrt(np.clip(a, 0, 1))) + df['cum_dist_km'] = np.cumsum(step_dist) # Elevation handling if 'elev' in df.columns: @@ -275,37 +278,27 @@ 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: - # save heart rate data into variable + # Heart rate handling + # Zweiter Durchlauf nötig: nach dropna() hat df weniger Zeilen als records, + # safe_add_column_to_dataframe behandelt den Längenunterschied korrekt. heart_rate = [] for record in fit_file.get_messages("record"): - # Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc) for data in record: - # Print the name and value of the data (and the units if it has any) if data.name == 'heart_rate': heart_rate.append(data.value) - # Hier variable neu überschrieben: df = safe_add_column_to_dataframe(df, 'heart_rate', heart_rate) - # ############## - # ###################################### - # IF issues with heart_rate values, usw these DEBUG prints: - #print(heart_rate) if 'heart_rate' in df.columns: df['heart_rate'] = pd.to_numeric(df['heart_rate'], errors='coerce') - df['hr_smooth'] = df['heart_rate'].rolling(window=5, center=True).mean() - # print(f"Heart rate range: {df['heart_rate'].min():.0f} - {df['heart_rate'].max():.0f} bpm") # Uncomment if needed - DEBUG purpose! + #df['hr_smooth'] = df['heart_rate'].rolling(window=5, center=True).mean() + df['hr_smooth'] = df['heart_rate'].rolling(window=2, center=True, min_periods=1).mean() else: - print("Keine Heart Rate Daten gefunden!") df['heart_rate'] = np.nan - df['hr_smooth'] = np.nan + df['hr_smooth'] = np.nan print(f"Verarbeitete FIT-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 @@ -366,12 +359,15 @@ def process_gpx(file_path): ((secs % 3600) // 60).astype(int).astype(str).str.zfill(2) + ':' + \ (secs % 60).astype(int).astype(str).str.zfill(2) - # 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 + # Cumulative distance (km) (vektorisiert) + lat_r = np.radians(df['lat'].values) + lon_r = np.radians(df['lon'].values) + dlat = np.diff(lat_r, prepend=lat_r[0]) + dlon = np.diff(lon_r, prepend=lon_r[0]) + a = np.sin(dlat/2)**2 + np.cos(lat_r) * np.cos(np.roll(lat_r,1)) * np.sin(dlon/2)**2 + a[0] = 0 + step_dist = 2 * 6371 * np.arcsin(np.sqrt(np.clip(a, 0, 1))) + df['cum_dist_km'] = np.cumsum(step_dist) # Elevation (gleiche Logik wie in deiner FIT-Funktion) df['elev'] = df['elev'].bfill() @@ -508,15 +504,12 @@ def calculate_calories_burned(df): vo2max = 15.0 * (hr_max / hr_rest) - cumulative = 0.0 - for i in range(1, len(ts)): - dt = ts[i] - ts[i - 1] - if dt <= 0 or dt > 10: - continue - frac = max(0.0, (hr[i] - hr_rest) / (hr_max - hr_rest)) - met = max(1.0, min((frac * vo2max) / 3.5, 18.0)) - kcal_per_s = met * _WEIGHT_KG * 3.5 / 12000.0 - cumulative += kcal_per_s * dt + dt = np.diff(ts, prepend=ts[0]) + mask = (dt > 0) & (dt <= 10) + frac = np.clip((hr - hr_rest) / (hr_max - hr_rest), 0, None) + met = np.clip((frac * vo2max) / 3.5, 1.0, 18.0) + kcal_per_s = met * _WEIGHT_KG * 3.5 / 12000.0 + cumulative = float(np.sum(kcal_per_s[mask] * dt[mask])) print(f"DEBUG calories: rows={len(df)}, hr_valid={df['heart_rate'].notna().sum()}, duration={df['time_diff_sec'].iloc[-1]:.0f}s, hr_mean={df['heart_rate'].mean():.1f}") return int(round(cumulative)) @@ -1070,8 +1063,7 @@ def create_pixel_heatmap(dataframes, 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 + np.add.at(count_grid, (ys, xs), 1) max_count = max(count_grid.max(), 1) log_max = np.log1p(max_count) @@ -1107,21 +1099,30 @@ def create_pixel_heatmap(dataframes, # - 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 + from matplotlib.collections import LineCollection + + # (LineCollection - alle Segmente in einem Aufruf): + for df in draw_frames: 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') + # Segmente als Array: shape (N-1, 2, 2) + points = np.array([xs, ys]).T.reshape(-1, 1, 2) + segments = np.concatenate([points[:-1], points[1:]], axis=1) + + # Farbe pro Segment aus count_grid + mid_xs = np.clip((xs[:-1] + xs[1:]) // 2, 0, img_width - 1) + mid_ys = np.clip((ys[:-1] + ys[1:]) // 2, 0, img_height - 1) + counts = count_grid[mid_ys, mid_xs] + norm_vals = np.log1p(counts) / log_max if log_max > 0 else np.zeros_like(counts) + colors = cmap(norm_vals) + + lc = LineCollection(segments, colors=colors, linewidths=line_width, + capstyle='round', joinstyle='round') + ax.add_collection(lc) # 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 @@ -1633,32 +1634,17 @@ def create_pixel_hr_map(df, img_width=900, img_height=900, # ELEVATION-PLOT: # ----------------------------------------------------------------------------- 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 + y_smooth = ( + pd.Series(y.values) + .interpolate(method='linear', limit=10, limit_direction='both') # kurze Lücken schließen + .rolling(window=15, center=True, min_periods=1) + .mean() + .values + ) + x_smooth = x fig = go.Figure() @@ -1957,12 +1943,12 @@ def create_heart_rate_plot(df): fig = go.Figure() # Heart Rate Linie (geglättet) + mask = df['heart_rate'].isna() fig.add_trace(go.Scatter( x=df['time'][~mask], - y=df['hr_smooth'][~mask], + y=df['heart_rate'][~mask], mode='lines', - #name='Geglättete Herzfrequenz', - line=dict(color='#ff2c48', width=2), + line=dict(color='#ff2c48', width=1.5), # etwas dünner für gezackte Linie showlegend=False, hovertemplate=( "Zeit: %{x}
" + @@ -2232,7 +2218,7 @@ def create_pace_bars_plot(df, formatted_pace=None): # ------------------------------------------------------------------------- fig.add_trace(go.Bar( x=pace_vals, - y=y_labels, + y=list(range(len(y_labels))), # ← 0,1,2,3... statt Strings y=y_labels, orientation='h', marker=dict( color=bar_colors, @@ -2296,7 +2282,7 @@ def create_pace_bars_plot(df, formatted_pace=None): if right_text: fig.add_annotation( x=x_max, - y=km_lbl, + y=i, # ← numerischer Index statt km_lbl String text=right_text, showarrow=False, xanchor='right', @@ -2329,13 +2315,16 @@ def create_pace_bars_plot(df, formatted_pace=None): yaxis=dict( title='', autorange='reversed', # km 1 oben, letzter km unten (wie Strava) + tickmode='array', + tickvals=list(range(len(y_labels))), # ← 0,1,2... + ticktext=y_labels, # ← "1","2",...,"0.4" tickfont=dict(size=11, color='white'), showgrid=False, zeroline=False, ), template='plotly_dark', height=plot_height, - margin=dict(l=50, r=80, t=45, b=45), + margin=dict(l=40, r=40, t=45, b=45), plot_bgcolor='#111111', paper_bgcolor='#1e1e1e', font=dict(color='white'), @@ -2567,7 +2556,7 @@ def update_all_plots(json_data): Output('heatmap-city-info', 'children'), Input('file-dropdown', 'value'), Input('heatmap-mode-toggle', 'value'), - prevent_initial_call=True + #prevent_initial_call=True # Sonst beim Start der App kein Renderprozess ) def update_pixel_heatmap(selected_file, toggle_value): """ @@ -2578,11 +2567,11 @@ def update_pixel_heatmap(selected_file, toggle_value): """ 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, '' + if not selected_file or selected_file in ['NO_FILES', 'NO_FOLDER', 'ERROR', None]: + empty = go.Figure() + empty.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'), + title=dict(text='Datei wählen...', font=dict(color='white'))) + return empty, '' city_code = extract_city_code(selected_file) city_info_text = f'Erkannte Region: {city_code}' if city_code else 'Kein Stadtcode erkannt' @@ -2631,28 +2620,6 @@ def update_pixel_heatmap(selected_file, toggle_value): 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