diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index 3f1f265..4f541f8 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -7,6 +7,9 @@ import SearchBar from "./components/SearchBar"; import NodeInfo from "./components/NodeInfo"; import LayerLegend from "./components/LayerLegend"; import DiffToggle from "./components/DiffToggle"; +import FilterPanel from "./components/FilterPanel"; +import ExportMenu from "./components/ExportMenu"; +import PathFinderModal from "./components/PathFinderModal"; import LearnPanel from "./components/LearnPanel"; import PersonaSelector from "./components/PersonaSelector"; import ProjectOverview from "./components/ProjectOverview"; @@ -23,6 +26,8 @@ function App() { const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen); const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer); const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay); + const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen); + const togglePathFinder = useDashboardStore((s) => s.togglePathFinder); const [loadError, setLoadError] = useState(null); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); @@ -44,7 +49,13 @@ function App() { action: () => { // Read from store at invocation time to avoid stale closures const state = useDashboardStore.getState(); - if (state.codeViewerOpen) { + if (state.pathFinderOpen) { + state.togglePathFinder(); + } else if (state.filterPanelOpen) { + state.toggleFilterPanel(); + } else if (state.exportMenuOpen) { + state.toggleExportMenu(); + } else if (state.codeViewerOpen) { state.closeCodeViewer(); } else if (state.selectedNodeId) { state.selectNode(null); @@ -109,6 +120,33 @@ function App() { }, category: "View", }, + { + key: "f", + description: "Toggle filter panel", + action: () => { + const state = useDashboardStore.getState(); + state.toggleFilterPanel(); + }, + category: "View", + }, + { + key: "e", + description: "Toggle export menu", + action: () => { + const state = useDashboardStore.getState(); + state.toggleExportMenu(); + }, + category: "View", + }, + { + key: "p", + description: "Open path finder", + action: () => { + const state = useDashboardStore.getState(); + state.togglePathFinder(); + }, + category: "View", + }, ], [] ); @@ -185,6 +223,28 @@ function App() {
+ + +
); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx index 111d7a7..14b1a83 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx @@ -37,6 +37,9 @@ export interface CustomNodeData extends Record { isDiffAffected: boolean; isDiffFaded: boolean; onNodeClick?: (nodeId: string) => void; + incomingCount?: number; + outgoingCount?: number; + tags?: string[]; } export type CustomFlowNode = Node; diff --git a/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx new file mode 100644 index 0000000..adc7ce0 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx @@ -0,0 +1,302 @@ +import { useEffect, useRef } from "react"; +import { useDashboardStore } from "../store"; +import type { KnowledgeGraph } from "@understand-anything/core/types"; +import { filterNodes, filterEdges } from "../utils/filters"; + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export default function ExportMenu() { + const graph = useDashboardStore((s) => s.graph); + const filters = useDashboardStore((s) => s.filters); + const exportMenuOpen = useDashboardStore((s) => s.exportMenuOpen); + const toggleExportMenu = useDashboardStore((s) => s.toggleExportMenu); + const reactFlowInstance = useDashboardStore((s) => s.reactFlowInstance); + const persona = useDashboardStore((s) => s.persona); + + const containerRef = useRef(null); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + if (exportMenuOpen) { + toggleExportMenu(); + } + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [exportMenuOpen, toggleExportMenu]); + + const exportPNG = async () => { + if (!reactFlowInstance) { + alert("Graph not ready for export"); + return; + } + + try { + // Get the viewport element + const viewport = document.querySelector(".react-flow__viewport") as HTMLElement; + if (!viewport) { + throw new Error("Viewport not found"); + } + + // Get the bounding box of all nodes + const nodes = reactFlowInstance.getNodes(); + if (nodes.length === 0) { + alert("No nodes to export"); + return; + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodes.forEach((node) => { + const x = node.position.x; + const y = node.position.y; + const width = (node.width ?? 200); + const height = (node.height ?? 80); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + width); + maxY = Math.max(maxY, y + height); + }); + + const padding = 40; + const width = maxX - minX + padding * 2; + const height = maxY - minY + padding * 2; + + // Clone the viewport + const clone = viewport.cloneNode(true) as HTMLElement; + clone.style.transform = `translate(${-minX + padding}px, ${-minY + padding}px)`; + + // Create an SVG with foreignObject + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", String(width * 2)); + svg.setAttribute("height", String(height * 2)); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.style.backgroundColor = "#0a0a0a"; + + const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject"); + foreignObject.setAttribute("width", "100%"); + foreignObject.setAttribute("height", "100%"); + foreignObject.appendChild(clone); + svg.appendChild(foreignObject); + + // Serialize SVG to string + const svgString = new XMLSerializer().serializeToString(svg); + const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + + // Create an image and draw to canvas + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = width * 2; + canvas.height = height * 2; + const ctx = canvas.getContext("2d"); + if (!ctx) { + alert("Failed to create canvas context"); + return; + } + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + + canvas.toBlob((blob) => { + if (blob) { + const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`; + downloadBlob(blob, filename); + toggleExportMenu(); + } + }, "image/png"); + }; + img.src = url; + } catch (error) { + console.error("PNG export failed:", error); + alert(`Failed to export PNG: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const exportSVG = () => { + if (!reactFlowInstance) { + alert("Graph not ready for export"); + return; + } + + try { + const nodes = reactFlowInstance.getNodes(); + const edges = reactFlowInstance.getEdges(); + + if (nodes.length === 0) { + alert("No nodes to export"); + return; + } + + // Calculate bounding box + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodes.forEach((node) => { + const x = node.position.x; + const y = node.position.y; + const width = (node.width ?? 200); + const height = (node.height ?? 80); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + width); + maxY = Math.max(maxY, y + height); + }); + + const padding = 40; + const width = maxX - minX + padding * 2; + const height = maxY - minY + padding * 2; + const offsetX = -minX + padding; + const offsetY = -minY + padding; + + // Build SVG manually + let svgContent = ``; + svgContent += ``; + + // Draw edges + edges.forEach((edge) => { + const sourceNode = nodes.find((n) => n.id === edge.source); + const targetNode = nodes.find((n) => n.id === edge.target); + if (!sourceNode || !targetNode) return; + + const sx = sourceNode.position.x + (sourceNode.width ?? 200) / 2 + offsetX; + const sy = sourceNode.position.y + (sourceNode.height ?? 80) / 2 + offsetY; + const tx = targetNode.position.x + (targetNode.width ?? 200) / 2 + offsetX; + const ty = targetNode.position.y + (targetNode.height ?? 80) / 2 + offsetY; + + svgContent += ``; + }); + + // Draw nodes + nodes.forEach((node) => { + if (node.type === "group") return; // Skip group nodes + + const x = node.position.x + offsetX; + const y = node.position.y + offsetY; + const w = node.width ?? 200; + const h = node.height ?? 80; + + svgContent += ``; + svgContent += `${node.data.label ?? node.id}`; + }); + + svgContent += ``; + + const blob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" }); + const filename = `${graph?.project.name ?? "knowledge-graph"}-export.svg`; + downloadBlob(blob, filename); + toggleExportMenu(); + } catch (error) { + console.error("SVG export failed:", error); + alert(`Failed to export SVG: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const exportJSON = () => { + if (!graph) { + alert("No graph loaded"); + return; + } + + try { + // Apply persona and filters to create filtered graph + let filteredGraphNodes = persona === "non-technical" + ? graph.nodes.filter((n) => n.type === "concept" || n.type === "module" || n.type === "file") + : graph.nodes; + + filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters); + const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); + + let filteredGraphEdges = graph.edges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target) + ); + filteredGraphEdges = filterEdges(filteredGraphEdges, filteredNodeIds, filters); + + const filteredGraph: KnowledgeGraph = { + ...graph, + nodes: filteredGraphNodes, + edges: filteredGraphEdges, + }; + + const json = JSON.stringify(filteredGraph, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const filename = `${graph.project.name ?? "knowledge-graph"}-export.json`; + downloadBlob(blob, filename); + toggleExportMenu(); + } catch (error) { + console.error("JSON export failed:", error); + alert(`Failed to export JSON: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + return ( +
+ + + {exportMenuOpen && ( +
+
+ + + +
+
+ )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx b/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx new file mode 100644 index 0000000..e4f5c23 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx @@ -0,0 +1,217 @@ +import { useEffect, useRef } from "react"; +import { useDashboardStore } from "../store"; +import type { NodeType, Complexity, EdgeCategory } from "../store"; + +export default function FilterPanel() { + const graph = useDashboardStore((s) => s.graph); + const filters = useDashboardStore((s) => s.filters); + const setFilters = useDashboardStore((s) => s.setFilters); + const resetFilters = useDashboardStore((s) => s.resetFilters); + const hasActiveFilters = useDashboardStore((s) => s.hasActiveFilters); + const filterPanelOpen = useDashboardStore((s) => s.filterPanelOpen); + const toggleFilterPanel = useDashboardStore((s) => s.toggleFilterPanel); + + const containerRef = useRef(null); + + const allNodeTypes: NodeType[] = ["file", "function", "class", "module", "concept"]; + const allComplexities: Complexity[] = ["simple", "moderate", "complex"]; + const allEdgeCategories: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic"]; + const layers = graph?.layers ?? []; + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + if (filterPanelOpen) { + toggleFilterPanel(); + } + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [filterPanelOpen, toggleFilterPanel]); + + const toggleNodeType = (type: NodeType) => { + const newTypes = new Set(filters.nodeTypes); + if (newTypes.has(type)) { + newTypes.delete(type); + } else { + newTypes.add(type); + } + setFilters({ nodeTypes: newTypes }); + }; + + const toggleComplexity = (complexity: Complexity) => { + const newComplexities = new Set(filters.complexities); + if (newComplexities.has(complexity)) { + newComplexities.delete(complexity); + } else { + newComplexities.add(complexity); + } + setFilters({ complexities: newComplexities }); + }; + + const toggleLayer = (layerId: string) => { + const newLayers = new Set(filters.layerIds); + if (newLayers.has(layerId)) { + newLayers.delete(layerId); + } else { + newLayers.add(layerId); + } + setFilters({ layerIds: newLayers }); + }; + + const toggleEdgeCategory = (category: EdgeCategory) => { + const newCategories = new Set(filters.edgeCategories); + if (newCategories.has(category)) { + newCategories.delete(category); + } else { + newCategories.add(category); + } + setFilters({ edgeCategories: newCategories }); + }; + + const isActive = hasActiveFilters(); + + return ( +
+ + + {filterPanelOpen && ( +
+
+ {/* Node Types */} +
+

+ Node Types +

+
+ {allNodeTypes.map((type) => ( + + ))} +
+
+ + {/* Complexity */} +
+

+ Complexity +

+
+ {allComplexities.map((complexity) => ( + + ))} +
+
+ + {/* Layers */} + {layers.length > 0 && ( +
+

+ Layers +

+
+ {layers.map((layer) => ( +
+ )} + + {/* Edge Categories */} +
+

+ Edge Categories +

+
+ {allEdgeCategories.map((category) => ( + + ))} +
+
+ + {/* Reset Button */} + {isActive && ( + + )} +
+
+ )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index b1d6fe3..bc4780e 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -15,8 +15,11 @@ import "@xyflow/react/dist/style.css"; import CustomNode from "./CustomNode"; import type { CustomFlowNode } from "./CustomNode"; +import NodeTooltip from "./NodeTooltip"; import { useDashboardStore } from "../store"; +import type { FilterState } from "../store"; import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout"; +import { filterNodes, filterEdges } from "../utils/filters"; const LAYER_PADDING = 40; @@ -101,8 +104,10 @@ function buildTopologyData( changedNodeIds: Set, affectedNodeIds: Set, handleNodeSelect: (nodeId: string) => void, + filters: FilterState, ) { - const filteredGraphNodes = + // Step 1: Apply persona filtering + let filteredGraphNodes = persona === "non-technical" ? graph.nodes.filter( (n) => @@ -110,13 +115,24 @@ function buildTopologyData( ) : graph.nodes; + // Step 2: Apply filter panel filters + filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters); + const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); - const filteredGraphEdges = - persona === "non-technical" - ? graph.edges.filter( - (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), - ) - : graph.edges; + + // Step 3: Filter edges based on visible nodes and edge categories + let filteredGraphEdges = graph.edges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), + ); + filteredGraphEdges = filterEdges(filteredGraphEdges, filteredNodeIds, filters); + + // Compute connection counts for each node + const incomingCounts = new Map(); + const outgoingCounts = new Map(); + for (const edge of filteredGraphEdges) { + outgoingCounts.set(edge.source, (outgoingCounts.get(edge.source) ?? 0) + 1); + incomingCounts.set(edge.target, (incomingCounts.get(edge.target) ?? 0) + 1); + } const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({ id: node.id, @@ -135,6 +151,9 @@ function buildTopologyData( isDiffAffected: diffMode && affectedNodeIds.has(node.id), isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id), onNodeClick: handleNodeSelect, + incomingCount: incomingCounts.get(node.id) ?? 0, + outgoingCount: outgoingCounts.get(node.id) ?? 0, + tags: node.tags ?? [], }, })); @@ -309,6 +328,8 @@ function GraphViewInner() { const diffMode = useDashboardStore((s) => s.diffMode); const changedNodeIds = useDashboardStore((s) => s.changedNodeIds); const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds); + const filters = useDashboardStore((s) => s.filters); + const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance); const [layouting, setLayouting] = useState(false); @@ -328,10 +349,10 @@ function GraphViewInner() { } const { flowNodes, flowEdges } = buildTopologyData( graph, persona, diffMode, changedNodeIds, affectedNodeIds, - handleNodeSelect, + handleNodeSelect, filters, ); return { topoNodes: flowNodes, topoEdges: flowEdges, needsAsyncLayout: flowNodes.length > ASYNC_LAYOUT_THRESHOLD }; - }, [graph, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds]); + }, [graph, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, filters]); // ── Laid-out nodes from the last completed layout pass ── // Stored in a ref so layout results persist across visual-state changes. @@ -443,6 +464,7 @@ function GraphViewInner() { onEdgesChange={onEdgesChange} onNodeClick={onNodeClick} onPaneClick={onPaneClick} + onInit={setReactFlowInstance} nodeTypes={nodeTypes} nodesDraggable={false} nodesConnectable={false} @@ -465,6 +487,23 @@ function GraphViewInner() { + + {/* Node tooltips */} + {nodes + .filter((n) => n.type === "custom") + .map((node) => { + const data = node.data as CustomFlowNode["data"]; + return ( + + ); + })}
); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/NodeTooltip.tsx b/understand-anything-plugin/packages/dashboard/src/components/NodeTooltip.tsx new file mode 100644 index 0000000..a821228 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/NodeTooltip.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import type { CustomNodeData } from "./CustomNode"; + +interface NodeTooltipProps { + data: CustomNodeData; + nodeId: string; + incomingCount: number; + outgoingCount: number; + tags?: string[]; +} + +export default function NodeTooltip({ + data, + nodeId, + incomingCount, + outgoingCount, + tags = [], +}: NodeTooltipProps) { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + setPosition({ x: e.clientX, y: e.clientY }); + }; + + const showTooltip = () => setVisible(true); + const hideTooltip = () => setVisible(false); + + // Find the node element + const nodeElement = document.querySelector(`[data-id="${nodeId}"]`); + if (nodeElement) { + nodeElement.addEventListener("mouseenter", showTooltip); + nodeElement.addEventListener("mouseleave", hideTooltip); + nodeElement.addEventListener("mousemove", handleMouseMove); + + return () => { + nodeElement.removeEventListener("mouseenter", showTooltip); + nodeElement.removeEventListener("mouseleave", hideTooltip); + nodeElement.removeEventListener("mousemove", handleMouseMove); + }; + } + }, [nodeId]); + + if (!visible) return null; + + const totalConnections = incomingCount + outgoingCount; + + return ( +
+
+ {/* Header */} +
+ + {data.nodeType} + + {data.complexity && ( + + {data.complexity} + + )} +
+ + {/* Name */} +

+ {data.label} +

+ + {/* Connections */} +
+
+ + + + {incomingCount} in +
+
+ + + + {outgoingCount} out +
+
+ + + + {totalConnections} +
+
+ + {/* Summary */} + {data.summary && ( +

+ {data.summary.length > 120 ? data.summary.slice(0, 120) + "..." : data.summary} +

+ )} + + {/* Tags */} + {tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {tags.length > 3 && ( + +{tags.length - 3} + )} +
+ )} +
+
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx b/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx new file mode 100644 index 0000000..4f40d70 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx @@ -0,0 +1,306 @@ +import { useEffect, useRef, useState } from "react"; +import { useDashboardStore } from "../store"; + +interface PathFinderModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProps) { + const graph = useDashboardStore((s) => s.graph); + const selectNode = useDashboardStore((s) => s.selectNode); + const [fromNodeId, setFromNodeId] = useState(""); + const [toNodeId, setToNodeId] = useState(""); + const [path, setPath] = useState(null); + const [searching, setSearching] = useState(false); + const modalRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [isOpen, onClose]); + + if (!isOpen || !graph) return null; + + const nodes = graph.nodes; + const edges = graph.edges; + + // BFS to find shortest path + const findPath = () => { + if (!fromNodeId || !toNodeId || fromNodeId === toNodeId) { + setPath(null); + return; + } + + setSearching(true); + + // Build adjacency list + const adjacency = new Map(); + for (const edge of edges) { + if (!adjacency.has(edge.source)) { + adjacency.set(edge.source, []); + } + adjacency.get(edge.source)!.push(edge.target); + } + + // BFS + const queue: Array<{ nodeId: string; path: string[] }> = [ + { nodeId: fromNodeId, path: [fromNodeId] }, + ]; + const visited = new Set([fromNodeId]); + + while (queue.length > 0) { + const { nodeId, path: currentPath } = queue.shift()!; + + if (nodeId === toNodeId) { + setPath(currentPath); + setSearching(false); + return; + } + + const neighbors = adjacency.get(nodeId) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push({ nodeId: neighbor, path: [...currentPath, neighbor] }); + } + } + } + + // No path found + setPath([]); + setSearching(false); + }; + + const handleNodeClick = (nodeId: string) => { + selectNode(nodeId); + onClose(); + }; + + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + return ( +
+
+ {/* Header */} +
+
+ + + +

Dependency Path Finder

+
+ +
+ + {/* Body */} +
+

+ Find the shortest path between two nodes in the dependency graph. +

+ + {/* From Node */} +
+ + +
+ + {/* To Node */} +
+ + +
+ + {/* Find Path Button */} + + + {/* Path Result */} + {path !== null && ( +
+ {path.length === 0 ? ( +
+ + + +

No path found between these nodes.

+
+ ) : ( +
+
+ + + +

+ Path Found ({path.length} nodes) +

+
+
+ {path.map((nodeId, idx) => { + const node = nodeMap.get(nodeId); + if (!node) return null; + + const isLast = idx === path.length - 1; + + return ( +
+ + {!isLast && ( +
+ + + +
+ )} +
+ ); + })} +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx b/understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx index 7438620..073d838 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx @@ -21,6 +21,30 @@ export default function ProjectOverview() { typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1; } + // Count complexity + const complexityCounts: Record = { simple: 0, moderate: 0, complex: 0 }; + for (const node of nodes) { + if (node.complexity) { + complexityCounts[node.complexity] = (complexityCounts[node.complexity] ?? 0) + 1; + } + } + + // Find top connected nodes + const nodeConnections = new Map(); + for (const edge of edges) { + nodeConnections.set(edge.source, (nodeConnections.get(edge.source) ?? 0) + 1); + nodeConnections.set(edge.target, (nodeConnections.get(edge.target) ?? 0) + 1); + } + const topNodes = Array.from(nodeConnections.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([nodeId, count]) => { + const node = nodes.find((n) => n.id === nodeId); + return { id: nodeId, name: node?.name ?? nodeId, count }; + }); + + const avgConnections = nodes.length > 0 ? (edges.length * 2 / nodes.length).toFixed(1) : "0"; + return (
{/* Project name */} @@ -75,6 +99,82 @@ export default function ProjectOverview() {
)} + {/* Node Type Breakdown */} +
+

Node Type Distribution

+
+ {Object.entries(typeCounts) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => { + const percentage = ((count / nodes.length) * 100).toFixed(0); + return ( +
+
+ {type} + {count} ({percentage}%) +
+
+
+
+
+ ); + })} +
+
+ + {/* Complexity Breakdown */} + {Object.values(complexityCounts).some((c) => c > 0) && ( +
+

Complexity Distribution

+
+
+
{complexityCounts.simple}
+
Simple
+
+
+
{complexityCounts.moderate}
+
Moderate
+
+
+
{complexityCounts.complex}
+
Complex
+
+
+
+ )} + + {/* Top Connected Nodes */} + {topNodes.length > 0 && ( +
+

Most Connected Nodes

+
+ {topNodes.map((node, idx) => ( +
+
+ {idx + 1} +
+ {node.name} + {node.count} +
+ ))} +
+
+ )} + + {/* Average Connections */} +
+
+ Avg Connections per Node + {avgConnections} +
+
+ {/* Analyzed at */}
Analyzed: {new Date(project.analyzedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })} diff --git a/understand-anything-plugin/packages/dashboard/src/store.ts b/understand-anything-plugin/packages/dashboard/src/store.ts index b01a15c..e912e4b 100644 --- a/understand-anything-plugin/packages/dashboard/src/store.ts +++ b/understand-anything-plugin/packages/dashboard/src/store.ts @@ -5,8 +5,27 @@ import type { KnowledgeGraph, TourStep, } from "@understand-anything/core/types"; +import type { ReactFlowInstance } from "@xyflow/react"; export type Persona = "non-technical" | "junior" | "experienced"; +export type NodeType = "file" | "function" | "class" | "module" | "concept"; +export type Complexity = "simple" | "moderate" | "complex"; +export type EdgeCategory = "structural" | "behavioral" | "data-flow" | "dependencies" | "semantic"; + +export interface FilterState { + nodeTypes: Set; + complexities: Set; + layerIds: Set; + edgeCategories: Set; +} + +export const EDGE_CATEGORY_MAP: Record = { + structural: ["imports", "exports", "contains", "inherits", "implements"], + behavioral: ["calls", "subscribes", "publishes", "middleware"], + "data-flow": ["reads_from", "writes_to", "transforms", "validates"], + dependencies: ["depends_on", "tested_by", "configures"], + semantic: ["related", "similar_to"], +}; interface DashboardStore { graph: KnowledgeGraph | null; @@ -32,6 +51,12 @@ interface DashboardStore { changedNodeIds: Set; affectedNodeIds: Set; + filters: FilterState; + filterPanelOpen: boolean; + exportMenuOpen: boolean; + pathFinderOpen: boolean; + reactFlowInstance: ReactFlowInstance | null; + setGraph: (graph: KnowledgeGraph) => void; selectNode: (nodeId: string | null) => void; setSearchQuery: (query: string) => void; @@ -44,6 +69,14 @@ interface DashboardStore { toggleDiffMode: () => void; clearDiffOverlay: () => void; + toggleFilterPanel: () => void; + toggleExportMenu: () => void; + togglePathFinder: () => void; + setReactFlowInstance: (instance: ReactFlowInstance | null) => void; + setFilters: (filters: Partial) => void; + resetFilters: () => void; + hasActiveFilters: () => boolean; + startTour: () => void; stopTour: () => void; setTourStep: (step: number) => void; @@ -79,6 +112,17 @@ export const useDashboardStore = create()((set, get) => ({ changedNodeIds: new Set(), affectedNodeIds: new Set(), + filters: { + nodeTypes: new Set(["file", "function", "class", "module", "concept"]), + complexities: new Set(["simple", "moderate", "complex"]), + layerIds: new Set(), + edgeCategories: new Set(["structural", "behavioral", "data-flow", "dependencies", "semantic"]), + }, + filterPanelOpen: false, + exportMenuOpen: false, + pathFinderOpen: false, + reactFlowInstance: null, + setGraph: (graph) => { const searchEngine = new SearchEngine(graph.nodes); const query = get().searchQuery; @@ -124,6 +168,49 @@ export const useDashboardStore = create()((set, get) => ({ affectedNodeIds: new Set(), }), + toggleFilterPanel: () => set((state) => ({ + filterPanelOpen: !state.filterPanelOpen, + exportMenuOpen: false, + })), + + toggleExportMenu: () => set((state) => ({ + exportMenuOpen: !state.exportMenuOpen, + filterPanelOpen: false, + })), + + togglePathFinder: () => set((state) => ({ + pathFinderOpen: !state.pathFinderOpen, + })), + + setReactFlowInstance: (instance) => set({ reactFlowInstance: instance }), + + setFilters: (newFilters) => set((state) => ({ + filters: { ...state.filters, ...newFilters }, + })), + + resetFilters: () => set({ + filters: { + nodeTypes: new Set(["file", "function", "class", "module", "concept"]), + complexities: new Set(["simple", "moderate", "complex"]), + layerIds: new Set(), + edgeCategories: new Set(["structural", "behavioral", "data-flow", "dependencies", "semantic"]), + }, + }), + + hasActiveFilters: () => { + const { filters } = get(); + const allNodeTypes = new Set(["file", "function", "class", "module", "concept"]); + const allComplexities = new Set(["simple", "moderate", "complex"]); + const allEdgeCategories = new Set(["structural", "behavioral", "data-flow", "dependencies", "semantic"]); + + const hasNodeTypeFilter = filters.nodeTypes.size !== allNodeTypes.size; + const hasComplexityFilter = filters.complexities.size !== allComplexities.size; + const hasLayerFilter = filters.layerIds.size > 0; + const hasEdgeCategoryFilter = filters.edgeCategories.size !== allEdgeCategories.size; + + return hasNodeTypeFilter || hasComplexityFilter || hasLayerFilter || hasEdgeCategoryFilter; + }, + startTour: () => { const { graph } = get(); if (!graph || !graph.tour || graph.tour.length === 0) return; diff --git a/understand-anything-plugin/packages/dashboard/src/utils/filters.ts b/understand-anything-plugin/packages/dashboard/src/utils/filters.ts new file mode 100644 index 0000000..6e49010 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/filters.ts @@ -0,0 +1,72 @@ +import type { GraphNode, GraphEdge, Layer } from "@understand-anything/core/types"; +import type { FilterState, NodeType, Complexity, EdgeCategory } from "../store"; +import { EDGE_CATEGORY_MAP } from "../store"; + +/** + * Filter nodes based on active filters + */ +export function filterNodes( + nodes: GraphNode[], + layers: Layer[], + filters: FilterState, +): GraphNode[] { + return nodes.filter((node) => { + // Filter by node type + if (!filters.nodeTypes.has(node.type as NodeType)) { + return false; + } + + // Filter by complexity + if (node.complexity && !filters.complexities.has(node.complexity as Complexity)) { + return false; + } + + // Filter by layer (if any layers are selected) + if (filters.layerIds.size > 0) { + const nodeInSelectedLayer = layers.some( + (layer) => filters.layerIds.has(layer.id) && layer.nodeIds.includes(node.id) + ); + if (!nodeInSelectedLayer) { + return false; + } + } + + return true; + }); +} + +/** + * Filter edges based on visible nodes and active edge category filters + */ +export function filterEdges( + edges: GraphEdge[], + visibleNodeIds: Set, + filters: FilterState, +): GraphEdge[] { + return edges.filter((edge) => { + // Only keep edges between visible nodes + if (!visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)) { + return false; + } + + // Filter by edge category + const edgeCategory = getEdgeCategory(edge.type); + if (edgeCategory && !filters.edgeCategories.has(edgeCategory)) { + return false; + } + + return true; + }); +} + +/** + * Determine which category an edge type belongs to + */ +function getEdgeCategory(edgeType: string): EdgeCategory | null { + for (const [category, types] of Object.entries(EDGE_CATEGORY_MAP)) { + if (types.includes(edgeType)) { + return category as EdgeCategory; + } + } + return null; +}