diff --git a/2025-09-15_HH_Run_10.90Km_overlay.svg b/2025-09-15_HH_Run_10.90Km_overlay.svg new file mode 100644 index 0000000..a1fbf92 --- /dev/null +++ b/2025-09-15_HH_Run_10.90Km_overlay.svg @@ -0,0 +1,12 @@ + + + + + + TOTAL DISTANCE + 10.9 km + TOTAL TIME + 0 days 01:03:04 + AVERAGE PACE + 5:47 /km + \ No newline at end of file diff --git a/DashboardApp_WebVersion.png b/DashboardApp_WebVersion.png index fd5e44e..3e80aa3 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 8a9f2f2..e234501 100644 --- a/jogging_dashboard_browser_app.py +++ b/jogging_dashboard_browser_app.py @@ -25,6 +25,9 @@ from scipy.interpolate import interp1d import gpxpy from fitparse import FitFile +from xml.etree.ElementTree import Element, SubElement, tostring +import xml.etree.ElementTree as ET + # === Helper Functions === def list_files(): """ @@ -497,6 +500,169 @@ def create_info_banner(df): return info_banner +# ============================================================================= +# EXPORT SUMMARY IMAGE (SVG) +# ============================================================================= +def create_strava_style_svg(df, total_distance_km=None, total_time=None, avg_pace=None, + width=800, height=600, padding=50): + """ + Erstellt ein STRAVA-style SVG mit transparentem Hintergrund + """ + + # SVG Root Element + svg = Element('svg') + svg.set('width', str(width)) + svg.set('height', str(height)) + svg.set('xmlns', 'http://www.w3.org/2000/svg') + svg.set('style', 'background: transparent;') + + # Route-Bereich (links 60% der Breite) + route_width = width * 0.6 + route_height = height - 2 * padding + + # Koordinaten normalisieren für den Route-Bereich + lats = df['lat'].values + lons = df['lon'].values + + # Bounding Box der Route + lat_min, lat_max = lats.min(), lats.max() + lon_min, lon_max = lons.min(), lons.max() + + # Aspect Ratio beibehalten + lat_range = lat_max - lat_min + lon_range = lon_max - lon_min + + if lat_range == 0 or lon_range == 0: + raise ValueError("Route hat keine Variation in Koordinaten") + + # Skalierung berechnen + scale_x = (route_width - 2 * padding) / lon_range + scale_y = (route_height - 2 * padding) / lat_range + + # Einheitliche Skalierung für korrekte Proportionen + scale = min(scale_x, scale_y) + + # Zentrieren + center_x = route_width / 2 + center_y = height / 2 + + # Route-Pfad erstellen + path_data = [] + for i, (lat, lon) in enumerate(zip(lats, lons)): + # Koordinaten transformieren (Y-Achse umkehren für SVG) + x = center_x + (lon - (lon_min + lon_max) / 2) * scale + y = center_y - (lat - (lat_min + lat_max) / 2) * scale + + if i == 0: + path_data.append(f"M {x:.2f} {y:.2f}") + else: + path_data.append(f"L {x:.2f} {y:.2f}") + + # Route-Pfad zum SVG hinzufügen + route_path = SubElement(svg, 'path') + route_path.set('d', ' '.join(path_data)) + route_path.set('stroke', '#ff6909') # Deine Routenfarbe + route_path.set('stroke-width', '4') + route_path.set('fill', 'none') + route_path.set('stroke-linecap', 'round') + route_path.set('stroke-linejoin', 'round') + + # Start-Punkt (grün) + start_x = center_x + (lons[0] - (lon_min + lon_max) / 2) * scale + start_y = center_y - (lats[0] - (lat_min + lat_max) / 2) * scale + start_circle = SubElement(svg, 'circle') + start_circle.set('cx', str(start_x)) + start_circle.set('cy', str(start_y)) + start_circle.set('r', '8') + start_circle.set('fill', '#4CAF50') # Grün + start_circle.set('stroke', 'white') + start_circle.set('stroke-width', '2') + + # End-Punkt (rot) + end_x = center_x + (lons[-1] - (lon_min + lon_max) / 2) * scale + end_y = center_y - (lats[-1] - (lat_min + lat_max) / 2) * scale + end_circle = SubElement(svg, 'circle') + end_circle.set('cx', str(end_x)) + end_circle.set('cy', str(end_y)) + end_circle.set('r', '8') + end_circle.set('fill', '#f44336') # Rot + end_circle.set('stroke', 'white') + end_circle.set('stroke-width', '2') + + # Stats-Bereich (rechts 40% der Breite) + stats_x = route_width + padding + stats_y_start = padding + 50 + + ## Hintergrund für Stats (optional, semi-transparent - SCHWARZE BOX) + #stats_bg = SubElement(svg, 'rect') + #stats_bg.set('x', str(stats_x - 20)) + #stats_bg.set('y', str(stats_y_start - 30)) + #stats_bg.set('width', str(width * 0.35)) + #stats_bg.set('height', str(250)) + #stats_bg.set('fill', 'rgba(0,0,0,0.7)') + #stats_bg.set('rx', '10') + + # Stats-Text hinzufügen + stats = [ + ("TOTAL DISTANCE", f"{total_distance_km:.1f} km" if total_distance_km else "N/A"), + ("TOTAL TIME", total_time or "N/A"), + ("AVERAGE PACE", avg_pace or "N/A") + ] + + for i, (label, value) in enumerate(stats): + y_pos = stats_y_start + i * 70 + + # Label (kleinere Schrift, grau) + label_text = SubElement(svg, 'text') + label_text.set('x', str(stats_x)) + label_text.set('y', str(y_pos)) + label_text.set('font-family', 'Arial, sans-serif') + label_text.set('font-size', '14') + label_text.set('font-weight', 'bold') + label_text.set('fill', '#000000') # TEXTFARBE #333333 + label_text.text = label + + # Wert (größere Schrift, weiß) + value_text = SubElement(svg, 'text') + value_text.set('x', str(stats_x)) + value_text.set('y', str(y_pos + 25)) + value_text.set('font-family', 'Arial, sans-serif') + value_text.set('font-size', '24') + value_text.set('font-weight', 'bold') + value_text.set('fill', 'white') # TEXTFARBE + value_text.text = value + + return svg + +def save_svg(svg_element, filename="run_overlay.svg"): + """SVG als Datei speichern""" + rough_string = tostring(svg_element, 'unicode') + + # Formatierung verbessern + dom = ET.fromstring(rough_string) + ET.indent(dom, space=" ", level=0) + + with open(filename, 'w') as f: + f.write('\n') + f.write(ET.tostring(dom, encoding='unicode')) + + print(f"SVG saved as {filename}") + +def calculate_pace(distance_km, total_seconds): + """ + Berechnet das Durchschnittstempo in min/km Format + """ + if distance_km == 0: + return "0:00 /km" + + pace_seconds_per_km = total_seconds / distance_km + pace_minutes = int(pace_seconds_per_km // 60) + pace_seconds = int(pace_seconds_per_km % 60) + + return f"{pace_minutes}:{pace_seconds:02d} /km" + + + # ============================================================================= # START OF THE PLOTS # ============================================================================= @@ -703,7 +869,7 @@ def create_elevation_plot(df, smooth_points=500): return fig -# Alte Version - normaler gradient fill between: +# Alte Version - normaler fill between: # def create_elevation_plot(df, smooth_points=500): # # Originale Daten # x = df['time'] @@ -1140,17 +1306,59 @@ app.layout = html.Div([ html.H1("Jogging Dashboard", style={'textAlign': 'center'}), dcc.Store(id='stored-df'), + # Horizontales Layout für Dropdown und Button html.Div([ - html.Label("Datei wählen:", style={'color': 'white'}), - dcc.Dropdown( - id='file-dropdown', - options=list_files(), - value=list_files()[0]['value'], # immer gültig - clearable=False, - style={'width': '300px', 'color': 'black'} - ) - ], style={'padding': '20px', 'backgroundColor': '#1e1e1e'}), + # Linke Seite: Datei-Dropdown + html.Div([ + html.Label("Datei wählen:", style={'color': '#aaaaaa', 'marginBottom': '5px'}), + dcc.Dropdown( + id='file-dropdown', + options=list_files(), + value=list_files()[0]['value'], + clearable=False, + style={'width': '300px', 'color': 'black'} + ) + ], style={'display': 'flex', 'flexDirection': 'column'}), + # Rechte Seite: Export Button + html.Div([ + html.Label("Export SVG:", + style={'color': '#aaaaaa', 'marginBottom': '8px'}), + html.Button( + [ + html.I(className="fas fa-download"), + "Summary Image" + ], + id='export-button', + style={ + 'backgroundColor': '#007bff', + 'border': 'none', + 'color': 'white', + 'padding': '10px 12px', + 'borderRadius': '5px', + 'fontSize': '14px', + 'cursor': 'pointer', + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'gap': '5px' + } + ) + ], style={'display': 'flex', 'flexDirection': 'column', 'alignItems': 'flex-end'}) + + ], style={ + 'padding': '20px', + 'backgroundColor': '#1e1e1e', + 'display': 'flex', + 'justifyContent': 'space-between', # Dropdown links, Button rechts + 'alignItems': 'flex-end', # Beide Elemente unten ausrichten + 'minHeight': '80px' # Mindesthöhe für konsistentes Layout + }), + + # Export Status + html.Div(id='export-status', children="", style={'padding': '0 20px'}), + + # Rest deines Layouts html.Div(id='info-banner'), dcc.Graph(id='fig-map'), dcc.Graph(id='fig-elevation'), @@ -1185,7 +1393,6 @@ def load_data(selected_file): # Dateipfad der ausgewählten Da ) 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) @@ -1196,8 +1403,70 @@ def update_all_plots(json_data): return info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, fig_pace +# Callback 3: Export SVG +@app.callback( + Output('export-status', 'children'), + Input('export-button', 'n_clicks'), + State('stored-df', 'data'), + State('file-dropdown', 'value'), + prevent_initial_call=True +) +def export_summary_image(n_clicks, json_data, selected_file): + if n_clicks and json_data and selected_file: + try: + print(f"Export wurde geklickt für Datei: {selected_file}") -# Callback 3: Hover → update only hover (dynamic) marker + # DataFrame aus bereits geladenen Daten erstellen + df = pd.read_json(io.StringIO(json_data), orient='split') + + if df.empty: + return html.Div("Export fehlgeschlagen: Keine Daten verfügbar", + style={'color': 'red', 'fontSize': '12px'}) + + # Statistiken berechnen (gleich wie im Info-Banner) + total_distance_km = df['cum_dist_km'].iloc[-1] if 'cum_dist_km' in df.columns else 0 + total_time_str = df['duration_hms'].iloc[-1] if 'duration_hms' in df.columns else "00:00:00" + total_seconds = df['time_diff_sec'].iloc[-1] if 'time_diff_sec' in df.columns else 0 + + # Pace berechnen + avg_pace = calculate_pace(total_distance_km, total_seconds) + + # Output-Dateiname + base_name = os.path.splitext(os.path.basename(selected_file))[0] + output_filename = f"{base_name}_overlay.svg" + + print(f"Stats - Distance: {total_distance_km:.1f}km, Time: {total_time_str}, Pace: {avg_pace}") + + # SVG erstellen + svg = create_strava_style_svg( + df=df, + total_distance_km=total_distance_km, + total_time=total_time_str, + avg_pace=avg_pace, + width=800, + height=600 + ) + + # SVG speichern + save_svg(svg, output_filename) + + return html.Div( + f"Export erfolgreich! Datei: {output_filename}", + style={'color': 'green', 'fontSize': '12px', 'marginTop': '5px'} + ) + + except Exception as e: + print(f"Export-Fehler: {str(e)}") + return html.Div( + f"Export fehlgeschlagen: {str(e)}", + style={'color': 'red', 'fontSize': '12px', 'marginTop': '5px'} + ) + + return "" + + + +# Callback 4: Hover → update only hover (dynamic) marker @app.callback( Output('fig-map', 'figure'), Input('fig-elevation', 'hoverData'),