feat: render activity map directly inline using iframe srcdoc
This commit is contained in:
@@ -114,9 +114,20 @@ async def generate():
|
|||||||
|
|
||||||
html_text = None
|
html_text = None
|
||||||
for item in result:
|
for item in result:
|
||||||
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text:
|
if isinstance(item, TextContent):
|
||||||
html_text = item.text
|
if "<!DOCTYPE html>" in item.text:
|
||||||
break
|
html_text = item.text
|
||||||
|
break
|
||||||
|
elif '<iframe srcdoc="' in item.text:
|
||||||
|
prefix = '<iframe srcdoc="'
|
||||||
|
suffix = '" style="'
|
||||||
|
start = item.text.find(prefix)
|
||||||
|
if start != -1:
|
||||||
|
start += len(prefix)
|
||||||
|
end = item.text.find(suffix, start)
|
||||||
|
if end != -1:
|
||||||
|
html_text = item.text[start:end].replace(""", '"')
|
||||||
|
break
|
||||||
elif (
|
elif (
|
||||||
isinstance(item, EmbeddedResource)
|
isinstance(item, EmbeddedResource)
|
||||||
and item.resource.mimeType == "text/html"
|
and item.resource.mimeType == "text/html"
|
||||||
@@ -181,9 +192,20 @@ async def generate():
|
|||||||
# Find the TextContent that holds the HTML
|
# Find the TextContent that holds the HTML
|
||||||
html_text = None
|
html_text = None
|
||||||
for item in result:
|
for item in result:
|
||||||
if isinstance(item, TextContent) and "<!DOCTYPE html>" in item.text:
|
if isinstance(item, TextContent):
|
||||||
html_text = item.text
|
if "<!DOCTYPE html>" in item.text:
|
||||||
break
|
html_text = item.text
|
||||||
|
break
|
||||||
|
elif '<iframe srcdoc="' in item.text:
|
||||||
|
prefix = '<iframe srcdoc="'
|
||||||
|
suffix = '" style="'
|
||||||
|
start = item.text.find(prefix)
|
||||||
|
if start != -1:
|
||||||
|
start += len(prefix)
|
||||||
|
end = item.text.find(suffix, start)
|
||||||
|
if end != -1:
|
||||||
|
html_text = item.text[start:end].replace(""", '"')
|
||||||
|
break
|
||||||
elif (
|
elif (
|
||||||
isinstance(item, EmbeddedResource)
|
isinstance(item, EmbeddedResource)
|
||||||
and item.resource.mimeType == "text/html"
|
and item.resource.mimeType == "text/html"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Strava MCP Server"""
|
"""Strava MCP Server"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.2"
|
||||||
|
|||||||
@@ -471,14 +471,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
<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>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {{
|
html, body {{
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}}
|
}}
|
||||||
.map-container {{
|
.map-container {{
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -619,38 +621,13 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
|
|
||||||
L.control.layers(baseMaps, null, {{ position: 'topleft' }}).addTo(map);
|
L.control.layers(baseMaps, null, {{ position: 'topleft' }}).addTo(map);
|
||||||
|
|
||||||
function decodePolyline(encoded) {{
|
var distanceData = {json.dumps(distance_data)};
|
||||||
var points = [];
|
var altitudeData = {json.dumps(altitude_data)};
|
||||||
var index = 0, len = encoded.length;
|
var latlngData = {json.dumps(latlng_data)};
|
||||||
var lat = 0, lng = 0;
|
|
||||||
while (index < len) {{
|
|
||||||
var b, shift = 0, result = 0;
|
|
||||||
do {{
|
|
||||||
b = encoded.charCodeAt(index++) - 63;
|
|
||||||
result |= (b & 0x1f) << shift;
|
|
||||||
shift += 5;
|
|
||||||
}} while (b >= 0x20);
|
|
||||||
var dlat = ((result & 1) ? ~(result >> 1) : (result >> 1));
|
|
||||||
lat += dlat;
|
|
||||||
shift = 0;
|
|
||||||
result = 0;
|
|
||||||
do {{
|
|
||||||
b = encoded.charCodeAt(index++) - 63;
|
|
||||||
result |= (b & 0x1f) << shift;
|
|
||||||
shift += 5;
|
|
||||||
}} while (b >= 0x20);
|
|
||||||
var dlng = ((result & 1) ? ~(result >> 1) : (result >> 1));
|
|
||||||
lng += dlng;
|
|
||||||
points.push([lat / 1e5, lng / 1e5]);
|
|
||||||
}}
|
|
||||||
return points;
|
|
||||||
}}
|
|
||||||
|
|
||||||
var encodedPolyline = {json.dumps(polyline)};
|
|
||||||
var coordinates = decodePolyline(encodedPolyline);
|
|
||||||
var track;
|
var track;
|
||||||
if (coordinates.length > 0) {{
|
if (latlngData.length > 0) {{
|
||||||
track = L.polyline(coordinates, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
|
track = L.polyline(latlngData, {{color: '#fc4c02', weight: 5, opacity: 0.8}}).addTo(map);
|
||||||
map.fitBounds(track.getBounds());
|
map.fitBounds(track.getBounds());
|
||||||
|
|
||||||
// Start- und End-Marker zeichnen
|
// Start- und End-Marker zeichnen
|
||||||
@@ -660,7 +637,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
iconAnchor: [9, 9],
|
iconAnchor: [9, 9],
|
||||||
className: 'start-marker-emoji'
|
className: 'start-marker-emoji'
|
||||||
}});
|
}});
|
||||||
L.marker(coordinates[0], {{ icon: startIcon, zIndexOffset: 500 }}).addTo(map);
|
L.marker(latlngData[0], {{ icon: startIcon, zIndexOffset: 500 }}).addTo(map);
|
||||||
|
|
||||||
var endIcon = L.divIcon({{
|
var endIcon = L.divIcon({{
|
||||||
html: '<span style="font-size: 22px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); line-height: 22px; display: block; text-align: center;">🏁</span>',
|
html: '<span style="font-size: 22px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); line-height: 22px; display: block; text-align: center;">🏁</span>',
|
||||||
@@ -668,7 +645,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
iconAnchor: [11, 20],
|
iconAnchor: [11, 20],
|
||||||
className: 'end-marker-emoji'
|
className: 'end-marker-emoji'
|
||||||
}});
|
}});
|
||||||
L.marker(coordinates[coordinates.length - 1], {{ icon: endIcon, zIndexOffset: 500 }}).addTo(map);
|
L.marker(latlngData[latlngData.length - 1], {{ icon: endIcon, zIndexOffset: 500 }}).addTo(map);
|
||||||
}} else {{
|
}} else {{
|
||||||
map.setView([0, 0], 2);
|
map.setView([0, 0], 2);
|
||||||
}}
|
}}
|
||||||
@@ -682,9 +659,6 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
// Render Elevation Chart if data is available
|
// Render Elevation Chart if data is available
|
||||||
if ({has_elevation_chart}) {{
|
if ({has_elevation_chart}) {{
|
||||||
var ctx = document.getElementById('elevationChart').getContext('2d');
|
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 sportEmoji = "{sport_emoji}";
|
||||||
var hoverIcon = L.divIcon({{
|
var hoverIcon = L.divIcon({{
|
||||||
@@ -870,16 +844,9 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
return [
|
escaped_html = html_content.strip().replace('"', """)
|
||||||
_user_html_resource(
|
iframe_content = f'<iframe srcdoc="{escaped_html}" style="width: 100%; height: 600px; border: none; border-radius: 12px;"></iframe>'
|
||||||
f"internal://activities/{activity_id}/map.html",
|
return [TextContent(type="text", text=iframe_content)]
|
||||||
html_content.strip(),
|
|
||||||
),
|
|
||||||
_resource(
|
|
||||||
f"internal://activities/{activity_id}/map",
|
|
||||||
{"polyline": polyline},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error rendering activity map: {str(e)}"
|
error_msg = f"Error rendering activity map: {str(e)}"
|
||||||
await ctx.error(error_msg)
|
await ctx.error(error_msg)
|
||||||
|
|||||||
+15
-20
@@ -46,27 +46,22 @@ async def test_get_activity_map_success():
|
|||||||
get_activity_map = mcp.tools["get_activity_map"]
|
get_activity_map = mcp.tools["get_activity_map"]
|
||||||
result = await get_activity_map(ctx, 12345)
|
result = await get_activity_map(ctx, 12345)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 1
|
||||||
# First block is the HTML embedded resource
|
assert result[0].type == "text"
|
||||||
assert result[0].type == "resource"
|
iframe_text = result[0].text
|
||||||
assert result[0].resource.mimeType == "text/html"
|
assert iframe_text.startswith('<iframe srcdoc="')
|
||||||
assert "internal://activities/12345/map.html" in str(result[0].resource.uri)
|
assert iframe_text.endswith('"></iframe>')
|
||||||
|
|
||||||
html_text = result[0].resource.text
|
decoded_html = iframe_text.replace(""", '"')
|
||||||
assert "<!DOCTYPE html>" in html_text
|
assert "<!DOCTYPE html>" in decoded_html
|
||||||
assert "a~l~FqzbwOq}@it@..." in html_text
|
assert "unpkg.com/leaflet" in decoded_html
|
||||||
assert "unpkg.com/leaflet" in html_text
|
assert "latlngData" in decoded_html
|
||||||
assert "latlngData" in html_text
|
assert "[47.1, 9.1]" in decoded_html
|
||||||
assert "[47.1, 9.1]" in html_text
|
assert 'sportEmoji = "🚴"' in decoded_html
|
||||||
assert 'sportEmoji = "🚴"' in html_text
|
assert "L.control.layers" in decoded_html
|
||||||
assert "L.control.layers" in html_text
|
assert "satellite" in decoded_html
|
||||||
assert "satellite" in html_text
|
assert "🟢" in decoded_html
|
||||||
assert "🟢" in html_text
|
assert "🏁" in decoded_html
|
||||||
assert "🏁" in html_text
|
|
||||||
|
|
||||||
# Second block is the internal resource
|
|
||||||
assert result[1].type == "resource"
|
|
||||||
assert "internal://activities/12345/map" in str(result[1].resource.uri)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user