Setup technology ecosystem page with network graph (#2)

* Add ecosystem page with network graph and company info panel

Co-authored-by: sd.eed1381 <sd.eed1381@gmail.com>

* Add unpaid company highlighting to network graph with toggle

Co-authored-by: sd.eed1381 <sd.eed1381@gmail.com>

* fix id something

* remove the useless files

* update the graph

* update the graph,fix the api ,also add some style and filters

* Refactor process impacts chart to use new CustomBarChart component (#3)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix somestyle , add charts in ecosystem

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Saeed 2025-08-16 16:48:15 +03:30 committed by GitHub
parent 699548c674
commit 40b5ad6e3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1709 additions and 5769 deletions

View File

@ -0,0 +1,169 @@
"use client";
import React, { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Area,
AreaChart,
Bar,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { CustomBarChart } from "~/components/ui/custom-bar-chart";
import apiService from "~/lib/api";
export interface InfoPanelProps {
selectedCompany: { id: string; label?: string } | null;
}
interface EcosystemCounts {
knowledge_based_count: string;
consultant_count: string;
startup_count: string;
innovation_center_count: string;
accelerator_count: string;
university_count: string;
fund_count: string;
company_count: string;
actor_count: string;
mou_count: string;
}
export function InfoPanel({ selectedCompany }: InfoPanelProps) {
const [counts, setCounts] = useState<EcosystemCounts | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchCounts = async () => {
setIsLoading(true);
try {
const res = await apiService.callInnovationProcess<EcosystemCounts>({
ecosystem_counts_function: {},
});
setCounts(res.data);
} catch (err) {
console.error("Failed to fetch ecosystem counts:", err);
} finally {
setIsLoading(false);
}
};
fetchCounts();
}, []);
const title = selectedCompany?.label || "نمای کلی";
const subTitle = selectedCompany ? `شناسه: ${selectedCompany.id}` : "انتخابی انجام نشده است";
// Transform counts into chart-friendly data
const barData =
counts && !selectedCompany
? [
{ name: "دانش بنیان", value: +counts.knowledge_based_count },
{ name: "مشاور", value: +counts.consultant_count },
{ name: "استارتاپ", value: +counts.startup_count },
{ name: "مرکز نوآوری", value: +counts.innovation_center_count },
{ name: "شتابدهنده", value: +counts.accelerator_count },
{ name: "دانشگاه", value: +counts.university_count },
{ name: "صندوق", value: +counts.fund_count },
{ name: "شرکت", value: +counts.company_count },
]
: [];
const lineData = [
{ month: "01", value: 3 },
{ month: "02", value: 6 },
{ month: "03", value: 4 },
{ month: "04", value: 9 },
{ month: "05", value: 7 },
{ month: "06", value: 11 },
];
return (
<div className="space-y-4 min-h-full">
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)]">
<CardHeader className="pb-2">
<CardTitle className="font-persian text-base">{title}</CardTitle>
<div className="text-xs text-gray-400 font-persian">{subTitle}</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-sm text-gray-300 font-persian">در حال بارگذاری...</div>
) : selectedCompany ? (
<div className="text-sm text-gray-300 font-persian">
این یک باکس اطلاعات نمونه است. پس از دریافت API، جزئیات شرکت نمایش داده میشود.
</div>
) : counts ? (
<div className="space-y-4 text-sm text-gray-300 font-persian">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="font-bold">تعداد بازیگران اکوسیستم: </span>
{counts.actor_count}
</div>
<div>
<span className="font-bold">تعداد تفاهم نامه ها: </span>
{counts.mou_count}
</div>
</div>
{/* Grid for categories */}
<div className="grid grid-cols-2 gap-4">
<div>دانش بنیان: {counts.knowledge_based_count}</div>
<div>مشاور: {counts.consultant_count}</div>
<div>استارتاپ: {counts.startup_count}</div>
<div>مرکز نوآوری: {counts.innovation_center_count}</div>
<div>شتابدهنده: {counts.accelerator_count}</div>
<div>دانشگاه: {counts.university_count}</div>
<div>صندوق: {counts.fund_count}</div>
<div>شرکت: {counts.company_count}</div>
</div>
</div>
) : (
<div className="text-sm text-gray-300 font-persian">خطا در بارگذاری دادهها.</div>
)}
</CardContent>
{/* Bar Chart Section */}
<div className="h-56">
<CardHeader className="pb-0">
<CardTitle className="font-persian text-sm">
{selectedCompany ? "نمودار میله‌ای" : "نمودار تعداد بر اساس دسته‌بندی"}
</CardTitle>
</CardHeader>
<CardContent className="h-[180px]">
{barData.length > 0 && (
<CustomBarChart data={barData} dataKey="value" labelKey="name" />
)}
</CardContent>
</div>
{/* Line/Area Chart Section */}
<div className="h-56">
<CardHeader className="pb-0">
<CardTitle className="font-persian text-sm">روند نمونه</CardTitle>
</CardHeader>
<CardContent className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={lineData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="value"
stroke="#34d399"
fill="rgba(52, 211, 153, 0.25)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</div>
</Card>
</div>
);
}
export default InfoPanel;

