|
6 | 6 | * a navigate-to tool for the host to control navigation. |
7 | 7 | */ |
8 | 8 | import { App } from "@modelcontextprotocol/ext-apps"; |
| 9 | +import { z } from "zod"; |
9 | 10 |
|
10 | 11 | // TypeScript declaration for Cesium loaded from CDN |
11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
@@ -654,6 +655,76 @@ function setViewToBoundingBox(cesiumViewer: any, bbox: BoundingBox): void { |
654 | 655 | ); |
655 | 656 | } |
656 | 657 |
|
| 658 | +/** |
| 659 | + * Fly camera to view a bounding box with animation |
| 660 | + */ |
| 661 | +function flyToBoundingBox( |
| 662 | + cesiumViewer: any, |
| 663 | + bbox: BoundingBox, |
| 664 | + duration = 2, |
| 665 | +): Promise<void> { |
| 666 | + return new Promise((resolve) => { |
| 667 | + const { destination, centerLon, centerLat, height } = |
| 668 | + calculateDestination(bbox); |
| 669 | + |
| 670 | + log.info("flyTo destination:", centerLon, centerLat, "height:", height); |
| 671 | + |
| 672 | + cesiumViewer.camera.flyTo({ |
| 673 | + destination, |
| 674 | + orientation: { |
| 675 | + heading: 0, |
| 676 | + pitch: Cesium.Math.toRadians(-90), // Look straight down |
| 677 | + roll: 0, |
| 678 | + }, |
| 679 | + duration, |
| 680 | + complete: () => { |
| 681 | + log.info( |
| 682 | + "flyTo complete, camera height:", |
| 683 | + cesiumViewer.camera.positionCartographic.height, |
| 684 | + ); |
| 685 | + resolve(); |
| 686 | + }, |
| 687 | + cancel: () => { |
| 688 | + log.warn("flyTo cancelled"); |
| 689 | + resolve(); |
| 690 | + }, |
| 691 | + }); |
| 692 | + }); |
| 693 | +} |
| 694 | + |
| 695 | +// Label element for displaying location info |
| 696 | +let labelElement: HTMLDivElement | null = null; |
| 697 | + |
| 698 | +/** |
| 699 | + * Set or clear the label displayed on the map |
| 700 | + */ |
| 701 | +function setLabel(text?: string): void { |
| 702 | + if (!labelElement) { |
| 703 | + labelElement = document.createElement("div"); |
| 704 | + labelElement.style.cssText = ` |
| 705 | + position: absolute; |
| 706 | + top: 10px; |
| 707 | + left: 10px; |
| 708 | + background: rgba(0, 0, 0, 0.7); |
| 709 | + color: white; |
| 710 | + padding: 8px 12px; |
| 711 | + border-radius: 4px; |
| 712 | + font-family: sans-serif; |
| 713 | + font-size: 14px; |
| 714 | + z-index: 100; |
| 715 | + pointer-events: none; |
| 716 | + `; |
| 717 | + document.body.appendChild(labelElement); |
| 718 | + } |
| 719 | + |
| 720 | + if (text) { |
| 721 | + labelElement.textContent = text; |
| 722 | + labelElement.style.display = "block"; |
| 723 | + } else { |
| 724 | + labelElement.style.display = "none"; |
| 725 | + } |
| 726 | +} |
| 727 | + |
657 | 728 | /** |
658 | 729 | * Wait for globe tiles to finish loading |
659 | 730 | */ |
@@ -886,57 +957,110 @@ app.ontoolinput = async (params) => { |
886 | 957 | } |
887 | 958 | }; |
888 | 959 |
|
889 | | -/* |
890 | | - Register tools for the model to interact w/ this component |
891 | | - Needs https://github.com/modelcontextprotocol/ext-apps/pull/72 |
892 | | -*/ |
893 | | -// app.registerTool( |
894 | | -// "navigate-to", |
895 | | -// { |
896 | | -// title: "Navigate To", |
897 | | -// description: "Navigate the globe to a new bounding box location", |
898 | | -// inputSchema: z.object({ |
899 | | -// west: z.number().describe("Western longitude (-180 to 180)"), |
900 | | -// south: z.number().describe("Southern latitude (-90 to 90)"), |
901 | | -// east: z.number().describe("Eastern longitude (-180 to 180)"), |
902 | | -// north: z.number().describe("Northern latitude (-90 to 90)"), |
903 | | -// duration: z |
904 | | -// .number() |
905 | | -// .optional() |
906 | | -// .describe("Animation duration in seconds (default: 2)"), |
907 | | -// label: z.string().optional().describe("Optional label to display"), |
908 | | -// }), |
909 | | -// }, |
910 | | -// async (args) => { |
911 | | -// if (!viewer) { |
912 | | -// return { |
913 | | -// content: [ |
914 | | -// { type: "text" as const, text: "Error: Viewer not initialized" }, |
915 | | -// ], |
916 | | -// isError: true, |
917 | | -// }; |
918 | | -// } |
919 | | - |
920 | | -// const bbox: BoundingBox = { |
921 | | -// west: args.west, |
922 | | -// south: args.south, |
923 | | -// east: args.east, |
924 | | -// north: args.north, |
925 | | -// }; |
926 | | - |
927 | | -// await flyToBoundingBox(viewer, bbox, args.duration ?? 2); |
928 | | -// setLabel(args.label); |
929 | | - |
930 | | -// return { |
931 | | -// content: [ |
932 | | -// { |
933 | | -// type: "text" as const, |
934 | | -// 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})` : ""}`, |
935 | | -// }, |
936 | | -// ], |
937 | | -// }; |
938 | | -// }, |
939 | | -// ); |
| 960 | +// Register tools for the model to interact with this component |
| 961 | +app.registerTool( |
| 962 | + "navigate-to", |
| 963 | + { |
| 964 | + title: "Navigate To", |
| 965 | + description: "Navigate the globe to a new bounding box location", |
| 966 | + inputSchema: z.object({ |
| 967 | + west: z.number().describe("Western longitude (-180 to 180)"), |
| 968 | + south: z.number().describe("Southern latitude (-90 to 90)"), |
| 969 | + east: z.number().describe("Eastern longitude (-180 to 180)"), |
| 970 | + north: z.number().describe("Northern latitude (-90 to 90)"), |
| 971 | + duration: z |
| 972 | + .number() |
| 973 | + .optional() |
| 974 | + .describe("Animation duration in seconds (default: 2)"), |
| 975 | + label: z.string().optional().describe("Optional label to display"), |
| 976 | + }), |
| 977 | + }, |
| 978 | + async (args) => { |
| 979 | + if (!viewer) { |
| 980 | + return { |
| 981 | + content: [ |
| 982 | + { type: "text" as const, text: "Error: Viewer not initialized" }, |
| 983 | + ], |
| 984 | + isError: true, |
| 985 | + }; |
| 986 | + } |
| 987 | + |
| 988 | + const bbox: BoundingBox = { |
| 989 | + west: args.west, |
| 990 | + south: args.south, |
| 991 | + east: args.east, |
| 992 | + north: args.north, |
| 993 | + }; |
| 994 | + |
| 995 | + await flyToBoundingBox(viewer, bbox, args.duration ?? 2); |
| 996 | + setLabel(args.label); |
| 997 | + |
| 998 | + return { |
| 999 | + content: [ |
| 1000 | + { |
| 1001 | + type: "text" as const, |
| 1002 | + 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})` : ""}`, |
| 1003 | + }, |
| 1004 | + ], |
| 1005 | + }; |
| 1006 | + }, |
| 1007 | +); |
| 1008 | + |
| 1009 | +app.registerTool( |
| 1010 | + "get-current-view", |
| 1011 | + { |
| 1012 | + title: "Get Current View", |
| 1013 | + description: |
| 1014 | + "Get the current camera position and bounding box visible on the globe", |
| 1015 | + }, |
| 1016 | + async () => { |
| 1017 | + if (!viewer) { |
| 1018 | + return { |
| 1019 | + content: [ |
| 1020 | + { type: "text" as const, text: "Error: Viewer not initialized" }, |
| 1021 | + ], |
| 1022 | + isError: true, |
| 1023 | + }; |
| 1024 | + } |
| 1025 | + |
| 1026 | + const camera = viewer.camera; |
| 1027 | + const positionCartographic = camera.positionCartographic; |
| 1028 | + const latitude = Cesium.Math.toDegrees(positionCartographic.latitude); |
| 1029 | + const longitude = Cesium.Math.toDegrees(positionCartographic.longitude); |
| 1030 | + const height = positionCartographic.height; |
| 1031 | + |
| 1032 | + // Get the visible bounding box |
| 1033 | + const rectangle = viewer.camera.computeViewRectangle(); |
| 1034 | + let bbox = null; |
| 1035 | + if (rectangle) { |
| 1036 | + bbox = { |
| 1037 | + west: Cesium.Math.toDegrees(rectangle.west), |
| 1038 | + south: Cesium.Math.toDegrees(rectangle.south), |
| 1039 | + east: Cesium.Math.toDegrees(rectangle.east), |
| 1040 | + north: Cesium.Math.toDegrees(rectangle.north), |
| 1041 | + }; |
| 1042 | + } |
| 1043 | + |
| 1044 | + const viewData = { |
| 1045 | + camera: { |
| 1046 | + latitude, |
| 1047 | + longitude, |
| 1048 | + height, |
| 1049 | + }, |
| 1050 | + bbox, |
| 1051 | + }; |
| 1052 | + |
| 1053 | + return { |
| 1054 | + content: [ |
| 1055 | + { |
| 1056 | + type: "text" as const, |
| 1057 | + text: JSON.stringify(viewData, null, 2), |
| 1058 | + }, |
| 1059 | + ], |
| 1060 | + structuredContent: viewData, |
| 1061 | + }; |
| 1062 | + }, |
| 1063 | +); |
940 | 1064 |
|
941 | 1065 | // Handle tool result - extract widgetUUID and restore persisted view if available |
942 | 1066 | app.ontoolresult = async (result) => { |
|
0 commit comments