From b4b023ec324da1df964caddbb5701cc8ca7e8261 Mon Sep 17 00:00:00 2001 From: mahmoodsht <106068383+mahmoodsht@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:41:00 +0330 Subject: [PATCH] =?UTF-8?q?=D8=AC=D8=B2=DB=8C=DB=8C=D8=A7=D8=AA=20=DA=AF?= =?UTF-8?q?=D8=B1=D8=A7=D9=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/ecosystem/network-graph.tsx | 249 ++++++++++----------- 1 file changed, 122 insertions(+), 127 deletions(-) diff --git a/app/components/ecosystem/network-graph.tsx b/app/components/ecosystem/network-graph.tsx index 9c05b67..ac04955 100644 --- a/app/components/ecosystem/network-graph.tsx +++ b/app/components/ecosystem/network-graph.tsx @@ -3,7 +3,6 @@ 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"; @@ -46,7 +45,6 @@ export interface NetworkGraphProps { onNodeClick?: (node: CompanyDetails) => void; } -// Helper to robustly parse backend response function parseApiResponse(raw: any): any[] { let data = raw; try { @@ -56,7 +54,6 @@ function parseApiResponse(raw: any): any[] { return Array.isArray(data) ? data : []; } -// Check if we're in browser environment function isBrowser(): boolean { return typeof window !== "undefined"; } @@ -70,7 +67,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { const [error, setError] = useState(null); const { token } = useAuth(); - // Ensure component only renders on client side useEffect(() => { if (isBrowser()) { const timer = setTimeout(() => setIsMounted(true), 100); @@ -78,7 +74,22 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { } }, []); - // Fetch data from API + 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({ + get_values_workflow_function: { + stage_id: stage_id, + }, + }); + }, []); + useEffect(() => { if (!isMounted) return; @@ -99,18 +110,28 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { Object.keys(data[0] || {}), ); - // Create center node + // نود مرکزی const centerNode: Node = { id: "center", - label: "پتروشیمی بندر امام", //مرکز زیست بوم + label: "پتروشیمی بندر امام", category: "center", stageid: 0, isCenter: true, }; - // Create ecosystem nodes - const ecosystemNodes: Node[] = data.map((item: any) => ({ - id: String(item.stageid), + // دسته‌بندی‌ها + 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, @@ -118,13 +139,16 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { rawData: item, })); - // Create links (all nodes connected to center) - const graphLinks: Link[] = ecosystemNodes.map((node) => ({ - source: "center", - target: node.id, - })); + // لینک‌ها: مرکز → دسته‌بندی‌ها → نودهای نهایی + 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, ...ecosystemNodes]); + setNodes([centerNode, ...categoryNodes, ...finalNodes]); setLinks(graphLinks); } catch (err: any) { if (err.name !== "AbortError") { @@ -142,43 +166,18 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { aborted = true; controller.abort(); }; - }, [isMounted, token]); + }, [isMounted, token, getImageUrl]); - // 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; - } + 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") @@ -196,20 +195,15 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { feMerge.append("feMergeNode").attr("in", "coloredBlur"); feMerge.append("feMergeNode").attr("in", "SourceGraphic"); - // Create zoom behavior + const container = svg.append("g"); + 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); - }); + .scaleExtent([0.3, 2.5]) + .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", @@ -222,7 +216,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { center: "#34D399", }; - // Create force simulation const simulation = d3 .forceSimulation(nodes) .force( @@ -231,16 +224,15 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .forceLink(links) .id((d) => d.id) .distance(150) - .strength(0.1), + .strength(0.2), ) .force("charge", d3.forceManyBody().strength(-300)) .force("center", d3.forceCenter(width / 2, height / 2)) - .force( - "collision", - d3.forceCollide().radius((d) => (d.isCenter ? 40 : 30)), - ); + .force("radial", d3.forceRadial(d => d.isCenter ? 0 : 300, width/2, height/2)) + .force("collision", d3.forceCollide().radius((d) => (d.isCenter ? 50 : 35))); - const initialScale = 0.85; + // Initial zoom to show entire graph + const initialScale = 0.6; const initialTranslate = [ width / 2 - (width / 2) * initialScale, height / 2 - (height / 2) * initialScale, @@ -252,25 +244,60 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .scale(initialScale), ); - // Fix center node position - const centerNode = nodes.find((n) => n.isCenter); + // Fix center node + const centerNode = nodes.find(n => n.isCenter); + const categoryNodes = nodes.filter(n => !n.isCenter && n.stageid === -1); + if (centerNode) { - centerNode.fx = width / 2; - centerNode.fy = height / 2; + 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); - // Create links +// 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("line") + .append("path") .attr("class", "link") .attr("stroke", "#E2E8F0") .attr("stroke-width", 2) - .attr("stroke-opacity", 0.6); + .attr("stroke-opacity", 0.6) + .attr("fill", "none"); - // Create node groups const nodeGroup = container .selectAll(".node") .data(nodes) @@ -279,7 +306,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .attr("class", "node") .style("cursor", "pointer"); - // Add drag behavior const drag = d3 .drag() .on("start", (event, d) => { @@ -301,18 +327,16 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { 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("width", 200) + .attr("height", 80) + .attr("x", -100) // نصف عرض جدید منفی + .attr("y", -40) // نصف ارتفاع جدید منفی .attr("rx", 8) .attr("ry", 8) .attr("fill", categoryToColor[d.category] || "#94A3B8") @@ -320,7 +344,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .attr("stroke-width", 3) .style("pointer-events", "none"); - // Add center image if available if (d.imageUrl || d.isCenter) { const pattern = defs .append("pattern") @@ -334,23 +357,21 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .append("image") .attr("x", 0) .attr("y", 0) - .attr("width", 150) - .attr("height", 60) + .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 { - // Regular nodes as circles const circle = group .append("circle") .attr("r", 25) - .attr("fill", categoryToColor[d.category] || "8#fff") + .attr("fill", categoryToColor[d.category] || "#fff") .attr("stroke", "#FFFFFF") .attr("stroke-width", 3); - // Add node image if available if (d.imageUrl) { const pattern = defs .append("pattern") @@ -367,10 +388,8 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .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}`) @@ -384,7 +403,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { } }); - // Add labels below nodes const labels = nodeGroup .append("text") .text((d) => d.label) @@ -397,7 +415,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .attr("stroke-width", 4) .attr("paint-order", "stroke"); - // Add hover effects nodeGroup .on("mouseenter", function (event, d) { if (d.isCenter) return; @@ -419,22 +436,17 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { .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( @@ -461,7 +473,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { 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, @@ -474,24 +485,26 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { } }); - // 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!); + 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})`); }); - // Cleanup function return () => { simulation.stop(); }; }, [nodes, links, isLoading, isMounted, onNodeClick, callAPI]); - // Show error message if (error) { return (
@@ -505,7 +518,6 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { ); } - // Don't render on server side if (!isMounted) { return (
@@ -519,14 +531,11 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { 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; @@ -547,42 +556,28 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) {
-
); })}
- -
-
- در حال بارگذاری نمودار... -
-
); } return ( -
- +
+
); } -export default NetworkGraph; + +export default NetworkGraph; \ No newline at end of file