inogen/app/components/ecosystem/network-graph.tsx
2025-10-06 10:49:00 +03:30

596 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useRef, useState, useCallback } from "react";
import * as d3 from "d3";
import apiService from "../../lib/api";
import { useAuth } from "../../contexts/auth-context";
const API_BASE_URL =
import.meta.env.VITE_API_URL || "https://inogen-back.pelekan.org/api";
export interface Node {
id: string;
label: string;
category: string;
stageid: number;
imageUrl?: string;
x?: number;
y?: number;
fx?: number | null;
fy?: number | null;
isCenter?: boolean;
rawData?: any;
}
export interface Link {
source: string;
target: string;
}
export interface CompanyDetails {
id: string;
label: string;
category: string;
stageid: number;
fields: {
F: string;
N: string;
V: string;
T: number;
U: string;
S: boolean;
}[];
description?: string;
}
export interface NetworkGraphProps {
onNodeClick?: (node: CompanyDetails) => void;
onLoadingChange?: (loading: boolean) => void;
}
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 : [];
}
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export function NetworkGraph({ onNodeClick, onLoadingChange }: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement | null>(null);
const [nodes, setNodes] = useState<Node[]>([]);
const [links, setLinks] = useState<Link[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const [error, setError] = useState<string | null>(null);
const { token } = useAuth();
useEffect(() => {
if (isBrowser()) {
const timer = setTimeout(() => setIsMounted(true), 100);
return () => clearTimeout(timer);
}
}, []);
const getImageUrl = useCallback(
(stageid: number) => {
if (!token?.accessToken) return null;
return `${API_BASE_URL}/getimage?stageID=${stageid}&nameOrID=image&token=${token.accessToken}`;
},
[token?.accessToken],
);
const callAPI = useCallback(async (stage_id: number) => {
return await apiService.call<any>({
get_values_workflow_function: {
stage_id: stage_id,
},
});
}, []);
useEffect(() => {
if (!isMounted) return;
let aborted = false;
const controller = new AbortController();
(async () => {
setIsLoading(true);
try {
const res = await apiService.call<any[]>({
graph_production_function: {},
});
if (aborted) return;
const data = parseApiResponse(JSON.parse(res.data)?.graph_production);
console.log(
"All available fields in first item:",
Object.keys(data[0] || {}),
);
// نود مرکزی
const centerNode: Node = {
id: "center",
label: "پتروشیمی بندر امام",
category: "center",
stageid: 0,
isCenter: true,
};
// دسته‌بندی‌ها
const categories = Array.from(new Set(data.map((item: any) => item.category)));
const categoryNodes: Node[] = categories.map((cat, index) => ({
id: `cat-${index}`,
label: cat,
category: cat,
stageid: -1,
}));
// نودهای نهایی
const finalNodes: Node[] = data.map((item: any) => ({
id: `node-${item.stageid}`,
label: item.title,
category: item.category,
stageid: item.stageid,
imageUrl: getImageUrl(item.stageid),
rawData: item,
}));
// لینک‌ها: مرکز → دسته‌بندی‌ها → نودهای نهایی
const graphLinks: Link[] = [
...categoryNodes.map((cat) => ({ source: "center", target: cat.id })),
...finalNodes.map((node) => {
const catIndex = categories.indexOf(node.category);
return { source: `cat-${catIndex}`, target: node.id };
}),
];
setNodes([centerNode, ...categoryNodes, ...finalNodes]);
setLinks(graphLinks);
} catch (err: any) {
if (err.name !== "AbortError") {
console.error("Failed to fetch graph data:", err);
setError("Failed to load graph data");
setNodes([]);
setLinks([]);
}
} finally {
if (!aborted) setIsLoading(false);
}
})();
return () => {
aborted = true;
controller.abort();
};
}, [isMounted, token, getImageUrl]);
useEffect(() => {
if (!isMounted || !svgRef.current || isLoading || nodes.length === 0) return;
const svg = d3.select(svgRef.current);
const width = svgRef.current.clientWidth;
const height = svgRef.current.clientHeight;
svg.selectAll("*").remove();
const defs = svg.append("defs");
const filter = defs
.append("filter")
.attr("id", "glow")
.attr("x", "-50%")
.attr("y", "-50%")
.attr("width", "200%")
.attr("height", "200%");
filter
.append("feGaussianBlur")
.attr("stdDeviation", "3")
.attr("result", "coloredBlur");
const feMerge = filter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
const container = svg.append("g");
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.3, 2.5])
.on("zoom", (event) => container.attr("transform", event.transform));
svg.call(zoom);
const categoryToColor: Record<string, string> = {
دانشگاه: "#3B82F6",
مشاور: "#10B981",
"دانش بنیان": "#F59E0B",
استارتاپ: "#EF4444",
شرکت: "#8B5CF6",
صندوق: "#06B6D4",
شتابدهنده: "#9333EA",
"مرکز نوآوری": "#F472B6",
center: "#34D399",
};
const simulation = d3
.forceSimulation<Node>(nodes)
.force(
"link",
d3
.forceLink<Node, Link>(links)
.id((d) => d.id)
.distance(150)
.strength(0.2),
)
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("radial", d3.forceRadial(d => d.isCenter ? 0 : 300, width/2, height/2))
.force("collision", d3.forceCollide().radius((d) => (d.isCenter ? 50 : 35)));
// Initial zoom to show entire graph
const initialScale = 0.6;
const initialTranslate = [
width / 2 - (width / 2) * initialScale,
height / 2 - (height / 2) * initialScale,
];
svg.call(
zoom.transform,
d3.zoomIdentity
.translate(initialTranslate[0], initialTranslate[1])
.scale(initialScale),
);
// Fix center node
const centerNode = nodes.find(n => n.isCenter);
const categoryNodes = nodes.filter(n => !n.isCenter && n.stageid === -1);
if (centerNode) {
const centerX = width / 2;
const centerY = height / 2;
centerNode.fx = centerX;
centerNode.fy = centerY;
const baseRadius = 450; // شعاع پایه
const variation = 100; // تغییر طول یکی در میان
const angleStep = (2 * Math.PI) / categoryNodes.length;
categoryNodes.forEach((catNode, i) => {
const angle = i * angleStep;
const radius = baseRadius + (i % 2 === 0 ? -variation : variation);
catNode.fx = centerX + radius * Math.cos(angle);
catNode.fy = centerY + radius * Math.sin(angle);
});
}
// نودهای نهایی **هیچ fx/fy نداشته باشند**
// فقط forceLink آن‌ها را به دسته‌ها متصل نگه می‌دارد
// const finalNodes = nodes.filter(n => !n.isCenter && n.stageid !== -1);
// categoryNodes.forEach((catNode) => {
// const childNodes = finalNodes.filter(n => n.category === catNode.category);
// const childCount = childNodes.length;
// const radius = 100; // فاصله از دسته
// const angleStep = (2 * Math.PI) / childCount;
// childNodes.forEach((node, i) => {
// const angle = i * angleStep;
// node.fx = catNode.fx! + radius * Math.cos(angle);
// node.fy = catNode.fy! + radius * Math.sin(angle);
// });
// });
// Curved links
const link = container
.selectAll(".link")
.data(links)
.enter()
.append("path")
.attr("class", "link")
.attr("stroke", "#E2E8F0")
.attr("stroke-width", 2)
.attr("stroke-opacity", 0.6)
.attr("fill", "none");
const nodeGroup = container
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr("class", "node")
.style("cursor", d => d.stageid === -1 ? "default" : "pointer");
const drag = d3
.drag<SVGGElement, Node>()
.on("start", (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) simulation.alphaTarget(0);
if (!d.isCenter) {
d.fx = null;
d.fy = null;
}
});
nodeGroup.call(drag);
nodeGroup.each(function (d) {
const group = d3.select(this);
if (d.isCenter) {
const rect = group
.append("rect")
.attr("width", 200)
.attr("height", 80)
.attr("x", -100) // نصف عرض جدید منفی
.attr("y", -40) // نصف ارتفاع جدید منفی
.attr("rx", 8)
.attr("ry", 8)
.attr("fill", categoryToColor[d.category] || "#94A3B8")
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 3)
.style("pointer-events", "none");
if (d.imageUrl || d.isCenter) {
const pattern = defs
.append("pattern")
.attr("id", `image-${d.id}`)
.attr("x", 0)
.attr("y", 0)
.attr("width", 1)
.attr("height", 1);
pattern
.append("image")
.attr("x", 0)
.attr("y", 0)
.attr("width", 200) // ← هم‌اندازه با مستطیل
.attr("height", 80)
.attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl)
.attr("preserveAspectRatio", "xMidYMid slice");
rect.attr("fill", `url(#image-${d.id})`);
}
} else {
const circle = group
.append("circle")
.attr("r", 25)
.attr("fill", categoryToColor[d.category] || "#fff")
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 3);
if (d.imageUrl) {
const pattern = defs
.append("pattern")
.attr("id", `image-${d.id}`)
.attr("x", 0)
.attr("y", 0)
.attr("width", 1)
.attr("height", 1);
pattern
.append("image")
.attr("x", 0)
.attr("y", 0)
.attr("width", 50)
.attr("height", 50)
.attr("href", d.imageUrl)
.attr("preserveAspectRatio", "xMidYMid slice");
defs
.append("clipPath")
.attr("id", `clip-${d.id}`)
.append("circle")
.attr("r", 25);
circle
.attr("fill", `url(#image-${d.id})`)
.attr("clip-path", `url(#clip-${d.id})`);
}
}
});
const labels = nodeGroup
.append("text")
.text((d) => d.label)
.attr("text-anchor", "middle")
.attr("dy", (d) => (d.isCenter ? 50 : 45))
.attr("font-size", (d) => (d.isCenter ? "14px" : "12px"))
.attr("font-weight", "bold")
.attr("fill", "#F9FAFB")
.attr("stroke", "rgba(17, 24, 39, 0.95)")
.attr("stroke-width", 4)
.attr("paint-order", "stroke");
nodeGroup
.on("mouseenter", function (event, d) {
if (d.isCenter) return;
d3.select(this)
.select(d.isCenter ? "rect" : "circle")
.attr("filter", "url(#glow)")
.transition()
.duration(200)
.attr("stroke", "#3B82F6")
.attr("stroke-width", 4);
})
.on("mouseleave", function (event, d) {
d3.select(this)
.select(d.isCenter ? "rect" : "circle")
.attr("filter", null)
.transition()
.duration(200)
.attr("stroke", "#FFFFFF")
.attr("stroke-width", 3);
});
nodeGroup.on("click", async function (event, d) {
event.stopPropagation();
// جلوگیری از کلیک روی مرکز و دسته‌بندی‌ها
if (d.isCenter || d.stageid === -1) return;
if (onNodeClick && d.stageid) {
// Open dialog immediately with basic info
const basicDetails: CompanyDetails = {
id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: [],
};
onNodeClick(basicDetails);
// Start loading
onLoadingChange?.(true);
try {
const res = await callAPI(d.stageid);
const responseData = JSON.parse(res.data);
const fieldValues =
JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || [];
const filteredFields = fieldValues.filter(
(field: any) =>
!["image", "img", "full_name", "about_collaboration"].includes(
field.F.toLowerCase(),
),
);
const descriptionField = fieldValues.find(
(field: any) =>
field.F.toLowerCase().includes("description") ||
field.F.toLowerCase().includes("about_collaboration") ||
field.F.toLowerCase().includes("about"),
);
const companyDetails: CompanyDetails = {
id: d.id,
label: d.label,
category: d.category,
stageid: d.stageid,
fields: filteredFields,
description: descriptionField?.V || undefined,
};
onNodeClick(companyDetails);
} catch (error) {
console.error("Failed to fetch company details:", error);
// Keep the basic details already shown
} finally {
// Stop loading
onLoadingChange?.(false);
}
}
});
simulation.on("tick", () => {
link.attr("d", (d: any) => {
const sx = (d.source as Node).x!;
const sy = (d.source as Node).y!;
const tx = (d.target as Node).x!;
const ty = (d.target as Node).y!;
const dx = tx - sx;
const dy = ty - sy;
const dr = Math.sqrt(dx * dx + dy * dy) * 1.2; // منحنی
return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;
});
nodeGroup.attr("transform", (d) => `translate(${d.x},${d.y})`);
});
return () => {
simulation.stop();
};
}, [nodes, links, isLoading, isMounted, onNodeClick, callAPI]);
if (error) {
return (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
<div className="text-center p-8 bg-black bg-opacity-50 rounded-lg border border-gray-700">
<div className="text-red-400 text-lg font-persian mb-4">
خطای بارگذاری
</div>
<div className="text-gray-300 font-persian text-sm">{error}</div>
</div>
</div>
);
}
if (!isMounted) {
return (
<div className="w-full h-full flex items-center justify-center bg-transparent">
<div className="text-white font-persian text-sm">
در حال بارگذاری...
</div>
</div>
);
}
if (isLoading) {
return (
<div className="w-full h-full relative bg-transparent">
<div className="w-full h-full flex items-center justify-center relative">
<div className="w-12 h-12 rounded-lg bg-gray-600 animate-pulse relative z-10">
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div>
</div>
{Array.from({ length: 8 }).map((_, i) => {
const angle = (i * 2 * Math.PI) / 8;
const radius = 120;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<div
key={i}
className="absolute w-8 h-8 rounded-full bg-gray-600 animate-pulse"
style={{
left: `calc(50% + ${x}px - 16px)`,
top: `calc(50% + ${y}px - 16px)`,
animationDelay: `${i * 200}ms`,
}}
>
<div className="w-full h-full rounded-full bg-gradient-to-r from-gray-500 to-gray-600 animate-pulse"></div>
<div
className="absolute w-16 h-3 bg-gray-600 rounded animate-pulse"
style={{
transform: `rotate(${(i * 360) / 8}deg) translateX(32px)`,
transformOrigin: "left center",
}}
></div>
</div>
);
})}
</div>
</div>
);
}
return (
<div className="w-full h-full">
<svg
ref={svgRef}
className="w-full h-full bg-transparent"
style={{ cursor: "grab" }}
/>
</div>
);
}
export default NetworkGraph;