feat(map-server): add screenshot to updateModelContext and remove reverse geocode + rounded corners (#340)
* feat(map-server): add screenshot to updateModelContext and remove rounded corners - Remove border-radius and overflow: hidden from cesiumContainer CSS - Capture map canvas as base64 PNG and include in updateModelContext - Only include image if host supports image content blocks * feat(map-server): reduce screenshot size for fewer tokens - Scale down to max 256px (longest dimension) - Use PNG for better quality with map graphics * prettier
This commit is contained in:
parent
c2037f3474
commit
9967693621
2 changed files with 50 additions and 142 deletions
|
|
@ -19,8 +19,6 @@
|
|||
#cesiumContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: .75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
#fullscreen-btn {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
* a navigate-to tool for the host to control navigation.
|
||||
*/
|
||||
import { App } from "@modelcontextprotocol/ext-apps";
|
||||
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
|
||||
|
||||
// TypeScript declaration for Cesium loaded from CDN
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -14,6 +15,8 @@ declare let Cesium: any;
|
|||
const CESIUM_VERSION = "1.123";
|
||||
const CESIUM_BASE_URL = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VERSION}/Build/Cesium`;
|
||||
|
||||
const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max width/height for screenshots in pixels for updateModelContext
|
||||
|
||||
/**
|
||||
* Dynamically load CesiumJS from CDN
|
||||
* This is necessary because external <script src=""> tags don't work in srcdoc iframes
|
||||
|
|
@ -262,133 +265,6 @@ function getScaleDimensions(extent: BoundingBox): {
|
|||
return { widthKm, heightKm };
|
||||
}
|
||||
|
||||
// Rate limiting for Nominatim (1 request per second per their usage policy)
|
||||
let lastNominatimRequest = 0;
|
||||
const NOMINATIM_RATE_LIMIT_MS = 1100; // 1.1 seconds to be safe
|
||||
|
||||
/**
|
||||
* Wait for rate limit before making a Nominatim request
|
||||
*/
|
||||
async function waitForRateLimit(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - lastNominatimRequest;
|
||||
if (timeSinceLastRequest < NOMINATIM_RATE_LIMIT_MS) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, NOMINATIM_RATE_LIMIT_MS - timeSinceLastRequest),
|
||||
);
|
||||
}
|
||||
lastNominatimRequest = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse geocode a single point using Nominatim
|
||||
* Returns the place name for that location
|
||||
*/
|
||||
async function reverseGeocode(
|
||||
lat: number,
|
||||
lon: number,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
await waitForRateLimit();
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "CesiumJS-Globe-MCP-App/1.0",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
log.warn("Reverse geocode failed:", response.status);
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
// Extract short place name from address
|
||||
const addr = data.address;
|
||||
if (!addr) return data.display_name?.split(",")[0] || null;
|
||||
// Prefer city > town > village > county > state
|
||||
return (
|
||||
addr.city ||
|
||||
addr.town ||
|
||||
addr.village ||
|
||||
addr.county ||
|
||||
addr.state ||
|
||||
data.display_name?.split(",")[0] ||
|
||||
null
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn("Reverse geocode error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample points within an extent based on the visible area size.
|
||||
* For small areas (city zoom), just sample center.
|
||||
* For larger areas, sample center + corners to discover multiple places.
|
||||
*/
|
||||
function getSamplePoints(
|
||||
extent: BoundingBox,
|
||||
extentSizeKm: number,
|
||||
): Array<{ lat: number; lon: number }> {
|
||||
const centerLat = (extent.north + extent.south) / 2;
|
||||
const centerLon = (extent.east + extent.west) / 2;
|
||||
|
||||
// Always include center
|
||||
const points: Array<{ lat: number; lon: number }> = [
|
||||
{ lat: centerLat, lon: centerLon },
|
||||
];
|
||||
|
||||
// For larger extents, add more sample points
|
||||
if (extentSizeKm > 100) {
|
||||
// > 100km: sample 4 quadrant centers
|
||||
const latOffset = (extent.north - extent.south) / 4;
|
||||
const lonOffset = (extent.east - extent.west) / 4;
|
||||
points.push(
|
||||
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
|
||||
{ lat: centerLat + latOffset, lon: centerLon + lonOffset }, // NE
|
||||
{ lat: centerLat - latOffset, lon: centerLon - lonOffset }, // SW
|
||||
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
|
||||
);
|
||||
} else if (extentSizeKm > 30) {
|
||||
// 30-100km: sample 2 opposite corners
|
||||
const latOffset = (extent.north - extent.south) / 4;
|
||||
const lonOffset = (extent.east - extent.west) / 4;
|
||||
points.push(
|
||||
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
|
||||
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
|
||||
);
|
||||
}
|
||||
// < 30km: just center (likely same city)
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get places visible in the extent by sampling multiple points
|
||||
* Returns array of unique place names
|
||||
*/
|
||||
async function getVisiblePlaces(extent: BoundingBox): Promise<string[]> {
|
||||
const { widthKm, heightKm } = getScaleDimensions(extent);
|
||||
const extentSizeKm = Math.max(widthKm, heightKm);
|
||||
const samplePoints = getSamplePoints(extent, extentSizeKm);
|
||||
|
||||
log.info(
|
||||
`Sampling ${samplePoints.length} points for extent ${extentSizeKm.toFixed(0)}km`,
|
||||
);
|
||||
|
||||
const places = new Set<string>();
|
||||
for (const point of samplePoints) {
|
||||
const place = await reverseGeocode(point.lat, point.lon);
|
||||
if (place) {
|
||||
places.add(place);
|
||||
log.info(
|
||||
`Found place: ${place} at ${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...places];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced location update using multi-point reverse geocoding.
|
||||
* Samples multiple points in the visible extent to discover places.
|
||||
|
|
@ -410,19 +286,53 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
|
|||
}
|
||||
|
||||
const { widthKm, heightKm } = getScaleDimensions(extent);
|
||||
const places = await getVisiblePlaces(extent);
|
||||
|
||||
// Update the model's context with the current map location.
|
||||
// If the host doesn't support this, the request will silently fail.
|
||||
const content = [
|
||||
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `,
|
||||
`and has changed to the following location: [${places.join(", ")}] `,
|
||||
`lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`,
|
||||
].join("\n");
|
||||
log.info("Updating model context:", content);
|
||||
app.updateModelContext({
|
||||
content: [{ type: "text", text: content }],
|
||||
});
|
||||
// Update the model's context with the current map location and screenshot.
|
||||
const text =
|
||||
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall, ` +
|
||||
`centered on lat. / long. [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`;
|
||||
|
||||
// Build content array with text and optional screenshot
|
||||
const content: ContentBlock[] = [{ type: "text", text }];
|
||||
|
||||
// Add screenshot if host supports image content
|
||||
if (app.getHostCapabilities()?.updateModelContext?.image) {
|
||||
try {
|
||||
// Scale down to reduce token usage (tokens depend on dimensions)
|
||||
const sourceCanvas = cesiumViewer.canvas;
|
||||
const scale = Math.min(
|
||||
1,
|
||||
MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION /
|
||||
Math.max(sourceCanvas.width, sourceCanvas.height),
|
||||
);
|
||||
const targetWidth = Math.round(sourceCanvas.width * scale);
|
||||
const targetHeight = Math.round(sourceCanvas.height * scale);
|
||||
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = targetWidth;
|
||||
tempCanvas.height = targetHeight;
|
||||
const ctx = tempCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.drawImage(sourceCanvas, 0, 0, targetWidth, targetHeight);
|
||||
const dataUrl = tempCanvas.toDataURL("image/png");
|
||||
const base64Data = dataUrl.split(",")[1];
|
||||
if (base64Data) {
|
||||
content.push({
|
||||
type: "image",
|
||||
data: base64Data,
|
||||
mimeType: "image/png",
|
||||
});
|
||||
log.info(
|
||||
`Added screenshot to model context (${targetWidth}x${targetHeight})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to capture screenshot:", err);
|
||||
}
|
||||
}
|
||||
|
||||
app.updateModelContext({ content });
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue