Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/15 Co-authored-by: Saeed Abadiyan <sd.eed1381@gmail.com> Co-committed-by: Saeed Abadiyan <sd.eed1381@gmail.com>
593 lines
17 KiB
TypeScript
593 lines
17 KiB
TypeScript
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", "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) 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; |