From 30e6b87c7d411832b49636d60153dddfde2675f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkcan=20G=C3=BCm=C3=BC=C5=9F=C4=B1=C5=9F=C4=B1k?= <75336900+berkcangumusisik@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:46:25 +0300 Subject: [PATCH 1/3] Add filters, export, path finder & tooltips Introduce interactive graph tools and UI improvements: add FilterPanel, ExportMenu, PathFinderModal and NodeTooltip components; extend CustomNode data with incoming/outgoing counts and tags. Wire new components into App (buttons + keyboard shortcuts F/E/P) and implement outside-click/escape handling. Update GraphView to apply persona + filter rules, compute connection counts, register ReactFlow instance, and render per-node tooltips. Add JSON/SVG/PNG export that respects persona/filters and a BFS-based path finder UI. Enhance ProjectOverview with complexity distribution, most-connected nodes and average connections. Extend store with FilterState, edge category types, reactFlowInstance and toggles. --- .../packages/dashboard/src/App.tsx | 68 +++- .../dashboard/src/components/CustomNode.tsx | 3 + .../dashboard/src/components/ExportMenu.tsx | 302 +++++++++++++++++ .../dashboard/src/components/FilterPanel.tsx | 217 +++++++++++++ .../dashboard/src/components/GraphView.tsx | 57 +++- .../dashboard/src/components/NodeTooltip.tsx | 123 +++++++ .../src/components/PathFinderModal.tsx | 306 ++++++++++++++++++ .../src/components/ProjectOverview.tsx | 100 ++++++ .../packages/dashboard/src/store.ts | 87 +++++ .../packages/dashboard/src/utils/filters.ts | 72 +++++ 10 files changed, 1325 insertions(+), 10 deletions(-) create mode 100644 understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/components/FilterPanel.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/components/NodeTooltip.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx create mode 100644 understand-anything-plugin/packages/dashboard/src/utils/filters.ts 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; +} From 8de31ea8e6fa32e3f789d3d631f575dc57b1ac44 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sun, 29 Mar 2026 11:13:57 +0800 Subject: [PATCH 2/3] fix(dashboard): fix PNG export by replacing foreignObject with clean SVG primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreignObject approach caused canvas tainting — browsers block canvas.toBlob() when the SVG contains embedded DOM content. Extracted a shared buildCleanSvg() helper using only rect/line/text elements, reused by both PNG and SVG export. Co-Authored-By: Claude Opus 4.6 --- .../dashboard/src/components/ExportMenu.tsx | 175 +++++++----------- 1 file changed, 67 insertions(+), 108 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx index 4b15fdd..5915ab4 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/ExportMenu.tsx @@ -41,6 +41,63 @@ export default function ExportMenu() { 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"); @@ -48,63 +105,20 @@ export default function ExportMenu() { } 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) { + const result = buildCleanSvg(); + if (!result) { 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 { svgContent, width, height } = result; + const svgBlob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); - // Create an image and draw to canvas const img = new Image(); img.onerror = () => { URL.revokeObjectURL(url); - console.error("PNG export failed: Image failed to load from SVG blob"); - alert("Failed to export PNG: could not render graph as image. Try SVG export instead."); + alert("Failed to export PNG: could not render graph as image."); }; img.onload = () => { const canvas = document.createElement("canvas"); @@ -116,7 +130,7 @@ export default function ExportMenu() { alert("Failed to create canvas context"); return; } - ctx.drawImage(img, 0, 0); + ctx.drawImage(img, 0, 0, width * 2, height * 2); URL.revokeObjectURL(url); const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`; @@ -125,8 +139,7 @@ export default function ExportMenu() { downloadBlob(blob, filename); toggleExportMenu(); } else { - console.error("PNG export failed: canvas.toBlob returned null (canvas may be tainted)"); - alert("Failed to export PNG: image encoding failed. Try SVG export instead."); + alert("Failed to export PNG: image encoding failed."); } }, "image/png"); }; @@ -144,67 +157,13 @@ export default function ExportMenu() { } try { - const nodes = reactFlowInstance.getNodes(); - const edges = reactFlowInstance.getEdges(); - - if (nodes.length === 0) { + const result = buildCleanSvg(); + if (!result) { 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 += `${escapeXml(String(node.data.label ?? node.id))}`; - }); - - svgContent += ``; - - const blob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" }); + 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(); From d4e5e7f8755eb1656505c3bccc323c3f88e5d49e Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sun, 29 Mar 2026 11:18:22 +0800 Subject: [PATCH 3/3] fix(dashboard): make header legends horizontally scrollable on overflow Split the header into three sections: fixed left (project name, persona), scrollable middle (diff toggle, node type filters, layer legend), and fixed right (filter, export, path, theme actions). Prevents toolbar overflow when projects have many layers or node types. Co-Authored-By: Claude Opus 4.6 --- .../packages/dashboard/src/App.tsx | 75 +++++++++++-------- .../dashboard/src/components/LayerLegend.tsx | 4 +- .../packages/dashboard/src/index.css | 9 +++ 3 files changed, 53 insertions(+), 35 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index a17dbf5..1d95f42 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -280,46 +280,55 @@ 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 */} +