import React, { useEffect, useRef, useState, useCallback } from "react"; import * as d3 from "d3"; import apiService from "../../lib/api"; import { useAuth } from "../../contexts/auth-context"; // Get API base URL at module level to avoid process.env access in browser 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; } // 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 : []; } // Check if we're in browser environment function isBrowser(): boolean { return typeof window !== "undefined"; } export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { const svgRef = useRef(null); const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isMounted, setIsMounted] = useState(false); const [error, setError] = useState(null); const { token } = useAuth(); // Ensure component only renders on client side useEffect(() => { if (isBrowser()) { const timer = setTimeout(() => setIsMounted(true), 100); return () => clearTimeout(timer); } }, []); // Fetch data from API useEffect(() => { if (!isMounted) return; let aborted = false; const controller = new AbortController(); (async () => { setIsLoading(true); try { const res = await apiService.call({ 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] || {}), ); // Create center node const centerNode: Node = { id: "center", label: "پتروشیمی بندر امام", //مرکز زیست بوم category: "center", stageid: 0, isCenter: true, }; // Create ecosystem nodes const ecosystemNodes: Node[] = data.map((item: any) => ({ id: String(item.stageid), label: item.title, category: item.category, stageid: item.stageid, imageUrl: getImageUrl(item.stageid), rawData: item, })); // Create links (all nodes connected to center) const graphLinks: Link[] = ecosystemNodes.map((node) => ({ source: "center", target: node.id, })); setNodes([centerNode, ...ecosystemNodes]); 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]); // Get image URL for a node const getImageUrl = useCallback( (stageid: number) => { if (!token?.accessToken) return null; return `${API_BASE_URL}/getimage?stageID=${stageid}&nameOrID=image&token=${token.accessToken}`; }, [token?.accessToken], ); // Import apiService for the onClick handler const callAPI = useCallback(async (stage_id: number) => { return await apiService.call({ get_values_workflow_function: { stage_id: stage_id, }, }); }, []); // Initialize D3 graph 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; // Clear previous content svg.selectAll("*").remove(); // Create defs for patterns and filters const defs = svg.append("defs"); // Add glow filter for hover effect 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"); // Create zoom behavior const zoom = d3 .zoom() .scaleExtent([0.8, 2.5]) // Limit zoom out to 1x, zoom in to 2.5x .on("zoom", (event) => { container.attr("transform", event.transform); }); svg.call(zoom); // Create container group const container = svg.append("g"); // Category colors const categoryToColor: Record = { دانشگاه: "#3B82F6", مشاور: "#10B981", "دانش بنیان": "#F59E0B", استارتاپ: "#EF4444", شرکت: "#8B5CF6", صندوق: "#06B6D4", شتابدهنده: "#9333EA", "مرکز نوآوری": "#F472B6", center: "#34D399", }; // Create force simulation const simulation = d3 .forceSimulation(nodes) .force( "link", d3 .forceLink(links) .id((d) => d.id) .distance(150) .strength(0.1), ) .force("charge", d3.forceManyBody().strength(-300)) .force("center", d3.forceCenter(width / 2, height / 2)) .force( "collision", d3.forceCollide().radius((d) => (d.isCenter ? 40 : 30)), ); const initialScale = 0.85; 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 position const centerNode = nodes.find((n) => n.isCenter); if (centerNode) { centerNode.fx = width / 2; centerNode.fy = height / 2; } // Create links const link = container .selectAll(".link") .data(links) .enter() .append("line") .attr("class", "link") .attr("stroke", "#E2E8F0") .attr("stroke-width", 2) .attr("stroke-opacity", 0.6); // Create node groups const nodeGroup = container .selectAll(".node") .data(nodes) .enter() .append("g") .attr("class", "node") .style("cursor", "pointer"); // Add drag behavior const drag = d3 .drag() .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); // Add node circles/rectangles nodeGroup.each(function (d) { const group = d3.select(this); if (d.isCenter) { // Center node as rectangle const rect = group .append("rect") .attr("width", 150) .attr("height", 60) .attr("x", -75) .attr("y", -30) .attr("rx", 8) .attr("ry", 8) .attr("fill", categoryToColor[d.category] || "#94A3B8") .attr("stroke", "#FFFFFF") .attr("stroke-width", 3) .style("pointer-events", "none"); // Add center image if available 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", 150) .attr("height", 60) .attr("href", d.isCenter ? "/main-circle.png" : d.imageUrl) .attr("preserveAspectRatio", "xMidYMid slice"); rect.attr("fill", `url(#image-${d.id})`); } } else { // Regular nodes as circles const circle = group .append("circle") .attr("r", 25) .attr("fill", categoryToColor[d.category] || "8#fff") .attr("stroke", "#FFFFFF") .attr("stroke-width", 3); // Add node image if available 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("backgroundColor", "#fff") .attr("preserveAspectRatio", "xMidYMid slice"); // Create circular clip path 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})`); } } }); // Add labels below nodes 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"); // Add hover effects 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); }); // Add click handlers nodeGroup.on("click", async function (event, d) { event.stopPropagation(); // Don't handle center node clicks if (d.isCenter) return; if (onNodeClick && d.stageid) { try { // Fetch detailed company data const res = await callAPI(d.stageid); const responseData = JSON.parse(res.data); const fieldValues = JSON.parse(responseData?.getvalue)?.[0]?.FieldValues || []; // Filter out image fields and find description 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); // Fallback to basic info const basicDetails: CompanyDetails = { id: d.id, label: d.label, category: d.category, stageid: d.stageid, fields: [], }; onNodeClick(basicDetails); } } }); // Update positions on simulation tick simulation.on("tick", () => { link .attr("x1", (d) => (d.source as Node).x!) .attr("y1", (d) => (d.source as Node).y!) .attr("x2", (d) => (d.target as Node).x!) .attr("y2", (d) => (d.target as Node).y!); nodeGroup.attr("transform", (d) => `translate(${d.x},${d.y})`); }); // Cleanup function return () => { simulation.stop(); }; }, [nodes, links, isLoading, isMounted, onNodeClick, callAPI]); // Show error message if (error) { return (
⚠️ خطای بارگذاری
{error}
); } // Don't render on server side if (!isMounted) { return (
در حال بارگذاری...
); } if (isLoading) { return (
{/* Skeleton Graph Container */}
{/* Center Node Skeleton */}
{/* Outer Ring Nodes Skeleton */} {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 (
); })}
در حال بارگذاری نمودار...
); } return (
); } export default NetworkGraph;