feat: add graph metrics and analysis to right panel

Implements section 6.1 from UX_ANALYSIS.md - Graph Metrics and Analysis.
Transforms the empty "No Selection" state in the right panel into a
valuable analysis dashboard.

Features:
- Graph analysis utility with metric calculations:
  - Actor/relation counts
  - Graph density (connectivity ratio)
  - Average connections per actor
  - Most connected actors (top 5)
  - Isolated actors count
  - Connected components detection
  - Breakdown by actor/relation type

- GraphMetrics component with sections:
  - Overview: basic stats and density
  - Most Connected Actors: ranked list
  - Graph Structure: isolated nodes, components
  - Type breakdowns: actors and relations by type
  - Visual polish: icons, tooltips, hover states
  - Warning highlights for isolated actors
  - Info highlights for multiple components

- Integration:
  - Replaces empty state in RightPanel
  - Automatically updates when graph changes
  - Memoized calculations for performance
  - Consistent styling with existing panels

Now provides immediate analytical value when opening a document,
making the application live up to its "Analyzer" name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 23:20:22 +02:00
parent 8998061262
commit 0e90f022fc
4 changed files with 439 additions and 22 deletions

View file

@ -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 (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
<div className="flex items-center space-x-2">
<BarChartIcon className="text-blue-600" fontSize="small" />
<h2 className="text-sm font-semibold text-gray-700">Graph Analysis</h2>
</div>
<Tooltip title="Refresh Metrics">
<IconButton size="small" onClick={handleRefresh}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Overview Section */}
<div>
<h3 className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
Overview
</h3>
<div className="space-y-2">
<MetricRow label="Actors" value={metrics.actorCount.toString()} />
<MetricRow label="Relations" value={metrics.relationCount.toString()} />
<MetricRow
label="Density"
value={formatPercentage(metrics.density)}
tooltip="Ratio of actual connections to maximum possible connections"
/>
<MetricRow
label="Avg Connections"
value={formatNumber(metrics.averageConnections)}
tooltip="Average number of relations per actor"
/>
</div>
</div>
{/* Most Connected Actors Section */}
{metrics.mostConnectedActors.length > 0 && (
<div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
Most Connected Actors
</h3>
<div className="space-y-1">
{metrics.mostConnectedActors.map((actor, index) => (
<div
key={actor.actorId}
className={`flex items-center justify-between text-xs py-1 px-2 rounded ${
onActorClick
? 'hover:bg-blue-50 cursor-pointer transition-colors'
: ''
}`}
onClick={() => onActorClick?.(actor.actorId)}
>
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-medium w-4">
{index + 1}.
</span>
<span className="text-gray-700 font-medium truncate max-w-[150px]">
{actor.actorLabel}
</span>
</div>
<span className="text-gray-500 ml-2">
{actor.degree} {actor.degree === 1 ? 'connection' : 'connections'}
</span>
</div>
))}
</div>
</div>
)}
{/* Graph Structure Section */}
<div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
Graph Structure
</h3>
<div className="space-y-2">
<MetricRow
label="Isolated Actors"
value={metrics.isolatedActorCount.toString()}
icon={
metrics.isolatedActorCount > 0 ? (
<WarningIcon className="text-orange-500" fontSize="small" />
) : undefined
}
tooltip="Actors with no connections to other actors"
highlight={metrics.isolatedActorCount > 0 ? 'warning' : undefined}
/>
<MetricRow
label="Connected Components"
value={metrics.connectedComponentCount.toString()}
icon={
metrics.connectedComponentCount > 1 ? (
<InfoIcon className="text-blue-500" fontSize="small" />
) : undefined
}
tooltip="Number of separate, disconnected subgraphs"
highlight={metrics.connectedComponentCount > 1 ? 'info' : undefined}
/>
</div>
</div>
{/* Actors by Type Section */}
{metrics.actorsByType.size > 0 && (
<div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
Actors by Type
</h3>
<div className="space-y-1">
{Array.from(metrics.actorsByType.entries())
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<div
key={type}
className="flex items-center justify-between text-xs py-1"
>
<span className="text-gray-600 capitalize">{type}</span>
<span className="text-gray-500 font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
{/* Relations by Type Section */}
{metrics.relationsByType.size > 0 && (
<div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
Relations by Type
</h3>
<div className="space-y-1">
{Array.from(metrics.relationsByType.entries())
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<div
key={type}
className="flex items-center justify-between text-xs py-1"
>
<span className="text-gray-600 capitalize">{type}</span>
<span className="text-gray-500 font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
{/* Empty graph state */}
{metrics.actorCount === 0 && (
<div className="text-center py-8 text-gray-400">
<BarChartIcon fontSize="large" className="mb-2" />
<p className="text-sm font-medium">No Data</p>
<p className="text-xs mt-1">Add actors to see graph metrics</p>
</div>
)}
</div>
</div>
);
};
/**
* 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 = (
<div
className={`flex items-center justify-between text-xs py-1 px-2 rounded ${
highlight === 'warning'
? 'bg-orange-50'
: highlight === 'info'
? 'bg-blue-50'
: ''
}`}
>
<div className="flex items-center space-x-2">
{icon && <span className="flex-shrink-0">{icon}</span>}
<span className="text-gray-600">{label}</span>
</div>
<span className="font-medium text-gray-800 ml-2">{value}</span>
</div>
);
if (tooltip) {
return (
<Tooltip title={tooltip} placement="left">
{content}
</Tooltip>
);
}
return content;
};
export default GraphMetrics;

View file

@ -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";

View file

@ -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 (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-gray-700">Properties</h2>
<Tooltip title="Collapse Panel (Ctrl+I)">
<IconButton size="small" onClick={collapseRightPanel}>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
{/* Empty state */}
<div className="flex-1 flex items-center justify-center px-4">
<div className="text-center text-gray-400">
<p className="text-sm font-medium">No Selection</p>
<p className="text-xs mt-1">
Select an actor or relation to view properties
</p>
</div>
</div>
<GraphMetrics nodes={nodes} edges={edges} />
{ConfirmDialogComponent}
</div>
);

192
src/utils/graphAnalysis.ts Normal file
View file

@ -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<string, number>;
relationsByType: Map<string, number>;
}
/**
* Calculate the degree (number of connections) for each actor
*/
export function calculateActorDegrees(nodes: Actor[], edges: Relation[]): ActorDegree[] {
const degreeMap = new Map<string, number>();
// 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<string, Set<string>>();
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<string>();
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<string, number> {
const counts = new Map<string, number>();
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<string, number> {
const counts = new Map<string, number>();
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,
};
}