Added new 'export SVG' functionality which creates a static SVG image of your current selected run
This commit is contained in:
12
2025-09-15_HH_Run_10.90Km_overlay.svg
Normal file
12
2025-09-15_HH_Run_10.90Km_overlay.svg
Normal file
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 |
@@ -25,6 +25,9 @@ from scipy.interpolate import interp1d
|
|||||||
import gpxpy
|
import gpxpy
|
||||||
from fitparse import FitFile
|
from fitparse import FitFile
|
||||||
|
|
||||||
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
# === Helper Functions ===
|
# === Helper Functions ===
|
||||||
def list_files():
|
def list_files():
|
||||||
"""
|
"""
|
||||||
@@ -497,6 +500,169 @@ def create_info_banner(df):
|
|||||||
return info_banner
|
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
|
# START OF THE PLOTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -703,7 +869,7 @@ def create_elevation_plot(df, smooth_points=500):
|
|||||||
|
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
# Alte Version - normaler gradient fill between:
|
# Alte Version - normaler fill between:
|
||||||
# def create_elevation_plot(df, smooth_points=500):
|
# def create_elevation_plot(df, smooth_points=500):
|
||||||
# # Originale Daten
|
# # Originale Daten
|
||||||
# x = df['time']
|
# x = df['time']
|
||||||
@@ -1140,17 +1306,59 @@ app.layout = html.Div([
|
|||||||
html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
|
html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
|
||||||
dcc.Store(id='stored-df'),
|
dcc.Store(id='stored-df'),
|
||||||
|
|
||||||
|
# Horizontales Layout für Dropdown und Button
|
||||||
html.Div([
|
html.Div([
|
||||||
html.Label("Datei wählen:", style={'color': 'white'}),
|
# Linke Seite: Datei-Dropdown
|
||||||
dcc.Dropdown(
|
html.Div([
|
||||||
id='file-dropdown',
|
html.Label("Datei wählen:", style={'color': '#aaaaaa', 'marginBottom': '5px'}),
|
||||||
options=list_files(),
|
dcc.Dropdown(
|
||||||
value=list_files()[0]['value'], # immer gültig
|
id='file-dropdown',
|
||||||
clearable=False,
|
options=list_files(),
|
||||||
style={'width': '300px', 'color': 'black'}
|
value=list_files()[0]['value'],
|
||||||
)
|
clearable=False,
|
||||||
], style={'padding': '20px', 'backgroundColor': '#1e1e1e'}),
|
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'),
|
html.Div(id='info-banner'),
|
||||||
dcc.Graph(id='fig-map'),
|
dcc.Graph(id='fig-map'),
|
||||||
dcc.Graph(id='fig-elevation'),
|
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):
|
def update_all_plots(json_data):
|
||||||
df = pd.read_json(io.StringIO(json_data), orient='split')
|
df = pd.read_json(io.StringIO(json_data), orient='split')
|
||||||
|
|
||||||
info = create_info_banner(df)
|
info = create_info_banner(df)
|
||||||
fig_map = create_map_plot(df)
|
fig_map = create_map_plot(df)
|
||||||
fig_elev = create_elevation_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
|
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(
|
@app.callback(
|
||||||
Output('fig-map', 'figure'),
|
Output('fig-map', 'figure'),
|
||||||
Input('fig-elevation', 'hoverData'),
|
Input('fig-elevation', 'hoverData'),
|
||||||
|
|||||||
Reference in New Issue
Block a user