AbdulElahGwaith's picture
Upload folder using huggingface_hub
e1cc3bc verified
/**
* Wiki Explorer - Force-directed graph visualization of Wikipedia link networks
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
} from "d3-force-3d";
import ForceGraph, { type LinkObject, type NodeObject } from "force-graph";
import "./global.css";
import "./mcp-app.css";
// =============================================================================
// Helpers & Types
// =============================================================================
// Helper to resolve CSS variables for canvas rendering
function getCSSColor(varName: string): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim() || "#000"
);
}
// Types
type NodeState = "default" | "expanded" | "error";
interface NodeData extends NodeObject {
url: string;
title: string;
state: NodeState;
errorMessage?: string;
}
interface LinkData extends LinkObject {
source: string | NodeData;
target: string | NodeData;
}
interface GraphData {
nodes: NodeData[];
links: LinkData[];
}
type PageInfo = { url: string; title: string };
type ToolResponse = {
page: PageInfo;
links: PageInfo[];
error: string | null;
};
// =============================================================================
// Graph State & DOM References
// =============================================================================
const graphData: GraphData = { nodes: [], links: [] };
let selectedNodeUrl: string | null = null;
let initialUrl: string | null = null;
// DOM elements
const container = document.getElementById("graph")!;
const popup = document.getElementById("popup")!;
const popupTitle = popup.querySelector(".popup-title")!;
const popupError = popup.querySelector(".popup-error")! as HTMLElement;
const openBtn = document.getElementById("open-btn")!;
const expandBtn = document.getElementById("expand-btn")!;
const zoomInBtn = document.getElementById("zoom-in")!;
const zoomOutBtn = document.getElementById("zoom-out")!;
const resetBtn = document.getElementById("reset-graph")!;
// =============================================================================
// Force-Graph Initialization
// =============================================================================
const graph = new ForceGraph<NodeData, LinkData>(container)
.nodeId("url")
.nodeLabel("title")
.nodeColor((node: NodeData) => {
switch (node.state) {
case "expanded":
return getCSSColor("--node-expanded");
case "error":
return getCSSColor("--node-error");
default:
return getCSSColor("--node-default");
}
})
.nodeVal(8)
.linkDirectionalArrowLength(6)
.linkDirectionalArrowRelPos(1)
.linkColor(() => getCSSColor("--link-color"))
.onNodeClick(handleNodeClick)
.onBackgroundClick(() => hidePopup())
// Configure forces for better node spreading
.d3Force("charge", forceManyBody().strength(-80))
.d3Force("link", forceLink().distance(60))
.d3Force("collide", forceCollide(12))
.d3Force("center", forceCenter())
.d3VelocityDecay(0.3)
.cooldownTime(Infinity)
.d3AlphaMin(0)
.d3Force("ambient", () => {
for (const node of graphData.nodes) {
if (node.vx !== undefined && node.vy !== undefined) {
node.vx += (Math.random() - 0.5) * 0.1;
node.vy += (Math.random() - 0.5) * 0.1;
}
}
})
.graphData(graphData);
// Handle window resize
function handleResize() {
const { width, height } = container.getBoundingClientRect();
graph.width(width).height(height);
}
window.addEventListener("resize", handleResize);
handleResize();
// =============================================================================
// Graph Data Management
// =============================================================================
function addNode(
url: string,
title: string,
state: NodeState = "default",
initialPos?: { x: number; y: number },
): boolean {
const existing = graphData.nodes.find((n) => n.url === url);
if (existing) {
return false;
}
const node: NodeData = { url, title, state };
if (initialPos) {
// Small random jitter so nodes don't stack exactly
node.x = initialPos.x + (Math.random() - 0.5) * 20;
node.y = initialPos.y + (Math.random() - 0.5) * 20;
}
graphData.nodes.push(node);
return true;
}
function updateNodeTitle(url: string, title: string): void {
const node = graphData.nodes.find((n) => n.url === url);
if (node) {
node.title = title;
}
}
function setNodeState(
url: string,
state: NodeState,
errorMessage?: string,
): void {
const node = graphData.nodes.find((n) => n.url === url);
if (node) {
node.state = state;
node.errorMessage = errorMessage;
}
}
function addEdge(sourceUrl: string, targetUrl: string): boolean {
const existing = graphData.links.find((l) => {
const src =
typeof l.source === "string" ? l.source : (l.source as NodeData).url;
const tgt =
typeof l.target === "string" ? l.target : (l.target as NodeData).url;
return src === sourceUrl && tgt === targetUrl;
});
if (existing) {
return false;
}
graphData.links.push({ source: sourceUrl, target: targetUrl });
return true;
}
function updateGraph(): void {
graph.graphData({ nodes: [...graphData.nodes], links: [...graphData.links] });
}
// =============================================================================
// Popup Management
// =============================================================================
function showPopup(node: NodeData, x: number, y: number): void {
popupTitle.textContent = node.title;
if (node.state === "error") {
popupError.textContent = node.errorMessage || "Failed to load page";
popupError.style.display = "block";
expandBtn.style.display = "none";
} else {
popupError.style.display = "none";
expandBtn.style.display = "inline-block";
if (node.state === "expanded") {
expandBtn.setAttribute("disabled", "true");
expandBtn.textContent = "Expanded";
} else {
expandBtn.removeAttribute("disabled");
expandBtn.textContent = "Expand";
}
}
popup.style.display = "block";
const rect = popup.getBoundingClientRect();
const gap = 15;
// Place popup on opposite side of cursor from screen center
const left =
x < window.innerWidth / 2
? x + gap // cursor on left half → popup to right
: x - rect.width - gap; // cursor on right half → popup to left
const top =
y < window.innerHeight / 2
? y + gap // cursor on top half → popup below
: y - rect.height - gap; // cursor on bottom half → popup above
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
}
function hidePopup(): void {
popup.style.display = "none";
selectedNodeUrl = null;
}
// =============================================================================
// UI Event Handlers
// =============================================================================
function handleNodeClick(node: NodeData, event: MouseEvent): void {
// Toggle popup if clicking same node
if (selectedNodeUrl === node.url) {
hidePopup();
return;
}
selectedNodeUrl = node.url;
showPopup(node, event.clientX, event.clientY);
}
// Close popup on Escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && popup.style.display === "block") {
hidePopup();
}
});
// Zoom controls
const ZOOM_FACTOR = 1.5;
zoomInBtn.addEventListener("click", () => {
const currentZoom = graph.zoom();
graph.zoom(currentZoom * ZOOM_FACTOR, 200);
});
zoomOutBtn.addEventListener("click", () => {
const currentZoom = graph.zoom();
graph.zoom(currentZoom / ZOOM_FACTOR, 200);
});
// =============================================================================
// MCP Apps SDK Integration
// =============================================================================
const app = new App({ name: "Wiki Explorer", version: "1.0.0" });
// Reset button - clears graph and reloads from initial URL
resetBtn.addEventListener("click", async () => {
if (!initialUrl) return;
// Fetch fresh data first, then clear and repopulate
const result = await app.callServerTool({
name: "get-first-degree-links",
arguments: { url: initialUrl },
});
// Clear current graph and repopulate with result
graphData.nodes = [];
graphData.links = [];
addNode(initialUrl, initialUrl, "default", { x: 0, y: 0 });
graph.warmupTicks(100);
handleToolResultData(result);
graph.centerAt(0, 0, 500);
});
// Open button - opens the Wikipedia page in browser
openBtn.addEventListener("click", async () => {
if (selectedNodeUrl) {
await app.openLink({ url: selectedNodeUrl });
hidePopup();
}
});
// Expand button - fetches and displays linked pages
expandBtn.addEventListener("click", async () => {
if (!selectedNodeUrl) return;
const sourceUrl = selectedNodeUrl;
expandBtn.setAttribute("disabled", "true");
expandBtn.textContent = "Loading...";
try {
const result = await app.callServerTool({
name: "get-first-degree-links",
arguments: { url: sourceUrl },
});
graph.warmupTicks(0);
handleToolResultData(result);
} catch (e) {
console.error("Expand error:", e);
setNodeState(sourceUrl, "error", "Request failed");
updateGraph();
} finally {
expandBtn.removeAttribute("disabled");
expandBtn.textContent = "Expand";
hidePopup();
}
});
// Handle tool input - create initial node with URL as placeholder title
app.ontoolinput = (params) => {
const args = params.arguments as { url?: string } | undefined;
if (args?.url) {
initialUrl = args.url; // Store for reset functionality
addNode(args.url, args.url, "default", { x: 0, y: 0 });
graph.warmupTicks(100);
updateGraph();
// Center on the new node
graph.centerAt(0, 0, 500);
}
};
// Handle tool result - update node and add linked pages (host-initiated, initial load)
app.ontoolresult = (result) => {
graph.warmupTicks(100);
handleToolResultData(result);
};
function handleToolResultData(result: CallToolResult): void {
if (result.isError) {
console.error("Tool result error:", result);
return;
}
const response = result.structuredContent as unknown as ToolResponse;
const { page, links, error } = response;
// Ensure the source node exists
addNode(page.url, page.title);
updateNodeTitle(page.url, page.title);
if (error) {
setNodeState(page.url, "error", error);
} else {
// Get source node position so new nodes appear nearby
const sourceNode = graphData.nodes.find((n) => n.url === page.url);
const sourcePos = sourceNode
? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 }
: undefined;
// Add all linked nodes and edges
for (const link of links) {
addNode(link.url, link.title, "default", sourcePos);
addEdge(page.url, link.url);
}
setNodeState(page.url, "expanded");
}
updateGraph();
}
app.onerror = (err) => {
console.error("[Wiki Explorer] App error:", err);
};
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
// Connect to host
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});