View File

@ -0,0 +1,296 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import apiService from "../../lib/api";
import Graph from "graphology";
export interface Node {
id: string;
label: string;
category: string;
stageid: number;
}
export interface NetworkGraphProps {
onNodeClick?: (node: { id: string; label?: string; [key: string]: unknown }) => 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 : [];
}
export function NetworkGraph({ onNodeClick}: NetworkGraphProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const sigmaRef = useRef<any>(null);
const graphRef = useRef<any>(null);
const [nodes, setNodes] = useState<Node[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState<string>("all");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
// ------------- Fetch and robust parse ----------------
useEffect(() => {
let aborted = false;
const controller = new AbortController();
(async () => {
setIsLoading(true);
try {
const res = await apiService.callInnovationProcess<any[]>(
{ graph_production_function: {} }
);
if (aborted) return;
// Use robust parser for backend response
const data = parseApiResponse(JSON.parse(res.data)?.graph_production)
setNodes(
data.map((item: any) => ({
id: String(item.stageid),
label: item.title,
category: item.category,
stageid: item.stageid,
}))
);
console.log('Fetched nodes:', data);
} catch (err: any) {
if (err.name === "AbortError") {
// ignore
} else {
console.error("Failed to fetch graph data:", err);
setNodes([]);
}
} finally {
if (!aborted) setIsLoading(false);
}
})();
return () => {
aborted = true;
controller.abort();
};
}, []);
// compute unique categories
const categories = useMemo(() => {
const set = new Set<string>();
nodes.forEach((n) => set.add(n.category));
return ["all", ...Array.from(set)];
}, [nodes]);
// ------------- Build graph + Sigma (client-only) ----------------
useEffect(() => {
// don't run on server or before container available or while loading
if (typeof window === "undefined" || !containerRef.current || isLoading) return;
let renderer: any = null;
let Sigma: any = null;
let isCancelled = false;
(async () => {
try {
// dynamic import for sigma only
const sigmaModule = await import("sigma");
Sigma = sigmaModule.default || sigmaModule.Sigma || sigmaModule;
if (isCancelled || !containerRef.current) return;
const graph = new Graph();
graphRef.current = graph;
// color map (you can extend)
const categoryToColor: Record<string, string> = {
دانشگاه: "#3B82F6",
مشاور: "#10B981",
"دانش بنیان": "#F59E0B",
استارتاپ: "#EF4444",
شرکت: "#8B5CF6",
صندوق: "#06B6D4",
شتابدهنده: "#9333EA",
"مرکز نوآوری": "#F472B6",
center: "#000000",
};
// add central node
const CENTER_ID = "center";
graph.addNode(CENTER_ID, {
label: "مرکز نوآوری اصلی",
x: 0,
y: 0,
size: 20,
category: "center",
color: categoryToColor.center,
});
// add all nodes
nodes.forEach((node, i) => {
// Place nodes in a circle, but all nodes are always present
const len = Math.max(1, nodes.length);
const radius = Math.max(5, Math.min(20, Math.ceil(len / 2)));
const angleStep = (2 * Math.PI) / len;
const angle = i * angleStep;
const jitter = (Math.random() - 0.5) * 0.4;
const x = Math.cos(angle) * (radius + jitter);
const y = Math.sin(angle) * (radius + jitter);
graph.addNode(node.id, {
label: node.label,
x,
y,
size: 8,
category: node.category,
color: categoryToColor[node.category] || "#94A3B8",
payload: node,
});
graph.addEdge(CENTER_ID, node.id, { size: 1, color: "#CBD5E1" });
});
// Highlight nodes by filter
const highlightByCategory = (category: string) => {
graph.forEachNode((n: string, attrs: any) => {
if (category === "all" || attrs.category === category) {
graph.setNodeAttribute(n, "color", categoryToColor[attrs.category] || "#94A3B8");
graph.setNodeAttribute(n, "size", attrs.category === "center" ? 20 : 12);
graph.setNodeAttribute(n, "zIndex", 1);
graph.setNodeAttribute(n, "opacity", 1);
} else {
graph.setNodeAttribute(n, "color", "#888888");
graph.setNodeAttribute(n, "size", 7);
graph.setNodeAttribute(n, "zIndex", 0);
graph.setNodeAttribute(n, "opacity", 0.3);
}
});
};
highlightByCategory(filterCategory);
// Listen for filterCategory changes to re-apply highlight
// (This is needed if filterCategory changes after initial render)
const filterListener = () => highlightByCategory(filterCategory);
// Optionally, you could use a custom event or observer if needed
// For now, we rely on the effect re-running due to filterCategory in deps
// create renderer
renderer = new Sigma(graph, containerRef.current, {
renderLabels: true,
defaultNodeColor: "#94A3B8",
defaultEdgeColor: "#CBD5E1",
labelColor: { color: "#fff" }, // Set label color to white
});
sigmaRef.current = renderer;
// Helper: set highlight states by mutating node attributes
const setHighlight = (nodeId: string | null) => {
graph.forEachNode((n: string, attrs: any) => {
if (nodeId && (n === nodeId || graph.hasEdge(n, nodeId) || graph.hasEdge(nodeId, n))) {
graph.setNodeAttribute(n, "size", attrs.size ? Math.min(24, attrs.size * 1.6) : 12);
graph.setNodeAttribute(n, "color", attrs.color ? attrs.color : "#fff");
graph.setNodeAttribute(n, "highlighted", true);
} else {
graph.setNodeAttribute(n, "size", attrs.size && attrs.category === "center" ? 20 : 8);
// restore original color if we stored it; otherwise keep
graph.setNodeAttribute(n, "color", attrs.category === "center" ? categoryToColor.center : attrs.color);
graph.setNodeAttribute(n, "highlighted", false);
}
});
// ask renderer to refresh (sigma v2 triggers update automatically when graph changes)
};
// events: hover highlight and click select
const onEnter = (e: any) => {
const nodeId = e.node;
setHighlight(nodeId);
};
const onLeave = () => {
setHighlight(selectedNodeId); // keep selected highlighted, or none
};
const onClick = (e: any) => {
const nodeId = e.node as string;
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
// call external callback with payload if exists
const attrs = graph.getNodeAttributes(nodeId);
onNodeClick?.({ id: nodeId, label: attrs?.label, ...(attrs?.payload ?? {}) });
};
renderer.on("enterNode", onEnter);
renderer.on("leaveNode", onLeave);
renderer.on("clickNode", onClick);
// if there is a pre-selected node (state), reflect it
if (selectedNodeId) setHighlight(selectedNodeId);
// cleanup on re-run
return () => {
try {
renderer.removeListener("enterNode", onEnter);
renderer.removeListener("leaveNode", onLeave);
renderer.removeListener("clickNode", onClick);
} catch {}
};
} catch (err) {
console.error("Failed to initialize graph / sigma:", err);
}
})();
return () => {
isCancelled = true;
// kill previous renderer & graph
if (sigmaRef.current) {
try {
sigmaRef.current.kill?.();
} catch {}
}
sigmaRef.current = null;
graphRef.current = null;
if (renderer) {
try {
renderer.kill?.();
} catch {}
}
renderer = null;
};
// rebuild whenever nodes, filterCategory or selectedNodeId changes
}, [nodes, filterCategory, isLoading, onNodeClick, selectedNodeId]);
return (
<div className="relative w-full h-full flex flex-col">
<div className="p-2 flex items-center gap-2">
<label className="text-sm">فیلتر:</label>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="px-2 py-1 border rounded bg-white text-gray-900 dark:bg-gray-800 dark:text-white dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
>
{categories.map((c) => (
<option key={c} value={c}>
{c === "all" ? "همه" : c}
</option>
))}
</select>
<div className="ml-4 text-sm text-gray-600">
{isLoading ? "در حال بارگذاری..." : `نمایش ${filterCategory === "all" ? nodes.length : nodes.filter(n => n.category === filterCategory).length} گره`}
</div>
</div>
<div ref={containerRef} className="flex-1 relative" style={{ minHeight: 360 }} />
{/* overlay selected info */}
<div className="p-2">
{selectedNodeId ? (
<>
<div className="text-sm">انتخاب شده: {selectedNodeId}</div>
<button
onClick={() => setSelectedNodeId(null)}
className="mt-1 px-2 py-1 text-sm border rounded"
>
پاک کردن انتخاب
</button>
</>
) : null}
</div>
</div>
);
}
export default NetworkGraph;

View File

@ -269,19 +269,19 @@ export const theme = {
// Quick color access
colors: {
primary: (shade: keyof typeof themeConfig.colors.primary = '500') =>
primary: (shade: keyof typeof themeConfig.colors.primary = 500) =>
`var(--color-primary-${shade})`,
secondary: (shade: keyof typeof themeConfig.colors.secondary = '500') =>
secondary: (shade: keyof typeof themeConfig.colors.secondary = 500) =>
`var(--color-secondary-${shade})`,
neutral: (shade: keyof typeof themeConfig.colors.neutral = '500') =>
neutral: (shade: keyof typeof themeConfig.colors.neutral = 500) =>
`var(--color-neutral-${shade})`,
success: (shade: keyof typeof themeConfig.colors.success = '500') =>
success: (shade: keyof typeof themeConfig.colors.success = 500) =>
`var(--color-success-${shade})`,
error: (shade: keyof typeof themeConfig.colors.error = '500') =>
error: (shade: keyof typeof themeConfig.colors.error = 500) =>
`var(--color-error-${shade})`,
warning: (shade: keyof typeof themeConfig.colors.warning = '500') =>
warning: (shade: keyof typeof themeConfig.colors.warning = 500) =>
`var(--color-warning-${shade})`,
info: (shade: keyof typeof themeConfig.colors.info = '500') =>
info: (shade: keyof typeof themeConfig.colors.info = 500) =>
`var(--color-info-${shade})`,
},
};

View File

@ -6,6 +6,7 @@ export default [
route("dashboard/project-management", "routes/project-management.tsx"),
route("dashboard/innovation-basket/process-innovation", "routes/innovation-basket.process-innovation.tsx"),
route("projects", "routes/projects.tsx"),
route("dashboard/ecosystem", "routes/ecosystem.tsx"),
route("404", "routes/404.tsx"),
route("unauthorized", "routes/unauthorized.tsx"),
route("*", "routes/$.tsx"), // Catch-all route for 404s

69
app/routes/ecosystem.tsx Normal file
View File

@ -0,0 +1,69 @@
import type { Route } from "./+types/ecosystem";
import React from "react";
import { ProtectedRoute } from "~/components/auth/protected-route";
import { DashboardLayout } from "~/components/dashboard/layout";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { NetworkGraph } from "~/components/ecosystem/network-graph";
import { InfoPanel } from "~/components/ecosystem/info-panel";
import { Checkbox } from "~/components/ui/checkbox";
export function meta({}: Route.MetaArgs) {
return [
{ title: "زیست بوم فناوری" },
{ name: "description", content: "نمایش زیست بوم فناوری با گراف شبکه‌ای شرکت‌ها" },
];
}
export default function EcosystemPage() {
const [selectedCompany, setSelectedCompany] = React.useState<
| { id: string; label?: string; [key: string]: unknown }
| null
>(null);
const [highlightUnpaid, setHighlightUnpaid] = React.useState(false);
const closeDialog = () => setSelectedCompany(null);
return (
<ProtectedRoute requireAuth={true}>
<DashboardLayout title="زیست بوم فناوری">
<div className="p-4 lg:p-6">
<div className="grid grid-cols-1 items-start lg:grid-cols-12 gap-4">
<div className="lg:col-span-4">
{//<InfoPanel selectedCompany={selectedCompany} />
}
</div>
<div className="lg:col-span-8">
<Card className="h-[70vh] lg:h-[calc(100vh-220px)]">
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-4">
<CardTitle className="font-persian text-base">گراف شبکهای شرکتها</CardTitle>
</CardHeader>
<CardContent className="p-0 h-full">
<NetworkGraph onNodeClick={setSelectedCompany} highlightUnpaid={highlightUnpaid} />
</CardContent>
</Card>
</div>
</div>
</div>
{/* Node info dialog */}
<Dialog open={!!selectedCompany} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="font-persian">
<DialogHeader>
<DialogTitle>{selectedCompany?.label || "اطلاعات شرکت"}</DialogTitle>
<DialogDescription>
شناسه: {selectedCompany?.id}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm">
<p>Test</p>
</div>
</DialogContent>
</Dialog>
</DashboardLayout>
</ProtectedRoute>
);
}

5746
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,14 +17,18 @@
"@radix-ui/react-slot": "^1.0.2",
"@react-router/node": "^7.7.0",
"@react-router/serve": "^7.7.1",
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"graphology": "^0.26.0",
"isbot": "^5.1.27",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2",
"react-router": "^7.7.0",
"recharts": "^3.1.2",
"sigma": "^3.0.2",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff