inogen/app/components/ecosystem/network-graph.tsx

589 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";
// 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,
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 (
<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;