React使用d3绘制拓扑图

1,455 阅读3分钟

版本

d3: 6.3.1
react: 16.8.6
参考:observablehq.com/@nitaku/tan…

效果图

npm install d3

在需要用的组件页面引入d3

import * as d3 from 'd3';

初始化Simulation

componentDidMount() {
  this.initSimulation();
}

initSimulation = () => {
  const width = this.chartRef.current.clientWidth;
  const height = this.chartRef.current.clientHeight;

  // 初始化simulation,如果指定了 force 则表示为仿真添加指定 name 的 force(力学模型) 并返回仿真。
  this.simulation = d3
    .forceSimulation() // 这里可以指定被引用的nodes数组,如果没有指定 nodes 则默认为空数组
    .force('center', d3.forceCenter(width / 2, height / 2))
    .force('collide', d3.forceCollide().radius(() => 60))
    .force('yPos', d3.forceY().strength(1))
    .force('xPos', d3.forceX().strength(1))
    .force('charge', d3.forceManyBody().strength(-520));

  // 绘制svg,并制定宽高
  this.svg = d3
    .select('#theChart')
    .append('svg') // 在id为‘theChart’的标签内创建svg
    .attr('width', width)
    .attr('height', height * 0.9)
    .call(this.zoom()); // 放大缩小
  this.graph = this.svg.append('g');
};

render() {
  return (
    <div
      className="theChart"
      id="theChart"
      ref={this.chartRef}
      style={{ width: 600, height: 600 }}
    ></div>
  );
}

获取到数据后往simulation里加入nodes

