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,
+ };
+}