Added new 'export SVG' functionality which creates a static SVG image of your current selected run

This commit is contained in:
2025-09-28 15:11:18 +02:00
parent 7240af316b
commit cdf93eee4b
3 changed files with 293 additions and 12 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -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('<?xml version="1.0" encoding="UTF-8"?>\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'}),
# 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'], # immer gültig
value=list_files()[0]['value'],
clearable=False,
style={'width': '300px', 'color': 'black'}
)
], style={'padding': '20px', 'backgroundColor': '#1e1e1e'}),
], 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'),