Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/3 Co-authored-by: Saeed Abadiyan <sd.eed1381@gmail.com> Co-committed-by: Saeed Abadiyan <sd.eed1381@gmail.com>
587 lines
17 KiB
TypeScript
587 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";
|
||
|
||
// 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<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();
|
||
|
||
// 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<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] || {}),
|
||
);
|
||
|
||
// 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<any>({
|
||
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<SVGSVGElement, unknown>()
|
||
.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<string, string> = {
|
||
دانشگاه: "#3B82F6",
|
||
مشاور: "#10B981",
|
||
"دانش بنیان": "#F59E0B",
|
||
استارتاپ: "#EF4444",
|
||
شرکت: "#8B5CF6",
|
||
صندوق: "#06B6D4",
|
||
شتابدهنده: "#9333EA",
|
||
"مرکز نوآوری": "#F472B6",
|
||
center: "#34D399",
|
||
};
|
||
|
||
// Create force simulation
|
||
const simulation = d3
|
||
.forceSimulation<Node>(nodes)
|
||
.force(
|
||
"link",
|
||
d3
|
||
.forceLink<Node, Link>(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<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);
|
||
|
||
// 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,
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// Don't render on server side
|
||
if (!isMounted) {
|
||
return (
|
||
<div className="w-full h-full flex items-center justify-center bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
||
<div className="text-white font-persian text-sm">
|
||
در حال بارگذاری...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="w-full h-full relative bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
|
||
{/* Skeleton Graph Container */}
|
||
<div className="w-full h-full flex items-center justify-center relative">
|
||
{/* Center Node Skeleton */}
|
||
<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>
|
||
|
||
{/* 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 (
|
||
<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={{
|
||
left: "50%",
|
||
top: "40px",
|
||
transform: "translateX(-50%)",
|
||
animationDelay: `${i * 200 + 100}ms`,
|
||
}}
|
||
></div>
|
||
<div
|
||
className="absolute w-0.5 bg-gray-600 animate-pulse opacity-30"
|
||
style={{
|
||
left: "50%",
|
||
top: "50%",
|
||
height: `${radius - 16}px`,
|
||
transformOrigin: "top",
|
||
transform: `translateX(-50%) rotate(${angle + Math.PI}rad)`,
|
||
animationDelay: `${i * 100}ms`,
|
||
}}
|
||
></div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
|
||
<div className="text-white font-persian text-sm animate-pulse">
|
||
در حال بارگذاری نمودار...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="w-full h-full relative bg-[linear-gradient(to_bottom_left,#464861,10%,#111628)] overflow-hidden">
|
||
<svg ref={svgRef} className="w-full h-full" style={{ minHeight: 500 }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default NetworkGraph;
|