feat: add interactive activity map visualization with elevation charts and update test suite to support local map generation.
This commit is contained in:
@@ -0,0 +1,201 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Add src directory to path
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||||
|
|
||||||
|
from strava_mcp_server.strava_client import StravaClient
|
||||||
|
from mcp.server.fastmcp import Context
|
||||||
|
from mcp.types import TextContent
|
||||||
|
|
||||||
|
|
||||||
|
class MockContext(Context):
|
||||||
|
def __init__(self):
|
||||||
|
# Initialize parent with dummy request / info
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def info(self, msg):
|
||||||
|
print(f"ℹ️ [INFO] {msg}")
|
||||||
|
|
||||||
|
async def warning(self, msg):
|
||||||
|
print(f"⚠️ [WARN] {msg}")
|
||||||
|
|
||||||
|
async def error(self, msg):
|
||||||
|
print(f"❌ [ERROR] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
from strava_mcp_server.config import get_config_file
|
||||||
|
|
||||||
|
# Load user config path
|
||||||
|
config_file = get_config_file()
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
load_dotenv(dotenv_path=config_file)
|
||||||
|
|
||||||
|
# Fallback to local .env
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
client_id = os.getenv("STRAVA_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("STRAVA_CLIENT_SECRET")
|
||||||
|
refresh_token = os.getenv("STRAVA_REFRESH_TOKEN")
|
||||||
|
|
||||||
|
if not all([client_id, client_secret, refresh_token]):
|
||||||
|
print(
|
||||||
|
"ℹ️ Missing Strava credentials in .env file. Generating a beautiful MOCK route (Alpe d'Huez, France) for testing!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A real encoded polyline representing the famous Alpe d'Huez climb in France!
|
||||||
|
mock_polyline = (
|
||||||
|
"y~yxFn{_e@_AmAcAaBo@sAGo@Gs@H_ANc@jAyB^kALu@D_@Fk@KqBIkACe@DuABsAd@sBHi@@e@KoAKo@eAsC"
|
||||||
|
"e@oAMa@Bs@Jc@Nk@bAgBv@wAd@{AJc@Ds@Fk@GqAMmAEc@ByA@uAb@sBDi@@e@KoAKo@eAsCe@oAMa@Bs@Jc@Nk"
|
||||||
|
"@bAgBv@wAd@{AJc@Ds@Fk@GqAMmAEc@ByA@uAb@sBDi@@e@KoAKo@eAsCe@oAMa@Bs@Jc@Nk@bAgBv@wAd@{"
|
||||||
|
"AJc@Ds@Fk@GqAMmAEc@ByA@oB`@cCDq@@e@IoAKo@eAsCe@oAMi@FkADqAd@sBHi@@e@KoAKo@eAsCe@oAM"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import the tool function
|
||||||
|
from strava_mcp_server.tools.activities import register
|
||||||
|
|
||||||
|
class MockMCP:
|
||||||
|
def __init__(self):
|
||||||
|
self.tools = {}
|
||||||
|
|
||||||
|
def tool(self):
|
||||||
|
def decorator(func):
|
||||||
|
self.tools[func.__name__] = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
mcp = MockMCP()
|
||||||
|
|
||||||
|
# Mock StravaClient that returns our mock activity and streams
|
||||||
|
class MockClient:
|
||||||
|
async def get_activity(self, activity_id):
|
||||||
|
return {
|
||||||
|
"name": "🏔️ Alpe d'Huez Climb (Mock Test)",
|
||||||
|
"map": {"polyline": mock_polyline},
|
||||||
|
"distance": 14000,
|
||||||
|
"moving_time": 3600,
|
||||||
|
"total_elevation_gain": 1110,
|
||||||
|
"average_speed": 3.88,
|
||||||
|
"average_heartrate": 165,
|
||||||
|
"calories": 950,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_activity_streams(self, activity_id, keys):
|
||||||
|
# Generate Alpe d'Huez climbing curve from 740m to 1850m
|
||||||
|
return [
|
||||||
|
{"type": "distance", "data": [i * 100 for i in range(141)]},
|
||||||
|
{
|
||||||
|
"type": "altitude",
|
||||||
|
"data": [
|
||||||
|
740 + int(1110 * (i / 140) ** 0.75) for i in range(141)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "latlng",
|
||||||
|
"data": [
|
||||||
|
[45.1105 + (i * 0.0001), 6.0375 + (i * 0.0001)]
|
||||||
|
for i in range(141)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
client = MockClient()
|
||||||
|
register(mcp, client)
|
||||||
|
|
||||||
|
get_activity_map = mcp.tools["get_activity_map"]
|
||||||
|
ctx = MockContext()
|
||||||
|
|
||||||
|
print("Generating mock map HTML...")
|
||||||
|
result = await get_activity_map(ctx, 99999)
|
||||||
|
|
||||||
|
html_text = None
|
||||||
|
for item in result:
|
||||||
|
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text:
|
||||||
|
html_text = item.text
|
||||||
|
break
|
||||||
|
|
||||||
|
if html_text:
|
||||||
|
output_path = os.path.join(os.path.dirname(__file__), "..", "test.html")
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_text)
|
||||||
|
print(f"✅ Success! Mock map written to '{os.path.abspath(output_path)}'")
|
||||||
|
print(
|
||||||
|
"👉 Double-click 'test.html' or open it in your browser to view the interactive map!"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
client = StravaClient()
|
||||||
|
|
||||||
|
print("Fetching recent activities to find one with GPS data...")
|
||||||
|
try:
|
||||||
|
activities = await client.list_activities(limit=20)
|
||||||
|
gps_activity = None
|
||||||
|
for a in activities:
|
||||||
|
map_data = a.get("map", {})
|
||||||
|
if map_data.get("summary_polyline") or map_data.get("polyline"):
|
||||||
|
gps_activity = a
|
||||||
|
break
|
||||||
|
|
||||||
|
if not gps_activity:
|
||||||
|
print(
|
||||||
|
"❌ No activity with GPS/polyline data found in your recent 20 activities."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
activity_id = gps_activity["id"]
|
||||||
|
print(f"Found GPS Activity: '{gps_activity.get('name')}' (ID: {activity_id})")
|
||||||
|
|
||||||
|
# Import the tool function
|
||||||
|
from strava_mcp_server.tools.activities import register
|
||||||
|
|
||||||
|
class MockMCP:
|
||||||
|
def __init__(self):
|
||||||
|
self.tools = {}
|
||||||
|
|
||||||
|
def tool(self):
|
||||||
|
def decorator(func):
|
||||||
|
self.tools[func.__name__] = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
mcp = MockMCP()
|
||||||
|
register(mcp, client)
|
||||||
|
|
||||||
|
get_activity_map = mcp.tools["get_activity_map"]
|
||||||
|
ctx = MockContext()
|
||||||
|
|
||||||
|
print("Generating map HTML...")
|
||||||
|
result = await get_activity_map(ctx, activity_id)
|
||||||
|
|
||||||
|
# Find the TextContent that holds the HTML
|
||||||
|
html_text = None
|
||||||
|
for item in result:
|
||||||
|
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text:
|
||||||
|
html_text = item.text
|
||||||
|
break
|
||||||
|
|
||||||
|
if html_text:
|
||||||
|
output_path = os.path.join(os.path.dirname(__file__), "..", "test.html")
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_text)
|
||||||
|
print(f"✅ Success! Map written to '{os.path.abspath(output_path)}'")
|
||||||
|
print(
|
||||||
|
"👉 Double-click 'test.html' or open it in your browser to view the interactive map!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Maybe it returned a warning message instead of HTML
|
||||||
|
for item in result:
|
||||||
|
if isinstance(item, TextContent):
|
||||||
|
print(f"Returned message: {item.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(generate())
|
||||||
@@ -386,6 +386,68 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
return [TextContent(type="text", text=warning_msg)]
|
return [TextContent(type="text", text=warning_msg)]
|
||||||
|
|
||||||
name = activity.get("name", "Aktivität")
|
name = activity.get("name", "Aktivität")
|
||||||
|
sport_type = activity.get("sport_type") or activity.get("type", "Aktivität")
|
||||||
|
sport_emoji = get_sport_emoji(sport_type)
|
||||||
|
dist_val = f"{activity.get('distance', 0) / 1000:.2f} km"
|
||||||
|
time_val = format_duration_markdown(activity.get("moving_time"))
|
||||||
|
elev_val = f"{activity.get('total_elevation_gain', 0):.0f} m"
|
||||||
|
speed_val = format_speed_or_pace(activity.get("average_speed"), sport_type)
|
||||||
|
hr_val = (
|
||||||
|
f"{activity.get('average_heartrate', 0):.0f} bpm"
|
||||||
|
if activity.get("average_heartrate")
|
||||||
|
else "-"
|
||||||
|
)
|
||||||
|
cal_val = (
|
||||||
|
f"{activity.get('calories', 0):.0f} kcal"
|
||||||
|
if activity.get("calories")
|
||||||
|
else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch streams for elevation chart and hover marker correlation
|
||||||
|
altitude_data = []
|
||||||
|
distance_data = []
|
||||||
|
latlng_data = []
|
||||||
|
try:
|
||||||
|
streams = await strava.get_activity_streams(
|
||||||
|
activity_id, ["distance", "altitude", "latlng"]
|
||||||
|
)
|
||||||
|
if isinstance(streams, dict):
|
||||||
|
altitude_stream = streams.get("altitude", {})
|
||||||
|
altitude_data = altitude_stream.get("data", [])
|
||||||
|
distance_stream = streams.get("distance", {})
|
||||||
|
distance_data = [
|
||||||
|
round(d / 1000, 2) for d in distance_stream.get("data", [])
|
||||||
|
]
|
||||||
|
latlng_stream = streams.get("latlng", {})
|
||||||
|
latlng_data = latlng_stream.get("data", [])
|
||||||
|
elif isinstance(streams, list):
|
||||||
|
for s in streams:
|
||||||
|
if isinstance(s, dict):
|
||||||
|
if s.get("type") == "altitude":
|
||||||
|
altitude_data = s.get("data", [])
|
||||||
|
elif s.get("type") == "distance":
|
||||||
|
distance_data = [
|
||||||
|
round(d / 1000, 2) for d in s.get("data", [])
|
||||||
|
]
|
||||||
|
elif s.get("type") == "latlng":
|
||||||
|
latlng_data = s.get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
await ctx.warning(
|
||||||
|
f"Could not fetch streams for elevation profile: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Downsample to max 150 points for smooth performance
|
||||||
|
max_points = 150
|
||||||
|
if len(altitude_data) > max_points:
|
||||||
|
step = len(altitude_data) // max_points
|
||||||
|
altitude_data = altitude_data[::step][:max_points]
|
||||||
|
distance_data = distance_data[::step][:max_points]
|
||||||
|
if latlng_data:
|
||||||
|
latlng_data = latlng_data[::step][:max_points]
|
||||||
|
|
||||||
|
has_elevation_chart = (
|
||||||
|
"true" if len(altitude_data) > 0 and len(distance_data) > 0 else "false"
|
||||||
|
)
|
||||||
|
|
||||||
html_content = f"""<!DOCTYPE html>
|
html_content = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -394,17 +456,155 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
<title>{name}</title>
|
<title>{name}</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<style>
|
<style>
|
||||||
html, body, #map {{ height: 100%; width: 100%; margin: 0; padding: 0; }}
|
body {{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}}
|
||||||
|
.map-container {{
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-height: 350px;
|
||||||
|
}}
|
||||||
|
#map {{
|
||||||
|
flex: 1;
|
||||||
|
}}
|
||||||
|
#recenter-btn {{
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #fc4c02;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||||
|
transition: background-color 0.2s, transform 0.1s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}}
|
||||||
|
#recenter-btn:hover {{
|
||||||
|
background: #e24300;
|
||||||
|
}}
|
||||||
|
#recenter-btn:active {{
|
||||||
|
transform: scale(0.97);
|
||||||
|
}}
|
||||||
|
.chart-container {{
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||||
|
height: 150px;
|
||||||
|
display: {"block" if has_elevation_chart == "true" else "none"};
|
||||||
|
}}
|
||||||
|
.stats-container {{
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
.stat-card {{
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}}
|
||||||
|
.stat-card:hover {{
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}}
|
||||||
|
.stat-label {{
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}}
|
||||||
|
.stat-value {{
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="map"></div>
|
<div class="map-container">
|
||||||
|
<div id="map"></div>
|
||||||
|
<button id="recenter-btn" onclick="recenterMap()">🗺️ Route zentrieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="elevationChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="stats-container">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">📐 Distanz</span>
|
||||||
|
<span class="stat-value">{dist_val}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">⏱️ Zeit</span>
|
||||||
|
<span class="stat-value">{time_val}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">⚡ Ø Pace/Geschw.</span>
|
||||||
|
<span class="stat-value">{speed_val}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">🏔️ Höhenmeter</span>
|
||||||
|
<span class="stat-value">{elev_val}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">❤️ Ø Herzfrequenz</span>
|
||||||
|
<span class="stat-value">{hr_val}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">🔥 Kalorien</span>
|
||||||
|
<span class="stat-value">{cal_val}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var map = L.map('map');
|
var osm = L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
|
||||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
}}).addTo(map);
|
}});
|
||||||
|
|
||||||
|
var satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{{z}}/{{y}}/{{x}}', {{
|
||||||
|
attribution: 'Tiles © Esri — Source: Esri, USDA, USGS, AEX, GeoEye, IGN, and the GIS User Community'
|
||||||
|
}});
|
||||||
|
|
||||||
|
var map = L.map('map', {{
|
||||||
|
layers: [osm]
|
||||||
|
}});
|
||||||
|
|
||||||
|
var baseMaps = {{
|
||||||
|
"🗺️ Standard": osm,
|
||||||
|
"🛰️ Satellit": satellite
|
||||||
|
}};
|
||||||
|
|
||||||
|
L.control.layers(baseMaps, null, {{ position: 'topleft' }}).addTo(map);
|
||||||
|
|
||||||
function decodePolyline(encoded) {{
|
function decodePolyline(encoded) {{
|
||||||
var points = [];
|
var points = [];
|
||||||
@@ -435,12 +635,208 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
|
|
||||||
var encodedPolyline = {json.dumps(polyline)};
|
var encodedPolyline = {json.dumps(polyline)};
|
||||||
var coordinates = decodePolyline(encodedPolyline);
|
var coordinates = decodePolyline(encodedPolyline);
|
||||||
|
var track;
|
||||||
if (coordinates.length > 0) {{
|
if (coordinates.length > 0) {{
|
||||||
var track = L.polyline(coordinates, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
|
track = L.polyline(coordinates, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
|
||||||
map.fitBounds(track.getBounds());
|
map.fitBounds(track.getBounds());
|
||||||
}} else {{
|
}} else {{
|
||||||
map.setView([0, 0], 2);
|
map.setView([0, 0], 2);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
function recenterMap() {{
|
||||||
|
if (track) {{
|
||||||
|
map.fitBounds(track.getBounds(), {{ animate: true, duration: 0.5 }});
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Render Elevation Chart if data is available
|
||||||
|
if ({has_elevation_chart}) {{
|
||||||
|
var ctx = document.getElementById('elevationChart').getContext('2d');
|
||||||
|
var distanceData = {json.dumps(distance_data)};
|
||||||
|
var altitudeData = {json.dumps(altitude_data)};
|
||||||
|
var latlngData = {json.dumps(latlng_data)};
|
||||||
|
|
||||||
|
var sportEmoji = "{sport_emoji}";
|
||||||
|
var hoverIcon = L.divIcon({{
|
||||||
|
html: '<span style="font-size: 24px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35)); line-height: 24px; display: block; text-align: center;">' + sportEmoji + '</span>',
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12],
|
||||||
|
className: 'hover-marker-emoji'
|
||||||
|
}});
|
||||||
|
|
||||||
|
var hoverMarker = L.marker([0, 0], {{ icon: hoverIcon }});
|
||||||
|
|
||||||
|
var hoverLinePlugin = {{
|
||||||
|
id: 'hoverLine',
|
||||||
|
afterDraw: function(chart) {{
|
||||||
|
if (chart.tooltip && chart.tooltip._active && chart.tooltip._active.length > 0) {{
|
||||||
|
var activePoint = chart.tooltip._active[0];
|
||||||
|
var ctx = chart.ctx;
|
||||||
|
var x = activePoint.element.x;
|
||||||
|
var topY = chart.scales.y.top;
|
||||||
|
var bottomY = chart.scales.y.bottom;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.setLineDash([5, 5]); // Gestrichelt
|
||||||
|
ctx.moveTo(x, topY);
|
||||||
|
ctx.lineTo(x, bottomY);
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeStyle = 'rgba(252, 76, 2, 0.5)'; // Strava-Orange transparent
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
var elevationChartInstance = new Chart(ctx, {{
|
||||||
|
type: 'line',
|
||||||
|
data: {{
|
||||||
|
labels: distanceData,
|
||||||
|
datasets: [{{
|
||||||
|
label: 'Höhe (m)',
|
||||||
|
data: altitudeData,
|
||||||
|
borderColor: '#fc4c02',
|
||||||
|
backgroundColor: 'rgba(252, 76, 2, 0.08)',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: '#fc4c02'
|
||||||
|
}}]
|
||||||
|
}},
|
||||||
|
options: {{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {{
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
}},
|
||||||
|
onHover: function(event, activeElements) {{
|
||||||
|
if (activeElements && activeElements.length > 0 && latlngData && latlngData.length > 0) {{
|
||||||
|
var index = activeElements[0].index;
|
||||||
|
var coord = latlngData[index];
|
||||||
|
if (coord && coord.length === 2) {{
|
||||||
|
hoverMarker.setLatLng(coord);
|
||||||
|
if (!map.hasLayer(hoverMarker)) {{
|
||||||
|
hoverMarker.addTo(map);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}} else {{
|
||||||
|
if (map.hasLayer(hoverMarker)) {{
|
||||||
|
map.removeLayer(hoverMarker);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
plugins: {{
|
||||||
|
legend: {{ display: false }},
|
||||||
|
tooltip: {{
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {{
|
||||||
|
title: function(context) {{
|
||||||
|
return 'Distanz: ' + context[0].label + ' km';
|
||||||
|
}},
|
||||||
|
label: function(context) {{
|
||||||
|
return 'Höhe: ' + context.raw + ' m';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
scales: {{
|
||||||
|
x: {{
|
||||||
|
grid: {{ display: false }},
|
||||||
|
ticks: {{
|
||||||
|
maxTicksLimit: 10,
|
||||||
|
callback: function(value, index, values) {{
|
||||||
|
return distanceData[index] + ' km';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
y: {{
|
||||||
|
grid: {{ color: '#f3f4f6' }},
|
||||||
|
ticks: {{ maxTicksLimit: 4 }}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
plugins: [hoverLinePlugin]
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Snapping coordinates from map hover to chart
|
||||||
|
map.on('mousemove', function(e) {{
|
||||||
|
if (!latlngData || latlngData.length === 0 || !elevationChartInstance) return;
|
||||||
|
|
||||||
|
var mouseLatLng = e.latlng;
|
||||||
|
var closestIndex = -1;
|
||||||
|
var minDistance = Infinity;
|
||||||
|
|
||||||
|
for (var i = 0; i < latlngData.length; i++) {{
|
||||||
|
var pt = L.latLng(latlngData[i]);
|
||||||
|
var dist = mouseLatLng.distanceTo(pt);
|
||||||
|
if (dist < minDistance) {{
|
||||||
|
minDistance = dist;
|
||||||
|
closestIndex = i;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Threshold: 300 meters, dynamically adjusted based on zoom so it snaps comfortably
|
||||||
|
var zoom = map.getZoom();
|
||||||
|
var threshold = Math.max(100, Math.min(1000, 4000 / Math.pow(1.5, zoom - 12)));
|
||||||
|
|
||||||
|
if (closestIndex !== -1 && minDistance < threshold) {{
|
||||||
|
// Snap marker to the closest track coordinate
|
||||||
|
var snapCoord = latlngData[closestIndex];
|
||||||
|
hoverMarker.setLatLng(snapCoord);
|
||||||
|
if (!map.hasLayer(hoverMarker)) {{
|
||||||
|
hoverMarker.addTo(map);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Programmatically highlight point in Chart.js using precise pre-calculated element coordinates
|
||||||
|
var meta = elevationChartInstance.getDatasetMeta(0);
|
||||||
|
var element = meta.data[closestIndex];
|
||||||
|
if (element) {{
|
||||||
|
elevationChartInstance.setActiveElements([{{ datasetIndex: 0, index: closestIndex }}]);
|
||||||
|
elevationChartInstance.tooltip.setActiveElements([{{ datasetIndex: 0, index: closestIndex }}], {{
|
||||||
|
x: element.x,
|
||||||
|
y: element.y
|
||||||
|
}});
|
||||||
|
elevationChartInstance.update('none'); // Update without animation for instant snap performance
|
||||||
|
}}
|
||||||
|
}} else {{
|
||||||
|
// Hide marker and clear chart highlight if too far away
|
||||||
|
if (map.hasLayer(hoverMarker)) {{
|
||||||
|
map.removeLayer(hoverMarker);
|
||||||
|
}}
|
||||||
|
elevationChartInstance.setActiveElements([]);
|
||||||
|
elevationChartInstance.tooltip.setActiveElements([], {{ x: 0, y: 0 }});
|
||||||
|
elevationChartInstance.update('none');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Also hide marker if mouse leaves the map container or canvas entirely
|
||||||
|
map.on('mouseout', function() {{
|
||||||
|
if (map.hasLayer(hoverMarker)) {{
|
||||||
|
map.removeLayer(hoverMarker);
|
||||||
|
}}
|
||||||
|
if (elevationChartInstance) {{
|
||||||
|
elevationChartInstance.setActiveElements([]);
|
||||||
|
elevationChartInstance.tooltip.setActiveElements([], {{ x: 0, y: 0 }});
|
||||||
|
elevationChartInstance.update('none');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
document.getElementById('elevationChart').addEventListener('mouseleave', function() {{
|
||||||
|
if (map.hasLayer(hoverMarker)) {{
|
||||||
|
map.removeLayer(hoverMarker);
|
||||||
|
}}
|
||||||
|
if (elevationChartInstance) {{
|
||||||
|
elevationChartInstance.setActiveElements([]);
|
||||||
|
elevationChartInstance.tooltip.setActiveElements([], {{ x: 0, y: 0 }});
|
||||||
|
elevationChartInstance.update('none');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ async def test_get_activity_map_success():
|
|||||||
return_value={
|
return_value={
|
||||||
"name": "Evening Ride",
|
"name": "Evening Ride",
|
||||||
"map": {"polyline": "a~l~FqzbwOq}@it@..."},
|
"map": {"polyline": "a~l~FqzbwOq}@it@..."},
|
||||||
|
"sport_type": "Ride",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
strava.get_activity_streams = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"altitude": {"data": [100.0, 101.0, 102.0]},
|
||||||
|
"distance": {"data": [0.0, 100.0, 200.0]},
|
||||||
|
"latlng": {"data": [[47.1, 9.1], [47.2, 9.2], [47.3, 9.3]]},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +52,11 @@ async def test_get_activity_map_success():
|
|||||||
assert "<!DOCTYPE html>" in result[0].text
|
assert "<!DOCTYPE html>" in result[0].text
|
||||||
assert "a~l~FqzbwOq}@it@..." in result[0].text
|
assert "a~l~FqzbwOq}@it@..." in result[0].text
|
||||||
assert "unpkg.com/leaflet" in result[0].text
|
assert "unpkg.com/leaflet" in result[0].text
|
||||||
|
assert "latlngData" in result[0].text
|
||||||
|
assert "[47.1, 9.1]" in result[0].text
|
||||||
|
assert 'sportEmoji = "🚴"' in result[0].text
|
||||||
|
assert "L.control.layers" in result[0].text
|
||||||
|
assert "satellite" in result[0].text
|
||||||
|
|
||||||
# Second block is the internal resource
|
# Second block is the internal resource
|
||||||
assert result[1].type == "resource"
|
assert result[1].type == "resource"
|
||||||
|
|||||||
Reference in New Issue
Block a user