inogen/app/components/ecosystem/network-graph.tsx

296 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;