diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index c32e098..1d95f42 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -8,6 +8,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"; @@ -72,6 +75,8 @@ function Dashboard({ accessToken }: { accessToken: string }) { 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 nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters); const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter); const [loadError, setLoadError] = useState(null); @@ -102,11 +107,17 @@ function Dashboard({ accessToken }: { accessToken: string }) { // Navigation { key: "Escape", - description: "Close panels / go back to overview", + description: "Close panels and modals / go back to overview", 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); @@ -164,6 +175,33 @@ function Dashboard({ accessToken }: { accessToken: string }) { }, 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", + }, ], [] ); @@ -242,46 +280,77 @@ function Dashboard({ accessToken }: { accessToken: string }) {
{/* Header */} -
-
+
+ {/* Left — fixed */} +

{graph?.project.name ?? "Understand Anything"}

-
- -
- {([ - { key: "code", label: "Code", color: "var(--color-node-file)" }, - { key: "config", label: "Config", color: "var(--color-node-config)" }, - { key: "docs", label: "Docs", color: "var(--color-node-document)" }, - { key: "infra", label: "Infra", color: "var(--color-node-service)" }, - { key: "data", label: "Data", color: "var(--color-node-table)" }, - ] as const).map((cat) => ( - - ))} + + {/* Middle — scrollable legends */} +
+
+ +
+ {([ + { key: "code", label: "Code", color: "var(--color-node-file)" }, + { key: "config", label: "Config", color: "var(--color-node-config)" }, + { key: "docs", label: "Docs", color: "var(--color-node-document)" }, + { key: "infra", label: "Infra", color: "var(--color-node-service)" }, + { key: "data", label: "Data", color: "var(--color-node-table)" }, + ] as const).map((cat) => ( + + ))} +
+
- +
+ + {/* Right — fixed actions */} +
+ + +
); diff --git a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx index f8de408..1cae970 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx @@ -57,6 +57,9 @@ export interface CustomNodeData extends Record { isNeighbor: boolean; isSelectionFaded: 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..5915ab4 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx @@ -0,0 +1,274 @@ +import { useEffect, useRef } from "react"; +import { useDashboardStore } from "../store"; +import type { KnowledgeGraph } from "@understand-anything/core/types"; +import { filterNodes, filterEdges } from "../utils/filters"; + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +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 buildCleanSvg = () => { + if (!reactFlowInstance) return null; + + const nodes = reactFlowInstance.getNodes(); + const edges = reactFlowInstance.getEdges(); + if (nodes.length === 0) return null; + + 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; + + let svgContent = ``; + svgContent += ``; + + 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 += ``; + }); + + nodes.forEach((node) => { + if (node.type === "group") return; + + const x = node.position.x + offsetX; + const y = node.position.y + offsetY; + const w = node.width ?? 200; + const h = node.height ?? 80; + + svgContent += ``; + svgContent += `${escapeXml(String(node.data.label ?? node.id))}`; + }); + + svgContent += ``; + return { svgContent, width, height }; + }; + + const exportPNG = async () => { + if (!reactFlowInstance) { + alert("Graph not ready for export"); + return; + } + + try { + const result = buildCleanSvg(); + if (!result) { + alert("No nodes to export"); + return; + } + + const { svgContent, width, height } = result; + const svgBlob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + + const img = new Image(); + img.onerror = () => { + URL.revokeObjectURL(url); + alert("Failed to export PNG: could not render graph as image."); + }; + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = width * 2; + canvas.height = height * 2; + const ctx = canvas.getContext("2d"); + if (!ctx) { + URL.revokeObjectURL(url); + alert("Failed to create canvas context"); + return; + } + ctx.drawImage(img, 0, 0, width * 2, height * 2); + URL.revokeObjectURL(url); + + const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`; + canvas.toBlob((blob) => { + if (blob) { + downloadBlob(blob, filename); + toggleExportMenu(); + } else { + alert("Failed to export PNG: image encoding failed."); + } + }, "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 result = buildCleanSvg(); + if (!result) { + alert("No nodes to export"); + return; + } + + const blob = new Blob([result.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..fe5df09 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx @@ -0,0 +1,217 @@ +import { useEffect, useRef } from "react"; +import { useDashboardStore, ALL_NODE_TYPES, ALL_COMPLEXITIES, ALL_EDGE_CATEGORIES } 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 = ALL_NODE_TYPES; + const allComplexities = ALL_COMPLEXITIES; + const allEdgeCategories = ALL_EDGE_CATEGORIES; + 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 66c47da..7e61e51 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -484,6 +484,7 @@ function GraphViewInner() { const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); const focusNodeId = useDashboardStore((s) => s.focusNodeId); const setFocusNode = useDashboardStore((s) => s.setFocusNode); + const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance); const { preset } = useTheme(); const overviewGraph = useOverviewGraph(); @@ -561,6 +562,7 @@ function GraphViewInner() { onEdgesChange={onEdgesChange} onNodeClick={onNodeClick} onPaneClick={onPaneClick} + onInit={setReactFlowInstance} nodeTypes={nodeTypes} nodesDraggable={false} nodesConnectable={false} diff --git a/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx b/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx index 90d12a1..72a32fb 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx @@ -29,7 +29,7 @@ export default function LayerLegend() { return (
- + {navigationLevel === "overview" ? `${layers.length} layers` : activeLayer?.name ?? "Layer"} @@ -40,7 +40,7 @@ export default function LayerLegend() { const color = getLayerColor(i); const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId; return ( -
+
{ + const handleMouseMove = (e: Event) => { + const me = e as globalThis.MouseEvent; + setPosition({ x: me.clientX, y: me.clientY }); + }; + + const showTooltip = () => setVisible(true); + const hideTooltip = () => setVisible(false); + + // Find the node element via data-id (React Flow convention) + const nodeElement = document.querySelector(`[data-id="${CSS.escape(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 81d4018..ae799f1 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"; + // Category breakdowns const categoryBreakdown = [ { label: "Code", color: "var(--color-node-file)", count: (typeCounts["file"] ?? 0) + (typeCounts["function"] ?? 0) + (typeCounts["class"] ?? 0) }, @@ -104,6 +128,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/index.css b/understand-anything-plugin/packages/dashboard/src/index.css index bb8f99d..03690b6 100644 --- a/understand-anything-plugin/packages/dashboard/src/index.css +++ b/understand-anything-plugin/packages/dashboard/src/index.css @@ -199,6 +199,15 @@ body { transition: opacity 0.3s ease, filter 0.3s ease; } +/* Hide scrollbar but keep scroll functionality */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + /* Custom scrollbar for dark luxury theme */ ::-webkit-scrollbar { width: 6px; diff --git a/understand-anything-plugin/packages/dashboard/src/store.ts b/understand-anything-plugin/packages/dashboard/src/store.ts index 61f310f..a2c1051 100644 --- a/understand-anything-plugin/packages/dashboard/src/store.ts +++ b/understand-anything-plugin/packages/dashboard/src/store.ts @@ -5,9 +5,39 @@ import type { KnowledgeGraph, TourStep, } from "@understand-anything/core/types"; +import type { ReactFlowInstance } from "@xyflow/react"; export type Persona = "non-technical" | "junior" | "experienced"; export type NavigationLevel = "overview" | "layer-detail"; +export type NodeType = "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource"; +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 ALL_NODE_TYPES: NodeType[] = ["file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"]; +export const ALL_COMPLEXITIES: Complexity[] = ["simple", "moderate", "complex"]; +export const ALL_EDGE_CATEGORIES: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic"]; + +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"], +}; + +const DEFAULT_FILTERS: FilterState = { + nodeTypes: new Set(ALL_NODE_TYPES), + complexities: new Set(ALL_COMPLEXITIES), + layerIds: new Set(), + edgeCategories: new Set(ALL_EDGE_CATEGORIES), +}; /** Categories used for node type filter toggles. Single source of truth for NodeCategory. */ export type NodeCategory = "code" | "config" | "docs" | "infra" | "data"; @@ -55,6 +85,13 @@ interface DashboardStore { // Sidebar navigation history (stack of visited node IDs) nodeHistory: string[]; + // Filter & Export features + filters: FilterState; + filterPanelOpen: boolean; + exportMenuOpen: boolean; + pathFinderOpen: boolean; + reactFlowInstance: ReactFlowInstance | null; + // Node type category filters nodeTypeFilters: Record; toggleNodeTypeFilter: (category: NodeCategory) => void; @@ -77,6 +114,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; @@ -131,6 +176,12 @@ export const useDashboardStore = create()((set, get) => ({ focusNodeId: null, nodeHistory: [], + filters: { ...DEFAULT_FILTERS, nodeTypes: new Set(DEFAULT_FILTERS.nodeTypes), complexities: new Set(DEFAULT_FILTERS.complexities), layerIds: new Set(DEFAULT_FILTERS.layerIds), edgeCategories: new Set(DEFAULT_FILTERS.edgeCategories) }, + filterPanelOpen: false, + exportMenuOpen: false, + pathFinderOpen: false, + reactFlowInstance: null, + nodeTypeFilters: { code: true, config: true, docs: true, infra: true, data: true }, toggleNodeTypeFilter: (category) => @@ -291,6 +342,43 @@ 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(ALL_NODE_TYPES), + complexities: new Set(ALL_COMPLEXITIES), + layerIds: new Set(), + edgeCategories: new Set(ALL_EDGE_CATEGORIES), + }, + }), + + hasActiveFilters: () => { + const { filters } = get(); + return filters.nodeTypes.size !== ALL_NODE_TYPES.length + || filters.complexities.size !== ALL_COMPLEXITIES.length + || filters.layerIds.size > 0 + || filters.edgeCategories.size !== ALL_EDGE_CATEGORIES.length; + }, + 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; +}