turash/bugulma/frontend/components/organization/NetworkGraph.tsx
Damir Mukimov 6347f42e20
Consolidate repositories: Remove nested frontend .git and merge into main repository
- Remove nested git repository from bugulma/frontend/.git
- Add all frontend files to main repository tracking
- Convert from separate frontend/backend repos to unified monorepo
- Preserve all frontend code and development history as tracked files
- Eliminate nested repository complexity for simpler development workflow

This creates a proper monorepo structure with frontend and backend
coexisting in the same repository for easier development and deployment.
2025-11-25 06:02:57 +01:00

285 lines
8.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { Network } from 'vis-network/standalone';
import { DataSet } from 'vis-data';
import Button from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
interface GraphNode {
id: string;
label: string;
type: string;
properties: Record<string, unknown>;
}
interface GraphEdge {
id: string;
source: string;
target: string;
type: string;
properties?: Record<string, unknown>;
}
interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
interface NetworkGraphProps {
organizationId: string;
organizationName: string;
depth?: number;
onNodeClick?: (nodeId: string, nodeType: string) => void;
}
export function NetworkGraph({
organizationId,
organizationName,
depth = 2,
onNodeClick,
}: NetworkGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const networkRef = useRef<Network | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [currentDepth, setCurrentDepth] = useState(depth);
const fetchNetworkData = useCallback(
async (depthLevel: number) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/graph/organizations/${organizationId}/network?depth=${depthLevel}`
);
if (!response.ok) {
throw new Error('Failed to fetch network data');
}
const data = await response.json();
setGraphData(data.graph);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load network graph');
console.error('Network graph error:', err);
} finally {
setIsLoading(false);
}
},
[organizationId]
);
useEffect(() => {
fetchNetworkData(currentDepth);
}, [fetchNetworkData, currentDepth]);
useEffect(() => {
if (!graphData || !containerRef.current) return;
// Dynamically import vis-network and vis-data only when needed
Promise.all([
import('vis-network'),
import('vis-data')
])
.then(([{ Network }, { DataSet }]) => {
const nodes = new DataSet(
graphData.nodes.map((node) => ({
id: node.id,
label: node.label,
title: `${node.type}: ${node.label}`,
color: getNodeColor(node.type),
shape: getNodeShape(node.type),
font: {
size: 14,
color: '#334155',
},
}))
);
const edges = new DataSet(
graphData.edges.map((edge) => ({
id: edge.id,
from: edge.source,
to: edge.target,
label: edge.type.replace('_', ' '),
arrows: 'to',
color: {
color: '#94a3b8',
highlight: '#3b82f6',
},
font: {
size: 10,
align: 'middle',
},
}))
);
const data = { nodes, edges };
const options = {
physics: {
enabled: true,
stabilization: {
enabled: true,
iterations: 100,
},
barnesHut: {
gravitationalConstant: -2000,
centralGravity: 0.3,
springLength: 150,
springConstant: 0.04,
damping: 0.09,
},
},
interaction: {
hover: true,
tooltipDelay: 100,
navigationButtons: true,
keyboard: true,
},
nodes: {
borderWidth: 2,
borderWidthSelected: 3,
size: 25,
},
edges: {
width: 2,
smooth: {
enabled: true,
type: 'continuous',
roundness: 0.5,
},
},
};
if (networkRef.current) {
networkRef.current.destroy();
}
networkRef.current = new Network(containerRef.current!, data, options);
// Handle node clicks
networkRef.current.on('click', (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0] as string;
const node = graphData.nodes.find((n) => n.id === nodeId);
if (node && onNodeClick) {
onNodeClick(node.id, node.type);
}
}
});
// Highlight the source organization
networkRef.current.selectNodes([organizationId]);
})
.catch((err) => {
console.error('Failed to load vis-network or vis-data:', err);
setError('Graph visualization libraries not loaded. Run: npm install vis-network vis-data');
});
return () => {
if (networkRef.current) {
networkRef.current.destroy();
networkRef.current = null;
}
};
}, [graphData, organizationId, onNodeClick]);
const getNodeColor = (type: string): string => {
const colors: Record<string, string> = {
Organization: '#3b82f6',
Site: '#10b981',
ResourceFlow: '#f59e0b',
Match: '#8b5cf6',
SharedAsset: '#ec4899',
};
return colors[type] || '#6b7280';
};
const getNodeShape = (type: string): string => {
const shapes: Record<string, string> = {
Organization: 'dot',
Site: 'square',
ResourceFlow: 'diamond',
Match: 'star',
SharedAsset: 'triangle',
};
return shapes[type] || 'dot';
};
const handleDepthChange = (newDepth: number) => {
setCurrentDepth(newDepth);
};
return (
<Card>
<CardHeader>
<CardTitle>Network Graph</CardTitle>
<p className="text-sm text-muted-foreground">
Explore how {organizationName} connects to other organizations, sites, and resources
</p>
<div className="flex gap-2 mt-4">
{[1, 2, 3].map((d) => (
<Button
key={d}
size="sm"
variant={currentDepth === d ? 'default' : 'outline'}
onClick={() => handleDepthChange(d)}
disabled={isLoading}
>
Depth {d}
</Button>
))}
</div>
</CardHeader>
<CardContent>
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
<p className="font-semibold">Error loading network graph</p>
<p className="text-sm">{error}</p>
</div>
)}
{isLoading && (
<div className="flex items-center justify-center h-96 bg-muted/30 rounded-lg">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading network graph...</p>
</div>
</div>
)}
{!isLoading && !error && graphData && (
<>
<div
ref={containerRef}
className="w-full h-96 border border-border rounded-lg bg-white"
/>
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<div className="flex gap-4">
<span>{graphData.nodes.length} nodes</span>
<span>{graphData.edges.length} connections</span>
</div>
<div className="flex gap-3 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-full bg-[#3b82f6]"></div>
<span>Organization</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-[#10b981]"></div>
<span>Site</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-[#f59e0b] rotate-45"></div>
<span>Resource</span>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}