diff --git a/src/components/Common/GraphMetrics.tsx b/src/components/Common/GraphMetrics.tsx new file mode 100644 index 0000000..bd2aec0 --- /dev/null +++ b/src/components/Common/GraphMetrics.tsx @@ -0,0 +1,243 @@ +import { useMemo } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import WarningIcon from '@mui/icons-material/Warning'; +import InfoIcon from '@mui/icons-material/Info'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import { calculateGraphMetrics } from '../../utils/graphAnalysis'; +import type { Actor, Relation } from '../../types'; + +interface GraphMetricsProps { + nodes: Actor[]; + edges: Relation[]; + onActorClick?: (actorId: string) => void; +} + +/** + * GraphMetrics - Display graph analysis and statistics + * + * Shows when no node or edge is selected in the right panel. + * Provides insights into graph structure, connectivity, and key actors. + */ +const GraphMetrics = ({ nodes, edges, onActorClick }: GraphMetricsProps) => { + // Calculate all metrics (memoized for performance) + const metrics = useMemo(() => { + return calculateGraphMetrics(nodes, edges); + }, [nodes, edges]); + + const handleRefresh = () => { + // Metrics are automatically recalculated via useMemo + // This is just for visual feedback + console.log('Metrics refreshed'); + }; + + const formatNumber = (num: number, decimals: number = 2): string => { + return num.toFixed(decimals); + }; + + const formatPercentage = (num: number): string => { + return `${(num * 100).toFixed(1)}%`; + }; + + return ( +
+ {/* Header */} +
+
+ +

Graph Analysis

+
+ + + + + +
+ + {/* Scrollable Content */} +
+ {/* Overview Section */} +
+

+ Overview +

+
+ + + + +
+
+ + {/* Most Connected Actors Section */} + {metrics.mostConnectedActors.length > 0 && ( +
+

+ Most Connected Actors +

+
+ {metrics.mostConnectedActors.map((actor, index) => ( +
onActorClick?.(actor.actorId)} + > +
+ + {index + 1}. + + + {actor.actorLabel} + +
+ + {actor.degree} {actor.degree === 1 ? 'connection' : 'connections'} + +
+ ))} +
+
+ )} + + {/* Graph Structure Section */} +
+

+ Graph Structure +

