mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
Some checks failed
CI/CD Pipeline / backend-build (push) Has been skipped
CI/CD Pipeline / backend-lint (push) Failing after 31s
CI/CD Pipeline / frontend-lint (push) Failing after 1m26s
CI/CD Pipeline / frontend-build (push) Has been skipped
CI/CD Pipeline / e2e-test (push) Has been skipped
- Fix prettier formatting issues in multiple components - Fix React Compiler memoization issues in ProductServiceMarkers.tsx - Replace literal strings with i18n keys across components - Address i18n issues in heritage, network graph, and match components - Fix dependency arrays in useMemo hooks to match React Compiler expectations
284 lines
8.2 KiB
TypeScript
284 lines
8.2 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { Network } from 'vis-network/standalone';
|
|
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 - only if the node exists in the graph
|
|
const nodeExists = graphData.nodes.some((n) => n.id === organizationId);
|
|
if (nodeExists) {
|
|
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>{t('organization.networkGraph')}</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('organization.networkGraphDescription', { name: organizationName })}
|
|
</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>
|
|
);
|
|
}
|