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 {
|
#cesiumContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: .75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
#fullscreen-btn {
|
#fullscreen-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
* a navigate-to tool for the host to control navigation.
|
* a navigate-to tool for the host to control navigation.
|
||||||
*/
|
*/
|
||||||
import { App } from "@modelcontextprotocol/ext-apps";
|
import { App } from "@modelcontextprotocol/ext-apps";
|
||||||
|
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
|
||||||
|
|
||||||
// TypeScript declaration for Cesium loaded from CDN
|
// TypeScript declaration for Cesium loaded from CDN
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -14,6 +15,8 @@ declare let Cesium: any;
|
||||||
const CESIUM_VERSION = "1.123";
|
const CESIUM_VERSION = "1.123";
|
||||||
const CESIUM_BASE_URL = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VERSION}/Build/Cesium`;
|
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
|
* Dynamically load CesiumJS from CDN
|
||||||
* This is necessary because external <script src=""> tags don't work in srcdoc iframes
|
* 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 };
|
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.
|
* Debounced location update using multi-point reverse geocoding.
|
||||||
* Samples multiple points in the visible extent to discover places.
|
* 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 { widthKm, heightKm } = getScaleDimensions(extent);
|
||||||
const places = await getVisiblePlaces(extent);
|
|
||||||
|
|
||||||
// Update the model's context with the current map location.
|
// Update the model's context with the current map location and screenshot.
|
||||||
// If the host doesn't support this, the request will silently fail.
|
const text =
|
||||||
const content = [
|
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall, ` +
|
||||||
`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)}]`;
|
||||||
`and has changed to the following location: [${places.join(", ")}] `,
|
|
||||||
`lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`,
|
// Build content array with text and optional screenshot
|
||||||
].join("\n");
|
const content: ContentBlock[] = [{ type: "text", text }];
|
||||||
log.info("Updating model context:", content);
|
|
||||||
app.updateModelContext({
|
// Add screenshot if host supports image content
|
||||||
content: [{ type: "text", text: 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);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue