296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import apiService from "../../lib/api";
|
||
import Graph from "graphology";
|
||
|
||
export interface Node {
|
||
id: string;
|
||
label: string;
|
||
category: string;
|
||
stageid: number;
|
||
}
|
||
|
||
export interface NetworkGraphProps {
|
||
onNodeClick?: (node: { id: string; label?: string; [key: string]: unknown }) => void;
|
||
}
|
||
|
||
// Helper to robustly parse backend response
|
||
function parseApiResponse(raw: any): any[] {
|
||
let data = raw;
|
||
try {
|
||
if (typeof data === "string") data = JSON.parse(data);
|
||
if (typeof data === "string") data = JSON.parse(data);
|
||
} catch {}
|
||
return Array.isArray(data) ? data : [];
|
||
}
|
||
|
||
export function NetworkGraph({ onNodeClick}: NetworkGraphProps) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const sigmaRef = useRef<any>(null);
|
||
const graphRef = useRef<any>(null);
|
||
const [nodes, setNodes] = useState<Node[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [filterCategory, setFilterCategory] = useState<string>("all");
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
|
||
// ------------- Fetch and robust parse ----------------
|
||
useEffect(() => {
|
||
let aborted = false;
|
||
const controller = new AbortController();
|
||
|
||
(async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const res = await apiService.callInnovationProcess<any[]>(
|
||
{ graph_production_function: {} }
|
||
);
|
||
if (aborted) return;
|
||
// Use robust parser for backend response
|
||
const data = parseApiResponse(JSON.parse(res.data)?.graph_production)
|
||
setNodes(
|
||
data.map((item: any) => ({
|
||
id: String(item.stageid),
|
||
label: item.title,
|
||
category: item.category,
|
||
stageid: item.stageid,
|
||
}))
|
||
);
|
||
} catch (err: any) {
|
||
if (err.name === "AbortError") {
|
||
// ignore
|
||
} else {
|
||
console.error("Failed to fetch graph data:", err);
|
||
setNodes([]);
|
||
}
|
||
} finally {
|
||
if (!aborted) setIsLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
aborted = true;
|
||
controller.abort();
|
||
};
|
||
}, []);
|
||
|
||
// compute unique categories
|
||
const categories = useMemo(() => {
|
||
const set = new Set<string>();
|
||
nodes.forEach((n) => set.add(n.category));
|
||
return ["all", ...Array.from(set)];
|
||
}, [nodes]);
|
||
|
||
// ------------- Build graph + Sigma (client-only) ----------------
|
||
useEffect(() => {
|
||
// don't run on server or before container available or while loading
|
||
if (typeof window === "undefined" || !containerRef.current || isLoading) return;
|
||
|
||
let renderer: any = null;
|
||
let Sigma: any = null;
|
||
let isCancelled = false;
|
||
|
||
(async () => {
|
||
try {
|
||
// dynamic import for sigma only
|
||
const sigmaModule = await import("sigma");
|
||
Sigma = sigmaModule.default || sigmaModule.Sigma || sigmaModule;
|
||
|
||
if (isCancelled || !containerRef.current) return;
|
||
|
||
const graph = new Graph();
|
||
graphRef.current = graph;
|
||
|
||
// color map (you can extend)
|
||
const categoryToColor: Record<string, string> = {
|
||
دانشگاه: "#3B82F6",
|
||
مشاور: "#10B981",
|
||
"دانش بنیان": "#F59E0B",
|
||
استارتاپ: "#EF4444",
|
||
شرکت: "#8B5CF6",
|
||
صندوق: "#06B6D4",
|
||
شتابدهنده: "#9333EA",
|
||
"مرکز نوآوری": "#F472B6",
|
||
center: "#000000",
|
||
};
|
||
|
||
// add central node
|
||
const CENTER_ID = "center";
|
||
graph.addNode(CENTER_ID, {
|
||
label: "مرکز نوآوری اصلی",
|
||
x: 0,
|
||
y: 0,
|
||
size: 20,
|
||
category: "center",
|
||
color: categoryToColor.center,
|
||
});
|
||
|
||
// add all nodes
|
||
nodes.forEach((node, i) => {
|
||
// Place nodes in a circle, but all nodes are always present
|
||
const len = Math.max(1, nodes.length);
|
||
const radius = Math.max(5, Math.min(20, Math.ceil(len / 2)));
|
||
const angleStep = (2 * Math.PI) / len;
|
||
const angle = i * angleStep;
|
||
const jitter = (Math.random() - 0.5) * 0.4;
|
||
const x = Math.cos(angle) * (radius + jitter);
|
||
const y = Math.sin(angle) * (radius + jitter);
|
||
graph.addNode(node.id, {
|
||
label: node.label,
|
||
x,
|
||
y,
|
||
size: 8,
|
||
category: node.category,
|
||
color: categoryToColor[node.category] || "#94A3B8",
|
||
payload: node,
|
||
});
|
||
graph.addEdge(CENTER_ID, node.id, { size: 1, color: "#CBD5E1" });
|
||
});
|
||
|
||
// Highlight nodes by filter
|
||
const highlightByCategory = (category: string) => {
|
||
graph.forEachNode((n: string, attrs: any) => {
|
||
if (category === "all" || attrs.category === category) {
|
||
graph.setNodeAttribute(n, "color", categoryToColor[attrs.category] || "#94A3B8");
|
||
graph.setNodeAttribute(n, "size", attrs.category === "center" ? 20 : 12);
|
||
graph.setNodeAttribute(n, "zIndex", 1);
|
||
graph.setNodeAttribute(n, "opacity", 1);
|
||
} else {
|
||
graph.setNodeAttribute(n, "color", "#888888");
|
||
graph.setNodeAttribute(n, "size", 7);
|
||
graph.setNodeAttribute(n, "zIndex", 0);
|
||
graph.setNodeAttribute(n, "opacity", 0.3);
|
||
}
|
||
});
|
||
};
|
||
highlightByCategory(filterCategory);
|
||
|
||
// Listen for filterCategory changes to re-apply highlight
|
||
// (This is needed if filterCategory changes after initial render)
|
||
const filterListener = () => highlightByCategory(filterCategory);
|
||
// Optionally, you could use a custom event or observer if needed
|
||
// For now, we rely on the effect re-running due to filterCategory in deps
|
||
|
||
// create renderer
|
||
renderer = new Sigma(graph, containerRef.current, {
|
||
renderLabels: true,
|
||
defaultNodeColor: "#94A3B8",
|
||
defaultEdgeColor: "#CBD5E1",
|
||
labelColor: { color: "#fff" }, // Set label color to white
|
||
});
|
||
|
||
sigmaRef.current = renderer;
|
||
|
||
// Helper: set highlight states by mutating node attributes
|
||
const setHighlight = (nodeId: string | null) => {
|
||
graph.forEachNode((n: string, attrs: any) => {
|
||
if (nodeId && (n === nodeId || graph.hasEdge(n, nodeId) || graph.hasEdge(nodeId, n))) {
|
||
graph.setNodeAttribute(n, "size", attrs.size ? Math.min(24, attrs.size * 1.6) : 12);
|
||
graph.setNodeAttribute(n, "color", attrs.color ? attrs.color : "#fff");
|
||
graph.setNodeAttribute(n, "highlighted", true);
|
||
} else {
|
||
graph.setNodeAttribute(n, "size", attrs.size && attrs.category === "center" ? 20 : 8);
|
||
// restore original color if we stored it; otherwise keep
|
||
graph.setNodeAttribute(n, "color", attrs.category === "center" ? categoryToColor.center : attrs.color);
|
||
graph.setNodeAttribute(n, "highlighted", false);
|
||
}
|
||
});
|
||
// ask renderer to refresh (sigma v2 triggers update automatically when graph changes)
|
||
};
|
||
|
||
// events: hover highlight and click select
|
||
const onEnter = (e: any) => {
|
||
const nodeId = e.node;
|
||
setHighlight(nodeId);
|
||
};
|
||
const onLeave = () => {
|
||
setHighlight(selectedNodeId); // keep selected highlighted, or none
|
||
};
|
||
const onClick = (e: any) => {
|
||
const nodeId = e.node as string;
|
||
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
|
||
// call external callback with payload if exists
|
||
const attrs = graph.getNodeAttributes(nodeId);
|
||
onNodeClick?.({ id: nodeId, label: attrs?.label, ...(attrs?.payload ?? {}) });
|
||
};
|
||
|
||
renderer.on("enterNode", onEnter);
|
||
renderer.on("leaveNode", onLeave);
|
||
renderer.on("clickNode", onClick);
|
||
|
||
// if there is a pre-selected node (state), reflect it
|
||
if (selectedNodeId) setHighlight(selectedNodeId);
|
||
|
||
// cleanup on re-run
|
||
return () => {
|
||
try {
|
||
renderer.removeListener("enterNode", onEnter);
|
||
renderer.removeListener("leaveNode", onLeave);
|
||
renderer.removeListener("clickNode", onClick);
|
||
} catch {}
|
||
};
|
||
} catch (err) {
|
||
console.error("Failed to initialize graph / sigma:", err);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
// kill previous renderer & graph
|
||
if (sigmaRef.current) {
|
||
try {
|
||
sigmaRef.current.kill?.();
|
||
} catch {}
|
||
}
|
||
sigmaRef.current = null;
|
||
graphRef.current = null;
|
||
if (renderer) {
|
||
try {
|
||
renderer.kill?.();
|
||
} catch {}
|
||
}
|
||
renderer = null;
|
||
};
|
||
// rebuild whenever nodes, filterCategory or selectedNodeId changes
|
||
}, [nodes, filterCategory, isLoading, onNodeClick, selectedNodeId]);
|
||
|
||
return (
|
||
<div className="relative w-full h-full flex flex-col">
|
||
<div className="p-2 flex items-center gap-2">
|
||
<label className="text-sm">فیلتر:</label>
|
||
<select
|
||
value={filterCategory}
|
||
onChange={(e) => setFilterCategory(e.target.value)}
|
||
className="px-2 py-1 border rounded bg-white text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
|
||
>
|
||
{categories.map((c) => (
|
||
<option key={c} value={c}>
|
||
{c === "all" ? "همه" : c}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="ml-4 text-sm text-gray-600">
|
||
{isLoading ? "در حال بارگذاری..." : `نمایش ${filterCategory === "all" ? nodes.length : nodes.filter(n => n.category === filterCategory).length} گره`}
|
||
</div>
|
||
</div>
|
||
|
||
<div ref={containerRef} className="flex-1 relative" style={{ minHeight: 360 }} />
|
||
|
||
{/* overlay selected info */}
|
||
<div className="p-2">
|
||
{selectedNodeId ? (
|
||
<>
|
||
<div className="text-sm">انتخاب شده: {selectedNodeId}</div>
|
||
<button
|
||
onClick={() => setSelectedNodeId(null)}
|
||
className="mt-1 px-2 py-1 text-sm border rounded"
|
||
>
|
||
پاک کردن انتخاب
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default NetworkGraph;
|