From 42252d7a1def17fde876e2907ebb85350b2b6491 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:36:59 +0000 Subject: [PATCH] style(ui): format ui/src with prettier and check it in CI `prettier --check src/` failed on 31 files: formatting had drifted with no check to catch it, and eslint-config-prettier switches ESLint's style rules off, so nothing else would. All files are reformatted with the repository's own .prettierrc (no config change), and the UI job gains a Prettier step (`bun run format:check`) after ESLint. Whitespace and wrapping only: lint, typecheck, the 119 vitest tests and the build all pass after the reformat. Follow-up to #517. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uv6LUrB5HCQcqBvmcixYn3 --- .github/workflows/ui.yml | 5 + ui/src/App.module.css | 5 +- ui/src/collapse.ts | 5 +- ui/src/components/ControlPanel.tsx | 48 ++- ui/src/components/FileContainerNode.test.tsx | 2 +- ui/src/components/FileContainerNode.tsx | 12 +- ui/src/components/GraphShell.tsx | 190 +++++------ ui/src/components/IslandContainerNode.tsx | 44 +-- ui/src/components/LegendPanel.tsx | 2 +- ui/src/constants.ts | 8 +- ui/src/hooks/useExport.test.ts | 1 - ui/src/hooks/useFlowNavigation.test.ts | 56 ++- ui/src/hooks/useFlowNavigation.ts | 66 ++-- ui/src/hooks/useLayoutComputation.ts | 102 +++--- ui/src/layout/IslandLayoutEngine.ts | 275 ++++++++------- .../__tests__/IslandLayoutEngine.test.ts | 318 +++++++++--------- ui/src/layout/index.ts | 2 +- ui/src/main.tsx | 20 +- ui/src/providers/GraphProvider.tsx | 25 +- ui/src/store/__tests__/useGraphStore.test.ts | 181 ++++++---- ui/src/store/useGraphStore.ts | 136 ++++---- ui/src/theme.ts | 24 +- ui/src/utils/__tests__/edgeMapper.test.ts | 80 ++--- ui/src/utils/aggregateEdges.test.ts | 15 +- ui/src/utils/aggregateEdges.ts | 5 +- ui/src/utils/applyHeatmap.test.ts | 78 ++--- ui/src/utils/applyHeatmap.ts | 20 +- ui/src/utils/applyHighlight.test.ts | 29 +- ui/src/utils/edgeMapper.test.ts | 1 - ui/src/utils/edgeMapper.ts | 9 +- ui/src/utils/nodeMapper.test.ts | 29 +- ui/src/utils/nodeMapper.ts | 7 +- 32 files changed, 980 insertions(+), 820 deletions(-) diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index eb61d2e1..359415d4 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -47,6 +47,11 @@ jobs: - name: ESLint run: bun run lint + - name: Prettier + # eslint-config-prettier turns ESLint's style rules off, so formatting is + # checked here or nowhere; 31 files had drifted before this step existed. + run: bun run format:check + - name: TypeScript # Vite transpiles TypeScript without type-checking it, so `build` below # passes a type error. This is the only step that reads the types. diff --git a/ui/src/App.module.css b/ui/src/App.module.css index bcf151ff..0f39e7c9 100644 --- a/ui/src/App.module.css +++ b/ui/src/App.module.css @@ -15,7 +15,10 @@ } :global(.react-flow__node[data-id]) { - transition: box-shadow 0.3s, transform 0.5s ease, opacity 0.7s ease-in-out; + transition: + box-shadow 0.3s, + transform 0.5s ease, + opacity 0.7s ease-in-out; cursor: pointer; } diff --git a/ui/src/collapse.ts b/ui/src/collapse.ts index ccb8ce0f..6253fa88 100644 --- a/ui/src/collapse.ts +++ b/ui/src/collapse.ts @@ -1,9 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -function collectAllDescendants( - id: string, - parentChildren: Map -): string[] { +function collectAllDescendants(id: string, parentChildren: Map): string[] { const result: string[] = []; const direct = parentChildren.get(id) || []; for (const c of direct) { diff --git a/ui/src/components/ControlPanel.tsx b/ui/src/components/ControlPanel.tsx index 2dd05bd8..74fcbd8f 100644 --- a/ui/src/components/ControlPanel.tsx +++ b/ui/src/components/ControlPanel.tsx @@ -42,7 +42,7 @@ export default function ControlPanel({ searchQuery: string; setSearchQuery: (q: string) => void; searchInputRef?: RefObject; - colorMode: 'type' | 'health'; + colorMode: "type" | "health"; onToggleColorMode: () => void; }) { const { fitView } = useReactFlow(); @@ -88,15 +88,28 @@ export default function ControlPanel({
{viewMode === "flow" ? ( <> - {flowRootId && ( - )} - @@ -120,21 +133,36 @@ export default function ControlPanel({ {showExternal ? "●Ext" : "○Ext"} - - - diff --git a/ui/src/components/FileContainerNode.test.tsx b/ui/src/components/FileContainerNode.test.tsx index a4331c96..6b13e418 100644 --- a/ui/src/components/FileContainerNode.test.tsx +++ b/ui/src/components/FileContainerNode.test.tsx @@ -14,7 +14,7 @@ function renderNode(props: any = {}) { return render( - , + ); } diff --git a/ui/src/components/FileContainerNode.tsx b/ui/src/components/FileContainerNode.tsx index 202e22fd..a0e0d5d7 100644 --- a/ui/src/components/FileContainerNode.tsx +++ b/ui/src/components/FileContainerNode.tsx @@ -14,8 +14,16 @@ function FileContainerNode({ data }: NodeProps) { {indicator} {cleanLabel}
- - + + ); } diff --git a/ui/src/components/GraphShell.tsx b/ui/src/components/GraphShell.tsx index 1c2cd383..03f42b71 100644 --- a/ui/src/components/GraphShell.tsx +++ b/ui/src/components/GraphShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react' +import { useCallback, useMemo, useRef, useState, type MouseEvent } from "react"; import { ReactFlow, Background, @@ -8,198 +8,195 @@ import { Panel, applyNodeChanges, type NodeChange, -} from '@xyflow/react' -import '@xyflow/react/dist/style.css' -import { ErrorBoundary, type FallbackProps } from 'react-error-boundary' - -import { useGraphStore } from '../store/useGraphStore' -import { useLayoutComputation } from '../hooks/useLayoutComputation' -import { useFlowNavigation } from '../hooks/useFlowNavigation' -import { useSearch } from '../hooks/useSearch' -import { useExport } from '../hooks/useExport' -import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts' -import { applyContextHighlight } from '../utils/applyHighlight' -import { ALL_EDGE_TYPES } from '../constants' - -import ControlPanel from './ControlPanel' -import StatsPanel from './StatsPanel' -import NodeTooltip from './NodeTooltip' -import LegendPanel from './LegendPanel' -import LoadingOverlay from './LoadingOverlay' -import FileContainerNode from './FileContainerNode' -import IslandContainerNode from './IslandContainerNode' - -import sharedStyles from '../shared.module.css' +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; + +import { useGraphStore } from "../store/useGraphStore"; +import { useLayoutComputation } from "../hooks/useLayoutComputation"; +import { useFlowNavigation } from "../hooks/useFlowNavigation"; +import { useSearch } from "../hooks/useSearch"; +import { useExport } from "../hooks/useExport"; +import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; +import { applyContextHighlight } from "../utils/applyHighlight"; +import { ALL_EDGE_TYPES } from "../constants"; + +import ControlPanel from "./ControlPanel"; +import StatsPanel from "./StatsPanel"; +import NodeTooltip from "./NodeTooltip"; +import LegendPanel from "./LegendPanel"; +import LoadingOverlay from "./LoadingOverlay"; +import FileContainerNode from "./FileContainerNode"; +import IslandContainerNode from "./IslandContainerNode"; + +import sharedStyles from "../shared.module.css"; function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { return ( -
+

Something went wrong

{error instanceof Error ? error.message : String(error)}

- ) + ); } function GraphCanvas() { - const { fitView } = useReactFlow() - const searchInputRef = useRef(null) - const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + const { fitView } = useReactFlow(); + const searchInputRef = useRef(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); // Store subscriptions — granular to avoid unnecessary re-renders - const layoutedNodes = useGraphStore((s) => s.layoutedNodes) - const layoutedEdges = useGraphStore((s) => s.layoutedEdges) - const flowNodes = useGraphStore((s) => s.flowNodes) - const flowEdges = useGraphStore((s) => s.flowEdges) - const viewMode = useGraphStore((s) => s.viewMode) - const hoveredNodeId = useGraphStore((s) => s.hoveredNodeId) - const graphVersion = useGraphStore((s) => s.graphVersion) - const setViewMode = useGraphStore((s) => s.setViewMode) - const setHoveredNodeId = useGraphStore((s) => s.setHoveredNodeId) - const toggleExpandedFile = useGraphStore((s) => s.toggleExpandedFile) - const setLayout = useGraphStore((s) => s.setLayout) - const activeEdgeTypes = useGraphStore((s) => s.activeEdgeTypes) - const showExternal = useGraphStore((s) => s.showExternal) - const toggleEdgeType = useGraphStore((s) => s.toggleEdgeType) - const toggleExternal = useGraphStore((s) => s.toggleExternal) - const colorMode = useGraphStore((s) => s.colorMode) - const setColorMode = useGraphStore((s) => s.setColorMode) + const layoutedNodes = useGraphStore((s) => s.layoutedNodes); + const layoutedEdges = useGraphStore((s) => s.layoutedEdges); + const flowNodes = useGraphStore((s) => s.flowNodes); + const flowEdges = useGraphStore((s) => s.flowEdges); + const viewMode = useGraphStore((s) => s.viewMode); + const hoveredNodeId = useGraphStore((s) => s.hoveredNodeId); + const graphVersion = useGraphStore((s) => s.graphVersion); + const setViewMode = useGraphStore((s) => s.setViewMode); + const setHoveredNodeId = useGraphStore((s) => s.setHoveredNodeId); + const toggleExpandedFile = useGraphStore((s) => s.toggleExpandedFile); + const setLayout = useGraphStore((s) => s.setLayout); + const activeEdgeTypes = useGraphStore((s) => s.activeEdgeTypes); + const showExternal = useGraphStore((s) => s.showExternal); + const toggleEdgeType = useGraphStore((s) => s.toggleEdgeType); + const toggleExternal = useGraphStore((s) => s.toggleExternal); + const colorMode = useGraphStore((s) => s.colorMode); + const setColorMode = useGraphStore((s) => s.setColorMode); // Layout computation hook — subscribes to filter state, runs dagre - useLayoutComputation() + useLayoutComputation(); // Flow navigation on node click - const { onNodeClick: onFlowClick } = useFlowNavigation() + const { onNodeClick: onFlowClick } = useFlowNavigation(); // Search filters displayed nodes - const activeNodes = viewMode === 'flow' ? flowNodes : layoutedNodes - const { searchQuery, setSearchQuery, displayedNodes } = useSearch(activeNodes) + const activeNodes = viewMode === "flow" ? flowNodes : layoutedNodes; + const { searchQuery, setSearchQuery, displayedNodes } = useSearch(activeNodes); // Export to PNG / SVG const { exportPng, exportSvg } = useExport( useCallback((dataUrl: string, filename: string) => { - const a = document.createElement('a') - a.setAttribute('download', filename) - a.setAttribute('href', dataUrl) - a.click() + const a = document.createElement("a"); + a.setAttribute("download", filename); + a.setAttribute("href", dataUrl); + a.click(); }, []) - ) + ); // Fix 2: Extract handleFit with useCallback - const handleFit = useCallback( - () => fitView({ padding: 0.15, duration: 250 }), - [fitView] - ) + const handleFit = useCallback(() => fitView({ padding: 0.15, duration: 250 }), [fitView]); // Fix 3: Memoize handleBack with useCallback - const handleBack = useCallback(() => setViewMode('full'), [setViewMode]) + const handleBack = useCallback(() => setViewMode("full"), [setViewMode]); // Keyboard shortcuts useKeyboardShortcuts({ onEscape: useCallback(() => { - if (viewMode === 'flow') setViewMode('full') + if (viewMode === "flow") setViewMode("full"); }, [viewMode, setViewMode]), onFit: handleFit, onFocusSearch: useCallback(() => searchInputRef.current?.focus(), []), - }) + }); // Highlight connected nodes/edges on hover - const activeEdges = viewMode === 'flow' ? flowEdges : layoutedEdges + const activeEdges = viewMode === "flow" ? flowEdges : layoutedEdges; const { nodes: highlightedNodes, edges: highlightedEdges } = useMemo( () => applyContextHighlight(displayedNodes, activeEdges, hoveredNodeId), [displayedNodes, activeEdges, hoveredNodeId] - ) + ); const nodeTypes = useMemo( () => ({ fileContainer: FileContainerNode, islandContainer: IslandContainerNode }), [] - ) + ); // Fix 1: Memoize ALL_EDGE_TYPES spread - const allEdgeTypesArray = useMemo(() => [...ALL_EDGE_TYPES], []) + const allEdgeTypesArray = useMemo(() => [...ALL_EDGE_TYPES], []); // Fix 4: Memoize handleMouseMove with useCallback const handleMouseMove = useCallback( (e: MouseEvent) => setMousePos({ x: e.clientX, y: e.clientY }), [] - ) + ); const handleToggleEdgeType = useCallback( (type: string) => toggleEdgeType(type as Parameters[0]), [toggleEdgeType] - ) + ); const handleToggleColorMode = useCallback( - () => setColorMode(colorMode === 'type' ? 'health' : 'type'), + () => setColorMode(colorMode === "type" ? "health" : "type"), [colorMode, setColorMode] - ) + ); const onNodeClick = useCallback( async (event: MouseEvent, node: Parameters[1]) => { - if (viewMode === 'full' && (node.data as Record)?.nodeType === 'FILE') { - toggleExpandedFile(node.id) + if (viewMode === "full" && (node.data as Record)?.nodeType === "FILE") { + toggleExpandedFile(node.id); } else { - await onFlowClick(event, node) + await onFlowClick(event, node); } }, [viewMode, toggleExpandedFile, onFlowClick] - ) + ); // Persist dragged positions back to the store (controlled-mode requirement). // For fileContainer drags, also move all child nodes by the same delta. const handleNodesChange = useCallback( (changes: NodeChange[]) => { - const expandedChanges = [...changes] + const expandedChanges = [...changes]; for (const change of changes) { - if (change.type !== 'position' || change.position == null) continue - const node = layoutedNodes.find((n) => n.id === change.id) - if (!node || node.type !== 'fileContainer') continue - const dx = change.position.x - node.position.x - const dy = change.position.y - node.position.y + if (change.type !== "position" || change.position == null) continue; + const node = layoutedNodes.find((n) => n.id === change.id); + if (!node || node.type !== "fileContainer") continue; + const dx = change.position.x - node.position.x; + const dy = change.position.y - node.position.y; for (const child of layoutedNodes) { - const groupId = (child.data as Record)?.groupId as string | undefined + const groupId = (child.data as Record)?.groupId as string | undefined; if (groupId === change.id) { expandedChanges.push({ - type: 'position', + type: "position", id: child.id, position: { x: child.position.x + dx, y: child.position.y + dy }, dragging: change.dragging, - }) + }); } } } - setLayout(applyNodeChanges(expandedChanges, layoutedNodes), layoutedEdges) + setLayout(applyNodeChanges(expandedChanges, layoutedNodes), layoutedEdges); }, [layoutedNodes, layoutedEdges, setLayout] - ) + ); - const graphLoading = graphVersion === 0 + const graphLoading = graphVersion === 0; // Adapt store Set to ControlPanel's string[] expectation - const activeEdgeTypesArray = useMemo(() => [...activeEdgeTypes], [activeEdgeTypes]) + const activeEdgeTypesArray = useMemo(() => [...activeEdgeTypes], [activeEdgeTypes]); // Compute stats for StatsPanel const stats = useMemo(() => { const externalCount = highlightedNodes.filter( (n) => (n.data as Record)?.isExternal === true - ).length + ).length; return { nodes: highlightedNodes.length, edges: highlightedEdges.length, external: externalCount, - } - }, [highlightedNodes, highlightedEdges]) + }; + }, [highlightedNodes, highlightedEdges]); // Find the hovered node for NodeTooltip const hoveredNode = useMemo( () => highlightedNodes.find((n) => n.id === hoveredNodeId) ?? null, [highlightedNodes, hoveredNodeId] - ) + ); return ( -
+
internally */} - {hoveredNodeId && ( - - )} + {hoveredNodeId && }
- ) + ); } export default function GraphShell() { @@ -259,5 +251,5 @@ export default function GraphShell() { - ) + ); } diff --git a/ui/src/components/IslandContainerNode.tsx b/ui/src/components/IslandContainerNode.tsx index 1cd92d34..5ee34b53 100644 --- a/ui/src/components/IslandContainerNode.tsx +++ b/ui/src/components/IslandContainerNode.tsx @@ -1,48 +1,48 @@ -import type { NodeProps, Node } from '@xyflow/react' -import type { CSSProperties } from 'react' -import { NAMESPACE_COLORS } from '../theme' +import type { NodeProps, Node } from "@xyflow/react"; +import type { CSSProperties } from "react"; +import { NAMESPACE_COLORS } from "../theme"; const NAMESPACE_LABEL: Record = { - 'owner-api': '🐍 owner-api', - 'owner-web': '⚛️ owner-web', - 'ownima-admin': '⚛️ admin', - 'rider-web': '🌐 rider-web', - _default: '📦 default', -} + "owner-api": "🐍 owner-api", + "owner-web": "⚛️ owner-web", + "ownima-admin": "⚛️ admin", + "rider-web": "🌐 rider-web", + _default: "📦 default", +}; -type IslandContainerData = { namespace: string; style?: CSSProperties } +type IslandContainerData = { namespace: string; style?: CSSProperties }; export default function IslandContainerNode({ data }: NodeProps>) { - const ns = data.namespace - const color = NAMESPACE_COLORS[ns] ?? '#546e7a' - const label = NAMESPACE_LABEL[ns] ?? ns + const ns = data.namespace; + const color = NAMESPACE_COLORS[ns] ?? "#546e7a"; + const label = NAMESPACE_LABEL[ns] ?? ns; return (
{label}
- ) + ); } diff --git a/ui/src/components/LegendPanel.tsx b/ui/src/components/LegendPanel.tsx index 7c70de0c..abe2d9b5 100644 --- a/ui/src/components/LegendPanel.tsx +++ b/ui/src/components/LegendPanel.tsx @@ -7,7 +7,7 @@ export default function LegendPanel({ visible = true }: { visible: boolean }) { if (!visible) return null; return ( - +
Legend
diff --git a/ui/src/constants.ts b/ui/src/constants.ts index 43aad184..1a57c8fc 100644 --- a/ui/src/constants.ts +++ b/ui/src/constants.ts @@ -8,8 +8,8 @@ export const NODE_SEP = 60; export const RANK_SEP = 100; // All edge type names — module-level constant, never re-created on render -export const ALL_EDGE_TYPES = ['CALLS', 'IMPORTS', 'EXTENDS', 'DECLARES'] as const -export type EdgeTypeName = (typeof ALL_EDGE_TYPES)[number] +export const ALL_EDGE_TYPES = ["CALLS", "IMPORTS", "EXTENDS", "DECLARES"] as const; +export type EdgeTypeName = (typeof ALL_EDGE_TYPES)[number]; -export const CANVAS_MAX_WIDTH = 3200 -export const ISLAND_GAP = 80 // padding between islands in bin-packing +export const CANVAS_MAX_WIDTH = 3200; +export const ISLAND_GAP = 80; // padding between islands in bin-packing diff --git a/ui/src/hooks/useExport.test.ts b/ui/src/hooks/useExport.test.ts index 8dab81b6..7db5fcc9 100644 --- a/ui/src/hooks/useExport.test.ts +++ b/ui/src/hooks/useExport.test.ts @@ -1,4 +1,3 @@ - import { renderHook } from "@testing-library/react"; import { useExport } from "./useExport"; diff --git a/ui/src/hooks/useFlowNavigation.test.ts b/ui/src/hooks/useFlowNavigation.test.ts index 28516ba2..1410c890 100644 --- a/ui/src/hooks/useFlowNavigation.test.ts +++ b/ui/src/hooks/useFlowNavigation.test.ts @@ -13,9 +13,12 @@ vi.mock("@xyflow/react", () => ({ vi.mock("../layout/IslandLayoutEngine", () => ({ IslandLayoutEngine: class { - constructor(public nodes: unknown[], public edges: unknown[]) {} + constructor( + public nodes: unknown[], + public edges: unknown[] + ) {} run(_expandedFiles: Set) { - return { nodes: this.nodes, edges: this.edges } + return { nodes: this.nodes, edges: this.edges }; } }, })); @@ -29,14 +32,51 @@ vi.mock("../utils/nodeMapper", () => ({ })); const rawNodes = [ - { id: "a", type: "FUNCTION", name: "funcA", file_path: "a.py", start_line: 1, end_line: 10, language: "py", namespace: "INTERNAL", ontology_class: null, domains: [], confidence_score: 1, metadata: {} }, - { id: "b", type: "FUNCTION", name: "funcB", file_path: "b.py", start_line: 1, end_line: 5, language: "py", namespace: "INTERNAL", ontology_class: null, domains: [], confidence_score: 1, metadata: {} }, - { id: "g1", type: "CLASS", name: "GroupNode", file_path: "g.py", start_line: 1, end_line: 20, language: "py", namespace: "INTERNAL", ontology_class: null, domains: [], confidence_score: 1, metadata: {} }, + { + id: "a", + type: "FUNCTION", + name: "funcA", + file_path: "a.py", + start_line: 1, + end_line: 10, + language: "py", + namespace: "INTERNAL", + ontology_class: null, + domains: [], + confidence_score: 1, + metadata: {}, + }, + { + id: "b", + type: "FUNCTION", + name: "funcB", + file_path: "b.py", + start_line: 1, + end_line: 5, + language: "py", + namespace: "INTERNAL", + ontology_class: null, + domains: [], + confidence_score: 1, + metadata: {}, + }, + { + id: "g1", + type: "CLASS", + name: "GroupNode", + file_path: "g.py", + start_line: 1, + end_line: 20, + language: "py", + namespace: "INTERNAL", + ontology_class: null, + domains: [], + confidence_score: 1, + metadata: {}, + }, ]; -const rawEdges = [ - { id: "e1", source: "a", target: "b", type: "CALLS" }, -]; +const rawEdges = [{ id: "e1", source: "a", target: "b", type: "CALLS" }]; vi.mock("../store/useGraphStore", () => ({ useGraphStore: (selector: (s: any) => any) => diff --git a/ui/src/hooks/useFlowNavigation.ts b/ui/src/hooks/useFlowNavigation.ts index a94246ed..83b3c497 100644 --- a/ui/src/hooks/useFlowNavigation.ts +++ b/ui/src/hooks/useFlowNavigation.ts @@ -1,55 +1,53 @@ // ui/src/hooks/useFlowNavigation.ts /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback, useRef, type MouseEvent } from 'react' -import { useReactFlow, type Node } from '@xyflow/react' -import { buildExecutionFlow } from '../flow' -import { IslandLayoutEngine } from '../layout/IslandLayoutEngine' -import { mapNodeToFlowView } from '../utils/nodeMapper' -import { mapEdgeToFlowView } from '../utils/edgeMapper' -import { useGraphStore } from '../store/useGraphStore' +import { useCallback, useRef, type MouseEvent } from "react"; +import { useReactFlow, type Node } from "@xyflow/react"; +import { buildExecutionFlow } from "../flow"; +import { IslandLayoutEngine } from "../layout/IslandLayoutEngine"; +import { mapNodeToFlowView } from "../utils/nodeMapper"; +import { mapEdgeToFlowView } from "../utils/edgeMapper"; +import { useGraphStore } from "../store/useGraphStore"; -const FLOW_DEPTH = 3 -const DEFAULT_ALLOWED = ['CALLS', 'IMPORTS', 'CONTAINS'] +const FLOW_DEPTH = 3; +const DEFAULT_ALLOWED = ["CALLS", "IMPORTS", "CONTAINS"]; export function useFlowNavigation(allowedEdgeTypes: string[] = DEFAULT_ALLOWED) { - const rawNodes = useGraphStore((s) => s.graphNodes) - const rawEdges = useGraphStore((s) => s.graphEdges) - const graphVersion = useGraphStore((s) => s.graphVersion) - const setFlow = useGraphStore((s) => s.setFlow) - const setViewMode = useGraphStore((s) => s.setViewMode) - const { fitView } = useReactFlow() + const rawNodes = useGraphStore((s) => s.graphNodes); + const rawEdges = useGraphStore((s) => s.graphEdges); + const graphVersion = useGraphStore((s) => s.graphVersion); + const setFlow = useGraphStore((s) => s.setFlow); + const setViewMode = useGraphStore((s) => s.setViewMode); + const { fitView } = useReactFlow(); // Local ref cache — keyed by graphVersion so stale data is never served - const cacheRef = useRef>(new Map()) + const cacheRef = useRef>(new Map()); const onNodeClick = useCallback( async (_event: MouseEvent, node: Node) => { - if (node.type === 'group' || node.type === 'fileContainer') return + if (node.type === "group" || node.type === "fileContainer") return; - const cacheKey = `${node.id}:${FLOW_DEPTH}:${allowedEdgeTypes.join(',')}:${graphVersion}` + const cacheKey = `${node.id}:${FLOW_DEPTH}:${allowedEdgeTypes.join(",")}:${graphVersion}`; - let flow: { nodes: any[]; edges: any[] } + let flow: { nodes: any[]; edges: any[] }; if (cacheRef.current.has(cacheKey)) { - flow = cacheRef.current.get(cacheKey)! + flow = cacheRef.current.get(cacheKey)!; } else { - const graphData = { nodes: rawNodes, edges: rawEdges } - flow = buildExecutionFlow(graphData, node.id, FLOW_DEPTH, 'both', allowedEdgeTypes) - cacheRef.current.set(cacheKey, flow) + const graphData = { nodes: rawNodes, edges: rawEdges }; + flow = buildExecutionFlow(graphData, node.id, FLOW_DEPTH, "both", allowedEdgeTypes); + cacheRef.current.set(cacheKey, flow); } - const flowNodes = flow.nodes.map((n) => - mapNodeToFlowView(n, { isRoot: n.id === node.id }) - ) - const flowEdges = flow.edges.map((e, i) => mapEdgeToFlowView(e, i)) - const engine = new IslandLayoutEngine(flowNodes, flowEdges) - const { nodes: layoutedNodes } = engine.run(new Set()) + const flowNodes = flow.nodes.map((n) => mapNodeToFlowView(n, { isRoot: n.id === node.id })); + const flowEdges = flow.edges.map((e, i) => mapEdgeToFlowView(e, i)); + const engine = new IslandLayoutEngine(flowNodes, flowEdges); + const { nodes: layoutedNodes } = engine.run(new Set()); - setFlow(layoutedNodes, flowEdges) - setViewMode('flow') - fitView({ padding: 0.15, duration: 250 }) + setFlow(layoutedNodes, flowEdges); + setViewMode("flow"); + fitView({ padding: 0.15, duration: 250 }); }, [rawNodes, rawEdges, graphVersion, allowedEdgeTypes, setFlow, setViewMode, fitView] - ) + ); - return { onNodeClick } + return { onNodeClick }; } diff --git a/ui/src/hooks/useLayoutComputation.ts b/ui/src/hooks/useLayoutComputation.ts index d3b9a685..5124be49 100644 --- a/ui/src/hooks/useLayoutComputation.ts +++ b/ui/src/hooks/useLayoutComputation.ts @@ -1,40 +1,40 @@ // ui/src/hooks/useLayoutComputation.ts /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useEffect, useRef } from 'react' -import { useReactFlow, type Node, type Edge } from '@xyflow/react' -import { useGraphStore } from '../store/useGraphStore' -import { getCollapsedView } from '../collapse' -import { IslandLayoutEngine } from '../layout/IslandLayoutEngine' -import { aggregateEdges } from '../utils/aggregateEdges' -import { applyHeatmapColors } from '../utils/applyHeatmap' +import { useEffect, useRef } from "react"; +import { useReactFlow, type Node, type Edge } from "@xyflow/react"; +import { useGraphStore } from "../store/useGraphStore"; +import { getCollapsedView } from "../collapse"; +import { IslandLayoutEngine } from "../layout/IslandLayoutEngine"; +import { aggregateEdges } from "../utils/aggregateEdges"; +import { applyHeatmapColors } from "../utils/applyHeatmap"; export function useLayoutComputation(): void { - const { fitView } = useReactFlow() - const generationRef = useRef(0) + const { fitView } = useReactFlow(); + const generationRef = useRef(0); - const rawNodes = useGraphStore((s) => s.rawNodes) - const rawEdges = useGraphStore((s) => s.rawEdges) - const expandedFiles = useGraphStore((s) => s.expandedFiles) - const activeEdgeTypes = useGraphStore((s) => s.activeEdgeTypes) - const showExternal = useGraphStore((s) => s.showExternal) - const viewMode = useGraphStore((s) => s.viewMode) - const colorMode = useGraphStore((s) => s.colorMode) - const setLayout = useGraphStore((s) => s.setLayout) + const rawNodes = useGraphStore((s) => s.rawNodes); + const rawEdges = useGraphStore((s) => s.rawEdges); + const expandedFiles = useGraphStore((s) => s.expandedFiles); + const activeEdgeTypes = useGraphStore((s) => s.activeEdgeTypes); + const showExternal = useGraphStore((s) => s.showExternal); + const viewMode = useGraphStore((s) => s.viewMode); + const colorMode = useGraphStore((s) => s.colorMode); + const setLayout = useGraphStore((s) => s.setLayout); useEffect(() => { - if (viewMode !== 'full' || rawNodes.length === 0) return + if (viewMode !== "full" || rawNodes.length === 0) return; - const gen = ++generationRef.current + const gen = ++generationRef.current; void (async () => { // Build parentChildren map from groupId metadata (FILE container logic) - const parentChildren = new Map() + const parentChildren = new Map(); for (const node of rawNodes) { - const groupId = (node.data as Record)?.groupId as string | undefined + const groupId = (node.data as Record)?.groupId as string | undefined; if (groupId) { - const children = parentChildren.get(groupId) ?? [] - children.push(node.id) - parentChildren.set(groupId, children) + const children = parentChildren.get(groupId) ?? []; + children.push(node.id); + parentChildren.set(groupId, children); } } @@ -44,53 +44,61 @@ export function useLayoutComputation(): void { rawEdges as any[], expandedFiles, parentChildren - ) + ); // Step 2: filter edges by active types (always keep CONTAINS for structure) const typeFiltered = collapsed.edges.filter((e) => { - const et: string = e.data?.edgeType ?? '' - return et === 'CONTAINS' || activeEdgeTypes.has(et as any) - }) + const et: string = e.data?.edgeType ?? ""; + return et === "CONTAINS" || activeEdgeTypes.has(et as any); + }); // Step 3: aggregate parallel edges (reduces clutter) - const aggregated = aggregateEdges(typeFiltered) + const aggregated = aggregateEdges(typeFiltered); // Step 4: filter external nodes if showExternal is false const visibleNodes = showExternal ? collapsed.nodes - : collapsed.nodes.filter( - (n) => !n.data?.namespace || n.data?.namespace === 'INTERNAL' - ) - const visibleIds = new Set(visibleNodes.map((n) => n.id as string)) + : collapsed.nodes.filter((n) => !n.data?.namespace || n.data?.namespace === "INTERNAL"); + const visibleIds = new Set(visibleNodes.map((n) => n.id as string)); const visibleEdges = aggregated.filter( (e) => visibleIds.has(e.source as string) && visibleIds.has(e.target as string) - ) + ); // Step 5: add ▶/▼ indicator labels to FILE nodes const labeledNodes = visibleNodes.map((n) => { - if (n.data?.nodeType !== 'FILE') return n - const indicator = expandedFiles.has(n.id as string) ? '▼ ' : '▶ ' + if (n.data?.nodeType !== "FILE") return n; + const indicator = expandedFiles.has(n.id as string) ? "▼ " : "▶ "; return { ...n, data: { ...n.data, - label: indicator + (n.data.label as string).replace(/^[▶▼]\s/, ''), + label: indicator + (n.data.label as string).replace(/^[▶▼]\s/, ""), isExpanded: expandedFiles.has(n.id as string), }, - } - }) + }; + }); // Step 6: apply heatmap colors if colorMode is 'health' - const coloredNodes = applyHeatmapColors(labeledNodes as Node[], colorMode === 'health') + const coloredNodes = applyHeatmapColors(labeledNodes as Node[], colorMode === "health"); // Step 7: run island layout - const engine = new IslandLayoutEngine(coloredNodes as Node[], visibleEdges as Edge[]) - const { nodes: layoutedNodes, edges: layoutedEdges } = engine.run(expandedFiles) + const engine = new IslandLayoutEngine(coloredNodes as Node[], visibleEdges as Edge[]); + const { nodes: layoutedNodes, edges: layoutedEdges } = engine.run(expandedFiles); - if (generationRef.current !== gen) return // stale async, discard + if (generationRef.current !== gen) return; // stale async, discard - setLayout(layoutedNodes, layoutedEdges) - fitView({ padding: 0.15, duration: 250 }) - })() - }, [rawNodes, rawEdges, expandedFiles, activeEdgeTypes, showExternal, viewMode, colorMode, setLayout, fitView]) + setLayout(layoutedNodes, layoutedEdges); + fitView({ padding: 0.15, duration: 250 }); + })(); + }, [ + rawNodes, + rawEdges, + expandedFiles, + activeEdgeTypes, + showExternal, + viewMode, + colorMode, + setLayout, + fitView, + ]); } diff --git a/ui/src/layout/IslandLayoutEngine.ts b/ui/src/layout/IslandLayoutEngine.ts index 6da3dae7..a292da86 100644 --- a/ui/src/layout/IslandLayoutEngine.ts +++ b/ui/src/layout/IslandLayoutEngine.ts @@ -1,5 +1,5 @@ -import dagre from 'dagre' -import type { Node, Edge } from '@xyflow/react' +import dagre from "dagre"; +import type { Node, Edge } from "@xyflow/react"; import { NODE_WIDTH, NODE_HEIGHT, @@ -11,162 +11,177 @@ import { FILE_CONTAINER_PADDING, FILE_HEADER_HEIGHT, FILE_HEADER_GAP, -} from '../constants' +} from "../constants"; interface IslandData { - nodes: Node[] - edges: Edge[] + nodes: Node[]; + edges: Edge[]; } interface IslandBBox { - namespace: string - width: number - height: number + namespace: string; + width: number; + height: number; } export class IslandLayoutEngine { - private nodes: Node[] - private edges: Edge[] - crossEdges: Edge[] = [] + private nodes: Node[]; + private edges: Edge[]; + crossEdges: Edge[] = []; constructor(nodes: Node[], edges: Edge[]) { - this.nodes = nodes - this.edges = edges + this.nodes = nodes; + this.edges = edges; } private getNamespace(node: Node): string { - const ns = (node.data as Record)?.namespace as string + const ns = (node.data as Record)?.namespace as string; // Split INTERNAL nodes by their top-level FQN segment (sub-package / project root). // e.g. "pipeline.IngestionPipeline" → island "pipeline" // "ui.src.components.GraphShell" → island "ui" - if (ns === 'INTERNAL') return node.id.split('.')[0] || 'internal' - return ns || '_default' + if (ns === "INTERNAL") return node.id.split(".")[0] || "internal"; + return ns || "_default"; } partition(): Map { - const islands = new Map() - const nodeIndex = new Map(this.nodes.map((n) => [n.id, n])) + const islands = new Map(); + const nodeIndex = new Map(this.nodes.map((n) => [n.id, n])); for (const node of this.nodes) { - const ns = this.getNamespace(node) - if (!islands.has(ns)) islands.set(ns, { nodes: [], edges: [] }) - islands.get(ns)!.nodes.push(node) + const ns = this.getNamespace(node); + if (!islands.has(ns)) islands.set(ns, { nodes: [], edges: [] }); + islands.get(ns)!.nodes.push(node); } - this.crossEdges = [] + this.crossEdges = []; for (const edge of this.edges) { - const srcNode = nodeIndex.get(edge.source) - const tgtNode = nodeIndex.get(edge.target) - if (!srcNode || !tgtNode) continue + const srcNode = nodeIndex.get(edge.source); + const tgtNode = nodeIndex.get(edge.target); + if (!srcNode || !tgtNode) continue; - const srcNs = this.getNamespace(srcNode) - const tgtNs = this.getNamespace(tgtNode) + const srcNs = this.getNamespace(srcNode); + const tgtNs = this.getNamespace(tgtNode); if (srcNs === tgtNs) { - islands.get(srcNs)?.edges.push(edge) + islands.get(srcNs)?.edges.push(edge); } else { - this.crossEdges.push(edge) + this.crossEdges.push(edge); } } - return islands + return islands; } - private layoutIsland(island: IslandData): { nodes: Node[]; bbox: { width: number; height: number } } { + private layoutIsland(island: IslandData): { + nodes: Node[]; + bbox: { width: number; height: number }; + } { // Separate connected nodes (have at least one edge) from isolated ones. // Dagre with rankdir:LR stacks all isolated nodes in rank-0 as a single // vertical column — instead we grid-layout them separately. - const connectedIds = new Set() + const connectedIds = new Set(); for (const edge of island.edges) { - connectedIds.add(edge.source) - connectedIds.add(edge.target) + connectedIds.add(edge.source); + connectedIds.add(edge.target); } - const connectedNodes = island.nodes.filter((n) => connectedIds.has(n.id)) - const isolatedNodes = island.nodes.filter((n) => !connectedIds.has(n.id)) + const connectedNodes = island.nodes.filter((n) => connectedIds.has(n.id)); + const isolatedNodes = island.nodes.filter((n) => !connectedIds.has(n.id)); - const g = new dagre.graphlib.Graph() - g.setDefaultEdgeLabel(() => ({})) - g.setGraph({ rankdir: LAYOUT_DIRECTION, nodesep: NODE_SEP, ranksep: RANK_SEP }) + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: LAYOUT_DIRECTION, nodesep: NODE_SEP, ranksep: RANK_SEP }); for (const node of connectedNodes) { - g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); } for (const edge of island.edges) { - g.setEdge(edge.source, edge.target) + g.setEdge(edge.source, edge.target); } - dagre.layout(g) + dagre.layout(g); // Position connected nodes from dagre output - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; const dagrePositioned = connectedNodes.map((node) => { - const pos = g.node(node.id) - const x = pos ? pos.x - NODE_WIDTH / 2 : 0 - const y = pos ? pos.y - NODE_HEIGHT / 2 : 0 - minX = Math.min(minX, x); minY = Math.min(minY, y) - maxX = Math.max(maxX, x + NODE_WIDTH); maxY = Math.max(maxY, y + NODE_HEIGHT) - return { ...node, position: { x, y } } - }) + const pos = g.node(node.id); + const x = pos ? pos.x - NODE_WIDTH / 2 : 0; + const y = pos ? pos.y - NODE_HEIGHT / 2 : 0; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + NODE_WIDTH); + maxY = Math.max(maxY, y + NODE_HEIGHT); + return { ...node, position: { x, y } }; + }); // Grid-layout isolated nodes to the right of the dagre block. // Use ~4 columns so they form a compact rectangle rather than a column. // gridOffsetX uses absolute maxX (not width) so isolated nodes never overlap // connected nodes after normalization. gridOffsetY aligns to the dagre top edge. - const GRID_COLS = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(isolatedNodes.length)))) - const gridOffsetX = connectedNodes.length > 0 ? maxX + RANK_SEP : 0 - const gridOffsetY = connectedNodes.length > 0 ? minY : 0 + const GRID_COLS = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(isolatedNodes.length)))); + const gridOffsetX = connectedNodes.length > 0 ? maxX + RANK_SEP : 0; + const gridOffsetY = connectedNodes.length > 0 ? minY : 0; const gridPositioned = isolatedNodes.map((node, i) => { - const col = i % GRID_COLS - const row = Math.floor(i / GRID_COLS) - const x = gridOffsetX + col * (NODE_WIDTH + NODE_SEP) - const y = gridOffsetY + row * (NODE_HEIGHT + NODE_SEP) - minX = Math.min(minX, x); minY = Math.min(minY, y) - maxX = Math.max(maxX, x + NODE_WIDTH); maxY = Math.max(maxY, y + NODE_HEIGHT) - return { ...node, position: { x, y } } - }) - - if (minX === Infinity) { minX = 0; minY = 0; maxX = 0; maxY = 0 } + const col = i % GRID_COLS; + const row = Math.floor(i / GRID_COLS); + const x = gridOffsetX + col * (NODE_WIDTH + NODE_SEP); + const y = gridOffsetY + row * (NODE_HEIGHT + NODE_SEP); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + NODE_WIDTH); + maxY = Math.max(maxY, y + NODE_HEIGHT); + return { ...node, position: { x, y } }; + }); + + if (minX === Infinity) { + minX = 0; + minY = 0; + maxX = 0; + maxY = 0; + } // Offset all nodes so they sit inside the container border + below the header - const allPositioned = [...dagrePositioned, ...gridPositioned] + const allPositioned = [...dagrePositioned, ...gridPositioned]; const normalised = allPositioned.map((n) => ({ ...n, position: { x: n.position.x - minX + FILE_CONTAINER_PADDING, y: n.position.y - minY + FILE_CONTAINER_PADDING + FILE_HEADER_HEIGHT + FILE_HEADER_GAP, }, - })) + })); const bbox = { width: maxX - minX + FILE_CONTAINER_PADDING * 2, height: maxY - minY + FILE_CONTAINER_PADDING * 2 + FILE_HEADER_HEIGHT + FILE_HEADER_GAP, - } + }; - return { nodes: normalised, bbox } + return { nodes: normalised, bbox }; } static computeOffsets( bboxes: IslandBBox[], canvasMaxWidth: number = CANVAS_MAX_WIDTH ): Map { - const sorted = [...bboxes].sort((a, b) => a.namespace.localeCompare(b.namespace)) + const sorted = [...bboxes].sort((a, b) => a.namespace.localeCompare(b.namespace)); - const offsets = new Map() - let rowX = 0 - let rowY = 0 - let rowMaxHeight = 0 + const offsets = new Map(); + let rowX = 0; + let rowY = 0; + let rowMaxHeight = 0; for (const island of sorted) { if (rowX > 0 && rowX + island.width > canvasMaxWidth) { - rowY += rowMaxHeight + ISLAND_GAP - rowX = 0 - rowMaxHeight = 0 + rowY += rowMaxHeight + ISLAND_GAP; + rowX = 0; + rowMaxHeight = 0; } - offsets.set(island.namespace, { x: rowX, y: rowY }) - rowX += island.width + ISLAND_GAP - rowMaxHeight = Math.max(rowMaxHeight, island.height) + offsets.set(island.namespace, { x: rowX, y: rowY }); + rowX += island.width + ISLAND_GAP; + rowMaxHeight = Math.max(rowMaxHeight, island.height); } - return offsets + return offsets; } // Transforms expanded FILE nodes into fileContainer background wrappers sized to their children. @@ -176,38 +191,41 @@ export class IslandLayoutEngine { // and absolute coordinates are simpler and sufficient here. private applyFileContainers(nodes: Node[], expandedFiles: Set): Node[] { // Build fileId → child node indices - const fileChildIndices = new Map() + const fileChildIndices = new Map(); for (let i = 0; i < nodes.length; i++) { - const groupId = (nodes[i].data as Record)?.groupId as string | undefined + const groupId = (nodes[i].data as Record)?.groupId as string | undefined; if (groupId) { - const list = fileChildIndices.get(groupId) ?? [] - list.push(i) - fileChildIndices.set(groupId, list) + const list = fileChildIndices.get(groupId) ?? []; + list.push(i); + fileChildIndices.set(groupId, list); } } - const result = [...nodes] + const result = [...nodes]; for (let i = 0; i < result.length; i++) { - const node = result[i] - if ((node.data as Record)?.nodeType !== 'FILE') continue - if (!expandedFiles.has(node.id)) continue + const node = result[i]; + if ((node.data as Record)?.nodeType !== "FILE") continue; + if (!expandedFiles.has(node.id)) continue; - const childIndices = fileChildIndices.get(node.id) ?? [] - if (childIndices.length === 0) continue + const childIndices = fileChildIndices.get(node.id) ?? []; + if (childIndices.length === 0) continue; - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; for (const ci of childIndices) { - const p = nodes[ci].position - minX = Math.min(minX, p.x) - maxX = Math.max(maxX, p.x + NODE_WIDTH) - minY = Math.min(minY, p.y) - maxY = Math.max(maxY, p.y + NODE_HEIGHT) + const p = nodes[ci].position; + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x + NODE_WIDTH); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y + NODE_HEIGHT); } result[i] = { ...node, - type: 'fileContainer', + type: "fileContainer", position: { x: minX - FILE_CONTAINER_PADDING, y: minY - FILE_CONTAINER_PADDING - FILE_HEADER_HEIGHT - FILE_HEADER_GAP, @@ -215,53 +233,60 @@ export class IslandLayoutEngine { style: { width: maxX - minX + FILE_CONTAINER_PADDING * 2, height: maxY - minY + FILE_CONTAINER_PADDING * 2 + FILE_HEADER_HEIGHT + FILE_HEADER_GAP, - border: 'none', - background: 'transparent', - boxShadow: 'none', + border: "none", + background: "transparent", + boxShadow: "none", padding: 0, }, data: { ...node.data, isExpanded: true }, - } + }; } // Containers (island + file) must precede content nodes for correct z-order - const containers = result.filter((n) => n.type === 'islandContainer' || n.type === 'fileContainer') - const content = result.filter((n) => n.type !== 'islandContainer' && n.type !== 'fileContainer') - return [...containers, ...content] + const containers = result.filter( + (n) => n.type === "islandContainer" || n.type === "fileContainer" + ); + const content = result.filter( + (n) => n.type !== "islandContainer" && n.type !== "fileContainer" + ); + return [...containers, ...content]; } // TODO(PR4): use expandedFiles to highlight the ego-subgraph overlay (issue #30) run(expandedFiles: Set = new Set()): { nodes: Node[]; edges: Edge[] } { - const islands = this.partition() - const bboxes: IslandBBox[] = [] - const islandResults = new Map() + const islands = this.partition(); + const bboxes: IslandBBox[] = []; + const islandResults = new Map< + string, + { nodes: Node[]; bbox: { width: number; height: number } } + >(); for (const [ns, island] of islands) { - const result = this.layoutIsland(island) - islandResults.set(ns, result) - bboxes.push({ namespace: ns, width: result.bbox.width, height: result.bbox.height }) + const result = this.layoutIsland(island); + islandResults.set(ns, result); + bboxes.push({ namespace: ns, width: result.bbox.width, height: result.bbox.height }); } - const offsets = IslandLayoutEngine.computeOffsets(bboxes) + const offsets = IslandLayoutEngine.computeOffsets(bboxes); - const allNodes: Node[] = [] + const allNodes: Node[] = []; for (const [ns, result] of islandResults) { - const offset = offsets.get(ns) ?? { x: 0, y: 0 } - const bbox = result.bbox + const offset = offsets.get(ns) ?? { x: 0, y: 0 }; + const bbox = result.bbox; allNodes.push({ id: `island-container-${ns}`, - type: 'islandContainer', + type: "islandContainer", position: { x: offset.x, y: offset.y }, style: { width: bbox.width, height: bbox.height, - pointerEvents: 'none' as const, + pointerEvents: "none" as const, }, data: { namespace: ns }, selectable: false, draggable: false, - }) + }); for (const node of result.nodes) { allNodes.push({ @@ -271,25 +296,25 @@ export class IslandLayoutEngine { y: node.position.y + offset.y, }, data: { ...node.data, islandNamespace: ns }, - }) + }); } } - const allEdges = [...this.crossEdges] + const allEdges = [...this.crossEdges]; for (const [, island] of islands) { - allEdges.push(...island.edges) + allEdges.push(...island.edges); } if (expandedFiles.size === 0) { - return { nodes: allNodes, edges: allEdges } + return { nodes: allNodes, edges: allEdges }; } // Filter CONTAINS edges from expanded files — they're implicit via the container const filteredEdges = allEdges.filter((e) => { - if ((e.data as Record)?.edgeType !== 'CONTAINS') return true - return !expandedFiles.has(e.source) - }) + if ((e.data as Record)?.edgeType !== "CONTAINS") return true; + return !expandedFiles.has(e.source); + }); - return { nodes: this.applyFileContainers(allNodes, expandedFiles), edges: filteredEdges } + return { nodes: this.applyFileContainers(allNodes, expandedFiles), edges: filteredEdges }; } } diff --git a/ui/src/layout/__tests__/IslandLayoutEngine.test.ts b/ui/src/layout/__tests__/IslandLayoutEngine.test.ts index b82604fe..33180f83 100644 --- a/ui/src/layout/__tests__/IslandLayoutEngine.test.ts +++ b/ui/src/layout/__tests__/IslandLayoutEngine.test.ts @@ -1,200 +1,196 @@ -import { describe, it, expect } from 'vitest' -import { IslandLayoutEngine } from '../IslandLayoutEngine' -import type { Node, Edge } from '@xyflow/react' +import { describe, it, expect } from "vitest"; +import { IslandLayoutEngine } from "../IslandLayoutEngine"; +import type { Node, Edge } from "@xyflow/react"; function makeNode(id: string, namespace: string): Node { return { id, position: { x: 0, y: 0 }, - data: { namespace, nodeType: 'FUNCTION', label: id }, - } + data: { namespace, nodeType: "FUNCTION", label: id }, + }; } function makeFileNode(id: string, namespace: string): Node { return { id, position: { x: 0, y: 0 }, - data: { namespace, nodeType: 'FILE', label: id }, - } + data: { namespace, nodeType: "FILE", label: id }, + }; } function makeChildNode(id: string, namespace: string, groupId: string): Node { return { id, position: { x: 0, y: 0 }, - data: { namespace, nodeType: 'FUNCTION', label: id, groupId }, - } + data: { namespace, nodeType: "FUNCTION", label: id, groupId }, + }; } -function makeEdge(source: string, target: string, type = 'CALLS'): Edge { +function makeEdge(source: string, target: string, type = "CALLS"): Edge { return { id: `${source}→${target}:${type}`, source, target, data: { edgeType: type }, - } as Edge + } as Edge; } -describe('IslandLayoutEngine.partition', () => { - it('groups nodes by namespace', () => { - const nodes = [ - makeNode('a', 'py'), - makeNode('b', 'py'), - makeNode('c', 'ts'), - ] - const engine = new IslandLayoutEngine(nodes, []) - const islands = engine.partition() - expect(islands.size).toBe(2) - expect(islands.get('py')?.nodes).toHaveLength(2) - expect(islands.get('ts')?.nodes).toHaveLength(1) - }) - - it('sets aside cross-namespace edges', () => { - const nodes = [makeNode('a', 'py'), makeNode('b', 'ts')] - const edges = [makeEdge('a', 'b'), makeEdge('a', 'a')] - const engine = new IslandLayoutEngine(nodes, edges) - engine.partition() - expect(engine.crossEdges).toHaveLength(1) - expect(engine.crossEdges[0].source).toBe('a') - }) - - it('nodes with no namespace go into _default island', () => { - const nodes = [{ id: 'x', position: { x: 0, y: 0 }, data: { label: 'x' } } as Node] - const engine = new IslandLayoutEngine(nodes, []) - const islands = engine.partition() - expect(islands.has('_default')).toBe(true) - }) -}) - -describe('IslandLayoutEngine.run', () => { - it('produces one islandContainer per namespace', () => { - const nodes = [makeNode('a', 'py'), makeNode('b', 'ts')] - const engine = new IslandLayoutEngine(nodes, []) - const { nodes: out } = engine.run(new Set()) - const containers = out.filter((n) => n.type === 'islandContainer') - expect(containers).toHaveLength(2) +describe("IslandLayoutEngine.partition", () => { + it("groups nodes by namespace", () => { + const nodes = [makeNode("a", "py"), makeNode("b", "py"), makeNode("c", "ts")]; + const engine = new IslandLayoutEngine(nodes, []); + const islands = engine.partition(); + expect(islands.size).toBe(2); + expect(islands.get("py")?.nodes).toHaveLength(2); + expect(islands.get("ts")?.nodes).toHaveLength(1); + }); + + it("sets aside cross-namespace edges", () => { + const nodes = [makeNode("a", "py"), makeNode("b", "ts")]; + const edges = [makeEdge("a", "b"), makeEdge("a", "a")]; + const engine = new IslandLayoutEngine(nodes, edges); + engine.partition(); + expect(engine.crossEdges).toHaveLength(1); + expect(engine.crossEdges[0].source).toBe("a"); + }); + + it("nodes with no namespace go into _default island", () => { + const nodes = [{ id: "x", position: { x: 0, y: 0 }, data: { label: "x" } } as Node]; + const engine = new IslandLayoutEngine(nodes, []); + const islands = engine.partition(); + expect(islands.has("_default")).toBe(true); + }); +}); + +describe("IslandLayoutEngine.run", () => { + it("produces one islandContainer per namespace", () => { + const nodes = [makeNode("a", "py"), makeNode("b", "ts")]; + const engine = new IslandLayoutEngine(nodes, []); + const { nodes: out } = engine.run(new Set()); + const containers = out.filter((n) => n.type === "islandContainer"); + expect(containers).toHaveLength(2); expect(containers.map((c) => (c.data as { namespace: string }).namespace).sort()).toEqual([ - 'py', - 'ts', - ]) - }) - - it('container ids are prefixed with island-container-', () => { - const nodes = [makeNode('x', 'ns')] - const engine = new IslandLayoutEngine(nodes, []) - const { nodes: out } = engine.run(new Set()) - const container = out.find((n) => n.type === 'islandContainer') - expect(container?.id).toBe('island-container-ns') - }) - - it('content nodes carry islandNamespace in data', () => { - const nodes = [makeNode('a', 'py')] - const engine = new IslandLayoutEngine(nodes, []) - const { nodes: out } = engine.run(new Set()) - const content = out.filter((n) => n.type !== 'islandContainer') - expect(content).toHaveLength(1) - expect((content[0].data as { islandNamespace: string }).islandNamespace).toBe('py') - }) - - it('cross-namespace edges appear in output edges', () => { - const nodes = [makeNode('a', 'py'), makeNode('b', 'ts')] - const edges = [makeEdge('a', 'b')] - const engine = new IslandLayoutEngine(nodes, edges) - const { edges: out } = engine.run(new Set()) - expect(out.some((e) => e.source === 'a' && e.target === 'b')).toBe(true) - }) - - it('container has positive width and height', () => { - const nodes = [makeNode('a', 'py'), makeNode('b', 'py')] - const engine = new IslandLayoutEngine(nodes, []) - const { nodes: out } = engine.run(new Set()) - const container = out.find((n) => n.type === 'islandContainer') - expect(Number(container?.style?.width)).toBeGreaterThan(0) - expect(Number(container?.style?.height)).toBeGreaterThan(0) - }) -}) - -describe('IslandLayoutEngine.run — file container wrapping', () => { - it('expanded FILE node becomes fileContainer type', () => { - const fileNode = makeFileNode('file1', 'py') - const child = makeChildNode('fn1', 'py', 'file1') - const engine = new IslandLayoutEngine([fileNode, child], []) - const { nodes: out } = engine.run(new Set(['file1'])) - const container = out.find((n) => n.id === 'file1') - expect(container?.type).toBe('fileContainer') - }) - - it('fileContainer is sized to contain its children', () => { - const fileNode = makeFileNode('file1', 'py') - const child1 = makeChildNode('fn1', 'py', 'file1') - const child2 = makeChildNode('fn2', 'py', 'file1') - const engine = new IslandLayoutEngine([fileNode, child1, child2], []) - const { nodes: out } = engine.run(new Set(['file1'])) - const container = out.find((n) => n.id === 'file1') - expect(Number(container?.style?.width)).toBeGreaterThan(0) - expect(Number(container?.style?.height)).toBeGreaterThan(0) - }) - - it('unexpanded FILE node keeps its original type', () => { - const fileNode = makeFileNode('file1', 'py') - const engine = new IslandLayoutEngine([fileNode], []) - const { nodes: out } = engine.run(new Set()) - const node = out.find((n) => n.id === 'file1') - expect(node?.type).not.toBe('fileContainer') - }) - - it('CONTAINS edges from expanded files are filtered out', () => { - const fileNode = makeFileNode('file1', 'py') - const child = makeChildNode('fn1', 'py', 'file1') - const containsEdge = makeEdge('file1', 'fn1', 'CONTAINS') - const callsEdge = makeEdge('fn1', 'fn1', 'CALLS') - const engine = new IslandLayoutEngine([fileNode, child], [containsEdge, callsEdge]) - const { edges: out } = engine.run(new Set(['file1'])) - expect(out.some((e) => e.data?.edgeType === 'CONTAINS' && e.source === 'file1')).toBe(false) - expect(out.some((e) => e.data?.edgeType === 'CALLS')).toBe(true) - }) - - it('fileContainer comes before its children in output (z-order)', () => { - const fileNode = makeFileNode('file1', 'py') - const child = makeChildNode('fn1', 'py', 'file1') - const engine = new IslandLayoutEngine([fileNode, child], []) - const { nodes: out } = engine.run(new Set(['file1'])) - const containerIdx = out.findIndex((n) => n.id === 'file1') - const childIdx = out.findIndex((n) => n.id === 'fn1') - expect(containerIdx).toBeLessThan(childIdx) - }) -}) - -describe('IslandLayoutEngine.placeIslands — bin-packing', () => { - it('single island placed at origin', () => { + "py", + "ts", + ]); + }); + + it("container ids are prefixed with island-container-", () => { + const nodes = [makeNode("x", "ns")]; + const engine = new IslandLayoutEngine(nodes, []); + const { nodes: out } = engine.run(new Set()); + const container = out.find((n) => n.type === "islandContainer"); + expect(container?.id).toBe("island-container-ns"); + }); + + it("content nodes carry islandNamespace in data", () => { + const nodes = [makeNode("a", "py")]; + const engine = new IslandLayoutEngine(nodes, []); + const { nodes: out } = engine.run(new Set()); + const content = out.filter((n) => n.type !== "islandContainer"); + expect(content).toHaveLength(1); + expect((content[0].data as { islandNamespace: string }).islandNamespace).toBe("py"); + }); + + it("cross-namespace edges appear in output edges", () => { + const nodes = [makeNode("a", "py"), makeNode("b", "ts")]; + const edges = [makeEdge("a", "b")]; + const engine = new IslandLayoutEngine(nodes, edges); + const { edges: out } = engine.run(new Set()); + expect(out.some((e) => e.source === "a" && e.target === "b")).toBe(true); + }); + + it("container has positive width and height", () => { + const nodes = [makeNode("a", "py"), makeNode("b", "py")]; + const engine = new IslandLayoutEngine(nodes, []); + const { nodes: out } = engine.run(new Set()); + const container = out.find((n) => n.type === "islandContainer"); + expect(Number(container?.style?.width)).toBeGreaterThan(0); + expect(Number(container?.style?.height)).toBeGreaterThan(0); + }); +}); + +describe("IslandLayoutEngine.run — file container wrapping", () => { + it("expanded FILE node becomes fileContainer type", () => { + const fileNode = makeFileNode("file1", "py"); + const child = makeChildNode("fn1", "py", "file1"); + const engine = new IslandLayoutEngine([fileNode, child], []); + const { nodes: out } = engine.run(new Set(["file1"])); + const container = out.find((n) => n.id === "file1"); + expect(container?.type).toBe("fileContainer"); + }); + + it("fileContainer is sized to contain its children", () => { + const fileNode = makeFileNode("file1", "py"); + const child1 = makeChildNode("fn1", "py", "file1"); + const child2 = makeChildNode("fn2", "py", "file1"); + const engine = new IslandLayoutEngine([fileNode, child1, child2], []); + const { nodes: out } = engine.run(new Set(["file1"])); + const container = out.find((n) => n.id === "file1"); + expect(Number(container?.style?.width)).toBeGreaterThan(0); + expect(Number(container?.style?.height)).toBeGreaterThan(0); + }); + + it("unexpanded FILE node keeps its original type", () => { + const fileNode = makeFileNode("file1", "py"); + const engine = new IslandLayoutEngine([fileNode], []); + const { nodes: out } = engine.run(new Set()); + const node = out.find((n) => n.id === "file1"); + expect(node?.type).not.toBe("fileContainer"); + }); + + it("CONTAINS edges from expanded files are filtered out", () => { + const fileNode = makeFileNode("file1", "py"); + const child = makeChildNode("fn1", "py", "file1"); + const containsEdge = makeEdge("file1", "fn1", "CONTAINS"); + const callsEdge = makeEdge("fn1", "fn1", "CALLS"); + const engine = new IslandLayoutEngine([fileNode, child], [containsEdge, callsEdge]); + const { edges: out } = engine.run(new Set(["file1"])); + expect(out.some((e) => e.data?.edgeType === "CONTAINS" && e.source === "file1")).toBe(false); + expect(out.some((e) => e.data?.edgeType === "CALLS")).toBe(true); + }); + + it("fileContainer comes before its children in output (z-order)", () => { + const fileNode = makeFileNode("file1", "py"); + const child = makeChildNode("fn1", "py", "file1"); + const engine = new IslandLayoutEngine([fileNode, child], []); + const { nodes: out } = engine.run(new Set(["file1"])); + const containerIdx = out.findIndex((n) => n.id === "file1"); + const childIdx = out.findIndex((n) => n.id === "fn1"); + expect(containerIdx).toBeLessThan(childIdx); + }); +}); + +describe("IslandLayoutEngine.placeIslands — bin-packing", () => { + it("single island placed at origin", () => { const offsets = IslandLayoutEngine.computeOffsets( - [{ namespace: 'py', width: 500, height: 300 }], + [{ namespace: "py", width: 500, height: 300 }], 3200 - ) - expect(offsets.get('py')).toEqual({ x: 0, y: 0 }) - }) + ); + expect(offsets.get("py")).toEqual({ x: 0, y: 0 }); + }); - it('two islands that fit in one row placed side by side', () => { + it("two islands that fit in one row placed side by side", () => { const offsets = IslandLayoutEngine.computeOffsets( [ - { namespace: 'py', width: 500, height: 300 }, - { namespace: 'ts', width: 400, height: 300 }, + { namespace: "py", width: 500, height: 300 }, + { namespace: "ts", width: 400, height: 300 }, ], 3200 - ) - expect(offsets.get('py')?.x).toBe(0) - expect(offsets.get('ts')?.x).toBeGreaterThan(0) - expect(offsets.get('ts')?.y).toBe(0) - }) + ); + expect(offsets.get("py")?.x).toBe(0); + expect(offsets.get("ts")?.x).toBeGreaterThan(0); + expect(offsets.get("ts")?.y).toBe(0); + }); - it('island wider than CANVAS_MAX_WIDTH wraps to new row', () => { + it("island wider than CANVAS_MAX_WIDTH wraps to new row", () => { const offsets = IslandLayoutEngine.computeOffsets( [ - { namespace: 'large', width: 3000, height: 400 }, - { namespace: 'small', width: 200, height: 200 }, + { namespace: "large", width: 3000, height: 400 }, + { namespace: "small", width: 200, height: 200 }, ], 3200 - ) - expect(offsets.get('small')?.y).toBeGreaterThan(0) - }) -}) + ); + expect(offsets.get("small")?.y).toBeGreaterThan(0); + }); +}); diff --git a/ui/src/layout/index.ts b/ui/src/layout/index.ts index aa4991b8..c5e33145 100644 --- a/ui/src/layout/index.ts +++ b/ui/src/layout/index.ts @@ -1 +1 @@ -export { IslandLayoutEngine } from './IslandLayoutEngine' +export { IslandLayoutEngine } from "./IslandLayoutEngine"; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 69fc8afd..2c5a8097 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,13 +1,13 @@ -import { StrictMode } from 'react' -import ReactDOM from 'react-dom/client' -import { ReactFlowProvider } from '@xyflow/react' -import './tokens.css' -import './index.css' -import { GraphProvider } from './providers/GraphProvider' -import GraphShell from './components/GraphShell' +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; +import { ReactFlowProvider } from "@xyflow/react"; +import "./tokens.css"; +import "./index.css"; +import { GraphProvider } from "./providers/GraphProvider"; +import GraphShell from "./components/GraphShell"; -const rootElement = document.getElementById('root') -if (!rootElement) throw new Error('Failed to find the root element') +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Failed to find the root element"); ReactDOM.createRoot(rootElement).render( @@ -17,4 +17,4 @@ ReactDOM.createRoot(rootElement).render( -) +); diff --git a/ui/src/providers/GraphProvider.tsx b/ui/src/providers/GraphProvider.tsx index 855d5a5c..dd3a5d83 100644 --- a/ui/src/providers/GraphProvider.tsx +++ b/ui/src/providers/GraphProvider.tsx @@ -1,20 +1,19 @@ -import { useEffect, type ReactNode } from 'react' -import { useGraphStore } from '../store/useGraphStore' -import { mapNodeToReactFlow } from '../utils/nodeMapper' -import { mapEdgeToReactFlow } from '../utils/edgeMapper' -import type { GraphData } from '../types' +import { useEffect, type ReactNode } from "react"; +import { useGraphStore } from "../store/useGraphStore"; +import { mapNodeToReactFlow } from "../utils/nodeMapper"; +import { mapEdgeToReactFlow } from "../utils/edgeMapper"; +import type { GraphData } from "../types"; -const GRAPH_URL = - (import.meta.env.VITE_GRAPH_DATA_URL as string | undefined) ?? '/graph.json' +const GRAPH_URL = (import.meta.env.VITE_GRAPH_DATA_URL as string | undefined) ?? "/graph.json"; export function GraphProvider({ children }: Readonly<{ children: ReactNode }>) { - const setGraphData = useGraphStore((s) => s.setGraphData) + const setGraphData = useGraphStore((s) => s.setGraphData); useEffect(() => { fetch(GRAPH_URL) .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status} loading graph`) - return r.json() as Promise + if (!r.ok) throw new Error(`HTTP ${r.status} loading graph`); + return r.json() as Promise; }) .then((data) => setGraphData( @@ -24,8 +23,8 @@ export function GraphProvider({ children }: Readonly<{ children: ReactNode }>) { data.edges ) ) - .catch((err) => console.error('GraphProvider: failed to load graph', err)) - }, [setGraphData]) + .catch((err) => console.error("GraphProvider: failed to load graph", err)); + }, [setGraphData]); - return <>{children} + return <>{children}; } diff --git a/ui/src/store/__tests__/useGraphStore.test.ts b/ui/src/store/__tests__/useGraphStore.test.ts index b9c697f3..12ca6e63 100644 --- a/ui/src/store/__tests__/useGraphStore.test.ts +++ b/ui/src/store/__tests__/useGraphStore.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { useGraphStore } from '../useGraphStore' +import { describe, it, expect, beforeEach } from "vitest"; +import { useGraphStore } from "../useGraphStore"; beforeEach(() => { useGraphStore.setState({ @@ -11,90 +11,129 @@ beforeEach(() => { layoutedEdges: [], expandedFiles: new Set(), islandPositions: new Map(), - activeEdgeTypes: new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'CONTAINS', 'DECLARES']), + activeEdgeTypes: new Set(["CALLS", "IMPORTS", "EXTENDS", "CONTAINS", "DECLARES"]), showExternal: false, activeNamespaces: new Set(), flowNodes: [], flowEdges: [], flowCache: new Map(), - viewMode: 'full', + viewMode: "full", hoveredNodeId: null, egoNodeId: null, - colorMode: 'type', - }) -}) + colorMode: "type", + }); +}); -describe('graphSlice', () => { - it('setGraphData bumps graphVersion', () => { - const { setGraphData, graphVersion } = useGraphStore.getState() - expect(graphVersion).toBe(0) - setGraphData([], []) - expect(useGraphStore.getState().graphVersion).toBe(1) - setGraphData([], []) - expect(useGraphStore.getState().graphVersion).toBe(2) - }) +describe("graphSlice", () => { + it("setGraphData bumps graphVersion", () => { + const { setGraphData, graphVersion } = useGraphStore.getState(); + expect(graphVersion).toBe(0); + setGraphData([], []); + expect(useGraphStore.getState().graphVersion).toBe(1); + setGraphData([], []); + expect(useGraphStore.getState().graphVersion).toBe(2); + }); - it('setGraphData extracts namespaces from graphNodes', () => { + it("setGraphData extracts namespaces from graphNodes", () => { const graphNodes = [ - { id: 'a', type: 'FILE', name: 'a', file_path: 'a.py', start_line: 1, end_line: 1, language: 'python', namespace: 'owner-api', ontology_class: null, domains: [], confidence_score: 1, metadata: {} }, - { id: 'b', type: 'FILE', name: 'b', file_path: 'b.py', start_line: 1, end_line: 1, language: 'python', namespace: 'owner-web', ontology_class: null, domains: [], confidence_score: 1, metadata: {} }, - { id: 'c', type: 'FILE', name: 'c', file_path: 'c.py', start_line: 1, end_line: 1, language: 'python', namespace: 'owner-api', ontology_class: null, domains: [], confidence_score: 1, metadata: {} }, - ] as any[] // eslint-disable-line @typescript-eslint/no-explicit-any - useGraphStore.getState().setGraphData([], [], graphNodes, []) + { + id: "a", + type: "FILE", + name: "a", + file_path: "a.py", + start_line: 1, + end_line: 1, + language: "python", + namespace: "owner-api", + ontology_class: null, + domains: [], + confidence_score: 1, + metadata: {}, + }, + { + id: "b", + type: "FILE", + name: "b", + file_path: "b.py", + start_line: 1, + end_line: 1, + language: "python", + namespace: "owner-web", + ontology_class: null, + domains: [], + confidence_score: 1, + metadata: {}, + }, + { + id: "c", + type: "FILE", + name: "c", + file_path: "c.py", + start_line: 1, + end_line: 1, + language: "python", + namespace: "owner-api", + ontology_class: null, + domains: [], + confidence_score: 1, + metadata: {}, + }, + ] as any[]; // eslint-disable-line @typescript-eslint/no-explicit-any + useGraphStore.getState().setGraphData([], [], graphNodes, []); expect(useGraphStore.getState().namespaces).toEqual( - expect.arrayContaining(['owner-api', 'owner-web']) - ) - expect(useGraphStore.getState().namespaces).toHaveLength(2) - }) + expect.arrayContaining(["owner-api", "owner-web"]) + ); + expect(useGraphStore.getState().namespaces).toHaveLength(2); + }); - it('setGraphData invalidates flow cache', () => { - useGraphStore.setState({ flowCache: new Map([['key', { nodes: [], edges: [] }]]) }) - useGraphStore.getState().setGraphData([], []) - expect(useGraphStore.getState().flowCache.size).toBe(0) - }) -}) + it("setGraphData invalidates flow cache", () => { + useGraphStore.setState({ flowCache: new Map([["key", { nodes: [], edges: [] }]]) }); + useGraphStore.getState().setGraphData([], []); + expect(useGraphStore.getState().flowCache.size).toBe(0); + }); +}); -describe('filterSlice', () => { - it('toggleEdgeType removes then restores', () => { - const { toggleEdgeType } = useGraphStore.getState() - toggleEdgeType('CALLS') - expect(useGraphStore.getState().activeEdgeTypes.has('CALLS')).toBe(false) - toggleEdgeType('CALLS') - expect(useGraphStore.getState().activeEdgeTypes.has('CALLS')).toBe(true) - }) +describe("filterSlice", () => { + it("toggleEdgeType removes then restores", () => { + const { toggleEdgeType } = useGraphStore.getState(); + toggleEdgeType("CALLS"); + expect(useGraphStore.getState().activeEdgeTypes.has("CALLS")).toBe(false); + toggleEdgeType("CALLS"); + expect(useGraphStore.getState().activeEdgeTypes.has("CALLS")).toBe(true); + }); - it('toggleExternal flips showExternal', () => { - expect(useGraphStore.getState().showExternal).toBe(false) - useGraphStore.getState().toggleExternal() - expect(useGraphStore.getState().showExternal).toBe(true) - }) -}) + it("toggleExternal flips showExternal", () => { + expect(useGraphStore.getState().showExternal).toBe(false); + useGraphStore.getState().toggleExternal(); + expect(useGraphStore.getState().showExternal).toBe(true); + }); +}); -describe('layoutSlice', () => { - it('toggleExpandedFile adds then removes fileId', () => { - const { toggleExpandedFile } = useGraphStore.getState() - toggleExpandedFile('file-a') - expect(useGraphStore.getState().expandedFiles.has('file-a')).toBe(true) - toggleExpandedFile('file-a') - expect(useGraphStore.getState().expandedFiles.has('file-a')).toBe(false) - }) +describe("layoutSlice", () => { + it("toggleExpandedFile adds then removes fileId", () => { + const { toggleExpandedFile } = useGraphStore.getState(); + toggleExpandedFile("file-a"); + expect(useGraphStore.getState().expandedFiles.has("file-a")).toBe(true); + toggleExpandedFile("file-a"); + expect(useGraphStore.getState().expandedFiles.has("file-a")).toBe(false); + }); - it('setIslandPosition stores offset', () => { - useGraphStore.getState().setIslandPosition('owner-api', { x: 100, y: 200 }) - expect(useGraphStore.getState().islandPositions.get('owner-api')).toEqual({ x: 100, y: 200 }) - }) -}) + it("setIslandPosition stores offset", () => { + useGraphStore.getState().setIslandPosition("owner-api", { x: 100, y: 200 }); + expect(useGraphStore.getState().islandPositions.get("owner-api")).toEqual({ x: 100, y: 200 }); + }); +}); -describe('uiSlice', () => { - it('setViewMode updates viewMode', () => { - useGraphStore.getState().setViewMode('flow') - expect(useGraphStore.getState().viewMode).toBe('flow') - }) +describe("uiSlice", () => { + it("setViewMode updates viewMode", () => { + useGraphStore.getState().setViewMode("flow"); + expect(useGraphStore.getState().viewMode).toBe("flow"); + }); - it('setColorMode updates colorMode', () => { - useGraphStore.getState().setColorMode('health') - expect(useGraphStore.getState().colorMode).toBe('health') - useGraphStore.getState().setColorMode('type') - expect(useGraphStore.getState().colorMode).toBe('type') - }) -}) + it("setColorMode updates colorMode", () => { + useGraphStore.getState().setColorMode("health"); + expect(useGraphStore.getState().colorMode).toBe("health"); + useGraphStore.getState().setColorMode("type"); + expect(useGraphStore.getState().colorMode).toBe("type"); + }); +}); diff --git a/ui/src/store/useGraphStore.ts b/ui/src/store/useGraphStore.ts index 2f99ecf1..c4301fff 100644 --- a/ui/src/store/useGraphStore.ts +++ b/ui/src/store/useGraphStore.ts @@ -1,57 +1,65 @@ -import { create } from 'zustand' -import type { Node, Edge } from '@xyflow/react' -import type { GraphNode, GraphEdge, NodeNamespace } from '../types' +import { create } from "zustand"; +import type { Node, Edge } from "@xyflow/react"; +import type { GraphNode, GraphEdge, NodeNamespace } from "../types"; -type EdgeTypeName = 'CALLS' | 'IMPORTS' | 'EXTENDS' | 'CONTAINS' | 'DECLARES' -type ViewMode = 'full' | 'flow' | 'ego' +type EdgeTypeName = "CALLS" | "IMPORTS" | "EXTENDS" | "CONTAINS" | "DECLARES"; +type ViewMode = "full" | "flow" | "ego"; -interface FlowCacheEntry { nodes: Node[]; edges: Edge[] } +interface FlowCacheEntry { + nodes: Node[]; + edges: Edge[]; +} -const ALL_EDGE_TYPE_NAMES: EdgeTypeName[] = ['CALLS', 'IMPORTS', 'EXTENDS', 'CONTAINS', 'DECLARES'] +const ALL_EDGE_TYPE_NAMES: EdgeTypeName[] = ["CALLS", "IMPORTS", "EXTENDS", "CONTAINS", "DECLARES"]; interface GraphStore { // graph slice - rawNodes: Node[] - rawEdges: Edge[] - graphNodes: GraphNode[] - graphEdges: GraphEdge[] - namespaces: string[] - graphVersion: number - setGraphData: (nodes: Node[], edges: Edge[], graphNodes?: GraphNode[], graphEdges?: GraphEdge[]) => void + rawNodes: Node[]; + rawEdges: Edge[]; + graphNodes: GraphNode[]; + graphEdges: GraphEdge[]; + namespaces: string[]; + graphVersion: number; + setGraphData: ( + nodes: Node[], + edges: Edge[], + graphNodes?: GraphNode[], + graphEdges?: GraphEdge[] + ) => void; // layout slice - layoutedNodes: Node[] - layoutedEdges: Edge[] - expandedFiles: Set - islandPositions: Map - setLayout: (nodes: Node[], edges: Edge[]) => void - toggleExpandedFile: (fileId: string) => void - setIslandPosition: (namespace: string, pos: { x: number; y: number }) => void + layoutedNodes: Node[]; + layoutedEdges: Edge[]; + expandedFiles: Set; + islandPositions: Map; + setLayout: (nodes: Node[], edges: Edge[]) => void; + toggleExpandedFile: (fileId: string) => void; + setIslandPosition: (namespace: string, pos: { x: number; y: number }) => void; // filter slice - activeEdgeTypes: Set - showExternal: boolean - activeNamespaces: Set - toggleEdgeType: (type: EdgeTypeName) => void - toggleExternal: () => void - toggleNamespace: (ns: string) => void + activeEdgeTypes: Set; + showExternal: boolean; + activeNamespaces: Set; + toggleEdgeType: (type: EdgeTypeName) => void; + toggleExternal: () => void; + toggleNamespace: (ns: string) => void; // flow slice - flowNodes: Node[] - flowEdges: Edge[] - flowCache: Map - setFlow: (nodes: Node[], edges: Edge[]) => void - cacheFlow: (key: string, entry: FlowCacheEntry) => void + flowNodes: Node[]; + flowEdges: Edge[]; + flowCache: Map; + setFlow: (nodes: Node[], edges: Edge[]) => void; + cacheFlow: (key: string, entry: FlowCacheEntry) => void; // ui slice - viewMode: ViewMode - hoveredNodeId: string | null - egoNodeId: string | null - colorMode: 'type' | 'health' - setViewMode: (mode: ViewMode) => void - setHoveredNodeId: (id: string | null) => void - setEgoNodeId: (id: string | null) => void - setColorMode: (mode: 'type' | 'health') => void + viewMode: ViewMode; + hoveredNodeId: string | null; + egoNodeId: string | null; + colorMode: "type" | "health"; + setViewMode: (mode: ViewMode) => void; + setHoveredNodeId: (id: string | null) => void; + setEgoNodeId: (id: string | null) => void; + setColorMode: (mode: "type" | "health") => void; } export const useGraphStore = create((set) => ({ @@ -67,9 +75,9 @@ export const useGraphStore = create((set) => ({ ...new Set( graphNodes .map((n) => n.namespace) - .filter((ns): ns is NodeNamespace => typeof ns === 'string' && ns.length > 0) + .filter((ns): ns is NodeNamespace => typeof ns === "string" && ns.length > 0) ), - ] + ]; set((state) => ({ rawNodes: nodes, rawEdges: edges, @@ -79,7 +87,7 @@ export const useGraphStore = create((set) => ({ graphVersion: state.graphVersion + 1, activeNamespaces: new Set(namespaces), flowCache: new Map(), - })) + })); }, // layout slice @@ -90,16 +98,16 @@ export const useGraphStore = create((set) => ({ setLayout: (nodes, edges) => set({ layoutedNodes: nodes, layoutedEdges: edges }), toggleExpandedFile: (fileId) => set((state) => { - const next = new Set(state.expandedFiles) - if (next.has(fileId)) next.delete(fileId) - else next.add(fileId) - return { expandedFiles: next } + const next = new Set(state.expandedFiles); + if (next.has(fileId)) next.delete(fileId); + else next.add(fileId); + return { expandedFiles: next }; }), setIslandPosition: (namespace, pos) => set((state) => { - const next = new Map(state.islandPositions) - next.set(namespace, pos) - return { islandPositions: next } + const next = new Map(state.islandPositions); + next.set(namespace, pos); + return { islandPositions: next }; }), // filter slice @@ -108,18 +116,18 @@ export const useGraphStore = create((set) => ({ activeNamespaces: new Set(), toggleEdgeType: (type) => set((state) => { - const next = new Set(state.activeEdgeTypes) - if (next.has(type)) next.delete(type) - else next.add(type) - return { activeEdgeTypes: next } + const next = new Set(state.activeEdgeTypes); + if (next.has(type)) next.delete(type); + else next.add(type); + return { activeEdgeTypes: next }; }), toggleExternal: () => set((state) => ({ showExternal: !state.showExternal })), toggleNamespace: (ns) => set((state) => { - const next = new Set(state.activeNamespaces) - if (next.has(ns)) next.delete(ns) - else next.add(ns) - return { activeNamespaces: next } + const next = new Set(state.activeNamespaces); + if (next.has(ns)) next.delete(ns); + else next.add(ns); + return { activeNamespaces: next }; }), // flow slice @@ -129,18 +137,18 @@ export const useGraphStore = create((set) => ({ setFlow: (nodes, edges) => set({ flowNodes: nodes, flowEdges: edges }), cacheFlow: (key, entry) => set((state) => { - const next = new Map(state.flowCache) - next.set(key, entry) - return { flowCache: next } + const next = new Map(state.flowCache); + next.set(key, entry); + return { flowCache: next }; }), // ui slice - viewMode: 'full', + viewMode: "full", hoveredNodeId: null, egoNodeId: null, - colorMode: 'type', + colorMode: "type", setViewMode: (mode) => set({ viewMode: mode }), setHoveredNodeId: (id) => set({ hoveredNodeId: id }), setEgoNodeId: (id) => set({ egoNodeId: id }), setColorMode: (mode) => set({ colorMode: mode }), -})) +})); diff --git a/ui/src/theme.ts b/ui/src/theme.ts index 5fc1a644..c06351f7 100644 --- a/ui/src/theme.ts +++ b/ui/src/theme.ts @@ -49,11 +49,11 @@ export const EDGE_LEGEND_ITEMS: LegendItem[] = [ ]; export const NAMESPACE_COLORS: Record = { - 'owner-api': '#2d6a2d', - 'owner-web': '#2d2d6a', - 'ownima-admin': '#5a2d6a', - 'rider-web': '#1a5a5a', - _default: '#546e7a', + "owner-api": "#2d6a2d", + "owner-web": "#2d2d6a", + "ownima-admin": "#5a2d6a", + "rider-web": "#1a5a5a", + _default: "#546e7a", }; // 5-stop color scale: healthy (low fan_out) → sick (high fan_out) @@ -63,13 +63,13 @@ export const HEATMAP_COLORS: NodeColor[] = [ { bg: "#3b2a00", border: "#ffb74d", text: "#ffe0b2" }, // 6–9 warning { bg: "#3b1500", border: "#ff8a65", text: "#ffccbc" }, // 10–14 danger { bg: "#3b0000", border: "#ef9a9a", text: "#ffcdd2" }, // 15+ critical -] +]; export function getHeatmapColor(fanOut: number, inCycle: boolean): NodeColor { - if (inCycle) return HEATMAP_COLORS[4] - if (fanOut <= 2) return HEATMAP_COLORS[0] - if (fanOut <= 5) return HEATMAP_COLORS[1] - if (fanOut <= 9) return HEATMAP_COLORS[2] - if (fanOut <= 14) return HEATMAP_COLORS[3] - return HEATMAP_COLORS[4] + if (inCycle) return HEATMAP_COLORS[4]; + if (fanOut <= 2) return HEATMAP_COLORS[0]; + if (fanOut <= 5) return HEATMAP_COLORS[1]; + if (fanOut <= 9) return HEATMAP_COLORS[2]; + if (fanOut <= 14) return HEATMAP_COLORS[3]; + return HEATMAP_COLORS[4]; } diff --git a/ui/src/utils/__tests__/edgeMapper.test.ts b/ui/src/utils/__tests__/edgeMapper.test.ts index 38de5693..94f19c82 100644 --- a/ui/src/utils/__tests__/edgeMapper.test.ts +++ b/ui/src/utils/__tests__/edgeMapper.test.ts @@ -1,50 +1,50 @@ -import { describe, it, expect } from 'vitest' -import { MarkerType } from '@xyflow/react' -import { mapEdgeToReactFlow, mapEdgeToFlowView } from '../edgeMapper' -import type { GraphEdge } from '../../types' +import { describe, it, expect } from "vitest"; +import { MarkerType } from "@xyflow/react"; +import { mapEdgeToReactFlow, mapEdgeToFlowView } from "../edgeMapper"; +import type { GraphEdge } from "../../types"; const edge: GraphEdge = { - id: 'test-edge-1', - source: 'src.pipeline.IngestionPipeline.run', - target: 'src.resolver.engine.ResolverEngine.resolve', - type: 'CALLS', + id: "test-edge-1", + source: "src.pipeline.IngestionPipeline.run", + target: "src.resolver.engine.ResolverEngine.resolve", + type: "CALLS", weight: 1, confidence: 1, - context: '', -} + context: "", +}; -describe('mapEdgeToReactFlow', () => { - it('generates stable content-hash ID', () => { - const e1 = mapEdgeToReactFlow(edge, 0) - const e2 = mapEdgeToReactFlow(edge, 99) // index doesn't matter - expect(e1.id).toBe(e2.id) +describe("mapEdgeToReactFlow", () => { + it("generates stable content-hash ID", () => { + const e1 = mapEdgeToReactFlow(edge, 0); + const e2 = mapEdgeToReactFlow(edge, 99); // index doesn't matter + expect(e1.id).toBe(e2.id); expect(e1.id).toBe( - 'src.pipeline.IngestionPipeline.run→src.resolver.engine.ResolverEngine.resolve:CALLS' - ) - }) + "src.pipeline.IngestionPipeline.run→src.resolver.engine.ResolverEngine.resolve:CALLS" + ); + }); - it('includes markerEnd with ArrowClosed', () => { - const e = mapEdgeToReactFlow(edge, 0) - expect(e.markerEnd).toBeDefined() - expect((e.markerEnd as { type: string }).type).toBe(MarkerType.ArrowClosed) - }) -}) + it("includes markerEnd with ArrowClosed", () => { + const e = mapEdgeToReactFlow(edge, 0); + expect(e.markerEnd).toBeDefined(); + expect((e.markerEnd as { type: string }).type).toBe(MarkerType.ArrowClosed); + }); +}); -describe('mapEdgeToFlowView', () => { - it('generates same stable ID as mapEdgeToReactFlow', () => { - const full = mapEdgeToReactFlow(edge, 0) - const flow = mapEdgeToFlowView(edge, 0) - expect(flow.id).toBe(full.id) - }) +describe("mapEdgeToFlowView", () => { + it("generates same stable ID as mapEdgeToReactFlow", () => { + const full = mapEdgeToReactFlow(edge, 0); + const flow = mapEdgeToFlowView(edge, 0); + expect(flow.id).toBe(full.id); + }); - it('includes markerEnd with ArrowClosed', () => { - const e = mapEdgeToFlowView(edge, 0) - expect(e.markerEnd).toBeDefined() - expect((e.markerEnd as { type: string }).type).toBe(MarkerType.ArrowClosed) - }) + it("includes markerEnd with ArrowClosed", () => { + const e = mapEdgeToFlowView(edge, 0); + expect(e.markerEnd).toBeDefined(); + expect((e.markerEnd as { type: string }).type).toBe(MarkerType.ArrowClosed); + }); - it('is animated', () => { - const e = mapEdgeToFlowView(edge, 0) - expect(e.animated).toBe(true) - }) -}) + it("is animated", () => { + const e = mapEdgeToFlowView(edge, 0); + expect(e.animated).toBe(true); + }); +}); diff --git a/ui/src/utils/aggregateEdges.test.ts b/ui/src/utils/aggregateEdges.test.ts index c0a58c7a..e2eb19fc 100644 --- a/ui/src/utils/aggregateEdges.test.ts +++ b/ui/src/utils/aggregateEdges.test.ts @@ -34,28 +34,19 @@ describe("aggregateEdges", () => { }); it("sets strokeWidth based on sqrt(count)", () => { - const edges = [ - makeEdge("e1", "a", "b", "CALLS"), - makeEdge("e2", "a", "b", "CALLS"), - ]; + const edges = [makeEdge("e1", "a", "b", "CALLS"), makeEdge("e2", "a", "b", "CALLS")]; const result = aggregateEdges(edges); expect(result[0].style?.strokeWidth).toBeCloseTo(Math.sqrt(2) * 1.5); }); it("does not aggregate non-CALLS edge types by default", () => { - const edges = [ - makeEdge("e1", "a", "b", "IMPORTS"), - makeEdge("e2", "a", "b", "IMPORTS"), - ]; + const edges = [makeEdge("e1", "a", "b", "IMPORTS"), makeEdge("e2", "a", "b", "IMPORTS")]; const result = aggregateEdges(edges); expect(result).toHaveLength(2); }); it("aggregates specified types when provided", () => { - const edges = [ - makeEdge("e1", "a", "b", "IMPORTS"), - makeEdge("e2", "a", "b", "IMPORTS"), - ]; + const edges = [makeEdge("e1", "a", "b", "IMPORTS"), makeEdge("e2", "a", "b", "IMPORTS")]; const result = aggregateEdges(edges, ["IMPORTS"]); expect(result).toHaveLength(1); expect(result[0].label).toBe("×2"); diff --git a/ui/src/utils/aggregateEdges.ts b/ui/src/utils/aggregateEdges.ts index a43d0351..3906b47c 100644 --- a/ui/src/utils/aggregateEdges.ts +++ b/ui/src/utils/aggregateEdges.ts @@ -1,9 +1,6 @@ import type { Edge } from "@xyflow/react"; -export function aggregateEdges( - edges: Edge[], - aggregateTypes: string[] = ["CALLS"] -): Edge[] { +export function aggregateEdges(edges: Edge[], aggregateTypes: string[] = ["CALLS"]): Edge[] { const singles: Edge[] = []; const groups = new Map(); diff --git a/ui/src/utils/applyHeatmap.test.ts b/ui/src/utils/applyHeatmap.test.ts index ba7d1a8b..b18c784e 100644 --- a/ui/src/utils/applyHeatmap.test.ts +++ b/ui/src/utils/applyHeatmap.test.ts @@ -1,52 +1,52 @@ -import { describe, it, expect } from 'vitest' -import type { Node } from '@xyflow/react' -import { applyHeatmapColors } from './applyHeatmap' +import { describe, it, expect } from "vitest"; +import type { Node } from "@xyflow/react"; +import { applyHeatmapColors } from "./applyHeatmap"; function makeNode(id: string, metadata: Record = {}): Node { return { id, - type: 'default', + type: "default", position: { x: 0, y: 0 }, data: { label: id, metadata }, - style: { background: '#000', border: '1px solid #fff' }, - } + style: { background: "#000", border: "1px solid #fff" }, + }; } -describe('applyHeatmapColors', () => { - it('returns same array reference when disabled', () => { - const nodes = [makeNode('a')] - const result = applyHeatmapColors(nodes, false) - expect(result).toBe(nodes) - }) +describe("applyHeatmapColors", () => { + it("returns same array reference when disabled", () => { + const nodes = [makeNode("a")]; + const result = applyHeatmapColors(nodes, false); + expect(result).toBe(nodes); + }); - it('overrides background/border for healthy node (fan_out=1)', () => { - const nodes = [makeNode('a', { fan_out: 1, in_cycle: false })] - const result = applyHeatmapColors(nodes, true) - expect(result[0].style?.background).toBe('#1b3a1b') - expect(result[0].style?.borderColor).toBe('#66bb6a') - }) + it("overrides background/border for healthy node (fan_out=1)", () => { + const nodes = [makeNode("a", { fan_out: 1, in_cycle: false })]; + const result = applyHeatmapColors(nodes, true); + expect(result[0].style?.background).toBe("#1b3a1b"); + expect(result[0].style?.borderColor).toBe("#66bb6a"); + }); - it('uses critical color for in_cycle=true regardless of fan_out', () => { - const nodes = [makeNode('a', { fan_out: 0, in_cycle: true })] - const result = applyHeatmapColors(nodes, true) - expect(result[0].style?.background).toBe('#3b0000') - }) + it("uses critical color for in_cycle=true regardless of fan_out", () => { + const nodes = [makeNode("a", { fan_out: 0, in_cycle: true })]; + const result = applyHeatmapColors(nodes, true); + expect(result[0].style?.background).toBe("#3b0000"); + }); - it('uses warning color for fan_out=7', () => { - const nodes = [makeNode('a', { fan_out: 7, in_cycle: false })] - const result = applyHeatmapColors(nodes, true) - expect(result[0].style?.background).toBe('#3b2a00') - }) + it("uses warning color for fan_out=7", () => { + const nodes = [makeNode("a", { fan_out: 7, in_cycle: false })]; + const result = applyHeatmapColors(nodes, true); + expect(result[0].style?.background).toBe("#3b2a00"); + }); - it('preserves other style properties', () => { - const nodes = [makeNode('a', { fan_out: 1, in_cycle: false })] - const result = applyHeatmapColors(nodes, true) - expect(result[0].style).toBeDefined() - }) + it("preserves other style properties", () => { + const nodes = [makeNode("a", { fan_out: 1, in_cycle: false })]; + const result = applyHeatmapColors(nodes, true); + expect(result[0].style).toBeDefined(); + }); - it('nodes without health metadata get healthy color', () => { - const nodes = [makeNode('a', {})] - const result = applyHeatmapColors(nodes, true) - expect(result[0].style?.background).toBe('#1b3a1b') - }) -}) + it("nodes without health metadata get healthy color", () => { + const nodes = [makeNode("a", {})]; + const result = applyHeatmapColors(nodes, true); + expect(result[0].style?.background).toBe("#1b3a1b"); + }); +}); diff --git a/ui/src/utils/applyHeatmap.ts b/ui/src/utils/applyHeatmap.ts index 4e6ef508..aae5d1bd 100644 --- a/ui/src/utils/applyHeatmap.ts +++ b/ui/src/utils/applyHeatmap.ts @@ -1,13 +1,15 @@ -import type { Node } from '@xyflow/react' -import { getHeatmapColor } from '../theme' +import type { Node } from "@xyflow/react"; +import { getHeatmapColor } from "../theme"; export function applyHeatmapColors(nodes: Node[], enabled: boolean): Node[] { - if (!enabled) return nodes + if (!enabled) return nodes; return nodes.map((node) => { - const meta = (node.data as Record)?.metadata as Record | undefined - const fanOut = typeof meta?.fan_out === 'number' ? meta.fan_out : 0 - const inCycle = meta?.in_cycle === true - const colors = getHeatmapColor(fanOut, inCycle) + const meta = (node.data as Record)?.metadata as + | Record + | undefined; + const fanOut = typeof meta?.fan_out === "number" ? meta.fan_out : 0; + const inCycle = meta?.in_cycle === true; + const colors = getHeatmapColor(fanOut, inCycle); return { ...node, style: { @@ -16,6 +18,6 @@ export function applyHeatmapColors(nodes: Node[], enabled: boolean): Node[] { borderColor: colors.border, color: colors.text, }, - } - }) + }; + }); } diff --git a/ui/src/utils/applyHighlight.test.ts b/ui/src/utils/applyHighlight.test.ts index 590958db..8ec1d332 100644 --- a/ui/src/utils/applyHighlight.test.ts +++ b/ui/src/utils/applyHighlight.test.ts @@ -32,10 +32,7 @@ describe("applyContextHighlight", () => { it("dims non-connected edges", () => { const nodes = [makeNode("a"), makeNode("b"), makeNode("c")]; - const edges = [ - makeEdge("e1", "a", "b"), - makeEdge("e2", "b", "c"), - ]; + const edges = [makeEdge("e1", "a", "b"), makeEdge("e2", "b", "c")]; const result = applyContextHighlight(nodes, edges, "a"); // e1 connects a-b expect(result.edges[0].style.opacity).toBe(0.8); @@ -72,8 +69,16 @@ describe("applyContextHighlight", () => { it("keeps children visible when hovering a fileContainer", () => { const container = { id: "file-a", data: { label: "file-a" }, style: { opacity: 1 } }; - const child1 = { id: "child1", data: { label: "child1", groupId: "file-a" }, style: { opacity: 1 } }; - const child2 = { id: "child2", data: { label: "child2", groupId: "file-a" }, style: { opacity: 1 } }; + const child1 = { + id: "child1", + data: { label: "child1", groupId: "file-a" }, + style: { opacity: 1 }, + }; + const child2 = { + id: "child2", + data: { label: "child2", groupId: "file-a" }, + style: { opacity: 1 }, + }; const unrelated = makeNode("other"); const nodes = [container, child1, child2, unrelated]; const result = applyContextHighlight(nodes, [], "file-a"); @@ -85,8 +90,16 @@ describe("applyContextHighlight", () => { it("keeps container and siblings visible when hovering a child node", () => { const container = { id: "file-a", data: { label: "file-a" }, style: { opacity: 1 } }; - const child1 = { id: "child1", data: { label: "child1", groupId: "file-a" }, style: { opacity: 1 } }; - const child2 = { id: "child2", data: { label: "child2", groupId: "file-a" }, style: { opacity: 1 } }; + const child1 = { + id: "child1", + data: { label: "child1", groupId: "file-a" }, + style: { opacity: 1 }, + }; + const child2 = { + id: "child2", + data: { label: "child2", groupId: "file-a" }, + style: { opacity: 1 }, + }; const unrelated = makeNode("other"); const nodes = [container, child1, child2, unrelated]; const result = applyContextHighlight(nodes, [], "child1"); diff --git a/ui/src/utils/edgeMapper.test.ts b/ui/src/utils/edgeMapper.test.ts index 938f0c1a..8d3ba67d 100644 --- a/ui/src/utils/edgeMapper.test.ts +++ b/ui/src/utils/edgeMapper.test.ts @@ -1,4 +1,3 @@ - import { mapEdgeToReactFlow, mapEdgeToFlowView } from "./edgeMapper"; import type { GraphEdge } from "../types"; diff --git a/ui/src/utils/edgeMapper.ts b/ui/src/utils/edgeMapper.ts index 0ce12337..bbb00446 100644 --- a/ui/src/utils/edgeMapper.ts +++ b/ui/src/utils/edgeMapper.ts @@ -2,7 +2,8 @@ import { MarkerType, type Edge } from "@xyflow/react"; import type { GraphEdge } from "../types"; import { EDGE_COLORS } from "../theme"; -const FULL_EDGE_STYLES: Record = {}; +const FULL_EDGE_STYLES: Record = + {}; for (const [type, stroke] of Object.entries(EDGE_COLORS)) { FULL_EDGE_STYLES[type] = { stroke, strokeWidth: 1.5, opacity: 0.8 }; } @@ -12,9 +13,9 @@ FULL_EDGE_STYLES.DECLARES = { stroke: EDGE_COLORS.DECLARES, strokeWidth: 1, opac // confidence=0.8 → resolved to external/builtin (medium) // confidence<0.5 → unresolved raw_call (thin) function confidenceToWidth(confidence: number | undefined): number { - if (confidence == null || confidence >= 0.9) return 2.2 - if (confidence >= 0.7) return 1.2 - return 0.6 + if (confidence == null || confidence >= 0.9) return 2.2; + if (confidence >= 0.7) return 1.2; + return 0.6; } function stableEdgeId(source: string, target: string, type: string): string { diff --git a/ui/src/utils/nodeMapper.test.ts b/ui/src/utils/nodeMapper.test.ts index fd05e849..671772c8 100644 --- a/ui/src/utils/nodeMapper.test.ts +++ b/ui/src/utils/nodeMapper.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ - import { mapNodeToReactFlow, mapNodeToFlowView } from "./nodeMapper"; import type { GraphNode } from "../types"; @@ -82,18 +81,28 @@ describe("mapNodeToReactFlow", () => { expect(result.data.groupId).toBeUndefined(); }); - it('passes metadata from GraphNode into RF node data', () => { + it("passes metadata from GraphNode into RF node data", () => { const n: GraphNode = { - id: 'a', type: 'FUNCTION', name: 'fn', file_path: 'a.py', - start_line: 1, end_line: 5, language: 'python', - namespace: 'INTERNAL', ontology_class: null, domains: [], + id: "a", + type: "FUNCTION", + name: "fn", + file_path: "a.py", + start_line: 1, + end_line: 5, + language: "python", + namespace: "INTERNAL", + ontology_class: null, + domains: [], confidence_score: 1, metadata: { fan_out: 7, fan_in: 2, depth: 1, in_cycle: false }, - } - const rfNode = mapNodeToReactFlow(n) - expect((rfNode.data as Record).metadata).toEqual( - { fan_out: 7, fan_in: 2, depth: 1, in_cycle: false } - ) + }; + const rfNode = mapNodeToReactFlow(n); + expect((rfNode.data as Record).metadata).toEqual({ + fan_out: 7, + fan_in: 2, + depth: 1, + in_cycle: false, + }); }); }); diff --git a/ui/src/utils/nodeMapper.ts b/ui/src/utils/nodeMapper.ts index cd7ecccc..e70f93b5 100644 --- a/ui/src/utils/nodeMapper.ts +++ b/ui/src/utils/nodeMapper.ts @@ -21,7 +21,10 @@ const isDimmed = (n: GraphNode) => n.namespace && n.namespace !== "INTERNAL"; // Mirrors file_path_to_module_fqn() from the Python extractor: // "cgis/cli.py" → "cgis.cli", "cgis/__init__.py" → "cgis" function filePathToNodeId(filePath: string): string { - return filePath.replace(/\//g, '.').replace(/\.py$/, '').replace(/\.__init__$/, '') + return filePath + .replace(/\//g, ".") + .replace(/\.py$/, "") + .replace(/\.__init__$/, ""); } export function mapNodeToReactFlow(n: GraphNode, { groupKey }: MapNodeOptions = {}): Node { @@ -36,7 +39,7 @@ export function mapNodeToReactFlow(n: GraphNode, { groupKey }: MapNodeOptions = nodeType: n.type, namespace: n.namespace, // Links non-FILE nodes to their parent FILE node for expand/collapse - ...(n.type !== 'FILE' && n.file_path ? { groupId: filePathToNodeId(n.file_path) } : {}), + ...(n.type !== "FILE" && n.file_path ? { groupId: filePathToNodeId(n.file_path) } : {}), metadata: n.metadata, }, position: { x: 0, y: 0 },