// 这里的levels的结构应该是
const levels = [  [{ id: 'Chaos' }], // 第一列数据
  [{ id: 'Gaea', parents: ['Chaos'] }], // 第二列数据
  [    { id: 'Oceanus', parents: ['Gaea'] },
    { id: 'Thethys', parents: ['Gaea'] },
    { id: 'Pontus' },
    { id: 'Rhea', parents: ['Gaea'] },
    { id: 'Cronus', parents: ['Gaea'] },
    { id: 'Coeus', parents: ['Gaea'] },
    { id: 'Phoebe', parents: ['Gaea'] },
    { id: 'Crius', parents: ['Gaea'] },
    { id: 'Hyperion', parents: ['Gaea'] },
    { id: 'Iapetus', parents: ['Gaea']' },
    { id: 'Thea', parents: ['Gaea'] },
    { id: 'Themis', parents: ['Gaea'] },
    { id: 'Mnemosyne', parents: ['Gaea'] }
  ] // 第三列数据
]
const data = this.tranferData(d3, levels);
this.simulation.nodes = data.nodes;
this.draw(data);

绘制节点连线

draw = data => {
// 绘制节点后面的连线
const color = d3.scaleOrdinal(d3.schemeDark2); // 添加颜色

data.bundles.map(b => {
  const d = b.links
    .map(
      l => `
      M${l.xt} ${l.yt}
      L${l.xb - l.c1} ${l.yt}
      A${l.c1} ${l.c1} 90 0 1 ${l.xb} ${l.yt + l.c1}
      L${l.xb} ${l.ys - l.c2}
      A${l.c2} ${l.c2} 90 0 0 ${l.xb + l.c2} ${l.ys}
      L${l.xs} ${l.ys}`
    )
    .join('');
  this.svg
    .select('g')
    .append('path')
    .attr('class', 'topo-line')
    .style('fill', 'none')
    .attr('d', d)
    .attr('stroke', 'white')
    .attr('stroke-width', 5);
  this.svg
    .select('g')
    .append('path')
    .attr('class', 'topo-line'')
    .style('fill', 'none')
    .attr('d', d)
    .attr('stroke', color(b.id)) // 连线颜色
    .attr('stroke-width', 2);
});

  // 绘制节点
  data.nodes.map(n => {
    // 绘制连线后的圆点
    const line = this.svg
      .select('g')
      .append('line')
      .style('stroke-linecap', 'round')
      .attr('stroke-width', 8)
      .attr('x1', n.x)
      .attr('y1', n.y - n.height / 2)
      .attr('x2', n.x)
      .attr('stroke', color(n.id))
      .attr('y2', n.y + n.height / 2);
      
    // 控制圆点空心部分
    this.svg
      .select('g')
      .append('line')
      .style('stroke-linecap', 'round')
      .attr('stroke', 'white')
      .attr('stroke-width', 4)
      .attr('x1', n.x)
      .attr('y1', n.y - n.height / 2)
      .attr('x2', n.x)
      .attr('y2', n.y + n.height / 2);

    // 绘制节点文本
    this.svg
      .select('g')
      .append('text')
      .style('font-size', '10px')
      .attr('stroke', 'white')
      .attr('stroke-width', 2)
      .attr('x', n.x + 4)
      .attr('y', n.y - n.height / 2 - 4)
      .text(n.id);
    this.svg
      .select('g')
      .append('text')
      .attr('fill', 'black') // 文本颜色
      .style('font-size', '10px')
      .style('cursor', 'pointer')
      .attr('x', n.x + 4)
      .attr('y', n.y - n.height / 2 - 4)
      .text(n.id)
      .on('click', () => { // 文本点击事件
        if (this.props.itemClick) {
          this.props.itemClick(n.id);
        }
      });
  });
};

放大缩小

zoom = () =>
  d3
    .zoom()
    .scaleExtent([1 / 10, 10]) // 设置最大缩放比例
    .on('start', this.onZoomStart)
    .on('zoom', this.zooming)
    .on('end', this.onZoomEnd);

onZoomStart = (event, d) => {
  // console.log('start zoom');
};

zooming = (event, d) => {
  // 缩放和拖拽整个g
  this.graph.attr('transform', event.transform); // 获取g的缩放系数和平移的坐标值。
};

onZoomEnd = () => {
  // console.log('zoom end');
};

拖拽

drag = () =>
  d3
    .drag()
    .on('start', this.onDragStart)
    .on('drag', this.dragging) // 拖拽过程
    .on('end', this.onDragEnd);

onDragStart = (event, d) => {
  if (!event.active) {
    this.simulation
      .alphaTarget(1) // 设置衰减系数,对节点位置移动过程的模拟,数值越高移动越快,数值范围[0,1]
      .restart(); // 拖拽节点后,重新启动模拟
  }
  d.fx = d.x; // d.x是当前位置,d.fx是静止时位置
  d.fy = d.y;
};

dragging = (event, d) => {
  d.fx = event.x;
  d.fy = event.y;
};

onDragEnd = (event, d) => {
  if (!event.active) this.simulation.alphaTarget(0);
  d.fx = null; // 解除dragged中固定的坐标
  d.fy = null;
};

自定义连线后面的图标

  1. 定义图标,append到svg里。
// 这里绘制的是连线后面的叉号。
const defs = this.svg.append('defs');
const marker = defs
  .append('marker')
  .attr('id', 'close')
  .attr('viewBox', '0 0 1024 1024')
  .attr('markerWidth', '1.5')
  .attr('markerHeight', '1.5')
  .attr('refX', '512')
  .attr('refY', '512')
  .attr('orient', 'auto');

marker
  .append('path')
  .attr(
    'd',
    'M810.666667 273.493333L750.506667 213.333333 512 451.84 273.493333 213.333333 213.333333 273.493333 451.84 512 213.333333 750.506667 273.493333 810.666667 512 572.16 750.506667 810.666667 810.666667 750.506667 572.16 512z' 
  )  //绘制叉号
  .attr('fill', 'red'); // 添加颜色
  1. 使用,只需要添加marker-end属性
line.attr('marker-end', 'url(#close)');

tranferData

tranferData = (d3, data) => {
  // precompute level depth
  data.forEach((l, i) => l.forEach(n => (n.level = i)));

  const nodes = data.reduce((a, x) => a.concat(x), []);
  const nodes_index = {};
  nodes.forEach(d => (nodes_index[d.id] = d));

  // objectification
  nodes.forEach(d => {
    d.parents = (d.parents === undefined ? [] : d.parents).map(
      p => nodes_index[p]
    );
  });

  // precompute bundles
  data.forEach((l, i) => {
    const index = {};
    l.forEach(n => {
      if (n.parents.length === 0) {
        return;
      }

      const id = n.parents
        .map(d => d.id)
        .sort()
        .join('--');
      if (id in index) {
        index[id].parents = index[id].parents.concat(n.parents);
      } else {
        index[id] = { id, parents: n.parents.slice(), level: i };
      }
      n.bundle = index[id];
    });
    l.bundles = Object.keys(index).map(k => index[k]);
    l.bundles.forEach((b, i) => (b.i = i));
  });

  const links = [];
  nodes.forEach(d => {
    d.parents.forEach(p =>
      links.push({ source: d, bundle: d.bundle, target: p })
    );
  });

  const bundles = data.reduce((a, x) => a.concat(x.bundles), []);

  // reverse pointer from parent to bundles
  bundles.forEach(b =>
    b.parents.forEach(p => {
      if (p.bundles_index === undefined) {
        p.bundles_index = {};
      }
      if (!(b.id in p.bundles_index)) {
        p.bundles_index[b.id] = [];
      }
      p.bundles_index[b.id].push(b);
    })
  );

  nodes.forEach(n => {
    if (n.bundles_index !== undefined) {
      n.bundles = Object.keys(n.bundles_index).map(k => n.bundles_index[k]);
    } else {
      n.bundles_index = {};
      n.bundles = [];
    }
    n.bundles.forEach((b, i) => (b.i = i));
  });

  links.forEach(l => {
    if (l.bundle.links === undefined) {
      l.bundle.links = [];
    }
    l.bundle.links.push(l);
  });

  // layout
  const padding = 8;
  const node_height = 22;
  const node_width = 70;
  const bundle_width = 40;
  const level_y_padding = 16;
  const metro_d = 4;
  const c = 16;
  const min_family_height = 16;

  nodes.forEach(
    n => (n.height = (Math.max(1, n.bundles.length) - 1) * metro_d)
  );

  let x_offset = padding;
  let y_offset = padding;
  data.forEach(l => {
    x_offset += l.bundles.length * bundle_width;
    y_offset += level_y_padding;
    l.forEach((n, i) => {
      n.x = n.level * node_width + x_offset;
      n.y = node_height + y_offset + n.height / 2;

      y_offset += node_height + n.height;
    });
  });

  let i = 0;
  data.forEach(l => {
    l.bundles.forEach(b => {
      b.x =
        b.parents[0].x +
        node_width +
        (l.bundles.length - 1 - b.i) * bundle_width;
      b.y = i * node_height;
    });
    i += l.length;
  });

  links.forEach(l => {
    l.xt = l.target.x;
    l.yt =
      l.target.y +
      l.target.bundles_index[l.bundle.id].i * metro_d -
      (l.target.bundles.length * metro_d) / 2 +
      metro_d / 2;
    l.xb = l.bundle.x;
    l.xs = l.source.x;
    l.ys = l.source.y;
  });

  // compress vertical space
  let y_negative_offset = 0;
  data.forEach(l => {
    y_negative_offset +=
      -min_family_height +
        d3.min(l.bundles, b =>
          d3.min(b.links, link => link.ys - c - (link.yt + c))
        ) || 0;
    l.forEach(n => (n.y -= y_negative_offset));
  });

  // very ugly, I know
  links.forEach(l => {
    l.yt =
      l.target.y +
      l.target.bundles_index[l.bundle.id].i * metro_d -
      (l.target.bundles.length * metro_d) / 2 +
      metro_d / 2;
    l.ys = l.source.y;
    l.c1 = l.source.level - l.target.level > 1 ? node_width + c : c;
    l.c2 = c;
  });

  const layout = {
    height: d3.max(nodes, n => n.y) + node_height / 2 + 2 * padding,
    node_height,
    node_width,
    bundle_width,
    level_y_padding,
    metro_d
  };
  return { data, nodes, nodes_index, links, bundles, layout };
}