+
+ 0 ? ( + + ) : undefined + } + tooltip="Actors with no connections to other actors" + highlight={metrics.isolatedActorCount > 0 ? 'warning' : undefined} + /> + 1 ? ( + + ) : undefined + } + tooltip="Number of separate, disconnected subgraphs" + highlight={metrics.connectedComponentCount > 1 ? 'info' : undefined} + /> +
+
+ + {/* Actors by Type Section */} + {metrics.actorsByType.size > 0 && ( +
+

+ Actors by Type +

+
+ {Array.from(metrics.actorsByType.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+ )} + + {/* Relations by Type Section */} + {metrics.relationsByType.size > 0 && ( +
+

+ Relations by Type +

+
+ {Array.from(metrics.relationsByType.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+ )} + + {/* Empty graph state */} + {metrics.actorCount === 0 && ( +
+ +

No Data

+

Add actors to see graph metrics

+
+ )} +
+
+ ); +}; + +/** + * MetricRow - Single metric display with label and value + */ +interface MetricRowProps { + label: string; + value: string; + icon?: React.ReactNode; + tooltip?: string; + highlight?: 'warning' | 'info'; +} + +const MetricRow = ({ label, value, icon, tooltip, highlight }: MetricRowProps) => { + const content = ( +
+
+ {icon && {icon}} + {label} +
+ {value} +
+ ); + + if (tooltip) { + return ( + + {content} + + ); + } + + return content; +}; + +export default GraphMetrics; diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 6c4de4e..28e03dc 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -34,7 +34,6 @@ import { createNode } from "../../utils/nodeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; import { useConfirm } from "../../hooks/useConfirm"; import { useGraphExport } from "../../hooks/useGraphExport"; -import { useToastStore } from "../../stores/toastStore"; import type { ExportOptions } from "../../utils/graphExport"; import type { Actor, Relation } from "../../types"; diff --git a/src/components/Panels/RightPanel.tsx b/src/components/Panels/RightPanel.tsx index d5302d1..43a45f3 100644 --- a/src/components/Panels/RightPanel.tsx +++ b/src/components/Panels/RightPanel.tsx @@ -6,6 +6,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import { usePanelStore } from '../../stores/panelStore'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useConfirm } from '../../hooks/useConfirm'; +import GraphMetrics from '../Common/GraphMetrics'; import type { Actor, Relation } from '../../types'; /** @@ -35,7 +36,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { expandRightPanel, } = usePanelStore(); - const { nodeTypes, edgeTypes, updateNode, updateEdge, deleteNode, deleteEdge, edges } = useGraphWithHistory(); + const { nodes, edges, nodeTypes, edgeTypes, updateNode, updateEdge, deleteNode, deleteEdge } = useGraphWithHistory(); const { confirm, ConfirmDialogComponent } = useConfirm(); // Node property states @@ -173,32 +174,14 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { ); } - // No selection state + // No selection state - show graph metrics if (!selectedNode && !selectedEdge) { return (
- {/* Header */} -
-

Properties

- - - - - -
- - {/* Empty state */} -
-
-

No Selection

-

- Select an actor or relation to view properties -

-
-
+ {ConfirmDialogComponent}
); diff --git a/src/utils/graphAnalysis.ts b/src/utils/graphAnalysis.ts new file mode 100644 index 0000000..b339bc9 --- /dev/null +++ b/src/utils/graphAnalysis.ts @@ -0,0 +1,192 @@ +import type { Actor, Relation } from '../types'; + +/** + * Graph Analysis Utilities + * + * Pure functions for calculating graph metrics and statistics. + * Used for the Graph Metrics panel when no node/edge is selected. + */ + +export interface ActorDegree { + actorId: string; + actorLabel: string; + degree: number; +} + +export interface GraphMetrics { + // Basic counts + actorCount: number; + relationCount: number; + + // Density metrics + density: number; // 0.0 to 1.0 + averageConnections: number; + + // Top actors + mostConnectedActors: ActorDegree[]; + + // Graph structure + isolatedActorCount: number; + connectedComponentCount: number; + + // Breakdown by type (optional enhancement) + actorsByType: Map; + relationsByType: Map; +} + +/** + * Calculate the degree (number of connections) for each actor + */ +export function calculateActorDegrees(nodes: Actor[], edges: Relation[]): ActorDegree[] { + const degreeMap = new Map(); + + // Initialize all nodes with degree 0 + nodes.forEach(node => { + degreeMap.set(node.id, 0); + }); + + // Count connections for each node + edges.forEach(edge => { + const sourceDegree = degreeMap.get(edge.source) || 0; + const targetDegree = degreeMap.get(edge.target) || 0; + + degreeMap.set(edge.source, sourceDegree + 1); + degreeMap.set(edge.target, targetDegree + 1); + }); + + // Convert to array with labels + return nodes.map(node => ({ + actorId: node.id, + actorLabel: node.data?.label || node.id, + degree: degreeMap.get(node.id) || 0, + })); +} + +/** + * Calculate graph density + * Density = actual_edges / max_possible_edges + * For directed graph: max = n * (n - 1) + * For undirected graph: max = n * (n - 1) / 2 + * + * We treat this as undirected since edges can be traversed both ways. + */ +export function calculateDensity(nodeCount: number, edgeCount: number): number { + if (nodeCount <= 1) return 0; + + const maxPossibleEdges = (nodeCount * (nodeCount - 1)) / 2; + return edgeCount / maxPossibleEdges; +} + +/** + * Find connected components using Depth-First Search (DFS) + * Returns the number of disconnected subgraphs + */ +export function findConnectedComponents(nodes: Actor[], edges: Relation[]): number { + if (nodes.length === 0) return 0; + + // Build adjacency list + const adjacency = new Map>(); + nodes.forEach(node => { + adjacency.set(node.id, new Set()); + }); + + edges.forEach(edge => { + adjacency.get(edge.source)?.add(edge.target); + adjacency.get(edge.target)?.add(edge.source); + }); + + // DFS to mark visited nodes + const visited = new Set(); + let componentCount = 0; + + const dfs = (nodeId: string) => { + visited.add(nodeId); + const neighbors = adjacency.get(nodeId) || new Set(); + + neighbors.forEach(neighborId => { + if (!visited.has(neighborId)) { + dfs(neighborId); + } + }); + }; + + // Find all components + nodes.forEach(node => { + if (!visited.has(node.id)) { + dfs(node.id); + componentCount++; + } + }); + + return componentCount; +} + +/** + * Count actors by type + */ +export function countActorsByType(nodes: Actor[]): Map { + const counts = new Map(); + + nodes.forEach(node => { + const type = node.data?.type || 'unknown'; + counts.set(type, (counts.get(type) || 0) + 1); + }); + + return counts; +} + +/** + * Count relations by type + */ +export function countRelationsByType(edges: Relation[]): Map { + const counts = new Map(); + + edges.forEach(edge => { + const type = edge.data?.type || 'unknown'; + counts.set(type, (counts.get(type) || 0) + 1); + }); + + return counts; +} + +/** + * Calculate all graph metrics at once + * Main entry point for the GraphMetrics component + */ +export function calculateGraphMetrics(nodes: Actor[], edges: Relation[]): GraphMetrics { + const actorDegrees = calculateActorDegrees(nodes, edges); + + // Sort by degree descending and take top 5 + const mostConnected = [...actorDegrees] + .sort((a, b) => b.degree - a.degree) + .slice(0, 5); + + // Count isolated actors (degree = 0) + const isolatedCount = actorDegrees.filter(ad => ad.degree === 0).length; + + // Calculate density + const density = calculateDensity(nodes.length, edges.length); + + // Calculate average connections + const totalConnections = actorDegrees.reduce((sum, ad) => sum + ad.degree, 0); + const averageConnections = nodes.length > 0 ? totalConnections / nodes.length : 0; + + // Find connected components + const componentCount = findConnectedComponents(nodes, edges); + + // Count by type + const actorsByType = countActorsByType(nodes); + const relationsByType = countRelationsByType(edges); + + return { + actorCount: nodes.length, + relationCount: edges.length, + density, + averageConnections, + mostConnectedActors: mostConnected, + isolatedActorCount: isolatedCount, + connectedComponentCount: componentCount, + actorsByType, + relationsByType, + }; +}