diff --git a/examples/cohort-heatmap-server/grid-cell.png b/examples/cohort-heatmap-server/grid-cell.png index 4349361f1..7c9fe20e1 100644 Binary files a/examples/cohort-heatmap-server/grid-cell.png and b/examples/cohort-heatmap-server/grid-cell.png differ diff --git a/examples/cohort-heatmap-server/screenshot.png b/examples/cohort-heatmap-server/screenshot.png index e469da81f..2c68d51ce 100644 Binary files a/examples/cohort-heatmap-server/screenshot.png and b/examples/cohort-heatmap-server/screenshot.png differ diff --git a/examples/map-server/grid-cell.png b/examples/map-server/grid-cell.png index 41ebc2247..8c10c7011 100644 Binary files a/examples/map-server/grid-cell.png and b/examples/map-server/grid-cell.png differ diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 473eb50f6..45057c7c5 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -7,6 +7,17 @@
- + + + + + +
+
+ + Annotations (0) + + +
+
+ +
+
Loading globe...
diff --git a/examples/map-server/screenshot.png b/examples/map-server/screenshot.png index 97c919126..661aad4f2 100644 Binary files a/examples/map-server/screenshot.png and b/examples/map-server/screenshot.png differ diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index 3dfa74906..5a95723d1 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -3,7 +3,8 @@ * * Provides tools for: * - geocode: Search for places using OpenStreetMap Nominatim - * - show-map: Display an interactive 3D globe at a given location + * - show-map: Display an interactive 3D globe with annotations (markers, routes, areas, circles) + * - interact: Navigate, add/update/remove annotations on an existing map view */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { @@ -27,6 +28,206 @@ const DIST_DIR = import.meta.filename.endsWith(".ts") : import.meta.dirname; const RESOURCE_URI = "ui://cesium-map/mcp-app.html"; +// ============================================================================= +// Annotation Schemas (discriminated union → oneOf in JSON Schema) +// ============================================================================= + +const PointCoord = z.object({ + latitude: z.number().describe("Latitude, -90 to 90"), + longitude: z.number().describe("Longitude, -180 to 180"), +}); + +/** Optional markdown notes shown in the annotation panel details card. */ +const Description = z + .string() + .optional() + .describe("Optional markdown description (shown in the annotation panel)"); + +const MarkerAnnotation = z.object({ + type: z.literal("marker"), + id: z.string().describe("Unique annotation id (chosen by caller)"), + latitude: z.number().describe("Latitude, -90 to 90"), + longitude: z.number().describe("Longitude, -180 to 180"), + label: z.string().optional().describe("Text label"), + description: Description, + color: z.string().optional().describe('CSS color (default "red")'), +}); + +const RouteAnnotation = z.object({ + type: z.literal("route"), + id: z.string().describe("Unique annotation id (chosen by caller)"), + points: z.array(PointCoord).describe("Ordered waypoints"), + label: z.string().optional().describe("Text label (shown at midpoint)"), + description: Description, + color: z.string().optional().describe('CSS color (default "blue")'), + width: z.number().optional().describe("Line width in px (default 3)"), + dashed: z.boolean().optional().describe("Dashed line style"), +}); + +const AreaAnnotation = z.object({ + type: z.literal("area"), + id: z.string().describe("Unique annotation id (chosen by caller)"), + points: z.array(PointCoord).describe("Polygon vertices (min 3, auto-closed)"), + label: z.string().optional().describe("Text label (shown at centroid)"), + description: Description, + color: z.string().optional().describe('Outline CSS color (default "blue")'), + fillColor: z + .string() + .optional() + .describe('Fill CSS color, e.g. "rgba(255,0,0,0.2)"'), +}); + +const CircleAnnotation = z.object({ + type: z.literal("circle"), + id: z.string().describe("Unique annotation id (chosen by caller)"), + latitude: z.number().describe("Center latitude"), + longitude: z.number().describe("Center longitude"), + radiusKm: z.number().describe("Radius in km"), + label: z.string().optional().describe("Text label (shown at center)"), + description: Description, + color: z.string().optional().describe('Outline CSS color (default "blue")'), + fillColor: z.string().optional().describe("Fill CSS color"), +}); + +const AnnotationSchema = z.discriminatedUnion("type", [ + MarkerAnnotation, + RouteAnnotation, + AreaAnnotation, + CircleAnnotation, +]); + +export type AnnotationDef = z.infer; + +// Update schemas: same discriminator, but type-specific fields are optional +const MarkerAnnotationUpdate = z.object({ + type: z.literal("marker"), + id: z.string().describe("Annotation id to update"), + latitude: z.number().optional().describe("New latitude"), + longitude: z.number().optional().describe("New longitude"), + label: z.string().optional().describe("New label"), + description: Description, + color: z.string().optional().describe("New color"), +}); + +const RouteAnnotationUpdate = z.object({ + type: z.literal("route"), + id: z.string().describe("Annotation id to update"), + points: z.array(PointCoord).optional().describe("Replacement waypoints"), + label: z.string().optional().describe("New label"), + description: Description, + color: z.string().optional().describe("New color"), + width: z.number().optional().describe("New line width"), + dashed: z.boolean().optional().describe("New dashed style"), +}); + +const AreaAnnotationUpdate = z.object({ + type: z.literal("area"), + id: z.string().describe("Annotation id to update"), + points: z + .array(PointCoord) + .optional() + .describe("Replacement polygon vertices"), + label: z.string().optional().describe("New label"), + description: Description, + color: z.string().optional().describe("New outline color"), + fillColor: z.string().optional().describe("New fill color"), +}); + +const CircleAnnotationUpdate = z.object({ + type: z.literal("circle"), + id: z.string().describe("Annotation id to update"), + latitude: z.number().optional().describe("New center latitude"), + longitude: z.number().optional().describe("New center longitude"), + radiusKm: z.number().optional().describe("New radius in km"), + label: z.string().optional().describe("New label"), + description: Description, + color: z.string().optional().describe("New outline color"), + fillColor: z.string().optional().describe("New fill color"), +}); + +const AnnotationUpdateSchema = z.discriminatedUnion("type", [ + MarkerAnnotationUpdate, + RouteAnnotationUpdate, + AreaAnnotationUpdate, + CircleAnnotationUpdate, +]); + +export type AnnotationUpdate = z.infer; + +// ============================================================================= +// Command Queue (shared across stateless server instances) +// ============================================================================= + +/** Commands expire after this many ms if never polled */ +const COMMAND_TTL_MS = 60_000; // 60 seconds + +/** Periodic sweep interval to drop stale queues */ +const SWEEP_INTERVAL_MS = 30_000; // 30 seconds + +/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */ +const POLL_BATCH_WAIT_MS = 200; + +export type MapCommand = + | { + type: "navigate"; + west: number; + south: number; + east: number; + north: number; + label?: string; + fly?: boolean; + } + | { + type: "add"; + annotations: AnnotationDef[]; + } + | { + type: "update"; + annotations: AnnotationUpdate[]; + } + | { + type: "remove"; + ids: string[]; + }; + +interface QueueEntry { + commands: MapCommand[]; + /** Timestamp of the most recent enqueue or dequeue */ + lastActivity: number; +} + +const commandQueues = new Map(); + +function pruneStaleQueues(): void { + const now = Date.now(); + for (const [uuid, entry] of commandQueues) { + if (now - entry.lastActivity > COMMAND_TTL_MS) { + commandQueues.delete(uuid); + } + } +} + +// Periodic sweep so abandoned queues don't leak +setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref(); + +function enqueueCommand(viewUUID: string, command: MapCommand): void { + let entry = commandQueues.get(viewUUID); + if (!entry) { + entry = { commands: [], lastActivity: Date.now() }; + commandQueues.set(viewUUID, entry); + } + entry.commands.push(command); + entry.lastActivity = Date.now(); +} + +function dequeueCommands(viewUUID: string): MapCommand[] { + const entry = commandQueues.get(viewUUID); + if (!entry) return []; + const commands = entry.commands; + commandQueues.delete(viewUUID); + return commands; +} + // Nominatim API response type interface NominatimResult { place_id: number; @@ -112,6 +313,8 @@ export function createServer(): McpServer { "https://*.cesium.com", ], }, + // Clipboard permission for the copy-annotations button + permissions: { clipboardWrite: {} }, }, }; @@ -141,117 +344,416 @@ export function createServer(): McpServer { ); // show-map tool - displays the CesiumJS globe - // Default bounding box: London area registerAppTool( server, "show-map", { title: "Show Map", description: - "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.", + "Display an interactive world map. Specify the view with either a bounding box (`west`/`south`/`east`/`north`) or a center point (`latitude`/`longitude`) with optional `radiusKm` (default 50). Optionally pass initial `annotations` (markers, routes, areas, circles). For a single location the map already centers there, so a marker is redundant unless you need a label.", inputSchema: { west: z .number() .optional() - .default(-0.5) - .describe("Western longitude (-180 to 180)"), + .describe("Western longitude (-180 to 180) — bounding box mode"), south: z .number() .optional() - .default(51.3) - .describe("Southern latitude (-90 to 90)"), + .describe("Southern latitude (-90 to 90) — bounding box mode"), east: z .number() .optional() - .default(0.3) - .describe("Eastern longitude (-180 to 180)"), + .describe("Eastern longitude (-180 to 180) — bounding box mode"), north: z .number() .optional() - .default(51.7) - .describe("Northern latitude (-90 to 90)"), + .describe("Northern latitude (-90 to 90) — bounding box mode"), + latitude: z + .number() + .optional() + .default(48.8566) + .describe("Center latitude (-90 to 90) — center+radius mode"), + longitude: z + .number() + .optional() + .default(2.3522) + .describe("Center longitude (-180 to 180) — center+radius mode"), + radiusKm: z + .number() + .optional() + .default(8) + .describe("Radius in km around center point (default 50)"), label: z .string() .optional() .describe("Optional label to display on the map"), + annotations: z + .array(AnnotationSchema) + .optional() + .default([ + { + type: "marker", + id: "eiffel", + latitude: 48.8584, + longitude: 2.2945, + label: "Eiffel Tower", + color: "#c0392b", + description: + "**Iconic iron lattice tower** built in 1889.\n\n- Height: 330m\n- Visitors: ~7M/year", + }, + { + type: "marker", + id: "louvre", + latitude: 48.8606, + longitude: 2.3376, + label: "Louvre", + color: "#2980b9", + description: + "World's *largest* art museum. See [website](https://www.louvre.fr/en).", + }, + { + type: "marker", + id: "notredame", + latitude: 48.853, + longitude: 2.3499, + label: "Notre-Dame", + color: "#27ae60", + }, + { + type: "route", + id: "walk", + label: "Seine walk", + points: [ + { latitude: 48.8584, longitude: 2.2945 }, + { latitude: 48.8606, longitude: 2.3376 }, + { latitude: 48.853, longitude: 2.3499 }, + ], + color: "#8e44ad", + dashed: true, + description: + "Scenic `3.5km` riverside walk past three landmarks.", + }, + ]) + .describe( + "Initial annotations: markers, routes, areas, or circles to display on the map", + ), }, _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI }, }, - async ({ west, south, east, north, label }): Promise => ({ - content: [ - { - type: "text", - text: `Displaying globe at: W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`, + async ({ + west, + south, + east, + north, + latitude, + longitude, + radiusKm, + label, + annotations, + }): Promise => { + const uuid = randomUUID(); + + // Resolve bounding box: either explicit or computed from center+radius + let bbox: { west: number; south: number; east: number; north: number }; + if (west != null && south != null && east != null && north != null) { + bbox = { west, south, east, north }; + } else if (latitude != null && longitude != null) { + // ~111 km per degree of latitude + const latDelta = (radiusKm ?? 50) / 111; + const lonDelta = + (radiusKm ?? 50) / (111 * Math.cos((latitude * Math.PI) / 180)); + bbox = { + west: longitude - lonDelta, + south: latitude - latDelta, + east: longitude + lonDelta, + north: latitude + latDelta, + }; + } else { + // Default: London area + bbox = { west: -0.5, south: 51.3, east: 0.3, north: 51.7 }; + } + + const initialAnnotations = annotations ?? []; + const annotationSummary = + initialAnnotations.length > 0 + ? ` with ${initialAnnotations.length} annotation(s)` + : ""; + + return { + content: [ + { + type: "text", + text: `Displaying globe (viewUUID: ${uuid}) at: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${label ? ` (${label})` : ""}${annotationSummary}. Use the interact tool with this viewUUID to navigate, add annotations, etc.`, + }, + ], + _meta: { + viewUUID: uuid, + ...(initialAnnotations.length > 0 ? { initialAnnotations } : {}), }, - ], - _meta: { - viewUUID: randomUUID(), - }, - }), + }; + }, ); - // geocode tool - searches for places using Nominatim (no UI) + // interact tool - send actions to an existing map view server.registerTool( - "geocode", + "interact", { - title: "Geocode", - description: - "Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.", + title: "Interact with Map", + description: `Send an action to an existing map view. Actions are queued and batched. + +Actions: +- navigate: Fly/jump to a bounding box. Requires \`west\`, \`south\`, \`east\`, \`north\`. Optional: \`fly\` (default true), \`label\`. +- add: Add annotations (markers, routes, areas, circles). Requires \`annotations\` array. +- update: Update existing annotations. Requires \`annotations\` array with \`id\` + \`type\` and fields to change. +- remove: Remove annotations by id. Requires \`ids\` array.`, inputSchema: { - query: z + viewUUID: z .string() + .describe("The viewUUID of the map (from show-map result)"), + action: z + .enum(["navigate", "add", "update", "remove"]) + .describe("Action to perform"), + // navigate fields + west: z + .number() + .optional() + .describe("Western longitude, -180 to 180 (for navigate)"), + south: z + .number() + .optional() + .describe("Southern latitude, -90 to 90 (for navigate)"), + east: z + .number() + .optional() + .describe("Eastern longitude, -180 to 180 (for navigate)"), + north: z + .number() + .optional() + .describe("Northern latitude, -90 to 90 (for navigate)"), + fly: z + .boolean() + .optional() + .default(true) + .describe("Animate camera flight (for navigate, default true)"), + label: z.string().optional().describe("Label text (for navigate)"), + // add annotations + annotations: z + .array(AnnotationSchema) + .optional() + .describe("Annotations to add (for add action)"), + // update annotations + updates: z + .array(AnnotationUpdateSchema) + .optional() .describe( - "Place name or address to search for (e.g., 'Paris', 'Golden Gate Bridge', '1600 Pennsylvania Ave')", + "Annotation updates with id + type + changed fields (for update action)", ), + // remove annotations + ids: z + .array(z.string()) + .optional() + .describe("Annotation ids to remove (for remove action)"), }, }, - async ({ query }): Promise => { - try { - const results = await geocodeWithNominatim(query); + async ({ + viewUUID: uuid, + action, + west, + south, + east, + north, + fly, + label, + annotations, + updates, + ids, + }): Promise => { + switch (action) { + case "navigate": + if (west == null || south == null || east == null || north == null) + return { + content: [ + { + type: "text", + text: "navigate requires `west`, `south`, `east`, `north`", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { + type: "navigate", + west, + south, + east, + north, + label, + fly, + }); + return { + content: [ + { + type: "text", + text: `Queued: navigate to W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`, + }, + ], + }; - if (results.length === 0) { + case "add": { + if (!annotations || annotations.length === 0) + return { + content: [ + { + type: "text", + text: "add requires a non-empty `annotations` array", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { type: "add", annotations }); + const types = [...new Set(annotations.map((a) => a.type))].join(", "); return { content: [ - { type: "text", text: `No results found for "${query}"` }, + { + type: "text", + text: `Added ${annotations.length} annotation(s) (${types})`, + }, ], }; } - const formattedResults = results.map((r) => ({ - displayName: r.display_name, - lat: parseFloat(r.lat), - lon: parseFloat(r.lon), - boundingBox: { - south: parseFloat(r.boundingbox[0]), - north: parseFloat(r.boundingbox[1]), - west: parseFloat(r.boundingbox[2]), - east: parseFloat(r.boundingbox[3]), - }, - type: r.type, - importance: r.importance, - })); + case "update": { + if (!updates || updates.length === 0) + return { + content: [ + { + type: "text", + text: "update requires a non-empty `updates` array", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { type: "update", annotations: updates }); + return { + content: [ + { + type: "text", + text: `Queued: update ${updates.length} annotation(s)`, + }, + ], + }; + } - const textContent = formattedResults - .map( - (r, i) => - `${i + 1}. ${r.displayName}\n Coordinates: ${r.lat.toFixed(6)}, ${r.lon.toFixed(6)}\n Bounding box: W:${r.boundingBox.west.toFixed(4)}, S:${r.boundingBox.south.toFixed(4)}, E:${r.boundingBox.east.toFixed(4)}, N:${r.boundingBox.north.toFixed(4)}`, - ) - .join("\n\n"); + case "remove": + if (!ids || ids.length === 0) + return { + content: [ + { + type: "text", + text: "remove requires a non-empty `ids` array", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { type: "remove", ids }); + return { + content: [ + { + type: "text", + text: `Queued: remove ${ids.length} annotation(s)`, + }, + ], + }; - return { - content: [{ type: "text", text: textContent }], - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Geocoding error: ${error instanceof Error ? error.message : String(error)}`, + default: + return { + content: [{ type: "text", text: `Unknown action: ${action}` }], + isError: true, + }; + } + }, + ); + + // poll_map_commands - app-only tool for polling pending commands + registerAppTool( + server, + "poll_map_commands", + { + title: "Poll Map Commands", + description: "Poll for pending commands for a map view", + inputSchema: { + viewUUID: z.string().describe("The viewUUID of the map"), + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ viewUUID: uuid }): Promise => { + // If commands are queued, wait a fixed window to let more accumulate + if (commandQueues.has(uuid)) { + await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS)); + } + const commands = dequeueCommands(uuid); + return { + content: [{ type: "text", text: `${commands.length} command(s)` }], + structuredContent: { commands }, + }; + }, + ); + + // geocode tool - searches for places using Nominatim (no UI) + server.registerTool( + "geocode", + { + title: "Geocode", + description: + "Search for places using OpenStreetMap. Accepts one or more queries. Returns coordinates and bounding boxes (top result per query).", + inputSchema: { + queries: z + .array(z.string()) + .describe( + "Place names or addresses to search for (e.g., ['Paris', 'Golden Gate Bridge'])", + ), + }, + }, + async ({ queries }): Promise => { + const sections: string[] = []; + let hasError = false; + + for (const query of queries) { + try { + const results = await geocodeWithNominatim(query); + if (results.length === 0) { + sections.push(`## ${query}\nNo results found.`); + continue; + } + const formatted = results.map((r) => ({ + displayName: r.display_name, + lat: parseFloat(r.lat), + lon: parseFloat(r.lon), + boundingBox: { + south: parseFloat(r.boundingbox[0]), + north: parseFloat(r.boundingbox[1]), + west: parseFloat(r.boundingbox[2]), + east: parseFloat(r.boundingbox[3]), }, - ], - isError: true, - }; + type: r.type, + importance: r.importance, + })); + const lines = formatted.map( + (r, i) => + `${i + 1}. ${r.displayName}\n Coordinates: ${r.lat.toFixed(6)}, ${r.lon.toFixed(6)}\n Bounding box: W:${r.boundingBox.west.toFixed(4)}, S:${r.boundingBox.south.toFixed(4)}, E:${r.boundingBox.east.toFixed(4)}, N:${r.boundingBox.north.toFixed(4)}`, + ); + sections.push(`## ${query}\n${lines.join("\n\n")}`); + } catch (error) { + hasError = true; + sections.push( + `## ${query}\nError: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + + return { + content: [{ type: "text", text: sections.join("\n\n") }], + isError: hasError ? true : undefined, + }; }, ); diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 1475d81e7..aa5f52da8 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -5,7 +5,12 @@ * Receives initial bounding box from the show-map tool and exposes * a navigate-to tool for the host to control navigation. */ -import { App } from "@modelcontextprotocol/ext-apps"; +import { + App, + applyDocumentTheme, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js"; // TypeScript declaration for Cesium loaded from CDN @@ -65,6 +70,10 @@ interface BoundingBox { // eslint-disable-next-line @typescript-eslint/no-explicit-any let viewer: any = null; +// CustomDataSource for annotations (enables EntityCluster) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let annotationDataSource: any = null; + // Debounce timer for reverse geocoding let reverseGeocodeTimer: ReturnType | null = null; @@ -286,11 +295,14 @@ function scheduleLocationUpdate(cesiumViewer: any): void { } const { widthKm, heightKm } = getScaleDimensions(extent); + const diff = computeDiff(); - // Update the model's context with the current map location and screenshot. + // Update the model's context with the current map location, annotation + // state diff, 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)}]`; + `Map view: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km, ` + + `centered on [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]. ` + + `Annotations: ${annotationMap.size} total (${describeDiff(diff)}).`; // Build content array with text and optional screenshot const content: ContentBlock[] = [{ type: "text", text }]; @@ -372,6 +384,10 @@ async function initCesium(): Promise { sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, + // Disable Cesium's built-in entity selection UI (green box + info popup). + // We manage selection via the annotation panel instead. + selectionIndicator: false, + infoBox: false, // Disable terrain (requires Ion) terrainProvider: undefined, // WebGL context options for sandboxed iframe rendering @@ -397,6 +413,7 @@ async function initCesium(): Promise { // CesiumJS sets image-rendering: pixelated by default which looks bad on scaled displays // Setting to "auto" allows the browser to apply smooth interpolation cesiumViewer.canvas.style.imageRendering = "auto"; + // Note: DO NOT set resolutionScale = devicePixelRatio here! // When useBrowserRecommendedResolution: false, Cesium already uses devicePixelRatio. // Setting resolutionScale = devicePixelRatio would double the scaling (e.g., 2x2=4x on Retina) @@ -484,10 +501,43 @@ async function initCesium(): Promise { log.info("Camera positioned, initial rendering started"); - // Set up camera move end listener for reverse geocoding and view persistence + // Create a CustomDataSource for markers with clustering enabled. + // Markers render as pin billboard + native label; both hide together when clustered. + const ds = new Cesium.CustomDataSource("annotations"); + ds.clustering.enabled = true; + // Looser clustering: tolerate more overlap before merging. Higher minimum + // avoids merging just two neighbours into a cluster. + ds.clustering.pixelRange = 25; + ds.clustering.minimumClusterSize = 3; + ds.clustering.clusterBillboards = true; + ds.clustering.clusterLabels = true; + ds.clustering.clusterPoints = true; + + // Style clusters with a count label rendered as a billboard + ds.clustering.clusterEvent.addEventListener( + (clusteredEntities: any[], cluster: any) => { + cluster.label.show = false; + cluster.billboard.show = true; + cluster.billboard.image = renderClusterImage(clusteredEntities.length); + cluster.billboard.verticalOrigin = Cesium.VerticalOrigin.CENTER; + cluster.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY; + const dpr = window.devicePixelRatio || 1; + cluster.billboard.scale = 1 / dpr; + }, + ); + + cesiumViewer.dataSources.add(ds); + annotationDataSource = ds; + log.info("Annotation data source with clustering created"); + + // Set up camera move end listener for reverse geocoding and view persistence. + // Also force a recluster: EntityCluster has hysteresis — clusters form at + // pixelRange on zoom-out but need MORE zoom-in to break apart. Toggling + // clustering off/on after each move gives symmetric behaviour. cesiumViewer.camera.moveEnd.addEventListener(() => { scheduleLocationUpdate(cesiumViewer); schedulePersistViewState(cesiumViewer); + scheduleRecluster(); }); log.info("Camera move listener registered"); @@ -650,7 +700,7 @@ async function toggleFullscreen(): Promise { /** * Handle keyboard shortcuts for fullscreen control * - Escape: Exit fullscreen (when in fullscreen mode) - * - Ctrl/Cmd+Enter: Toggle fullscreen + * - Alt+Enter: Toggle fullscreen */ function handleFullscreenKeyboard(event: KeyboardEvent): void { // Escape to exit fullscreen @@ -660,11 +710,12 @@ function handleFullscreenKeyboard(event: KeyboardEvent): void { return; } - // Ctrl+Enter (Windows/Linux) or Cmd+Enter (Mac) to toggle fullscreen + // Alt+Enter to toggle fullscreen if ( event.key === "Enter" && - (event.ctrlKey || event.metaKey) && - !event.altKey + event.altKey && + !event.ctrlKey && + !event.metaKey ) { event.preventDefault(); toggleFullscreen(); @@ -699,6 +750,7 @@ function handleDisplayModeChange( // Register handlers BEFORE connecting app.onteardown = async () => { log.info("App is being torn down"); + stopPolling(); if (viewer) { viewer.destroy(); viewer = null; @@ -708,23 +760,1630 @@ app.onteardown = async () => { app.onerror = log.error; +/** Apply theme + style variables from the host (for annotation panel light/dark). */ +function applyHostContextTheme(ctx: McpUiHostContext): void { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); +} + // Listen for host context changes (display mode, theme, etc.) app.onhostcontextchanged = (params) => { log.info("Host context changed:", params); + applyHostContextTheme(params); + if (params.displayMode) { handleDisplayModeChange( params.displayMode as "inline" | "fullscreen" | "pip", ); } - // Update button if available modes changed if (params.availableDisplayModes) { updateFullscreenButton(); } }; -// Handle initial tool input (bounding box from show-map tool) +/** + * Compute a bounding box from a center point and radius in km. + */ +function bboxFromCenter( + lat: number, + lon: number, + radiusKm: number, +): BoundingBox { + const latDelta = radiusKm / 111; + const lonDelta = radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); + return { + west: lon - lonDelta, + south: lat - latDelta, + east: lon + lonDelta, + north: lat + latDelta, + }; +} + +// ============================================================================= +// Annotation Types & Tracking +// ============================================================================= + +/** Discriminated union for all annotation types (mirrors server-side AnnotationDef) */ +type AnnotationDef = + | { + type: "marker"; + id: string; + latitude: number; + longitude: number; + label?: string; + description?: string; + color?: string; + } + | { + type: "route"; + id: string; + points: { latitude: number; longitude: number }[]; + label?: string; + description?: string; + color?: string; + width?: number; + dashed?: boolean; + } + | { + type: "area"; + id: string; + points: { latitude: number; longitude: number }[]; + label?: string; + description?: string; + color?: string; + fillColor?: string; + } + | { + type: "circle"; + id: string; + latitude: number; + longitude: number; + radiusKm: number; + label?: string; + description?: string; + color?: string; + fillColor?: string; + }; + +/** Partial updates — id + type required, everything else optional */ +type AnnotationUpdate = + | { + type: "marker"; + id: string; + latitude?: number; + longitude?: number; + label?: string; + description?: string; + color?: string; + } + | { + type: "route"; + id: string; + points?: { latitude: number; longitude: number }[]; + label?: string; + description?: string; + color?: string; + width?: number; + dashed?: boolean; + } + | { + type: "area"; + id: string; + points?: { latitude: number; longitude: number }[]; + label?: string; + description?: string; + color?: string; + fillColor?: string; + } + | { + type: "circle"; + id: string; + latitude?: number; + longitude?: number; + radiusKm?: number; + label?: string; + description?: string; + color?: string; + fillColor?: string; + }; + +type MapCommand = + | { + type: "navigate"; + west: number; + south: number; + east: number; + north: number; + label?: string; + fly?: boolean; + } + | { type: "add"; annotations: AnnotationDef[] } + | { type: "update"; annotations: AnnotationUpdate[] } + | { type: "remove"; ids: string[] }; + +/** Tracked annotation with its Cesium entities */ +interface TrackedAnnotation { + def: AnnotationDef; + /** Entities in the clustered data source (markers only) */ + clusteredEntities: any[]; + /** Entities in viewer.entities (geometry, non-marker labels) */ + viewerEntities: any[]; + /** User-toggleable visibility (eye icon). Hidden annotations stay in the map but entities.show = false. */ + visible: boolean; +} + +/** All annotations on the map, keyed by id */ +const annotationMap = new Map(); + +/** Get all annotations as a flat array */ +function allAnnotations(): TrackedAnnotation[] { + return Array.from(annotationMap.values()); +} + +// ============================================================================= +// Cesium Rendering Helpers +// ============================================================================= + +/** + * Parse a CSS color string to a Cesium Color + */ +function parseCesiumColor(cssColor: string, fallback?: string): any { + try { + return Cesium.Color.fromCssColorString(cssColor); + } catch { + try { + if (fallback) return Cesium.Color.fromCssColorString(fallback); + } catch { + /* ignore */ + } + return Cesium.Color.RED; + } +} + +/** + * Render a cluster count badge as a canvas image. + */ +function renderClusterImage(count: number): string { + const dpr = window.devicePixelRatio || 1; + const size = Math.round(36 * dpr); + const fontSize = Math.round(13 * dpr); + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + + const r = size / 2; + ctx.beginPath(); + ctx.arc(r, r, r - 1, 0, Math.PI * 2); + ctx.fillStyle = "rgba(50, 100, 200, 0.85)"; + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = Math.round(2 * dpr); + ctx.stroke(); + + ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "#fff"; + ctx.fillText(String(count), r, r); + + return canvas.toDataURL("image/png"); +} + +/** + * Render a map-pin shape (teardrop with inner hole) as a canvas image. + * The pin tip is at the bottom center of the canvas. + */ +function renderPinImage(cssColor: string): string { + const dpr = window.devicePixelRatio || 1; + const w = Math.round(24 * dpr); + const h = Math.round(32 * dpr); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d")!; + + const cx = w / 2; + const r = w * 0.42; // head radius + const cy = r + 1 * dpr; // head center + + // Teardrop: circle on top, triangular tail to the bottom tip + ctx.beginPath(); + ctx.arc(cx, cy, r, Math.PI, 0, false); + ctx.quadraticCurveTo(cx + r, cy + r * 0.4, cx, h - 1); + ctx.quadraticCurveTo(cx - r, cy + r * 0.4, cx - r, cy); + ctx.closePath(); + ctx.fillStyle = cssColor; + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,0.35)"; + ctx.lineWidth = 1.5 * dpr; + ctx.stroke(); + + // Inner white hole + ctx.beginPath(); + ctx.arc(cx, cy, r * 0.42, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.95)"; + ctx.fill(); + + return canvas.toDataURL("image/png"); +} + +/** Cache of rendered pin images by color (avoids redundant canvas work). */ +const pinImageCache = new Map(); +function pinImage(cssColor: string): string { + let img = pinImageCache.get(cssColor); + if (!img) { + img = renderPinImage(cssColor); + pinImageCache.set(cssColor, img); + } + return img; +} + +/** + * Common label style for annotation text. Native labels cluster correctly + * (unlike billboard images). Rectangular background only — Cesium's native + * label doesn't support rounded corners — so we keep padding tight. + */ +function labelGraphics(text: string, pixelOffsetY = -36): any { + return { + text, + font: '500 12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.fromCssColorString("rgba(0,0,0,0.8)"), + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: new Cesium.Cartesian2(0, pixelOffsetY), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + showBackground: true, + backgroundColor: Cesium.Color.fromCssColorString("rgba(30,30,30,0.75)"), + backgroundPadding: new Cesium.Cartesian2(5, 3), + }; +} + +/** + * Compute the midpoint of a points array (for route label placement) + */ +function midpoint(points: { latitude: number; longitude: number }[]): { + latitude: number; + longitude: number; +} { + if (points.length === 0) return { latitude: 0, longitude: 0 }; + const mid = points[Math.floor(points.length / 2)]; + return { latitude: mid.latitude, longitude: mid.longitude }; +} + +/** + * Compute the centroid of a points array (for area label placement) + */ +function centroid(points: { latitude: number; longitude: number }[]): { + latitude: number; + longitude: number; +} { + if (points.length === 0) return { latitude: 0, longitude: 0 }; + let lat = 0, + lon = 0; + for (const p of points) { + lat += p.latitude; + lon += p.longitude; + } + return { latitude: lat / points.length, longitude: lon / points.length }; +} + +/** + * Convert a points array to a flat Cesium positions array [lon, lat, lon, lat, ...] + */ +function pointsToDegreesArray( + points: { latitude: number; longitude: number }[], +): number[] { + const arr: number[] = []; + for (const p of points) { + arr.push(p.longitude, p.latitude); + } + return arr; +} + +// ============================================================================= +// Annotation CRUD +// ============================================================================= + +/** + * Add a new annotation to the map. + * Idempotent: re-adding an existing id replaces entities but preserves `visible` state. + */ +function addAnnotation(cesiumViewer: any, def: AnnotationDef): void { + // Preserve visibility across upserts (e.g. from updateAnnotation) + const priorVisible = annotationMap.get(def.id)?.visible ?? true; + if (annotationMap.has(def.id)) { + removeAnnotation(cesiumViewer, def.id); + } + + // Markers go into the clustered data source (so nearby markers merge). + // Routes/areas/circles go into viewer.entities (geometry can't be clustered). + const clusteredEntities: any[] = []; + const viewerEntities: any[] = []; + // Helper: add an entity, tag it with our annotation id, track it. + // Initial visibility respects both the per-item flag and the global override. + const initialShow = globalVisible && priorVisible; + const add = (coll: any, opts: any, bucket: any[]) => { + const ent = coll.add(opts); + ent._annId = def.id; + ent.show = initialShow; + bucket.push(ent); + return ent; + }; + + switch (def.type) { + case "marker": { + const position = Cesium.Cartesian3.fromDegrees( + def.longitude, + def.latitude, + ); + const color = def.color || "red"; + const dpr = window.devicePixelRatio || 1; + + // Single entity: pin billboard + native label. Both hide when clustered. + // The pin's tip is at the bottom-center, so anchor it there. + add( + annotationDataSource.entities, + { + position, + billboard: { + image: pinImage(color), + scale: 1 / dpr, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + ...(def.label ? { label: labelGraphics(def.label) } : {}), + }, + clusteredEntities, + ); + break; + } + + case "route": { + // Note: routes render at actual coordinates. If an endpoint marker gets + // clustered, the route still points to the true location (geographically + // correct) but visually "detaches" from the cluster badge. A future + // refinement could exempt markers that match route waypoints from + // clustering (requires cross-referencing coords). + if (def.points.length < 2) { + log.warn("Route needs at least 2 points, got", def.points.length); + break; + } + const positions = Cesium.Cartesian3.fromDegreesArray( + pointsToDegreesArray(def.points), + ); + const cesiumColor = parseCesiumColor(def.color || "blue"); + const material = def.dashed + ? new Cesium.PolylineDashMaterialProperty({ + color: cesiumColor, + dashLength: 16, + }) + : cesiumColor; + + const mid = midpoint(def.points); + add( + cesiumViewer.entities, + { + // Position is used for the label and for viewer.flyTo to compute a bounding sphere + position: Cesium.Cartesian3.fromDegrees(mid.longitude, mid.latitude), + polyline: { + positions, + width: def.width ?? 3, + material, + clampToGround: true, + }, + ...(def.label ? { label: labelGraphics(def.label, 0) } : {}), + }, + viewerEntities, + ); + break; + } + + case "area": { + if (def.points.length < 3) { + log.warn("Area needs at least 3 points, got", def.points.length); + break; + } + const positions = Cesium.Cartesian3.fromDegreesArray( + pointsToDegreesArray(def.points), + ); + const outlineColor = parseCesiumColor(def.color || "blue"); + const fillColor = def.fillColor + ? parseCesiumColor(def.fillColor) + : outlineColor.withAlpha(0.2); + + const c = centroid(def.points); + add( + cesiumViewer.entities, + { + position: Cesium.Cartesian3.fromDegrees(c.longitude, c.latitude), + polygon: { + hierarchy: positions, + material: fillColor, + outline: true, + outlineColor, + outlineWidth: 2, + }, + ...(def.label ? { label: labelGraphics(def.label, 0) } : {}), + }, + viewerEntities, + ); + break; + } + + case "circle": { + const position = Cesium.Cartesian3.fromDegrees( + def.longitude, + def.latitude, + ); + const outlineColor = parseCesiumColor(def.color || "blue"); + const fillColor = def.fillColor + ? parseCesiumColor(def.fillColor) + : outlineColor.withAlpha(0.15); + + add( + cesiumViewer.entities, + { + position, + ellipse: { + semiMajorAxis: def.radiusKm * 1000, + semiMinorAxis: def.radiusKm * 1000, + material: fillColor, + outline: true, + outlineColor, + outlineWidth: 2, + }, + ...(def.label ? { label: labelGraphics(def.label, 0) } : {}), + }, + viewerEntities, + ); + break; + } + } + + annotationMap.set(def.id, { + def, + clusteredEntities, + viewerEntities, + visible: priorVisible, + }); + updateToolbarButtons(); + renderAnnotationPanel(); + // Workaround: CesiumJS may not cluster entities until camera moves (issue #4536). + // Toggle clustering off/on to force a re-cluster pass. + if (clusteredEntities.length > 0) { + scheduleRecluster(); + } + log.info("Added annotation", def.type, def.id); +} + +let reclusterTimer: ReturnType | null = null; + +/** Debounced clustering refresh (batches rapid add/remove calls) */ +function scheduleRecluster(): void { + if (reclusterTimer) return; // already scheduled + reclusterTimer = setTimeout(() => { + reclusterTimer = null; + if (!annotationDataSource) return; + const c = annotationDataSource.clustering; + c.enabled = false; + c.enabled = true; + }, 0); +} + +/** + * Update an existing annotation by removing and re-adding with merged fields + */ +function updateAnnotation(cesiumViewer: any, update: AnnotationUpdate): void { + const tracked = annotationMap.get(update.id); + if (!tracked) { + log.warn("updateAnnotation: unknown id", update.id); + return; + } + + // Merge update into existing def + const merged = { ...tracked.def, ...update } as AnnotationDef; + + // Remove old and re-add with merged def + removeAnnotation(cesiumViewer, update.id); + addAnnotation(cesiumViewer, merged); + log.info("Updated annotation", update.type, update.id); +} + +/** + * Remove an annotation from the map + */ +function removeAnnotation(cesiumViewer: any, id: string): void { + const tracked = annotationMap.get(id); + if (!tracked) { + log.warn("removeAnnotation: unknown id", id); + return; + } + for (const entity of tracked.clusteredEntities) { + annotationDataSource.entities.remove(entity); + } + for (const entity of tracked.viewerEntities) { + cesiumViewer.entities.remove(entity); + } + annotationMap.delete(id); + selectedIds.delete(id); + if (selectedAnnotationId === id) selectedAnnotationId = null; + if (selectionAnchorId === id) selectionAnchorId = null; + updateToolbarButtons(); + renderAnnotationPanel(); + log.info("Removed annotation", id); +} + +/** + * Global visibility override (master eye). Individual `tracked.visible` flags + * are preserved — effective visibility is `globalVisible && tracked.visible`, + * so toggling the master eye off→on restores per-item state exactly. + */ +let globalVisible = true; + +/** Apply effective visibility (global ∧ individual) to an annotation's entities. */ +function applyEntityVisibility(tracked: TrackedAnnotation): void { + const show = globalVisible && tracked.visible; + for (const e of tracked.clusteredEntities) e.show = show; + for (const e of tracked.viewerEntities) e.show = show; +} + +/** Toggle an annotation's individual visibility (eye icon). */ +function setAnnotationVisibility(id: string, visible: boolean): void { + const tracked = annotationMap.get(id); + if (!tracked) return; + tracked.visible = visible; + applyEntityVisibility(tracked); + if (tracked.clusteredEntities.length > 0) scheduleRecluster(); + persistAnnotations(); + renderAnnotationPanel(); +} + +/** Toggle the global override. Individual flags are untouched. */ +function setGlobalVisibility(visible: boolean): void { + globalVisible = visible; + let hasClustered = false; + for (const t of annotationMap.values()) { + applyEntityVisibility(t); + if (t.clusteredEntities.length > 0) hasClustered = true; + } + if (hasClustered) scheduleRecluster(); + renderAnnotationPanel(); +} + +// ============================================================================= +// Persistence (diff-based) +// ============================================================================= + +/** + * Baseline annotations from the tool invocation (ontoolinput.args.annotations + * + ontoolresult._meta.initialAnnotations). We persist only the *diff* from + * this baseline so localStorage stays small and readable. + */ +const baselineAnnotations = new Map(); + +function recordBaseline(defs: AnnotationDef[]): void { + for (const d of defs) baselineAnnotations.set(d.id, d); +} + +/** Diff of current state vs the baseline. */ +interface AnnotationDiff { + /** Baseline ids the user has deleted. */ + removed: string[]; + /** Baseline ids the user has hidden (visible=false). */ + hidden: string[]; + /** Annotations not present in the baseline (user-added via interact tool). */ + added: AnnotationDef[]; + /** Currently selected ids (restored on reload). */ + selected: string[]; +} + +function computeDiff(): AnnotationDiff { + const removed: string[] = []; + const hidden: string[] = []; + const added: AnnotationDef[] = []; + + // Baseline entries: check if removed or hidden + for (const id of baselineAnnotations.keys()) { + const tracked = annotationMap.get(id); + if (!tracked) removed.push(id); + else if (!tracked.visible) hidden.push(id); + } + // Current entries not in baseline → user-added + for (const t of annotationMap.values()) { + if (!baselineAnnotations.has(t.def.id)) { + added.push(t.def); + // Also record hidden state for added annotations + if (!t.visible) hidden.push(t.def.id); + } + } + + return { removed, hidden, added, selected: [...selectedIds] }; +} + +/** Persist the diff (not the full list) to localStorage. */ +function persistAnnotations(): void { + if (!viewUUID) return; + try { + const diff = computeDiff(); + localStorage.setItem(`${viewUUID}:ann-diff`, JSON.stringify(diff)); + } catch (e) { + log.warn("Failed to persist annotation diff:", e); + } +} + +/** + * Apply a persisted diff on top of the baseline. Called AFTER the baseline + * has been loaded into the map (from ontoolinput / ontoolresult). + */ +function restorePersistedAnnotations(cesiumViewer: any): void { + if (!viewUUID) return; + try { + const stored = localStorage.getItem(`${viewUUID}:ann-diff`); + if (!stored) return; + const diff = JSON.parse(stored) as Partial; + + // Apply additions first (so hidden can also target them) + for (const def of diff.added ?? []) { + if (!annotationMap.has(def.id)) addAnnotation(cesiumViewer, def); + } + // Apply removals + for (const id of diff.removed ?? []) { + if (annotationMap.has(id)) removeAnnotation(cesiumViewer, id); + } + // Apply hidden flags + for (const id of diff.hidden ?? []) { + if (annotationMap.has(id)) setAnnotationVisibility(id, false); + } + // Restore selection (without flying — user will pick up where they left off) + selectedIds.clear(); + for (const id of diff.selected ?? []) { + if (annotationMap.has(id)) selectedIds.add(id); + } + if (selectedIds.size > 0) { + selectionAnchorId = [...selectedIds].slice(-1)[0]; + selectedAnnotationId = selectionAnchorId; + } + log.info("Restored annotation diff:", diff); + } catch (e) { + log.warn("Failed to restore annotation diff:", e); + } +} + +/** One-line summary of the diff for model context. */ +function describeDiff(d: AnnotationDiff): string { + const parts: string[] = []; + if (d.added.length > 0) parts.push(`${d.added.length} added`); + if (d.removed.length > 0) parts.push(`${d.removed.length} removed`); + if (d.hidden.length > 0) parts.push(`${d.hidden.length} hidden`); + return parts.length > 0 ? parts.join(", ") : "unchanged"; +} + +// ============================================================================= +// Command Queue Polling +// ============================================================================= + +/** + * Fly camera to a bounding box with animation + */ +function flyToBoundingBox( + cesiumViewer: any, + bbox: BoundingBox, + duration: number = 2, +): Promise { + return new Promise((resolve) => { + const { destination } = calculateDestination(bbox); + cesiumViewer.camera.flyTo({ + destination, + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-90), + roll: 0, + }, + duration, + complete: resolve, + cancel: resolve, + }); + }); +} + +/** + * Process a batch of commands from the server queue + */ +async function processCommands(commands: MapCommand[]): Promise { + if (!viewer || commands.length === 0) return; + + for (const cmd of commands) { + log.info("Processing command:", cmd.type, cmd); + switch (cmd.type) { + case "navigate": { + const bbox: BoundingBox = { + west: cmd.west, + south: cmd.south, + east: cmd.east, + north: cmd.north, + }; + if (cmd.fly === false) { + setViewToBoundingBox(viewer, bbox); + } else { + await flyToBoundingBox(viewer, bbox); + } + break; + } + case "add": { + for (const ann of cmd.annotations) { + addAnnotation(viewer, ann); + } + break; + } + case "update": { + for (const ann of cmd.annotations) { + updateAnnotation(viewer, ann); + } + break; + } + case "remove": { + for (const id of cmd.ids) { + removeAnnotation(viewer, id); + } + break; + } + } + } + // Persist once after the entire batch + persistAnnotations(); +} + +let pollTimer: ReturnType | null = null; + +/** + * Start polling for commands from the server queue + */ +function startPolling(): void { + if (pollTimer) return; + pollTimer = setInterval(async () => { + if (!viewUUID) return; + try { + const result = await app.callServerTool({ + name: "poll_map_commands", + arguments: { viewUUID }, + }); + const commands = + (result.structuredContent as { commands?: MapCommand[] })?.commands || + []; + if (commands.length > 0) { + log.info(`Received ${commands.length} command(s)`); + await processCommands(commands); + } + } catch (err) { + log.warn("Poll error:", err); + } + }, 300); +} + +/** + * Stop polling for commands + */ +function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +// ============================================================================= +// Copy / Export Annotations +// ============================================================================= + +/** Human-readable location/size details for an annotation. */ +function annDetails(d: AnnotationDef): string { + switch (d.type) { + case "marker": + return `${d.latitude.toFixed(6)}, ${d.longitude.toFixed(6)}`; + case "route": + return `${d.points.length} waypoints`; + case "area": + return `${d.points.length} vertices`; + case "circle": + return `${d.latitude.toFixed(6)}, ${d.longitude.toFixed(6)} r=${d.radiusKm}km`; + } +} + +/** + * Format annotations as Markdown: a summary table, then a section per + * annotation that has a description (so multi-line markdown renders properly). + */ +function annotationsToMarkdown(annotations: TrackedAnnotation[]): string { + const lines = [ + "| # | Type | ID | Label | Details | Color |", + "| --- | --- | --- | --- | --- | --- |", + ]; + const descSections: string[] = []; + for (let i = 0; i < annotations.length; i++) { + const d = annotations[i].def; + lines.push( + `| ${i + 1} | ${d.type} | ${d.id} | ${d.label || ""} | ${annDetails(d)} | ${d.color || (d.type === "marker" ? "red" : "blue")} |`, + ); + if (d.description) { + descSections.push(`### ${d.label || d.id}\n\n${d.description}`); + } + } + let md = lines.join("\n"); + if (descSections.length > 0) { + md += "\n\n" + descSections.join("\n\n"); + } + return md; +} + +/** + * Format annotations as GeoJSON FeatureCollection. Includes description in + * properties.description where present. + */ +function annotationsToGeoJSON(annotations: TrackedAnnotation[]): string { + const features = annotations.map((t) => { + const d = t.def; + const props: Record = { + name: d.label || d.id, + annotationType: d.type, + color: d.color, + ...(d.description ? { description: d.description } : {}), + }; + + switch (d.type) { + case "marker": + return { + type: "Feature" as const, + properties: { ...props, "marker-color": d.color || "red" }, + geometry: { + type: "Point" as const, + coordinates: [d.longitude, d.latitude], + }, + }; + case "route": + return { + type: "Feature" as const, + properties: { ...props, width: d.width, dashed: d.dashed }, + geometry: { + type: "LineString" as const, + coordinates: d.points.map((p) => [p.longitude, p.latitude]), + }, + }; + case "area": + return { + type: "Feature" as const, + properties: { ...props, fillColor: d.fillColor }, + geometry: { + type: "Polygon" as const, + coordinates: [ + [ + ...d.points.map((p) => [p.longitude, p.latitude]), + [d.points[0].longitude, d.points[0].latitude], // close ring + ], + ], + }, + }; + case "circle": + return { + type: "Feature" as const, + properties: { + ...props, + radiusKm: d.radiusKm, + fillColor: d.fillColor, + }, + geometry: { + type: "Point" as const, + coordinates: [d.longitude, d.latitude], + }, + }; + } + }); + return JSON.stringify({ type: "FeatureCollection", features }, null, 2); +} + +/** Combined Markdown + fenced GeoJSON export string (used by both copy & download). */ +function exportText(): string | null { + const anns = allAnnotations(); + if (anns.length === 0) return null; + return `${annotationsToMarkdown(anns)}\n\n\`\`\`geojson\n${annotationsToGeoJSON(anns)}\n\`\`\``; +} + +/** Copy annotations to clipboard (text/plain + text/html). */ +async function copyAnnotations(): Promise { + const anns = allAnnotations(); + const text = exportText(); + if (!text) return; + + const btn = document.getElementById("copy-btn"); + const flash = () => { + btn?.classList.add("copied"); + setTimeout(() => btn?.classList.remove("copied"), 1200); + }; + + // Rich HTML for pasting into docs: table + GeoJSON in a
+ const geojson = annotationsToGeoJSON(anns); + const rows = anns + .map( + (t, i) => + `${i + 1}${t.def.type}${t.def.id}${t.def.label || ""}${annDetails(t.def)}${t.def.color || (t.def.type === "marker" ? "red" : "blue")}`, + ) + .join("\n"); + const html = + `\n\n${rows}\n
#TypeIDLabelDetailsColor
\n` + + `
GeoJSON
${geojson.replace(/
`; + + try { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([text], { type: "text/plain" }), + "text/html": new Blob([html], { type: "text/html" }), + }), + ]); + flash(); + log.info(`Copied ${anns.length} annotation(s)`); + } catch { + try { + await navigator.clipboard.writeText(text); + flash(); + } catch (e) { + log.error("Copy failed:", e); + } + } +} + +/** Download annotations as a .md file (Markdown table + descriptions + GeoJSON). */ +function downloadAnnotations(): void { + const text = exportText(); + if (!text) return; + const blob = new Blob([text], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `map-annotations-${new Date().toISOString().slice(0, 10)}.md`; + a.click(); + URL.revokeObjectURL(url); + log.info("Downloaded annotations"); +} + +// ============================================================================= +// Annotation Panel (floating, draggable list with hide/delete/navigate) +// ============================================================================= + +// DOM handles +const panelEl = document.getElementById("ann-panel") as HTMLElement; +const panelBtnEl = document.getElementById("panel-btn") as HTMLButtonElement; +const panelBadgeEl = document.getElementById("panel-badge") as HTMLElement; +const annListEl = document.getElementById("ann-list") as HTMLElement; +const annCountEl = document.getElementById("ann-count") as HTMLElement; +const annPrevBtn = document.getElementById("ann-prev") as HTMLButtonElement; +const annNextBtn = document.getElementById("ann-next") as HTMLButtonElement; +const annFooterInfoEl = document.getElementById( + "ann-footer-info", +) as HTMLElement; + +const annMasterEyeBtn = document.getElementById( + "ann-master-eye", +) as HTMLButtonElement; + +let panelOpen = false; +/** Multi-select: set of selected annotation ids. */ +const selectedIds = new Set(); +/** Single-id tracker for removeAnnotation() cleanup + ↑/↓ anchoring. */ +let selectedAnnotationId: string | null = null; +/** Anchor for shift-click range selection. */ +let selectionAnchorId: string | null = null; +/** + * 3-state Space cycle for selected items: + * 0 (null snapshot) → snapshot + hide all + * 1 → show all + * 2 → restore snapshot, clear + * Cleared whenever selection changes. + */ +let spaceCycle: 0 | 1 | 2 = 0; +let spaceSnapshot: Map | null = null; +type PanelCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left"; +let panelCorner: PanelCorner = "bottom-right"; + +/** Show/hide copy & panel buttons, badge count. Auto-closes panel if empty. */ +function updateToolbarButtons(): void { + const count = annotationMap.size; + const show = count > 0 ? "flex" : "none"; + const copyBtn = document.getElementById("copy-btn"); + const dlBtn = document.getElementById("download-btn"); + if (copyBtn) { + copyBtn.style.display = show; + copyBtn.title = `Copy ${count} annotation(s) as Markdown + GeoJSON`; + } + if (dlBtn) { + dlBtn.style.display = show; + dlBtn.title = `Download ${count} annotation(s) as Markdown + GeoJSON`; + } + panelBtnEl.style.display = show; + panelBtnEl.classList.toggle("active", panelOpen); + if (count > 0 && !panelOpen) { + panelBadgeEl.textContent = String(count); + panelBadgeEl.style.display = "flex"; + } else { + panelBadgeEl.style.display = "none"; + } + if (count === 0 && panelOpen) setPanelOpen(false); +} + +function setPanelOpen(open: boolean): void { + panelOpen = open; + panelEl.style.display = open ? "flex" : "none"; + if (open) { + applyPanelPosition(); + renderAnnotationPanel(); + annListEl.focus({ preventScroll: true }); + } + updateToolbarButtons(); +} + +function togglePanel(): void { + setPanelOpen(!panelOpen); +} + +/** Anchor the panel to its current corner with 10px inset. */ +function applyPanelPosition(): void { + panelEl.style.top = panelEl.style.bottom = ""; + panelEl.style.left = panelEl.style.right = ""; + const inset = 10; + // Leave room for toolbar buttons when in top-right corner + const topInset = panelCorner === "top-right" ? 56 : inset; + const isRight = panelCorner.includes("right"); + const isBottom = panelCorner.includes("bottom"); + if (isBottom) panelEl.style.bottom = `${inset}px`; + else panelEl.style.top = `${topInset}px`; + if (isRight) panelEl.style.right = `${inset}px`; + else panelEl.style.left = `${inset}px`; +} + +// --- Markdown (minimal, safe) --- + +const escapeHtml = (s: string): string => + s.replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ]!, + ); + +/** + * Tiny markdown → HTML. Supports: **bold**, *italic*, `code`, [text](url), - lists, paragraphs. + * Input is escaped first so only these patterns produce tags (no raw HTML passthrough). + */ +function renderMarkdown(md: string): string { + const lines = md.split(/\r?\n/); + const blocks: string[] = []; + let list: string[] | null = null; + const inline = (s: string) => + escapeHtml(s) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + // Links use data-href; click handler calls app.openLink (iframe sandbox + // blocks direct navigation). + .replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ); + const flush = () => { + if (list) { + blocks.push(`
    ${list.map((i) => `
  • ${i}
  • `).join("")}
`); + list = null; + } + }; + for (const raw of lines) { + const line = raw.trimEnd(); + const m = /^\s*[-*]\s+(.*)$/.exec(line); + if (m) { + (list ??= []).push(inline(m[1])); + } else if (line.trim() === "") { + flush(); + } else { + flush(); + blocks.push(`

${inline(line)}

`); + } + } + flush(); + return blocks.join(""); +} + +// --- Panel rendering --- + +function annColor(d: AnnotationDef): string { + return d.color || (d.type === "marker" ? "red" : "blue"); +} + +/** SVG icon literals (stroke-based). */ +const SVG_EYE = ``; +const SVG_EYE_OFF = ``; +const SVG_TRASH = ``; + +/** Build a single annotation card. Layout: [eye] [swatch] [label] [trash]. */ +function createAnnCard(tracked: TrackedAnnotation): HTMLElement { + const d = tracked.def; + const isSelected = selectedIds.has(d.id); + // Dim when hidden by EITHER the individual flag OR the global override; + // the individual eye icon still shows only the per-item state so the user + // can see (and edit) their fine-grained choices even while all are hidden. + const effectivelyVisible = globalVisible && tracked.visible; + const card = document.createElement("div"); + card.className = + "ann-card" + + (isSelected ? " selected expanded" : "") + + (effectivelyVisible ? "" : " hidden-ann"); + card.dataset.annId = d.id; + + const row = document.createElement("div"); + row.className = "ann-card-row"; + + // Eye toggle (leading) — shows & edits the per-item flag only + const eyeBtn = document.createElement("button"); + eyeBtn.className = "ann-eye"; + eyeBtn.title = tracked.visible ? "Hide" : "Show"; + eyeBtn.innerHTML = tracked.visible ? SVG_EYE : SVG_EYE_OFF; + eyeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + setAnnotationVisibility(d.id, !tracked.visible); + }); + row.appendChild(eyeBtn); + + // Color swatch + const swatch = document.createElement("div"); + swatch.className = "ann-swatch"; + swatch.style.background = annColor(d); + row.appendChild(swatch); + + // Label (no type prefix) + const label = document.createElement("span"); + label.className = "ann-label"; + label.textContent = d.label || d.id; + label.title = d.label || d.id; + row.appendChild(label); + + // Trash (trailing) + const delBtn = document.createElement("button"); + delBtn.className = "ann-delete"; + delBtn.title = "Delete"; + delBtn.innerHTML = SVG_TRASH; + delBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (viewer) removeAnnotation(viewer, d.id); + persistAnnotations(); + }); + row.appendChild(delBtn); + + card.appendChild(row); + + // Details — markdown description only (coords live in the MD/GeoJSON export). + if (d.description) { + const details = document.createElement("div"); + details.className = "ann-details"; + const desc = document.createElement("div"); + desc.className = "ann-desc"; + desc.innerHTML = renderMarkdown(d.description); + // Intercept link clicks → app.openLink (iframe sandbox blocks navigation) + desc.addEventListener("click", (e) => { + const a = (e.target as HTMLElement).closest("a[data-href]"); + if (a) { + e.preventDefault(); + e.stopPropagation(); + const url = a.getAttribute("data-href")!; + app.openLink({ url }).catch((err) => log.warn("openLink failed", err)); + } + }); + details.appendChild(desc); + card.appendChild(details); + } + + // Click: select (cmd/ctrl = additive toggle, shift = range) + fly to fit selection + card.addEventListener("click", (e) => { + selectAnnotation(d.id, { + fly: true, + additive: e.metaKey || e.ctrlKey, + range: e.shiftKey, + }); + }); + + return card; +} + +function renderAnnotationPanel(): void { + if (!panelOpen) return; + const all = allAnnotations(); + annCountEl.textContent = String(all.length); + annListEl.textContent = ""; + for (const t of all) annListEl.appendChild(createAnnCard(t)); + + // Master eye reflects the global override only (per-item flags are independent) + annMasterEyeBtn.innerHTML = globalVisible ? SVG_EYE : SVG_EYE_OFF; + annMasterEyeBtn.title = globalVisible + ? "Hide all (preserves individual visibility)" + : "Show all"; + + // Footer nav state + annPrevBtn.disabled = all.length <= 1; + annNextBtn.disabled = all.length <= 1; + if (selectedIds.size > 1) { + annFooterInfoEl.textContent = `${selectedIds.size} selected`; + } else if (selectedIds.size === 1) { + const ids = all.map((t) => t.def.id); + const idx = ids.indexOf([...selectedIds][0]); + annFooterInfoEl.textContent = `${idx + 1} / ${all.length}`; + } else { + annFooterInfoEl.textContent = `${all.length} item(s)`; + } +} + +// --- Selection & fly-to --- + +/** Compute combined bbox for a set of annotation defs. */ +function combinedBbox(defs: AnnotationDef[]): BoundingBox | null { + let west = Infinity, + south = Infinity, + east = -Infinity, + north = -Infinity; + const expand = (lat: number, lon: number) => { + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + }; + for (const d of defs) { + switch (d.type) { + case "marker": + expand(d.latitude, d.longitude); + break; + case "circle": { + const dLat = d.radiusKm / 111; + const dLon = + d.radiusKm / (111 * Math.cos((d.latitude * Math.PI) / 180)); + expand(d.latitude - dLat, d.longitude - dLon); + expand(d.latitude + dLat, d.longitude + dLon); + break; + } + case "route": + case "area": + for (const p of d.points) expand(p.latitude, p.longitude); + break; + } + } + if (!isFinite(west)) return null; + // Pad by 20% for breathing room, with a minimum ~1km span. + const padLat = Math.max((north - south) * 0.2, 0.01); + const padLon = Math.max((east - west) * 0.2, 0.01); + return { + west: west - padLon, + south: south - padLat, + east: east + padLon, + north: north + padLat, + }; +} + +/** + * Fly the camera to fit a bbox. Applies a horizontal shift to keep the + * framed area clear of the floating panel. + */ +function flyToBbox(bbox: BoundingBox): void { + if (!viewer) return; + const centerLat = (bbox.north + bbox.south) / 2; + const centerLon = (bbox.west + bbox.east) / 2; + const latSpanKm = (bbox.north - bbox.south) * 111; + const lonSpanKm = + (bbox.east - bbox.west) * 111 * Math.cos((centerLat * Math.PI) / 180); + const spanKm = Math.max(latSpanKm, lonSpanKm, 1); + // Height ≈ 1.5× the dominant span gives a comfortable top-down framing. + const height = Math.max(1000, Math.min(4_000_000, spanKm * 1000 * 1.5)); + + let targetLon = centerLon; + if (panelOpen) { + const frac = Math.min( + 0.5, + (panelEl.offsetWidth || 0) / Math.max(viewer.canvas.clientWidth, 1), + ); + const sign = panelCorner.includes("right") ? 1 : -1; + targetLon = centerLon + (sign * (bbox.east - bbox.west) * frac) / 2; + } + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(targetLon, centerLat, height), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 }, + duration: 1.2, + }); +} + +/** + * Fly to fit all currently selected annotations. Includes hidden items so + * selecting a hidden marker still navigates to where it would appear. + */ +function flyToSelection(): void { + const defs = [...selectedIds] + .map((id) => annotationMap.get(id)) + .filter((t): t is TrackedAnnotation => !!t) + .map((t) => t.def); + const bbox = combinedBbox(defs); + if (bbox) flyToBbox(bbox); +} + +/** Fly to fit all visible annotations. Used for initial view framing. */ +function fitAllAnnotations(): void { + const defs = allAnnotations() + .filter((t) => globalVisible && t.visible) + .map((t) => t.def); + const bbox = combinedBbox(defs); + if (bbox) flyToBbox(bbox); +} + +/** + * Select an annotation by id. Supports additive (cmd/ctrl-click) and range + * (shift-click) multi-select. Flies to fit the whole selection. + */ +function selectAnnotation( + id: string, + opts: { fly?: boolean; additive?: boolean; range?: boolean } = {}, +): void { + if (!annotationMap.has(id)) return; + const ids = allAnnotations().map((t) => t.def.id); + + if (opts.range && selectionAnchorId && annotationMap.has(selectionAnchorId)) { + const a = ids.indexOf(selectionAnchorId); + const b = ids.indexOf(id); + const [lo, hi] = a < b ? [a, b] : [b, a]; + selectedIds.clear(); + for (let i = lo; i <= hi; i++) selectedIds.add(ids[i]); + } else if (opts.additive) { + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + selectionAnchorId = id; + } else { + selectedIds.clear(); + selectedIds.add(id); + selectionAnchorId = id; + } + selectedAnnotationId = + selectedIds.size > 0 ? [...selectedIds].slice(-1)[0] : null; + + // Selection changed → reset the Space visibility cycle + spaceCycle = 0; + spaceSnapshot = null; + + persistAnnotations(); // diff includes selection + + if (!panelOpen) setPanelOpen(true); + else renderAnnotationPanel(); + + annListEl + .querySelector(`.ann-card[data-ann-id="${CSS.escape(id)}"]`) + ?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + + if (opts.fly) flyToSelection(); +} + +/** Navigate selection by ±1 with wrap-around. Replaces any multi-selection. */ +function navAnnotation(delta: 1 | -1): void { + const ids = allAnnotations().map((t) => t.def.id); + if (ids.length === 0) return; + const cur = selectionAnchorId ? ids.indexOf(selectionAnchorId) : -1; + const next = (cur + delta + ids.length) % ids.length; + selectAnnotation(ids[next], { fly: true }); +} + +// --- Panel setup (called from initialize()) --- + +function initAnnotationPanel(): void { + panelBtnEl.addEventListener("click", togglePanel); + document.getElementById("ann-close")!.addEventListener("click", togglePanel); + document.getElementById("ann-clear")!.addEventListener("click", () => { + if (!viewer) return; + for (const id of [...annotationMap.keys()]) removeAnnotation(viewer, id); + persistAnnotations(); + }); + annPrevBtn.addEventListener("click", () => navAnnotation(-1)); + annNextBtn.addEventListener("click", () => navAnnotation(1)); + + // Master eye toggles the global override only; per-item flags survive. + annMasterEyeBtn.addEventListener("click", () => + setGlobalVisibility(!globalVisible), + ); + + // Keyboard nav: ↑/↓ navigate, Space toggles visibility, Backspace deletes, + // Escape closes. Attached to panel so any click inside keeps focus in-tree. + panelEl.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + navAnnotation(1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + navAnnotation(-1); + } else if (e.key === " ") { + e.preventDefault(); + if (selectedIds.size === 0) return; + // When entering the cycle, decide: if all selected items share the same + // visibility, a simple 2-state toggle suffices (no snapshot/revert needed). + // Otherwise, run the 3-state cycle: hide all → show all → revert snapshot. + if (spaceCycle === 0) { + const states = [...selectedIds].map( + (id) => annotationMap.get(id)?.visible ?? true, + ); + const uniform = states.every((v) => v === states[0]); + if (uniform) { + // Simple toggle — no cycle state, stays at 0 so next Space toggles back. + for (const id of selectedIds) setAnnotationVisibility(id, !states[0]); + return; + } + // Mixed → snapshot + hide all, enter 3-state cycle + spaceSnapshot = new Map( + [...selectedIds].map((id, i) => [id, states[i]]), + ); + for (const id of selectedIds) setAnnotationVisibility(id, false); + spaceCycle = 1; + } else if (spaceCycle === 1) { + for (const id of selectedIds) setAnnotationVisibility(id, true); + spaceCycle = 2; + } else { + for (const [id, v] of spaceSnapshot ?? []) + if (selectedIds.has(id)) setAnnotationVisibility(id, v); + spaceSnapshot = null; + spaceCycle = 0; + } + } else if (e.key === "Backspace" || e.key === "Delete") { + e.preventDefault(); + if (!viewer) return; + for (const id of [...selectedIds]) removeAnnotation(viewer, id); + persistAnnotations(); + } else if (e.key === "Escape") { + setPanelOpen(false); + } + }); + + // Drag the panel by its header; snap to nearest corner on release + const header = document.getElementById("ann-panel-header")!; + header.addEventListener("mousedown", (e) => { + if ((e.target as HTMLElement).closest("button")) return; + e.preventDefault(); + const startX = e.clientX, + startY = e.clientY; + const r = panelEl.getBoundingClientRect(); + const pr = panelEl.parentElement!.getBoundingClientRect(); + let curL = r.left - pr.left, + curT = r.top - pr.top, + moved = false; + panelEl.classList.add("dragging"); + panelEl.style.right = panelEl.style.bottom = ""; + panelEl.style.left = `${curL}px`; + panelEl.style.top = `${curT}px`; + const mm = (ev: MouseEvent) => { + const dx = ev.clientX - startX, + dy = ev.clientY - startY; + if (Math.abs(dx) + Math.abs(dy) > 3) moved = true; + panelEl.style.left = `${Math.max(0, Math.min(curL + dx, pr.width - panelEl.offsetWidth))}px`; + panelEl.style.top = `${Math.max(0, Math.min(curT + dy, pr.height - panelEl.offsetHeight))}px`; + }; + const mu = () => { + document.removeEventListener("mousemove", mm); + document.removeEventListener("mouseup", mu); + panelEl.classList.remove("dragging"); + if (!moved) return; + const fr = panelEl.getBoundingClientRect(); + const cx = fr.left + fr.width / 2 - pr.left; + const cy = fr.top + fr.height / 2 - pr.top; + const right = cx > pr.width / 2, + bottom = cy > pr.height / 2; + panelCorner = + `${bottom ? "bottom" : "top"}-${right ? "right" : "left"}` as PanelCorner; + applyPanelPosition(); + }; + document.addEventListener("mousemove", mm); + document.addEventListener("mouseup", mu); + }); + + // Click on map entities → select in panel. Also handles clicks on + // cluster billboards (they carry an `id` array of clustered entities). + if (viewer) { + const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas); + handler.setInputAction((click: any) => { + const picked = viewer!.scene.pick(click.position); + if (!picked) return; + // Cluster: picked.id is an array of entities + const ids = Array.isArray(picked.id) ? picked.id : [picked.id]; + for (const ent of ids) { + const annId = ent?._annId ?? ent?.id?._annId; + if (annId && annotationMap.has(annId)) { + selectAnnotation(annId, { fly: Array.isArray(picked.id) }); + return; + } + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + updateToolbarButtons(); +} + +// ============================================================================= +// Tool Input Handlers +// ============================================================================= + +// Track whether we've already positioned the camera from streaming +let hasPositionedFromPartial = false; + +// Handle streaming tool input (progressive annotation rendering) +app.ontoolinputpartial = (params) => { + if (!viewer) return; + const args = params.arguments as + | { + west?: number; + south?: number; + east?: number; + north?: number; + latitude?: number; + longitude?: number; + radiusKm?: number; + annotations?: AnnotationDef[]; + } + | undefined; + if (!args) return; + + // Position camera as soon as bbox/center fields are available (once) + if (!hasPositionedFromPartial) { + let bbox: BoundingBox | null = null; + if ( + args.west != null && + args.south != null && + args.east != null && + args.north != null + ) { + bbox = { + west: args.west, + south: args.south, + east: args.east, + north: args.north, + }; + } else if (args.latitude != null && args.longitude != null) { + bbox = bboxFromCenter(args.latitude, args.longitude, args.radiusKm ?? 50); + } + if (bbox) { + hasPositionedFromPartial = true; + hasReceivedToolInput = true; + setViewToBoundingBox(viewer, bbox); + hideLoading(); + log.info("Positioned camera from streaming partial"); + } + } + + // Process annotations (all but last which may be truncated) + if (!args.annotations || args.annotations.length === 0) return; + const safe = args.annotations.slice(0, -1); + for (const ann of safe) { + if (!ann.id || !ann.type) continue; + // Validate required fields per type to avoid creating broken entities + if ( + ann.type === "marker" && + (ann.latitude == null || ann.longitude == null) + ) + continue; + if ( + ann.type === "circle" && + (ann.latitude == null || ann.longitude == null || ann.radiusKm == null) + ) + continue; + if ( + (ann.type === "route" || ann.type === "area") && + (!ann.points || ann.points.length === 0) + ) + continue; + // Idempotent upsert: addAnnotation already handles existing IDs + addAnnotation(viewer, ann); + } +}; + +// Handle initial tool input (bounding box or center+radius from show-map tool) app.ontoolinput = async (params) => { log.info("Received tool input:", params); const args = params.arguments as @@ -734,12 +2393,16 @@ app.ontoolinput = async (params) => { south?: number; east?: number; north?: number; + latitude?: number; + longitude?: number; + radiusKm?: number; label?: string; + annotations?: AnnotationDef[]; } | undefined; if (args && viewer) { - // Handle both nested boundingBox and flat format + // Resolve bounding box let bbox: BoundingBox | null = null; if (args.boundingBox) { @@ -756,22 +2419,34 @@ app.ontoolinput = async (params) => { east: args.east, north: args.north, }; + } else if (args.latitude !== undefined && args.longitude !== undefined) { + bbox = bboxFromCenter(args.latitude, args.longitude, args.radiusKm ?? 50); } - if (bbox) { - // Mark that we received explicit tool input (overrides persisted state) + // Only position camera if we haven't already (from streaming partial). + // If the user panned/zoomed during streaming, don't override their view. + if (bbox && !hasPositionedFromPartial) { hasReceivedToolInput = true; log.info("Positioning camera to bbox:", bbox); - - // Position camera instantly (no animation) setViewToBoundingBox(viewer, bbox); + } - // Wait for tiles to load at this location - await waitForTilesLoaded(viewer); + // Add annotations immediately (before waiting for tiles so they appear ASAP) + if (args.annotations && args.annotations.length > 0) { + recordBaseline(args.annotations); + for (const ann of args.annotations) { + addAnnotation(viewer, ann); + } + log.info( + "Added", + args.annotations.length, + "initial annotation(s) from tool input", + ); + } - // Now hide loading indicator + if (bbox && !hasPositionedFromPartial) { + await waitForTilesLoaded(viewer); hideLoading(); - log.info( "Camera positioned, tiles loaded. Height:", viewer.camera.positionCartographic.height, @@ -780,73 +2455,68 @@ app.ontoolinput = async (params) => { } }; -/* - Register tools for the model to interact w/ this component - Needs https://github.com/modelcontextprotocol/ext-apps/pull/72 -*/ -// app.registerTool( -// "navigate-to", -// { -// title: "Navigate To", -// description: "Navigate the globe to a new bounding box location", -// inputSchema: z.object({ -// west: z.number().describe("Western longitude (-180 to 180)"), -// south: z.number().describe("Southern latitude (-90 to 90)"), -// east: z.number().describe("Eastern longitude (-180 to 180)"), -// north: z.number().describe("Northern latitude (-90 to 90)"), -// duration: z -// .number() -// .optional() -// .describe("Animation duration in seconds (default: 2)"), -// label: z.string().optional().describe("Optional label to display"), -// }), -// }, -// async (args) => { -// if (!viewer) { -// return { -// content: [ -// { type: "text" as const, text: "Error: Viewer not initialized" }, -// ], -// isError: true, -// }; -// } - -// const bbox: BoundingBox = { -// west: args.west, -// south: args.south, -// east: args.east, -// north: args.north, -// }; - -// await flyToBoundingBox(viewer, bbox, args.duration ?? 2); -// setLabel(args.label); - -// return { -// content: [ -// { -// type: "text" as const, -// text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`, -// }, -// ], -// }; -// }, -// ); - -// Handle tool result - extract viewUUID and restore persisted view if available +// Handle tool result - extract viewUUID, restore persisted view, start polling app.ontoolresult = async (result) => { viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined; log.info("Tool result received, viewUUID:", viewUUID); - // Now that we have viewUUID, try to restore persisted view - // This overrides the tool input position if a saved state exists + // Now that we have viewUUID, try to restore persisted view. + // If the user had a prior camera position we honour it and skip auto-fit. + let restoredView = false; if (viewer && viewUUID) { - const restored = restorePersistedView(viewer); - if (restored) { + restoredView = restorePersistedView(viewer); + if (restoredView) { log.info("Restored persisted view from tool result handler"); await waitForTilesLoaded(viewer); hideLoading(); } } + + // Step 1: record + load the baseline (initial annotations from _meta). + // ontoolinput may have already added some via args.annotations; those are + // also in baselineAnnotations already. We skip duplicates. + const initialAnnotations = result._meta?.initialAnnotations as + | AnnotationDef[] + | undefined; + if (viewer && initialAnnotations && initialAnnotations.length > 0) { + recordBaseline(initialAnnotations); + for (const ann of initialAnnotations) { + if (!annotationMap.has(ann.id)) { + addAnnotation(viewer, ann); + } + } + log.info( + "Added", + initialAnnotations.length, + "initial annotation(s) from tool result", + ); + } + + // Step 2: apply the persisted diff on top of the baseline (removals, + // hidden flags, user-added annotations, selection). + if (viewer && viewUUID) { + restorePersistedAnnotations(viewer); + } + + // Auto-fit: if no persisted camera position and we have annotations, frame + // them all. This gives a sensible initial view that depends on annotation + // spread rather than the (often too-wide) bbox from tool input. + if (!restoredView && annotationMap.size > 0) { + fitAllAnnotations(); + hasReceivedToolInput = true; + hideLoading(); + } + + // Ensure all current annotations are persisted (initial annotations from ontoolinput + // were added before viewUUID was set, so persistAnnotations() was a no-op then) + if (viewUUID && annotationMap.size > 0) { + persistAnnotations(); + } + + // Start polling for commands now that we have viewUUID + if (viewUUID) { + startPolling(); + } }; // Initialize Cesium and connect to host @@ -863,8 +2533,9 @@ async function initialize() { await app.connect(); log.info("Connected to host"); - // Get initial display mode from host context + // Apply initial theme + get display mode from host context const context = app.getHostContext(); + if (context) applyHostContextTheme(context); if (context?.displayMode) { currentDisplayMode = context.displayMode as | "inline" @@ -886,9 +2557,18 @@ async function initialize() { fullscreenBtn.addEventListener("click", toggleFullscreen); } - // Set up keyboard shortcuts for fullscreen (Escape to exit, Ctrl/Cmd+Enter to toggle) + // Set up keyboard shortcuts for fullscreen (Escape to exit, Alt+Enter to toggle) document.addEventListener("keydown", handleFullscreenKeyboard); + // Set up copy button and annotation panel (must run after viewer exists) + document + .getElementById("copy-btn") + ?.addEventListener("click", copyAnnotations); + document + .getElementById("download-btn") + ?.addEventListener("click", downloadAnnotations); + initAnnotationPanel(); + // Wait a bit for tool input, then try restoring persisted view or show default setTimeout(async () => { const loadingEl = document.getElementById("loading"); diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png index 5acd22272..bf2d6dc48 100644 Binary files a/examples/pdf-server/grid-cell.png and b/examples/pdf-server/grid-cell.png differ diff --git a/examples/pdf-server/screenshot.png b/examples/pdf-server/screenshot.png index bdb8f1760..5dbedc174 100644 Binary files a/examples/pdf-server/screenshot.png and b/examples/pdf-server/screenshot.png differ diff --git a/package-lock.json b/package-lock.json index 0dcf02400..35ee8e62c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -689,6 +689,30 @@ "dev": true, "license": "MIT" }, + "examples/phone-call-server": { + "name": "@modelcontextprotocol/server-phone-call", + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", + "twilio": "^5.0.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, "examples/qr-server": { "name": "@modelcontextprotocol/server-qr", "version": "1.1.2",