feat: add interactive activity map visualization with elevation charts and update test suite to support local map generation.
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Publish to PyPI (push) Successful in 13s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m33s

This commit is contained in:
2026-05-30 12:46:22 +02:00
parent b4ab185240
commit d9523dd35b
3 changed files with 616 additions and 6 deletions
+201
View File
@@ -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())
+402 -6
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution: '&copy; <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 &copy; Esri &mdash; 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>"""
+13
View File
@@ -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"