mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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:
parent
8998061262
commit
0e90f022fc
4 changed files with 439 additions and 22 deletions
243
src/components/Common/GraphMetrics.tsx
Normal file
243
src/components/Common/GraphMetrics.tsx
Normal 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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
192
src/utils/graphAnalysis.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue