diff --git a/CLAUDE_MEMORY.md b/CLAUDE_MEMORY.md new file mode 100644 index 0000000..db89fdd --- /dev/null +++ b/CLAUDE_MEMORY.md @@ -0,0 +1,172 @@ +# Claude Memory - Community Graph 项目记忆文件 + +## 项目概览 +这是一个社区网络图谱可视化分析平台,基于Next.js 15 + React 19 + TypeScript构建,使用D3.js进行数据可视化,集成了AI驱动的社区分析功能。 + +## 核心技术架构 + +### 前端技术栈 +- **Next.js 15.1.4** - 全栈React框架,使用App Router +- **React 19.1.1** - 现代React UI库 +- **TypeScript** - 类型安全的JavaScript +- **D3.js 7.9.0** - 数据可视化和图形渲染 +- **Tailwind CSS 3.4.1** - 样式框架 + +### 关键依赖 +- @heroicons/react 2.2.0 - 图标库 +- d3 7.9.0 - 数据可视化核心 +- next 15.1.4 - 框架核心 +- react 19.1.1 - UI库 +- tailwindcss 3.4.1 - CSS框架 + +## 项目结构详解 + +### 主要目录结构 +``` +/app # Next.js App Router根目录 +├── api/chat/route.ts # AI聊天API端点 - 核心分析功能 +├── components/ # React组件目录(全部TypeScript化) +│ ├── CommunityGraph.tsx # 主要D3.js图形组件 - 最重要的可视化组件 +│ ├── AIChat.tsx # AI聊天界面组件 +│ └── Sidebar.tsx # 数据上传侧边栏 +├── types/ # TypeScript类型定义目录 +│ └── index.ts # 核心数据类型接口 +├── utils/ # TypeScript工具函数库 +│ ├── graph.ts # D3图形工具函数 +│ ├── csvParser.ts # CSV数据解析逻辑 +│ └── timeline.ts # 时间线可视化工具 +├── layout.tsx # 根布局组件 +├── page.tsx # 主应用入口 - 三面板布局管理 +└── globals.css # 全局样式定义 + +/public # 静态资源目录 +├── data2.json # 样本图形数据 +├── graph_data.json # 图形可视化数据 +└── mock_data1.json # 额外样本数据 + +/python/ # Python数据处理脚本 +├── graph_model.py # 图形数据建模逻辑 +├── mock_data.py # 模拟数据生成 +└── mock_community_events.csv # 样本CSV数据文件 +``` + +## 核心功能模块 + +### 1. 图形可视化系统 (CommunityGraph.tsx) +- **完全TypeScript化** - 重构为.tsx文件,提供完整类型安全 +- **D3.js力导向图** - 实现节点和边的动态布局 +- **节点类型系统** - member(成员)、event(活动)、space(场地)三种类型 +- **关系类型** - initiates(发起)、participates(参与)、hosts(主办) +- **交互功能** - 节点点击、拖拽、高亮、缩放 +- **时间线集成** - 支持时间过滤和动态更新 + +### 2. AI分析系统 (app/api/chat/route.ts) +- **多AI提供商** - 支持DeepSeek和Gemini API +- **流式响应** - 使用Server-Sent Events实现实时对话 +- **上下文感知** - 基于当前图形数据进行分析 +- **分析能力** - 社区健康度、成员参与度、关键人物识别等 + +### 3. 数据处理系统 +- **CSV解析器** (csvParser.ts) - 处理复杂的社区活动数据 +- **数据结构转换** - CSV到图形数据的转换逻辑 +- **错误处理** - 详细的解析错误反馈 + +## 关键数据结构 + +### TypeScript接口定义 (types/index.ts) +```typescript +interface Node { + id: string; // 唯一标识符 + type: 'member' | 'event' | 'space'; // 节点类型 + name: string; // 显示名称 + time: string | null; // 时间信息(仅活动节点) +} + +interface Edge { + source: string; // 源节点ID + target: string; // 目标节点ID + relationship: 'initiates' | 'participates' | 'hosts'; // 关系类型 + value: number; // 关系权重 +} + +interface GraphData { + nodes: Node[]; // 节点数组 + edges: Edge[]; // 边数组 +} +``` + +## 开发要点 + +### 环境配置 +- 需要配置AI API密钥在.env.local文件中 +- DEEPSEEK_API_KEY和GEMINI_API_KEY +- Node.js 18+环境 + +### 开发命令 +- `npm run dev` - 启动开发服务器 +- `npm run build` - 构建生产版本 +- `npm run lint` - 代码检查 + +### 主要开发注意事项 + +1. **D3.js集成** - CommunityGraph.tsx是核心组件,处理所有图形渲染逻辑(已TypeScript化) +2. **状态管理** - 主要状态在page.tsx中管理,通过props传递给子组件 +3. **AI API集成** - 需要处理不同AI提供商的API格式差异 +4. **数据处理** - CSV解析需要处理多值字段(如多个发起人用分号分隔) +5. **TypeScript类型** - 严格遵循types/index.ts中定义的接口,全项目类型安全 +6. **重构完成** - 所有JavaScript文件已成功重构为TypeScript,提供更好的开发体验 + +### 扩展指南 + +#### 添加新的节点类型 +1. 更新types/index.ts中的Node接口 +2. 在CommunityGraph.tsx中添加对应的视觉样式 +3. 更新CSV解析逻辑以支持新类型 + +#### 添加新的AI提供商 +1. 在app/api/chat/route.ts中添加新的provider配置 +2. 实现对应的API调用函数 +3. 更新AIChat.tsx中的provider选择器 + +#### 自定义图形样式 +主要修改CommunityGraph.tsx中的D3配置: +- 节点颜色、大小、形状 +- 边的样式、宽度、颜色 +- 力导向图参数 + +## 常见问题解决 + +### 性能优化 +- 大数据集时考虑使用虚拟化 +- D3.js计算密集操作使用Web Workers +- 图形更新时使用requestAnimationFrame + +### 数据一致性 +- CSV解析时验证数据格式 +- 节点ID生成确保唯一性 +- 时间格式标准化处理 + +### 调试技巧 +- 使用React DevTools查看组件状态 +- D3.js调试使用console.log输出中间数据 +- AI API调用检查网络请求和响应 + +## 测试数据 +- public/data2.json - 基础测试数据 +- public/graph_data.json - 完整图形数据 +- python/mock_community_events.csv - CSV格式测试数据 + +## 未来扩展方向 +1. 实时数据更新支持 +2. 更多网络分析算法集成 +3. 图形导出功能(PNG/SVG) +4. 更丰富的AI分析功能 +5. 移动端适配优化 +6. 数据持久化存储 + +## 文件修改提醒 +- 修改CommunityGraph.tsx时需要小心D3.js的DOM操作逻辑(已TypeScript化) +- API路由修改需要保持向后兼容性 +- TypeScript类型定义更新需要同步修改相关实现 +- CSS样式修改需要注意响应式设计 +- **重构完成状态** - 项目已完全迁移到TypeScript,所有新开发应使用TypeScript \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..65ea400 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,210 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Community Graph - 社区网络图谱可视化分析平台 -## Getting Started +一个基于Next.js和D3.js的智能社区关系网络可视化分析平台,集成了AI驱动的社区分析功能,帮助社区组织者理解成员参与模式和优化社区策略。 -First, run the development server: +## 🌟 核心功能 +### 🔗 交互式网络可视化 +- **多层级关系展示** - 成员、活动、场地之间的关系网络 +- **动态节点类型** - 不同类型节点使用不同的视觉符号 +- **实时交互** - 节点选择、高亮、拖拽等交互操作 +- **时间线集成** - 支持基于时间的网络演化分析 + +### 🤖 AI智能分析 +- **多AI提供商支持** - 集成DeepSeek和Gemini API +- **实时流式响应** - 基于SSE的实时AI分析对话 +- **上下文感知** - 基于上传的图谱数据进行智能分析 +- **社区指标分析**: + - 成员参与度追踪 + - 活动分布分析 + - 关键意见领袖识别 + - 网络健康度评估 + +### 📊 数据处理能力 +- **CSV批量导入** - 支持社区活动数据的批量处理 +- **智能数据解析** - 自动处理多发起人、多参与者的复杂活动数据 +- **错误处理** - 详细的数据解析反馈和错误提示 +- **样本数据** - 提供测试和演示用的样本数据下载 + +### 📈 高级图谱分析 +- **网络指标计算** - 中心性、参与率等关键指标 +- **下游节点追踪** - 从发起人追踪影响链 +- **时间约束过滤** - 支持基于时间的条件筛选 +- **响应式设计** - 自动适应不同屏幕尺寸 + +## 🛠 技术栈 + +### 前端框架 +- **Next.js 15.1.4** - React全栈框架,使用App Router +- **React 19.1.1** - 现代React UI库 +- **TypeScript** - 类型安全的JavaScript + +### 可视化与UI +- **D3.js 7.9.0** - 数据可视化和图形渲染 +- **Tailwind CSS 3.4.1** - 实用优先的CSS框架 +- **Heroicons** - 现代图标库 + +### 开发工具 +- **ESLint 9** - 代码质量检查 +- **PostCSS** - CSS处理 +- **Turbopack** - 快速打包工具 + +## 📁 项目结构 + +``` +├── app/ # Next.js App Router目录 +│ ├── api/chat/route.ts # AI聊天API端点 +│ ├── components/ # React组件 +│ │ ├── CommunityGraph.tsx # 主要的D3.js图形组件 +│ │ ├── AIChat.tsx # AI聊天界面 +│ │ └── Sidebar.tsx # 数据上传侧边栏 +│ ├── types/ # TypeScript类型定义 +│ │ └── index.ts # 核心数据类型接口 +│ ├── utils/ # TypeScript工具函数 +│ │ ├── graph.ts # D3图形工具 +│ │ ├── csvParser.ts # CSV数据解析 +│ │ └── timeline.ts # 时间线可视化 +│ ├── layout.tsx # 根布局 +│ ├── page.tsx # 主页面 +│ └── globals.css # 全局样式 +├── public/ # 静态资源和数据 +│ ├── data2.json # 样本图形数据 +│ ├── graph_data.json # 图形可视化数据 +│ └── mock_data1.json # 额外样本数据 +├── python/ # Python数据处理脚本 +│ ├── graph_model.py # 图形数据建模 +│ ├── mock_data.py # 模拟数据生成 +│ └── mock_community_events.csv # 样本CSV数据 +├── package.json # 依赖和脚本 +├── tsconfig.json # TypeScript配置 +├── next.config.ts # Next.js配置 +└── tailwind.config.ts # Tailwind CSS配置 +``` + +## 🚀 快速开始 + +### 环境要求 +- Node.js 18+ +- npm 或 yarn + +### 安装依赖 +```bash +npm install +``` + +### 配置环境变量 +创建 `.env.local` 文件并配置AI API密钥: +```env +DEEPSEEK_API_KEY=your_deepseek_api_key +GEMINI_API_KEY=your_gemini_api_key +``` + +### 启动开发服务器 ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。 + +## 📖 使用指南 + +### 1. 数据导入 +- 支持CSV格式的社区活动数据 +- 点击侧边栏的"上传CSV"按钮导入数据 +- 可以下载样本数据进行测试 + +### 2. 图形交互 +- 点击节点查看详细信息 +- 拖拽节点调整布局 +- 使用时间线筛选特定时间段的数据 + +### 3. AI分析 +- 在AI聊天面板中询问关于社区的问题 +- AI会基于当前的图形数据提供分析建议 +- 支持参与度分析、活跃成员识别等查询 + +## 🎯 数据格式 + +### CSV数据格式 +```csv +event_name,initiators,participants,topics,venue,start_time +社区分享会,张三;李四,王五;赵六;钱七,技术分享,咖啡厅,2024-01-15 14:00 +``` + +### 图形数据结构 +```typescript +interface Node { + id: string; + type: 'member' | 'event' | 'space'; + name: string; + time: string | null; +} + +interface Edge { + source: string; + target: string; + relationship: 'initiates' | 'participates' | 'hosts'; + value: number; +} +``` + +## 🔧 开发指南 + +### 主要组件 +- `app/page.tsx` - 主应用入口,管理三面板布局 +- `app/components/CommunityGraph.tsx` - D3.js图形渲染组件(TypeScript重构) +- `app/api/chat/route.ts` - AI分析API端点 + +### 添加新的AI提供商 +1. 在 `app/api/chat/route.ts` 中添加新的provider配置 +2. 实现对应的API调用逻辑 +3. 更新前端provider选择器 + +### 自定义图形样式 +修改 `app/utils/graph.ts` 中的D3配置来自定义节点和边的样式。 + +### TypeScript开发优势 +- **类型安全** - 所有组件和函数都有严格的类型定义 +- **更好的IDE支持** - 自动补全和错误检查 +- **易于维护** - 清晰的接口定义提高代码可读性 +- **重构友好** - 类型系统帮助安全地进行代码重构 + +## 📚 API文档 + +### AI聊天API +- **端点**: `/api/chat` +- **方法**: POST +- **请求体**: +```json +{ + "messages": [{"role": "user", "content": "分析社区参与度"}], + "provider": "deepseek" | "gemini", + "graphData": {...} +} +``` -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 🤝 贡献指南 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +1. Fork 项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request -## Learn More +## 📄 许可证 -To learn more about Next.js, take a look at the following resources: +本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## 🙋‍♂️ 适用场景 -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +这个项目特别适合: -## Deploy on Vercel +- **社区运营者** - 理解社区成员参与模式 +- **活动组织者** - 分析活动效果和参与度 +- **社交网络分析师** - 研究社区网络结构 +- **产品经理** - 了解用户社区的健康状况 +- **研究人员** - 社交网络和数据可视化研究 -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## 🐛 问题反馈 -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +如果您遇到问题或有建议,请在 [GitHub Issues](https://github.com/your-repo/community-graph/issues) 中提出。 diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e2a531f..7b139d6 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import type { GraphData, Node, Edge } from '../../types/graph'; +import type { GraphData, GraphNode, GraphLink } from '@/types'; // 定义类型接口 interface MemberParticipation { @@ -45,16 +45,16 @@ export async function POST(request: NextRequest) { const edges = graphData.edges; // 统计信息 - const memberNodes = nodes.filter((n: Node) => n.type === 'member'); - const eventNodes = nodes.filter((n: Node) => n.type === 'event'); - const spaceNodes = nodes.filter((n: Node) => n.type === 'space'); + const memberNodes = nodes.filter((n: GraphNode) => n.type === 'member'); + const eventNodes = nodes.filter((n: GraphNode) => n.type === 'event'); + const spaceNodes = nodes.filter((n: GraphNode) => n.type === 'space'); // 活动分析 - const eventsBySpace = eventNodes.reduce((acc: Record, event: Node) => { - const spaceEdge = edges.find((e: Edge) => e.target === event.id && e.relationship === 'hosts'); + const eventsBySpace = eventNodes.reduce((acc: Record, event: GraphNode) => { + const spaceEdge = edges.find((e: GraphLink) => e.target === event.id && e.type === 'hosts'); if (spaceEdge) { - const space = nodes.find((n: Node) => n.id === spaceEdge.source); - if (space) { + const space = nodes.find((n: GraphNode) => n.id === spaceEdge.source); + if (space && space.name) { acc[space.name] = (acc[space.name] || 0) + 1; } } @@ -62,21 +62,21 @@ export async function POST(request: NextRequest) { }, {}); // 成员参与度分析 - const memberParticipation: MemberParticipation[] = memberNodes.map((member: Node) => { - const participateEdges = edges.filter((e: Edge) => - (e.source === member.id && e.relationship === 'initiates') || - (e.target === member.id && e.relationship === 'participates') + const memberParticipation: MemberParticipation[] = memberNodes.map((member: GraphNode) => { + const participateEdges = edges.filter((e: GraphLink) => + (e.source === member.id && e.type === 'initiates') || + (e.target === member.id && e.type === 'participates') ); return { - name: member.name, + name: member.name || '未知', participation: participateEdges.length }; }).sort((a: MemberParticipation, b: MemberParticipation) => b.participation - a.participation); // 时间分布分析 const eventTimes = eventNodes - .filter((e: Node) => e.time) - .map((e: Node) => new Date(e.time!)) + .filter((e: GraphNode) => e.time) + .map((e: GraphNode) => new Date(e.time!)) .sort((a: Date, b: Date) => a.getTime() - b.getTime()); detailedContext = ` @@ -88,9 +88,9 @@ export async function POST(request: NextRequest) { - 时间范围: ${eventTimes.length > 0 ? `${eventTimes[0].toLocaleDateString()} 至 ${eventTimes[eventTimes.length-1].toLocaleDateString()}` : '未知'} 具体数据样本: -成员: ${memberNodes.slice(0, 5).map((n: Node) => n.name).join(', ')}${memberNodes.length > 5 ? '等' : ''} -活动: ${eventNodes.slice(0, 3).map((n: Node) => n.name).join(', ')}${eventNodes.length > 3 ? '等' : ''} -场地: ${spaceNodes.map((n: Node) => n.name).join(', ')}`; +成员: ${memberNodes.slice(0, 5).map((n: GraphNode) => n.name || '未知').join(', ')}${memberNodes.length > 5 ? '等' : ''} +活动: ${eventNodes.slice(0, 3).map((n: GraphNode) => n.name || '未知').join(', ')}${eventNodes.length > 3 ? '等' : ''} +场地: ${spaceNodes.map((n: GraphNode) => n.name || '未知').join(', ')}`; } else { detailedContext = '当前没有上传图数据。请用户先上传CSV文件来分析社区网络。'; } diff --git a/app/components/AIChat.tsx b/app/components/AIChat.tsx index e3d661c..e55b82f 100644 --- a/app/components/AIChat.tsx +++ b/app/components/AIChat.tsx @@ -6,7 +6,7 @@ import { XMarkIcon, PaperAirplaneIcon } from '@heroicons/react/24/outline'; -import type { GraphData } from '../types/graph'; +import type { GraphData } from '@/types'; interface Message { id: string; diff --git a/app/components/CommunityGraph2.js b/app/components/CommunityGraph.tsx similarity index 57% rename from app/components/CommunityGraph2.js rename to app/components/CommunityGraph.tsx index 3640d83..bf6a050 100644 --- a/app/components/CommunityGraph2.js +++ b/app/components/CommunityGraph.tsx @@ -1,29 +1,37 @@ -import { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import * as d3 from 'd3'; -import { - symbolTypes, - findDownstreamNodes, - calculateLinkDistance, - createArrowMarkers, +import { + symbolTypes, + findDownstreamNodes, + calculateLinkDistance, + createArrowMarkers, createTooltip, - getNodeLabel -} from '../utils/graphUtils'; -import { - createTimeScale, - createTimeAxis, - createTimeGrid, - addTimeConstraints -} from '../utils/timelineUtils'; - -export default function CommunityGraph({ width = 800, height = 600, data: externalData }) { - const containerRef = useRef(null); - const svgRef = useRef(null); - const simulationRef = useRef(null); - const elementsRef = useRef({}); // 存储 D3 选择集引用 - - const [data, setData] = useState(null); - const [size, setSize] = useState({ width, height }); - const [selectedNode, setSelectedNode] = useState(null); + getNodeLabel +} from '../utils/graph'; +import { + createTimeScale, + createTimeAxis, + createTimeGrid, + addTimeConstraints +} from '../utils/timeline'; +import { + CommunityGraphProps, + GraphData, + SimulationNode, + SimulationLink, + Size, + D3SelectionRefs +} from '@/types'; + +export default function CommunityGraph({ width = 800, height = 600, data: externalData }: CommunityGraphProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const simulationRef = useRef | null>(null); + const elementsRef = useRef({}); + + const [data, setData] = useState(null); + const [size, setSize] = useState({ width, height }); + const [selectedNode, setSelectedNode] = useState(null); // 数据加载 effect useEffect(() => { @@ -32,7 +40,7 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern } else { fetch('/graph_data.json') .then(response => response.json()) - .then(data => setData(data)) + .then(graphData => setData(graphData)) .catch(error => console.error('Error fetching the data:', error)); } }, [externalData]); @@ -41,9 +49,9 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern useEffect(() => { const roEl = containerRef.current; if (!roEl) return; - + const observer = new ResizeObserver(entries => { - for (let entry of entries) { + for (const entry of entries) { const cr = entry.contentRect; setSize({ width: Math.max(1, Math.floor(cr.width)), @@ -51,63 +59,52 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern }); } }); - + observer.observe(roEl); return () => observer.disconnect(); }, []); - // 绘制 effect - 只在数据或尺寸变化时重建 - useEffect(() => { - if (data && size.width && size.height) { - draw(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, size]); - - // 选中节点变化时只更新样式 - useEffect(() => { - if (data && elementsRef.current.linkSelection && elementsRef.current.nodeSelection && elementsRef.current.labelSelection) { - updateHighlighting(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedNode]); - // 添加拖拽行为的辅助函数 - const addDragBehavior = (nodeSelection, simulation) => { - return nodeSelection.call(d3.drag() - .on('start', function (event, d) { + const addDragBehavior = useCallback(( + nodeSelection: d3.Selection, + simulation: d3.Simulation + ): void => { + nodeSelection.call(d3.drag() + .on('start', function (event: d3.D3DragEvent) { if (!event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; + event.subject.fx = event.subject.x; + event.subject.fy = event.subject.y; }) - .on('drag', function (event, d) { - d.fx = event.x; - d.fy = event.y; + .on('drag', function (event: d3.D3DragEvent) { + event.subject.fx = event.x; + event.subject.fy = event.y; }) - .on('end', function (event, d) { + .on('end', function (event: d3.D3DragEvent) { if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; + event.subject.fx = null; + event.subject.fy = null; }) ); - }; + }, []); - const updateHighlighting = () => { - if (!data || !elementsRef.current.linkSelection) return; + // 更新高亮状态的函数 + const updateHighlighting = useCallback(() => { + if (!data || !elementsRef.current.linkSelection || !elementsRef.current.nodeSelection || !elementsRef.current.labelSelection) { + return; + } - // 使用存储的原始数据进行计算 const color = d3.scaleOrdinal(d3.schemeCategory10); // 计算高亮的节点和边 - let highlightedNodes = new Set(); - let highlightedEdgeIndices = new Set(); - + let highlightedNodes = new Set(); + const highlightedEdgeIndices = new Set(); + if (selectedNode) { - const downstream = findDownstreamNodes(selectedNode.id, elementsRef.current.originalEdges); + const downstream = findDownstreamNodes(selectedNode.id, elementsRef.current.originalEdges || []); highlightedNodes = new Set([selectedNode.id, ...downstream.nodes]); - + // 通过边的索引来标记高亮边 - elementsRef.current.originalEdges.forEach((edge, index) => { + elementsRef.current.originalEdges?.forEach((edge, index) => { if (downstream.edges.has(edge)) { highlightedEdgeIndices.add(index); } @@ -116,27 +113,27 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern // 更新链接样式 elementsRef.current.linkSelection - .attr('stroke', (d, i) => highlightedEdgeIndices.has(i) ? '#ff6b35' : '#999') - .attr('stroke-opacity', (d, i) => highlightedEdgeIndices.has(i) ? 1 : (selectedNode ? 0.1 : 0.6)) - .attr('stroke-width', (d, i) => highlightedEdgeIndices.has(i) ? 3 : Math.sqrt(d.value || 1)) - .attr('marker-end', (d, i) => highlightedEdgeIndices.has(i) ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'); + .attr('stroke', (_d: SimulationLink, i: number) => highlightedEdgeIndices.has(i) ? '#ff6b35' : '#999') + .attr('stroke-opacity', (_d: SimulationLink, i: number) => highlightedEdgeIndices.has(i) ? 1 : (selectedNode ? 0.1 : 0.6)) + .attr('stroke-width', (d: SimulationLink, i: number) => highlightedEdgeIndices.has(i) ? 3 : Math.sqrt(d.value || 1)) + .attr('marker-end', (_d: SimulationLink, i: number) => highlightedEdgeIndices.has(i) ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'); // 更新节点样式 elementsRef.current.nodeSelection - .attr('fill', d => { + .attr('fill', (d: SimulationNode) => { if (selectedNode && d.id === selectedNode.id) { return '#ff6b35'; } else if (highlightedNodes.has(d.id)) { return '#ffb347'; } else { - return color(d.type || d.group); + return color(d.type || d.group?.toString() || 'default'); } }) - .attr('opacity', d => { + .attr('opacity', (d: SimulationNode) => { if (!selectedNode) return 1; return highlightedNodes.has(d.id) ? 1 : 0.2; }) - .attr('stroke-width', d => { + .attr('stroke-width', (d: SimulationNode) => { if (selectedNode && d.id === selectedNode.id) { return 3; } else if (highlightedNodes.has(d.id)) { @@ -148,13 +145,16 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern // 更新标签样式 elementsRef.current.labelSelection - .attr('opacity', d => { + .attr('opacity', (d: SimulationNode) => { if (!selectedNode) return 1; return highlightedNodes.has(d.id) ? 1 : 0.3; }); - }; + }, [data, selectedNode]); + + // 绘制主函数 + const draw = useCallback(() => { + if (!svgRef.current || !data) return; - const draw = () => { const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); @@ -177,15 +177,15 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern const color = d3.scaleOrdinal(d3.schemeCategory10); // 数据处理 - const links = data.edges.map(d => ({ ...d })); - const nodes = data.nodes.map(d => ({ ...d })); + const links: SimulationLink[] = data.edges.map(d => ({ ...d })); + const nodes: SimulationNode[] = data.nodes.map(d => ({ ...d })); // 时间轴处理 const { timeScale } = createTimeScale(nodes, size, margin); // 创建力导向图仿真 - const simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links) + const simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links) .id(d => d.id) .distance(calculateLinkDistance) ) @@ -194,13 +194,15 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern .distanceMax(200) ) .force('center', d3.forceCenter(graphWidth / 2 + margin.left, graphHeight / 2 + margin.top)) - .force('collision', d3.forceCollide().radius(30)); + .force('collision', d3.forceCollide().radius(30)); // 存储仿真引用 simulationRef.current = simulation; // 添加时间约束 - addTimeConstraints(simulation, timeScale, size, margin); + if (timeScale) { + addTimeConstraints(simulation, timeScale, size, margin); + } // 创建SVG结构 const defs = svg.append('defs'); @@ -210,23 +212,27 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern const zoomableContainer = svg.append('g').attr('class', 'zoomable-container'); // 创建时间轴 - createTimeAxis(fixedLayer, timeScale, size, margin); - createTimeGrid(zoomableContainer, timeScale, size, margin); + if (timeScale) { + createTimeAxis(fixedLayer, timeScale, size, margin); + createTimeGrid(zoomableContainer, timeScale, size, margin); + } // 创建缩放行为 - const zoom = d3.zoom() + const zoom = d3.zoom() .scaleExtent([0.1, 10]) .on('zoom', (event) => { zoomableContainer.attr('transform', event.transform); - + if (timeScale) { const newTimeScale = event.transform.rescaleX(timeScale); const newTimeAxis = d3.axisBottom(newTimeScale) - .tickFormat(d3.timeFormat("%m/%d %H:%M")) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .tickFormat(d3.timeFormat("%m/%d %H:%M") as any) .ticks(8); - + fixedLayer.select('.time-axis') - .call(newTimeAxis) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .call(newTimeAxis as any) .selectAll('text') .style('font-size', '12px') .style('fill', '#666') @@ -253,10 +259,14 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern .selectAll('path') .data(nodes) .join('path') - .attr('d', d => symbol.type(symbolTypes[d.type || d.group || 'member'])()) - .attr('fill', d => color(d.type || d.group)) + .attr('d', d => { + const symbolType = symbolTypes[d.type || d.group?.toString() || 'member']; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (symbol.type(symbolType) as any)(); + }) + .attr('fill', d => color(d.type || d.group?.toString() || 'default')) .style('cursor', 'pointer') - .on("click", function (event, d) { + .on("click", function (event: MouseEvent, d: SimulationNode) { event.stopPropagation(); if (selectedNode && selectedNode.id === d.id) { setSelectedNode(null); @@ -264,16 +274,16 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern setSelectedNode(d); } }) - .on("mouseover", function (event, d) { + .on("mouseover", function (event: MouseEvent, d: SimulationNode) { const tooltipText = d.time ? `${d.id}\n${d.time}` : d.id; tooltip.html(tooltipText.replace('\n', '
')); return tooltip.style("visibility", "visible"); }) - .on("mousemove", function (event) { + .on("mousemove", function (event: MouseEvent) { return tooltip.style("top", (event.pageY - 10) + "px").style("left", (event.pageX + 10) + "px"); }) - .on("mouseout", function () { - return tooltip.style("visibility", "hidden"); + .on("mouseout", function () { + return tooltip.style("visibility", "hidden"); }); const labelSelection = zoomableContainer.append('g') @@ -291,14 +301,15 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern linkSelection, nodeSelection, labelSelection, - originalEdges: links // 保存原始边数据用于高亮计算 + originalEdges: links }; // 添加拖拽行为 - addDragBehavior(nodeSelection, simulation); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addDragBehavior(nodeSelection as any, simulation); // 点击空白处取消选中 - svg.on("click", function(event) { + svg.on("click", function(event: MouseEvent) { if (event.target === event.currentTarget) { setSelectedNode(null); } @@ -308,27 +319,41 @@ export default function CommunityGraph({ width = 800, height = 600, data: extern simulation.on('tick', () => { // 限制节点在图形区域内 nodes.forEach(d => { - d.x = Math.max(margin.left + 20, Math.min(size.width - margin.right - 20, d.x)); - d.y = Math.max(margin.top + 20, Math.min(size.height - margin.bottom - 20, d.y)); + d.x = Math.max(margin.left + 20, Math.min(size.width - margin.right - 20, d.x!)); + d.y = Math.max(margin.top + 20, Math.min(size.height - margin.bottom - 20, d.y!)); }); linkSelection - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y); + .attr('x1', d => (d.source as SimulationNode).x!) + .attr('y1', d => (d.source as SimulationNode).y!) + .attr('x2', d => (d.target as SimulationNode).x!) + .attr('y2', d => (d.target as SimulationNode).y!); nodeSelection .attr('transform', d => `translate(${d.x},${d.y})`); labelSelection - .attr('x', d => d.x) - .attr('y', d => d.y + 20); + .attr('x', d => d.x!) + .attr('y', d => d.y! + 20); }); // 初始化时应用当前的高亮状态 updateHighlighting(); - }; + }, [data, size, selectedNode, addDragBehavior, updateHighlighting]); + + // 绘制 effect - 只在数据或尺寸变化时重建 + useEffect(() => { + if (data && size.width && size.height) { + draw(); + } + }, [data, size, draw]); + + // 选中节点变化时只更新样式 + useEffect(() => { + if (data && elementsRef.current.linkSelection && elementsRef.current.nodeSelection && elementsRef.current.labelSelection) { + updateHighlighting(); + } + }, [selectedNode, data, updateHighlighting]); // 清理函数 useEffect(() => { diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 0cb6b02..1d64848 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { DocumentArrowUpIcon, ArrowDownTrayIcon, } from "@heroicons/react/24/outline"; -import { GraphData } from "../types/graph"; +import { GraphData } from "@/types"; import { parseCSV, downloadSampleCSV } from "../utils/csvParser"; interface SidebarProps { diff --git a/app/page.tsx b/app/page.tsx index 57cf6eb..616a18d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,10 @@ "use client"; import { useState } from "react"; -import D3Example from "@/app/components/CommunityGraph2"; +import D3Example from "@/app/components/CommunityGraph"; import Sidebar from "@/app/components/Sidebar"; import AIChat from "@/app/components/AIChat"; -import { GraphData } from "./types/graph"; +import { GraphData } from "@/types"; export default function Home() { const [uploadedData, setUploadedData] = useState(null); diff --git a/app/types/graph.ts b/app/types/graph.ts deleted file mode 100644 index c1e5540..0000000 --- a/app/types/graph.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface Node { - id: string; - type: string; - name: string; - time: string | null; -} - -export interface Edge { - source: string; - target: string; - relationship: string; - value: number; -} - -export interface GraphData { - nodes: Node[]; - edges: Edge[]; -} \ No newline at end of file diff --git a/app/utils/csvParser.ts b/app/utils/csvParser.ts index 90aa997..6da7637 100644 --- a/app/utils/csvParser.ts +++ b/app/utils/csvParser.ts @@ -1,12 +1,12 @@ -import { GraphData, Node, Edge } from '../types/graph'; +import { GraphData, GraphNode, GraphLink } from '@/types'; export const parseCSV = (csvText: string): GraphData => { try { const lines = csvText.trim().split("\n"); const headers = lines[0].split(",").map((h) => h.trim()); - const nodes = new Map(); - const edges: Edge[] = []; + const nodes = new Map(); + const edges: GraphLink[] = []; // 解析每一行数据 for (let i = 1; i < lines.length; i++) { @@ -40,9 +40,9 @@ export const parseCSV = (csvText: string): GraphData => { if (!nodes.has(initiatorId)) { nodes.set(initiatorId, { id: initiatorId, - type: "member", + type: "member" as const, name: initiator, - time: null, + time: undefined, }); } }); @@ -53,9 +53,9 @@ export const parseCSV = (csvText: string): GraphData => { if (!nodes.has(participantId)) { nodes.set(participantId, { id: participantId, - type: "member", + type: "member" as const, name: participant, - time: null, + time: undefined, }); } }); @@ -64,7 +64,7 @@ export const parseCSV = (csvText: string): GraphData => { if (!nodes.has(eventId)) { nodes.set(eventId, { id: eventId, - type: "event", + type: "event" as const, name: topic, time: time, }); @@ -74,9 +74,9 @@ export const parseCSV = (csvText: string): GraphData => { if (!nodes.has(spaceId)) { nodes.set(spaceId, { id: spaceId, - type: "space", + type: "space" as const, name: venue, - time: null, + time: undefined, }); } @@ -87,7 +87,7 @@ export const parseCSV = (csvText: string): GraphData => { edges.push({ source: initiatorId, target: eventId, - relationship: "initiates", + type: "initiates", value: 1, }); }); @@ -98,7 +98,7 @@ export const parseCSV = (csvText: string): GraphData => { edges.push({ source: eventId, target: participantId, - relationship: "participates", + type: "participates", value: 1, }); }); @@ -107,7 +107,7 @@ export const parseCSV = (csvText: string): GraphData => { edges.push({ source: spaceId, target: eventId, - relationship: "hosts", + type: "hosts", value: 1, }); } diff --git a/app/utils/graphUtils.js b/app/utils/graph.ts similarity index 65% rename from app/utils/graphUtils.js rename to app/utils/graph.ts index ca04aff..6438d83 100644 --- a/app/utils/graphUtils.js +++ b/app/utils/graph.ts @@ -1,7 +1,15 @@ import * as d3 from 'd3'; +import { + SymbolTypeMap, + SimulationNode, + SimulationLink, + DownstreamResult, + TooltipFunction, + NodeLabelFunction +} from '@/types'; // 符号类型定义 -export const symbolTypes = { +export const symbolTypes: SymbolTypeMap = { event: d3.symbolSquare, space: d3.symbolTriangle, member: d3.symbolCircle, @@ -10,29 +18,31 @@ export const symbolTypes = { area: d3.symbolTriangle }; -// 递归找到从指定节点出发的所有下游节点(只考虑出边) -export const findDownstreamNodes = (startNodeId, edges) => { - const visited = new Set(); - const downstreamNodes = new Set(); - const downstreamEdges = new Set(); - +/** + * 递归找到从指定节点出发的所有下游节点(只考虑出边) + */ +export const findDownstreamNodes = (startNodeId: string, edges: SimulationLink[]): DownstreamResult => { + const visited = new Set(); + const downstreamNodes = new Set(); + const downstreamEdges = new Set(); + // 创建邻接表(只存储出边) - const outEdges = new Map(); + const outEdges = new Map>(); edges.forEach(edge => { const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source; const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target; - + if (!outEdges.has(sourceId)) { outEdges.set(sourceId, []); } - outEdges.get(sourceId).push({ target: targetId, edge }); + outEdges.get(sourceId)!.push({ target: targetId, edge }); }); // DFS递归遍历 - const dfs = (nodeId) => { + const dfs = (nodeId: string): void => { if (visited.has(nodeId)) return; visited.add(nodeId); - + const neighbors = outEdges.get(nodeId) || []; neighbors.forEach(({ target, edge }) => { downstreamNodes.add(target); @@ -45,11 +55,13 @@ export const findDownstreamNodes = (startNodeId, edges) => { return { nodes: downstreamNodes, edges: downstreamEdges }; }; -// 计算链接距离 -export const calculateLinkDistance = (d) => { +/** + * 计算链接距离 + */ +export const calculateLinkDistance: (d: SimulationLink) => number = (d) => { const baseLength = 50; - const sourceType = d.source.type; - const targetType = d.target.type; + const sourceType = (d.source as SimulationNode).type; + const targetType = (d.target as SimulationNode).type; if (sourceType === 'person' && targetType === 'act') { return baseLength * 2; @@ -61,8 +73,10 @@ export const calculateLinkDistance = (d) => { return baseLength; }; -// 创建箭头标记 -export const createArrowMarkers = (defs) => { +/** + * 创建箭头标记 + */ +export const createArrowMarkers = (defs: d3.Selection): void => { // 普通箭头 defs.append('marker') .attr('id', 'arrowhead') @@ -90,8 +104,10 @@ export const createArrowMarkers = (defs) => { .attr('fill', '#ff6b35'); }; -// 创建Tooltip -export const createTooltip = () => { +/** + * 创建Tooltip + */ +export const createTooltip = (): TooltipFunction => { return d3.select("body") .append("div") .attr('class', 'cg-tooltip') @@ -105,8 +121,10 @@ export const createTooltip = () => { .style("font-size", "12px"); }; -// 节点标签文本处理 -export const getNodeLabel = (d) => { +/** + * 节点标签文本处理 + */ +export const getNodeLabel: NodeLabelFunction = (d) => { if (d.type === 'event') { return d.name || d.id.split('@')[0] || d.id; } diff --git a/app/utils/renderUtils.js b/app/utils/renderUtils.js deleted file mode 100644 index fbbb851..0000000 --- a/app/utils/renderUtils.js +++ /dev/null @@ -1,106 +0,0 @@ -import * as d3 from 'd3'; - -// 创建并配置链接 -export const createLinks = (container, links, highlightedEdges, selectedNode) => { - return container.append('g') - .attr('stroke', '#999') - .attr('stroke-opacity', 0.6) - .selectAll('line') - .data(links) - .join('line') - .attr('stroke-width', d => Math.sqrt(d.value || 1)) - .attr('stroke', d => highlightedEdges.has(d) ? '#ff6b35' : '#999') - .attr('stroke-opacity', d => highlightedEdges.has(d) ? 1 : (selectedNode ? 0.1 : 0.6)) - .attr('stroke-width', d => highlightedEdges.has(d) ? 3 : Math.sqrt(d.value || 1)) - .attr('marker-end', d => highlightedEdges.has(d) ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'); -}; - -// 创建并配置节点 -export const createNodes = (container, nodes, symbol, symbolTypes, color, highlightedNodes, selectedNode, setSelectedNode, tooltip) => { - return container.append('g') - .attr('stroke', '#fff') - .attr('stroke-width', 1.5) - .selectAll('path') - .data(nodes) - .join('path') - .attr('d', d => symbol.type(symbolTypes[d.type || d.group || 'member'])()) - .attr('fill', d => { - if (selectedNode && d.id === selectedNode.id) { - return '#ff6b35'; - } else if (highlightedNodes.has(d.id)) { - return '#ffb347'; - } else { - return color(d.type || d.group); - } - }) - .attr('opacity', d => { - if (!selectedNode) return 1; - return highlightedNodes.has(d.id) ? 1 : 0.2; - }) - .attr('stroke-width', d => { - if (selectedNode && d.id === selectedNode.id) { - return 3; - } else if (highlightedNodes.has(d.id)) { - return 2; - } else { - return 1.5; - } - }) - .style('cursor', 'pointer') - .on("click", function (event, d) { - event.stopPropagation(); - if (selectedNode && selectedNode.id === d.id) { - setSelectedNode(null); - } else { - setSelectedNode(d); - } - }) - .on("mouseover", function (event, d) { - const tooltipText = d.time ? `${d.id}\n${d.time}` : d.id; - tooltip.html(tooltipText.replace('\n', '
')); - return tooltip.style("visibility", "visible"); - }) - .on("mousemove", function (event) { - return tooltip.style("top", (event.pageY - 10) + "px").style("left", (event.pageX + 10) + "px"); - }) - .on("mouseout", function () { - return tooltip.style("visibility", "hidden"); - }); -}; - -// 创建节点标签 -export const createLabels = (container, nodes, highlightedNodes, selectedNode, getNodeLabel) => { - return container.append('g') - .selectAll('text') - .data(nodes) - .join('text') - .text(getNodeLabel) - .style('font-size', '10px') - .style('fill', '#333') - .style('text-anchor', 'middle') - .style('pointer-events', 'none') - .attr('opacity', d => { - if (!selectedNode) return 1; - return highlightedNodes.has(d.id) ? 1 : 0.3; - }); -}; - -// 添加拖拽行为 -export const addDragBehavior = (nodeSelection, simulation) => { - return nodeSelection.call(d3.drag() - .on('start', function (event, d) { - if (!event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; - }) - .on('drag', function (event, d) { - d.fx = event.x; - d.fy = event.y; - }) - .on('end', function (event, d) { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - }) - ); -}; \ No newline at end of file diff --git a/app/utils/timeline.ts b/app/utils/timeline.ts new file mode 100644 index 0000000..450e582 --- /dev/null +++ b/app/utils/timeline.ts @@ -0,0 +1,146 @@ +import * as d3 from 'd3'; +import { + SimulationNode, + SimulationLink, + TimeScale, + Size, + Margin, + ParsedEventNode +} from '@/types'; + +interface TimeScaleResult { + timeScale: TimeScale | null; + eventNodes: ParsedEventNode[]; +} + +/** + * 解析时间并创建时间比例尺 + */ +export const createTimeScale = (nodes: SimulationNode[], size: Size, margin: Margin): TimeScaleResult => { + const parseTime = d3.timeParse("%Y-%m-%d %H:%M"); + const eventNodes = nodes.filter(d => d.type === 'event' && d.time) as ParsedEventNode[]; + + // 为事件节点解析时间 + eventNodes.forEach(d => { + if (d.time) { + d.parsedTime = parseTime(d.time)!; + } + }); + + let timeScale: TimeScale | null = null; + if (eventNodes.length > 0) { + const timeExtent = d3.extent(eventNodes, d => d.parsedTime) as [Date, Date]; + timeScale = d3.scaleTime() + .domain(timeExtent) + .range([margin.left, size.width - margin.right]) as unknown as TimeScale; + } + + return { timeScale, eventNodes }; +}; + +/** + * 创建时间轴 + */ +export const createTimeAxis = ( + fixedLayer: d3.Selection, + timeScale: TimeScale, + size: Size, + margin: Margin +): void => { + if (!timeScale) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const timeAxis = d3.axisBottom(timeScale as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .tickFormat(d3.timeFormat("%m/%d %H:%M") as any) + .ticks(8); + + // 时间轴背景 + fixedLayer.append('rect') + .attr('x', 0) + .attr('y', size.height - margin.bottom) + .attr('width', size.width) + .attr('height', margin.bottom) + .attr('fill', '#f8f9fa') + .attr('stroke', '#e9ecef'); + + // 时间轴 + fixedLayer.append('g') + .attr('class', 'time-axis') + .attr('transform', `translate(0, ${size.height - margin.bottom + 10})`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .call(timeAxis as any) + .selectAll('text') + .style('font-size', '12px') + .style('fill', '#666') + .attr('transform', 'rotate(-45)') + .style('text-anchor', 'end'); + + // 时间轴标题 + fixedLayer.append('text') + .attr('x', size.width / 2) + .attr('y', size.height - 10) + .attr('text-anchor', 'middle') + .style('font-size', '14px') + .style('font-weight', 'bold') + .style('fill', '#333') + .text('Timeline'); +}; + +/** + * 创建时间网格线 + */ +export const createTimeGrid = ( + zoomableContainer: d3.Selection, + timeScale: TimeScale, + size: Size, + margin: Margin +): void => { + if (!timeScale) return; + + zoomableContainer.append('g') + .attr('class', 'time-grid') + .selectAll('line') + .data(timeScale.ticks(8)) + .enter() + .append('line') + .attr('x1', d => timeScale(d)) + .attr('x2', d => timeScale(d)) + .attr('y1', margin.top) + .attr('y2', size.height - margin.bottom) + .attr('stroke', '#e9ecef') + .attr('stroke-dasharray', '2,2') + .attr('opacity', 0.5); +}; + +/** + * 添加时间约束力 + */ +export const addTimeConstraints = ( + simulation: d3.Simulation, + timeScale: TimeScale, + size: Size, + margin: Margin +): void => { + if (!timeScale) return; + + simulation.force('timeConstraint', d3.forceY() + .y(d => { + if (d.type === 'event' && (d as ParsedEventNode).parsedTime) { + return size.height * 0.7 + margin.top; + } + return size.height / 2 + margin.top; + }) + .strength(0.3) + ); + + simulation.force('timeX', d3.forceX() + .x(d => { + if (d.type === 'event' && (d as ParsedEventNode).parsedTime) { + return timeScale((d as ParsedEventNode).parsedTime); + } + return d.x || size.width / 2 + margin.left; + }) + .strength(d => d.type === 'event' && (d as ParsedEventNode).parsedTime ? 0.8 : 0.1) + ); +}; \ No newline at end of file diff --git a/app/utils/timelineUtils.js b/app/utils/timelineUtils.js deleted file mode 100644 index 66c2fbb..0000000 --- a/app/utils/timelineUtils.js +++ /dev/null @@ -1,105 +0,0 @@ -import * as d3 from 'd3'; - -// 解析时间并创建时间比例尺 -export const createTimeScale = (nodes, size, margin) => { - const parseTime = d3.timeParse("%Y-%m-%d %H:%M"); - const eventNodes = nodes.filter(d => d.type === 'event' && d.time); - - // 为事件节点解析时间 - eventNodes.forEach(d => { - d.parsedTime = parseTime(d.time); - }); - - let timeScale = null; - if (eventNodes.length > 0) { - const timeExtent = d3.extent(eventNodes, d => d.parsedTime); - timeScale = d3.scaleTime() - .domain(timeExtent) - .range([margin.left, size.width - margin.right]); - } - - return { timeScale, eventNodes }; -}; - -// 创建时间轴 -export const createTimeAxis = (fixedLayer, timeScale, size, margin) => { - if (!timeScale) return; - - const timeAxis = d3.axisBottom(timeScale) - .tickFormat(d3.timeFormat("%m/%d %H:%M")) - .ticks(8); - - // 时间轴背景 - fixedLayer.append('rect') - .attr('x', 0) - .attr('y', size.height - margin.bottom) - .attr('width', size.width) - .attr('height', margin.bottom) - .attr('fill', '#f8f9fa') - .attr('stroke', '#e9ecef'); - - // 时间轴 - fixedLayer.append('g') - .attr('class', 'time-axis') - .attr('transform', `translate(0, ${size.height - margin.bottom + 10})`) - .call(timeAxis) - .selectAll('text') - .style('font-size', '12px') - .style('fill', '#666') - .attr('transform', 'rotate(-45)') - .style('text-anchor', 'end'); - - // 时间轴标题 - fixedLayer.append('text') - .attr('x', size.width / 2) - .attr('y', size.height - 10) - .attr('text-anchor', 'middle') - .style('font-size', '14px') - .style('font-weight', 'bold') - .style('fill', '#333') - .text('Timeline'); -}; - -// 创建时间网格线 -export const createTimeGrid = (zoomableContainer, timeScale, size, margin) => { - if (!timeScale) return; - - zoomableContainer.append('g') - .attr('class', 'time-grid') - .selectAll('line') - .data(timeScale.ticks(8)) - .enter() - .append('line') - .attr('x1', d => timeScale(d)) - .attr('x2', d => timeScale(d)) - .attr('y1', margin.top) - .attr('y2', size.height - margin.bottom) - .attr('stroke', '#e9ecef') - .attr('stroke-dasharray', '2,2') - .attr('opacity', 0.5); -}; - -// 添加时间约束力 -export const addTimeConstraints = (simulation, timeScale, size, margin) => { - if (!timeScale) return; - - simulation.force('timeConstraint', d3.forceY() - .y(d => { - if (d.type === 'event' && d.parsedTime) { - return size.height * 0.7 + margin.top; - } - return size.height / 2 + margin.top; - }) - .strength(0.3) - ); - - simulation.force('timeX', d3.forceX() - .x(d => { - if (d.type === 'event' && d.parsedTime) { - return timeScale(d.parsedTime); - } - return d.x || size.width / 2 + margin.left; - }) - .strength(d => d.type === 'event' && d.parsedTime ? 0.8 : 0.1) - ); -}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 191250b..9b60712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/d3": "^7.4.3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -915,6 +916,290 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.6.tgz", @@ -922,6 +1207,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 8e7c9fb..26be248 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/d3": "^7.4.3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..6d4658c --- /dev/null +++ b/types/index.ts @@ -0,0 +1,129 @@ +// 核心图数据结构类型定义 + +export type NodeType = 'event' | 'space' | 'member' | 'person' | 'act' | 'area'; + +export interface GraphNode { + id: string; + name?: string; + type: NodeType; + group?: string; + time?: string; + parsedTime?: Date; + x?: number; + y?: number; + fx?: number | null; + fy?: number | null; + [key: string]: any; // 允许额外属性 +} + +export interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; + value?: number; + type?: string; + [key: string]: any; // 允许额外属性 +} + +export interface GraphData { + nodes: GraphNode[]; + edges: GraphLink[]; +} + +// D3.js 相关类型 +export interface SimulationNode extends GraphNode { + index?: number; + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +export interface SimulationLink extends GraphLink { + source: SimulationNode | string; + target: SimulationNode | string; + index?: number; +} + +// 力导向图配置类型 +export interface ForceConfig { + linkDistance?: number | ((d: SimulationLink) => number); + chargeStrength?: number; + collisionRadius?: number; + centerStrength?: number; +} + +// 时间轴相关类型 +export interface TimeScale { + (date: Date): number; + domain(): [Date, Date]; + range(): [number, number]; + ticks(count: number): Date[]; + invert(x: number): Date; +} + +export interface ParsedEventNode extends GraphNode { + parsedTime: Date; + type: 'event'; +} + +// 下游节点查找结果类型 +export interface DownstreamResult { + nodes: Set; + edges: Set; +} + +// 组件 Props 类型 +export interface CommunityGraphProps { + width?: number; + height?: number; + data?: GraphData | null; +} + +// 尺寸相关类型 +export interface Size { + width: number; + height: number; +} + +export interface Margin { + top: number; + right: number; + bottom: number; + left: number; +} + +// 符号类型映射 +export interface SymbolTypeMap { + [key: string]: d3.SymbolType; +} + +// 选中状态类型 +export interface SelectionState { + selectedNode: GraphNode | null; + highlightedNodes: Set; + highlightedEdges: Set; +} + +// D3 选择集引用类型 +export interface D3SelectionRefs { + linkSelection?: any; + nodeSelection?: any; + labelSelection?: any; + originalEdges?: SimulationLink[]; +} + +// 缩放和变换类型 +export interface ZoomTransform { + x: number; + y: number; + k: number; +} + +// 工具函数类型 +export type TooltipFunction = d3.Selection; + +export type NodeLabelFunction = (d: SimulationNode) => string; + +export type LinkDistanceFunction = (d: SimulationLink) => number; \ No newline at end of file