You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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的曲线生成器:

  1. 生成连线数据:先从stages数组中提取所有依赖关系,转换成{source, target}格式的连线数组。
  2. 使用曲线生成器:d3.linkHorizontal专门用于生成横向的连接曲线,配合.curve()方法可以指定不同的曲线类型(比如d3.curveBasis是平滑的基础曲线,d3.curveBundle会让曲线更紧凑,d3.curveCardinal则有更明显的弯曲效果,你可以根据需求调整)。
  3. 调整端点位置:连线的起点和终点要避开节点的半径,防止曲线和节点重叠。

替换掉原来的直线连线代码后,就能得到平滑的曲线连接线了。如果想要自定义弯曲的弧度,也可以手动编写贝塞尔曲线的路径,比如用M ${sourceX} ${sourceY} Q ${midX} ${midY} ${targetX} ${targetY}这样的路径字符串来控制曲线的形状。

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 14:08:06