refactor(map): use app.updateModelContext() instead of adhoc request (#245)
* feat(map): add keyboard shortcuts for fullscreen control - Escape: exit fullscreen when in fullscreen mode - Ctrl/Cmd+Enter: toggle fullscreen from any mode * feat(map): persist view state to localStorage - Save camera position/orientation on moveEnd (debounced 500ms) - Restore persisted view when no explicit tool input is received - Key by toolInfo.id (tool invocation ID) - localStorage is per-conversation - Tool input (explicit bbox) takes precedence over persisted state * refactor(map): use app.updateModelContext() instead of adhoc request
This commit is contained in:
parent
9fcee5b5a6
commit
53142efd2d
1 changed files with 199 additions and 20 deletions
|
|
@ -6,7 +6,6 @@
|
|||
* a navigate-to tool for the host to control navigation.
|
||||
*/
|
||||
import { App } from "@modelcontextprotocol/ext-apps";
|
||||
import { z } from "zod";
|
||||
|
||||
// TypeScript declaration for Cesium loaded from CDN
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -66,6 +65,155 @@ let viewer: any = null;
|
|||
// Debounce timer for reverse geocoding
|
||||
let reverseGeocodeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Debounce timer for persisting view state
|
||||
let persistViewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Track whether tool input has been received (to know if we should restore persisted state)
|
||||
let hasReceivedToolInput = false;
|
||||
|
||||
/**
|
||||
* Persisted camera state for localStorage
|
||||
*/
|
||||
interface PersistedCameraState {
|
||||
longitude: number; // degrees
|
||||
latitude: number; // degrees
|
||||
height: number; // meters
|
||||
heading: number; // radians
|
||||
pitch: number; // radians
|
||||
roll: number; // radians
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localStorage key for persisting view state
|
||||
* Uses toolInfo.id (tool invocation ID) - localStorage is scoped per conversation per server,
|
||||
* so each tool call remembers its own view state within the conversation.
|
||||
*/
|
||||
function getViewStorageKey(): string | null {
|
||||
const context = app.getHostContext();
|
||||
const toolId = context?.toolInfo?.id;
|
||||
if (!toolId) return null;
|
||||
return `cesium-view:${toolId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current camera state for persistence
|
||||
*/
|
||||
function getCameraState(cesiumViewer: any): PersistedCameraState | null {
|
||||
try {
|
||||
const camera = cesiumViewer.camera;
|
||||
const cartographic = camera.positionCartographic;
|
||||
return {
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
height: cartographic.height,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
};
|
||||
} catch (e) {
|
||||
log.warn("Failed to get camera state:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current view state to localStorage (debounced)
|
||||
*/
|
||||
function schedulePersistViewState(cesiumViewer: any): void {
|
||||
if (persistViewTimer) {
|
||||
clearTimeout(persistViewTimer);
|
||||
}
|
||||
persistViewTimer = setTimeout(() => {
|
||||
persistViewState(cesiumViewer);
|
||||
}, 500); // 500ms debounce
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist current view state to localStorage
|
||||
*/
|
||||
function persistViewState(cesiumViewer: any): void {
|
||||
const key = getViewStorageKey();
|
||||
if (!key) {
|
||||
log.info("No storage key available, skipping view persistence");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getCameraState(cesiumViewer);
|
||||
if (!state) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
log.info(
|
||||
"Persisted view state:",
|
||||
key,
|
||||
state.latitude.toFixed(2),
|
||||
state.longitude.toFixed(2),
|
||||
);
|
||||
} catch (e) {
|
||||
log.warn("Failed to persist view state:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted view state from localStorage
|
||||
*/
|
||||
function loadPersistedViewState(): PersistedCameraState | null {
|
||||
const key = getViewStorageKey();
|
||||
if (!key) return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as PersistedCameraState;
|
||||
// Basic validation
|
||||
if (
|
||||
typeof state.longitude !== "number" ||
|
||||
typeof state.latitude !== "number" ||
|
||||
typeof state.height !== "number"
|
||||
) {
|
||||
log.warn("Invalid persisted view state, ignoring");
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
} catch (e) {
|
||||
log.warn("Failed to load persisted view state:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore camera to persisted state
|
||||
*/
|
||||
function restorePersistedView(cesiumViewer: any): boolean {
|
||||
const state = loadPersistedViewState();
|
||||
if (!state) return false;
|
||||
|
||||
try {
|
||||
log.info(
|
||||
"Restoring persisted view:",
|
||||
state.latitude.toFixed(2),
|
||||
state.longitude.toFixed(2),
|
||||
);
|
||||
cesiumViewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
state.longitude,
|
||||
state.latitude,
|
||||
state.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: state.heading,
|
||||
pitch: state.pitch,
|
||||
roll: state.roll,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.warn("Failed to restore persisted view:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the center point of the current camera view
|
||||
*/
|
||||
|
|
@ -294,20 +442,11 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
|
|||
|
||||
log.info("Updating model context:", contextText);
|
||||
|
||||
// EXPERIMENTAL: This request method isn't standard yet and may change.
|
||||
// It updates the model's context with the current map location.
|
||||
// See https://github.com/modelcontextprotocol/ext-apps/pull/125
|
||||
// Update the model's context with the current map location.
|
||||
// If the host doesn't support this, the request will silently fail.
|
||||
app.request(
|
||||
<any>{
|
||||
method: "ui/update-model-context",
|
||||
params: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: contextText }],
|
||||
},
|
||||
},
|
||||
z.object({}),
|
||||
);
|
||||
app.updateModelContext({
|
||||
content: [{ type: "text", text: contextText }],
|
||||
});
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
|
@ -460,9 +599,10 @@ async function initCesium(): Promise<any> {
|
|||
|
||||
log.info("Camera positioned, initial rendering started");
|
||||
|
||||
// Set up camera move end listener for reverse geocoding
|
||||
// Set up camera move end listener for reverse geocoding and view persistence
|
||||
cesiumViewer.camera.moveEnd.addEventListener(() => {
|
||||
scheduleLocationUpdate(cesiumViewer);
|
||||
schedulePersistViewState(cesiumViewer);
|
||||
});
|
||||
log.info("Camera move listener registered");
|
||||
|
||||
|
|
@ -622,6 +762,30 @@ async function toggleFullscreen(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts for fullscreen control
|
||||
* - Escape: Exit fullscreen (when in fullscreen mode)
|
||||
* - Ctrl/Cmd+Enter: Toggle fullscreen
|
||||
*/
|
||||
function handleFullscreenKeyboard(event: KeyboardEvent): void {
|
||||
// Escape to exit fullscreen
|
||||
if (event.key === "Escape" && currentDisplayMode === "fullscreen") {
|
||||
event.preventDefault();
|
||||
toggleFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Enter (Windows/Linux) or Cmd+Enter (Mac) to toggle fullscreen
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle display mode changes - resize Cesium and update UI
|
||||
*/
|
||||
|
|
@ -710,6 +874,8 @@ app.ontoolinput = async (params) => {
|
|||
}
|
||||
|
||||
if (bbox) {
|
||||
// Mark that we received explicit tool input (overrides persisted state)
|
||||
hasReceivedToolInput = true;
|
||||
log.info("Positioning camera to bbox:", bbox);
|
||||
|
||||
// Position camera instantly (no animation)
|
||||
|
|
@ -793,16 +959,26 @@ async function init() {
|
|||
// and for tiles to load before hiding the loading indicator
|
||||
log.info("CesiumJS initialized, waiting for tool input...");
|
||||
|
||||
// Fallback: if no tool input received within 5 seconds, hide loading anyway
|
||||
// (handles case where map is shown without a specific bbox)
|
||||
// Fallback: if no tool input received within 2 seconds, try restoring
|
||||
// persisted view or show default view
|
||||
setTimeout(async () => {
|
||||
const loadingEl = document.getElementById("loading");
|
||||
if (loadingEl && loadingEl.style.display !== "none") {
|
||||
log.info("No tool input received, waiting for initial tiles...");
|
||||
if (
|
||||
loadingEl &&
|
||||
loadingEl.style.display !== "none" &&
|
||||
!hasReceivedToolInput
|
||||
) {
|
||||
// No explicit tool input - try to restore persisted view
|
||||
const restored = restorePersistedView(viewer!);
|
||||
if (restored) {
|
||||
log.info("Restored persisted view, waiting for tiles...");
|
||||
} else {
|
||||
log.info("No persisted view, using default view...");
|
||||
}
|
||||
await waitForTilesLoaded(viewer!);
|
||||
hideLoading();
|
||||
}
|
||||
}, 5000);
|
||||
}, 2000);
|
||||
|
||||
// Connect to host (auto-creates PostMessageTransport)
|
||||
await app.connect();
|
||||
|
|
@ -830,6 +1006,9 @@ async function init() {
|
|||
if (fullscreenBtn) {
|
||||
fullscreenBtn.addEventListener("click", toggleFullscreen);
|
||||
}
|
||||
|
||||
// Set up keyboard shortcuts for fullscreen (Escape to exit, Ctrl/Cmd+Enter to toggle)
|
||||
document.addEventListener("keydown", handleFullscreenKeyboard);
|
||||
} catch (error) {
|
||||
log.error("Failed to initialize:", error);
|
||||
const loadingEl = document.getElementById("loading");
|
||||
|
|
|
|||
Loading…
Reference in a new issue