React + D3 状态追踪器工作流图表:连接线无法显示为曲线的问题求助
React + D3 状态追踪器工作流图表:连接线无法显示为曲线的问题求助
大家好,我正在用React结合D3图表库搭建一个状态追踪工作流。目前节点已经能正常渲染,但连接线始终是直线,没法做成我想要的曲线效果。以下是我的完整代码,麻烦各位帮忙看看问题出在哪,怎么调整才能让连接线变成平滑的曲线?
App 组件代码
import React, { useState } from 'react'; import { FundStatus, Stage, StageStatus } from './FundStatus'; import { StageDetailsModal, StageDetails } from './StageDetailsModal'; import './App.css'; const stages: Stage[] = [ { id: 'cdt', name: 'CDT', dependsOn: [] }, { id: 'fct', name: 'FCT', dependsOn: ['cdt'] }, { id: 'kyc', name: 'KYC', dependsOn: ['fct'] }, { id: 'rdd', name: 'RDD', dependsOn: ['fct'] }, { id: 'tax', name: 'TAX', dependsOn: ['fct'], optional: true }, { id: 'cobam', name: 'COBAM', dependsOn: ['kyc', 'rdd', 'tax'] }, { id: 'offramp', name: 'OffRamp', dependsOn: ['cobam'] }, ]; const stageStatus: Record<string, StageStatus> = { cdt: 'completed', fct: 'completed', kyc: 'in-progress', rdd: 'in-progress', tax: 'in-progress', cobam: 'not-started', offramp: 'not-started', }; const stageDetails: Record<string, StageDetails> = { cdt: { assignee: 'Alice', startDate: '2025-06-01', completedDate: '2025-06-02' }, fct: { assignee: 'Bob', startDate: '2025-06-02', completedDate: '2025-06-03' }, kyc: { assignee: 'Carol', startDate: '2025-06-03', completedDate: '' }, rdd: { assignee: 'Dave', startDate: '2025-06-03', completedDate: '' }, tax: { assignee: 'Eve', startDate: '2025-06-03', completedDate: '' }, cobam: { assignee: 'Frank', startDate: '', completedDate: '' }, offramp: { assignee: 'Grace', startDate: '', completedDate: '' }, }; function App() { const [selectedStage, setSelectedStage] = useState<string | null>(null); return ( <div className="App" style={{ minHeight: '100vh', background: '#f3f6fb' }}> <h2 style={{ textAlign: 'center', margin: '32px 0 16px 0', color: '#2b2d42' }}>Fund Status Workflow</h2> <FundStatus stages={stages} stageStatus={stageStatus} stageDetails={stageDetails} onStageClick={setSelectedStage} /> <StageDetailsModal open={!!selectedStage} onClose={() => setSelectedStage(null)} stageName={selectedStage ? stages.find(s => s.id === selectedStage)?.name || '' : ''} details={selectedStage ? stageDetails[selectedStage] : null} /> </div> ); } export default App;
FundStatus 组件代码
import React, { useRef, useEffect } from 'react'; import * as d3 from 'd3'; import { StageDetails } from './StageDetailsModal'; // Types for stage and status export type Stage = { id: string; name: string; dependsOn: string[]; optional?: boolean; }; export type StageStatus = 'completed' | 'in-progress' | 'not-started'; // Status color map export const statusColorMap: Record<StageStatus, string> = { 'completed': '#4CAF50', // green 'in-progress': '#FFC107', // amber 'not-started': '#BDBDBD', // grey }; // Legend labels const statusLabels: Record<StageStatus, string> = { 'completed': 'Completed', 'in-progress': 'In Progress', 'not-started': 'Not Started', }; // Props interface interface FundStatusProps { stages: Stage[]; stageStatus: Record<string, StageStatus>; stageDetails: Record<string, StageDetails>; onStageClick?: (stageId: string) => void; } // Utility to compute column positions for each stage // Now returns {col, y} where y is the vertical position (not just row index) function computeStageLayout(stages: Stage[]): Record<string, {col: number, y: number}> { // Map of stageId to column const stageCol: Record<string, number> = {}; // Map of column to list of stageIds const colStages: Record<number, string[]> = {}; // Helper to get column for a stage function getCol(stageId: string): number { if (stageCol[stageId] !== undefined) return stageCol[stageId]; const stage = stages.find(s => s.id === stageId)!; if (!stage.dependsOn || stage.dependsOn.length === 0) { stageCol[stageId] = 0; colStages[0] = colStages[0] || []; colStages[0].push(stageId); return 0; } // Max col of dependencies + 1 const depCols = stage.dependsOn.map(getCol); const col = Math.max(...depCols) + 1; stageCol[stageId] = col; colStages[col] = colStages[col] || []; colStages[col].push(stageId); return col; } stages.forEach(s => getCol(s.id)); // For each column, distribute the nodes equally along the vertical axis const layout: Record<string, {col: number, y: number}> = {}; const colCount = Object.keys(colStages).length; // Find the max number of nodes in any column for vertical scaling const maxNodesInCol = Math.max(...Object.values(colStages).map(ids => ids.length)); const minY = 60; const maxY = minY + (maxNodesInCol - 1) * rowHeight; Object.entries(colStages).forEach(([colStr, ids]) => { const col = +colStr; const n = ids.length; // Distribute n nodes from minY to maxY ids.forEach((id, idx) => { let y; if (n === 1) { y = minY + (maxY - minY) / 2; } else { y = minY + idx * (maxY - minY) / (n - 1); } layout[id] = { col, y }; }); }); return layout; } const nodeRadius = 36; const colWidth = 180; const rowHeight = 120; export const FundStatus: React.FC<FundStatusProps> = ({ stages, stageStatus, stageDetails, onStageClick }) => { const svgRef = useRef<SVGSVGElement>(null); useEffect(() => { if (!svgRef.current) return; // Clear previous content d3.select(svgRef.current).selectAll('*').remove(); // Compute layout const layout = computeStageLayout(stages); const colCount = Math.max(...Object.values(layout).map(d => d.col)) + 1; const maxY = Math.max(...Object.values(layout).map(d => d.y)) + 60; // Set up SVG dimensions const svg = d3.select(svgRef.current) .attr('width', colCount * colWidth) .attr('height', maxY); // Generate links data: each dependency is a link from source to target const links = stages.flatMap(stage => stage.dependsOn.map(depId => ({ source: depId, target: stage.id })) ); // --- 替换成曲线连线的代码 --- // 定义横向链接的曲线生成器,使用Basis曲线实现平滑弯曲 const linkGenerator = d3.linkHorizontal() .x(d => d.x) .y(d => d.y) .curve(d3.curveBasis); // 可以换成curveBundle、curveCardinal等调整弯曲程度 // 绘制曲线连线 svg.selectAll('.link') .data(links) .enter() .append('path') .attr('class', 'link') .attr('d', d => { // 定义源节点和目标节点的连线端点(避开节点半径,防止和节点重叠) const source = { x: layout[d.source].col * colWidth + nodeRadius, y: layout[d.source].y }; const target = { x: layout[d.target].col * colWidth - nodeRadius, y: layout[d.target].y }; // 生成曲线路径 return linkGenerator({ source, target }); }) .style('stroke', '#999') .style('stroke-width', 2) .style('fill', 'none') .style('opacity', 0.7); // 绘制节点 const nodes = svg.selectAll('.node') .data(stages) .enter() .append('g') .attr('class', 'node') .attr('transform', d => `translate(${layout[d.id].col * colWidth}, ${layout[d.id].y})`) .on('click', (event, d) => onStageClick?.(d.id)); // 节点圆圈 nodes.append('circle') .attr('r', nodeRadius) .attr('fill', d => statusColorMap[stageStatus[d.id]]) .style('cursor', 'pointer'); // 节点文字 nodes.append('text') .attr('text-anchor', 'middle') .attr('dy', '.35em') .style('fill', '#fff') .style('font-weight', 'bold') .style('font-size', '14px') .text(d => d.name); // 绘制图例 const legend = svg.append('g') .attr('transform', `translate(20, ${maxY - 40})`); Object.entries(statusLabels).forEach(([status, label], idx) => { const legendItem = legend.append('g') .attr('transform', `translate(${idx * 150}, 0)`); legendItem.append('circle') .attr('r', 8) .attr('fill', statusColorMap[status as StageStatus]); legendItem.append('text') .attr('x', 15) .attr('y', 5) .style('font-size', '12px') .text(label); }); }, [stages, stageStatus, stageDetails, onStageClick]); return <svg ref={svgRef}></svg>; };
问题解决思路说明
我推测你之前是用<line>元素绘制的直线连线,要改成曲线的话,需要用<path>元素结合D3的曲线生成器:
- 生成连线数据:先从stages数组中提取所有依赖关系,转换成
{source, target}格式的连线数组。 - 使用曲线生成器:
d3.linkHorizontal专门用于生成横向的连接曲线,配合.curve()方法可以指定不同的曲线类型(比如d3.curveBasis是平滑的基础曲线,d3.curveBundle会让曲线更紧凑,d3.curveCardinal则有更明显的弯曲效果,你可以根据需求调整)。 - 调整端点位置:连线的起点和终点要避开节点的半径,防止曲线和节点重叠。
替换掉原来的直线连线代码后,就能得到平滑的曲线连接线了。如果想要自定义弯曲的弧度,也可以手动编写贝塞尔曲线的路径,比如用M ${sourceX} ${sourceY} Q ${midX} ${midY} ${targetX} ${targetY}这样的路径字符串来控制曲线的形状。
内容来源于stack exchange
相关产品推荐
相关产品推荐

