inogen/app/components/ecosystem/network-graph.tsx
Saeed 40b5ad6e3c
Setup technology ecosystem page with network graph (#2)
* Add ecosystem page with network graph and company info panel

Co-authored-by: sd.eed1381 <sd.eed1381@gmail.com>

* Add unpaid company highlighting to network graph with toggle

Co-authored-by: sd.eed1381 <sd.eed1381@gmail.com>

* fix id something

* remove the useless files

* update the graph

* update the graph,fix the api ,also add some style and filters

* Refactor process impacts chart to use new CustomBarChart component (#3)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix somestyle , add charts in ecosystem

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-08-16 16:48:15 +03:30

297 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,
}))
);
console.log('Fetched nodes:', data);
} 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;