從零開始學D3js:在react中搭建d3力導向圖

網際網路學徒發表於2018-01-12

從零開始學D3js:在react中搭建d3力導向圖

本文作者:BP-Captain

來源:juejin.im/post/5a5755…

D3js是什麼?

  • 是一個能夠製作精美、複雜圖表的資料視覺化庫。
  • 是一個由資料驅動的資料視覺化庫,要把資料繫結到DOM上,然後才能展示。
  • 是一個基於Html、CSS、svg/canvas的js資料視覺化庫。

本文程式碼實現效果:

  • 連線有箭頭
  • 點選節點能改變該節點顏色及其連線線的粗細
  • 能夠縮放
  • 能夠拖拽。

版本:4.X

安裝和匯入

npm安裝:npm install d3

前端匯入:import * as d3 from 'd3';

一、完整程式碼

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import * as d3 from 'd3';
import { Row, Form } from 'antd';

import { chartReq} from './actionCreator';
import './Chart.less';

const WIDTH = 1900;
const HEIGHT = 580;
const R = 30;

let simulation;

class Chart extends Component {
  constructor(props, context) {
    super(props, context);
    this.print = this.print.bind(this);
    this.forceChart = this.forceChart.bind(this);
    this.state = {

    };
  }

  componentWillMount() {
    this.props.dispatch(push('/Chart'));
  }

  componentDidMount() {
    this.print();
  }

  print() {
    let callback = (res) => { // callback獲取後臺返回的資料,並存入state
      let nodeData = res.data.nodes;
      let relationData = res.data.rels;
      this.setState({
        nodeData: res.data.nodes,
        relationData: res.data.rels,
      });
      let nodes = [];
      for (let i = 0; i < nodeData.length; i++) {
        nodes.push({
          id: (nodeData[i] && nodeData[i].id) || '',
          name: (nodeData[i] && nodeData[i].name) || '',
          type: (nodeData[i] && nodeData[i].type) || '',
          definition: (nodeData[i] && nodeData[i].definition) || '',
        });
      }
      let edges = [];
      for (let i = 0; i < relationData.length; i++) {
        edges.push({
          id: (relationData[i] && (relationData[i].id)) || '',
          source: (relationData[i] && relationData[i].start.id) || '',
          target: (relationData[i] && relationData[i].end.id) || '',
          tag: (relationData[i] && relationData[i].name) || '',
        });
      }
      this.forceChart(nodes, edges); // d3力導向圖內容
    };
    this.props.dispatch(chartReq({ param: param }, callback));
  }

  // func
  forceChart(nodes, edges) {
    this.refs['theChart'].innerHTML = '';

    // 函式內其餘程式碼請看下文的**【拆解程式碼】**
    
    }

      render() {
        return (
          <Row style={{ minWidth: 900 }}>
            <div className="outerDiv">
              <div className="theChart" id="theChart" ref="theChart">
    
              </div>
            </div>
          </Row>
        );
      }
    }

    Chart.propTypes = {
      dispatch: PropTypes.func.isRequired,
    };
    
    function mapStateToProps(state) {
      return {
    
      };
    }
    
    const WrappedChart = Form.create({})(Chart);
    export default connect(mapStateToProps)(WrappedChart);
複製程式碼

二、拆解程式碼

1.元件

<div className="theChart" id="theChart" ref="theChart">
</div>
複製程式碼

整個圖都將在div裡繪製。

2.構造節點和連線

在【完整程式碼】中已經構造了節點和連線,但那是基於後臺傳來的資料,可能不夠直觀。現在我給出兩組資料,然後構造節點和連線的資料。

const nodeData = [
    { id: 1, name: '中國' },
    { id: 2, name: '北京' },
    { id: 3, name: '天津' },
    { id: 4, name: '上海' },
    { id: 5, name: '重慶' },
    { id: 6, name: '福建' },
    { id: 7, name: '廣東' },
    { id: 8, name: '廣西' },
    { id: 9, name: '浙江' },
    { id: 10, name: '江蘇' },
    { id: 11, name: '河北' },
    { id: 12, name: '山西' },
    { id: 13, name: '吉林' },
    { id: 14, name: '遼寧' },
    { id: 15, name: '黑龍江' },
    { id: 16, name: '安徽' },
    { id: 17, name: '江西' },
    { id: 18, name: '山東' },
    { id: 19, name: '河南' },
    { id: 20, name: '湖南' },
    { id: 21, name: '湖北' },
    { id: 22, name: '海南' },
    { id: 23, name: '貴州' },
    { id: 24, name: '雲南' },
    { id: 25, name: '新疆' },
    { id: 26, name: '西藏' },
    { id: 27, name: '臺灣' },
    { id: 28, name: '澳門' },
    { id: 29, name: '香港' },
    { id: 30, name: '陝西' },
    { id: 31, name: '甘肅' },
    { id: 32, name: '青海' },
    { id: 33, name: '內蒙古' },
    { id: 34, name: '寧夏' },
    { id: 35, name: '四川' },
    
    { id: 36, name: '福州' },
    { id: 37, name: '廈門' },
    { id: 38, name: '漳州' },
    { id: 39, name: '莆田' },
    { id: 40, name: '南平' },
    { id: 41, name: '龍巖' },
    { id: 42, name: '三明' },
    { id: 43, name: '寧德' },
    { id: 44, name: '泉州' },
];
let nodes = [];
for (let i = 0; i < nodeData.length; i++) {
  nodes.push({
    id: (nodeData[i] && nodeData[i].id) || '', // 節點id
    name: (nodeData[i] && nodeData[i].name) || '', // 節點名稱
  });
}
const relData = [
    { id: 1, source: 1, target: 2, tag: '省份' },
    { id: 2, source: 1, target: 3, tag: '省份' },
    { id: 3, source: 1, target: 4, tag: '省份' },
    { id: 4, source: 1, target: 5, tag: '省份' },
    { id: 5, source: 1, target: 6, tag: '省份' },
    { id: 6, source: 6, target: 36, tag: '地級市' },
    { id: 7, source: 6, target: 37, tag: '地級市' },
    { id: 8, source: 6, target: 38, tag: '地級市' },
    { id: 9, source: 6, target: 39, tag: '地級市' },
    { id: 10, source: 6, target: 40, tag: '地級市' },
    { id: 11, source: 6, target: 41, tag: '地級市' },
    { id: 12, source: 6, target: 42, tag: '地級市' },
    { id: 13, source: 6, target: 43, tag: '地級市' },
    { id: 14, source: 6, target: 44, tag: '地級市' },
    { id: 15, source: 1, target: 7, tag: '省份' },
    { id: 16, source: 1, target: 8, tag: '省份' },
    { id: 17, source: 1, target: 9, tag: '省份' },
    { id: 18, source: 1, target: 44, tag: '省份' },
    { id: 19, source: 1, target: 10, tag: '省份' },
    { id: 20, source: 1, target: 11, tag: '省份' },
    { id: 21, source: 1, target: 12, tag: '省份' },
    { id: 22, source: 1, target: 13, tag: '省份' },
    { id: 23, source: 1, target: 14, tag: '省份' },
    { id: 24, source: 1, target: 15, tag: '省份' },
    { id: 25, source: 1, target: 16, tag: '省份' },
    { id: 26, source: 1, target: 17, tag: '省份' },
    { id: 27, source: 1, target: 18, tag: '省份' },
    { id: 28, source: 1, target: 19, tag: '省份' },
    { id: 29, source: 1, target: 20, tag: '省份' },
    { id: 23, source: 1, target: 21, tag: '省份' },
    { id: 31 source: 1, target: 22, tag: '省份' },
    { id: 32, source: 1, target: 23, tag: '省份' },
    { id: 33, source: 1, target: 24, tag: '省份' },
    { id: 34, source: 1, target: 25, tag: '省份' },
    { id: 35, source: 1, target: 26, tag: '省份' },
    { id: 36, source: 1, target: 27, tag: '省份' },
    { id: 37, source: 1, target: 28, tag: '省份' },
    { id: 38, source: 1, target: 29, tag: '省份' },
    { id: 39, source: 1, target: 30, tag: '省份' },
    { id: 40, source: 1, target: 31, tag: '省份' },
    { id: 41, source: 1, target: 32, tag: '省份' },
    { id: 42, source: 1, target: 33, tag: '省份' },
    { id: 43, source: 1, target: 34, tag: '省份' },
];
let edges = [];
for (let i = 0; i < relData.length; i++) {
  edges.push({
    id: (relData[i] && (relData[i].id)) || '', // 連線id
    source: relData[i].source, // 開始節點
    target: relData[i].target, // 結束節點
    tag: (relData[i].tag) || '', // 連線名稱
  });
}
複製程式碼

依據自己的專案,力導向圖需要什麼就給它什麼。

3.定義力模型

const simulation = d3.forceSimulation(nodes) // 指定被引用的nodes陣列
    .force('link', d3.forceLink(edges).id(d => d.id).distance(150))
    .force('collision', d3.forceCollide(1).strength(0.1))
    .force('center', d3.forceCenter(WIDTH / 2, HEIGHT / 2))
    .force('charge', d3.forceManyBody().strength(-1000).distanceMax(800));
複製程式碼

通過simulation.force()設定力,可以設定這幾種力:

  • Center:重力點,設定力導向圖的力重心位置。設定之後無論怎麼拖拽,力的重心都不會變;不設定的話力重心會改變,但力重心的初始位置會在原點,這意味著剛進入頁面你只能看到1/4的圖,很影響體驗。

  • Collision:節點碰撞作用力,.strength引數範圍為[0,1]。

  • Links:連線的作用力;.distance設定連線兩端節點的距離。

  • Many-Body:.strength的引數為正時,模擬重力,為負時,模擬電荷力;.distanceMax的引數設定最大距離。

  • Positioning:給定向某個方向的力。

通過simulation.on監聽力圖元素位置變化。(請查閱下文【監聽圖元素的位置變化】)

4.繪製svg

const svg = d3.select('#theChart').append('svg') // 在id為‘theChart’的標籤內建立svg
      .style('width', WIDTH)
      .style('height', HEIGHT * 0.9)
      .on('click', () => {
        console.log('click', d3.event.target.tagName);
      })
      .call(zoom); // 縮放
const g = svg.append('g'); // 則svg中建立g
複製程式碼

建立svg,在svg裡建立g,將節點連線等內容放在g內。

  • select:選擇第一個對應的元素

  • selectAll:選擇所有對應的元素

  • append:建立元素

  • style:設定樣式

  • on('click', function()):click設定點選響應事件

  • call(zoom):縮放函式,詳細請查閱下文【縮放】部分

5.繪製連線

const edgesLine = svg.select('g')
    .selectAll('line')
    .data(edges) // 繫結資料
    .enter() // 為資料新增對應數量的佔位符
    .append('path') // 在佔位符上面生成折線(用path畫)
    .attr('d', (d) => { return d && 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y; }) //遍歷所有資料。d表示當前遍歷到的資料,返回繪製的貝塞爾曲線
    .attr('id', (d, i) => { return i && 'edgepath' + i; }) // 設定id,用於連線文字
    .attr('marker-end', 'url(#arrow)') // 根據箭頭標記的id號標記箭頭
    .style('stroke', '#000') // 顏色
    .style('stroke-width', 1); // 粗細
複製程式碼
  • data(),enter(),append():這仨是一起的,繫結資料到建立圖形

  • attr:設定屬性

  • style:設定樣式

  • 連線用貝塞爾曲線繪製:(M 起點X 起點y L 終點x 終點y)

如果你想詳細瞭解貝塞爾曲線,請移步:點我瞭解貝塞爾曲線更多知識.

6.連線名稱

const edgesText = svg.select('g').selectAll('.edgelabel')
    .data(edges)
    .enter()
    .append('text') // 為每一條連線建立文字區域
    .attr('class', 'edgelabel')
    .attr('dx', 80)
    .attr('dy', 0);
edgesText.append('textPath')
    .attr('xlink:href', (d, i) => { return i && '#edgepath' + i; }) // 文字佈置在對應id的連線上
    .style('pointer-events', 'none') // 禁止滑鼠事件
    .text((d) => { return d && d.tag; }); // 設定文字內容
複製程式碼
  • attr()放在.append()後面,表示為.append()建立的元素設定屬性
  • .style('pointer-events', 'none')禁止滑鼠事件:無法選中,無法點選,滑鼠在上面不會變成豎杆。

7.繪製連線上的箭頭

const defs = g.append('defs'); // defs定義可重複使用的元素
const arrowheads = defs.append('marker') // 建立箭頭
    .attr('id', 'arrow')
    // .attr('markerUnits', 'strokeWidth') // 設定為strokeWidth箭頭會隨著線的粗細進行縮放
    .attr('markerUnits', 'userSpaceOnUse') // 設定為userSpaceOnUse箭頭不受連線元素的影響
    .attr('class', 'arrowhead')
    .attr('markerWidth', 20) // viewport
    .attr('markerHeight', 20) // viewport
    .attr('viewBox', '0 0 20 20') // viewBox
    .attr('refX', 9.3 + R) // 偏離圓心距離
    .attr('refY', 5) // 偏離圓心距離
    .attr('orient', 'auto'); // 繪製方向,可設定為:auto(自動確認方向)和 角度值
arrowheads.append('path')
    .attr('d', 'M0,0 L0,10 L10,5 z') // d: 路徑描述,貝塞爾曲線
    .attr('fill', '#000'); // 填充顏色
複製程式碼
  • viewport:可視區域
  • viewBox:實際大小,會自動縮放直至填充viewport

對viewport和viewBox的關係還是不懂,請移步:理解SVG的viewport,viewBox,preserveAspectRatio.

8.繪製節點

const nodesCircle = svg.select('g')
    .selectAll('circle')
    .data(nodes)
    .enter()
    .append('circle') // 建立圓
    .attr('r', 30) // 半徑
    .style('fill', '#9FF') // 填充顏色
    .style('stroke', '#0CF') // 邊框顏色
    .style('stroke-width', 2) // 邊框粗細
    .on('click', (node) => { // 點選事件
        console.log('click');
    })
    .call(drag); // 拖拽單個節點帶動整個圖
複製程式碼

建立圓作為節點。

.call()呼叫拖拽函式。

9.節點名稱

const nodesTexts = svg.select('g')
    .selectAll('text')
    .data(nodes)
    .enter()
    .append('text')
    .attr('dy', '.3em') // 偏移量
    .attr('text-anchor', 'middle') // 節點名稱放在圓圈中間位置
    .style('fill', 'black') // 顏色
    .style('pointer-events', 'none') // 禁止滑鼠事件
    .text((d) => { // 文字內容
        return d && d.name; // 遍歷nodes每一項,獲取對應的name
    });
複製程式碼

這裡禁止滑鼠事件的意義有兩點:

  • 滑鼠移到文字上不會變成豎杆,還是保持箭頭的樣子,這樣體驗好。

  • 我們給節點設定了點選事件,如果沒有禁止滑鼠事件,那麼文字是佔空間的,而且在節點上層,我們點選文字時就無法響應節點事件了。

10.滑鼠移到節點上有氣泡提示

nodesCircle.append('title')
    .text((node) => { // .text設定氣泡提示內容
        return node.name; // 氣泡提示為node的名稱
    });
複製程式碼

11.監聽圖元素的位置變化

simulation.on('tick', () => {
    // 更新節點座標
    nodesCircle.attr('transform', (d) => {
        return d && 'translate(' + d.x + ',' + d.y + ')';
    });
    // 更新節點文字座標
    nodesTexts.attr('transform', (d) => {
        return 'translate(' + (d.x) + ',' + d.y + ')';
    });
    // 更新連線位置
    edgesLine.attr('d', (d) => {
        const path = 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
        return path;
    });
    // 更新連線文字位置
    edgesText.attr('transform', (d, i) => {
        return 'rotate(0)';
    });
});
複製程式碼

12.拖拽

function onDragStart(d) {
    // console.log('start');
    // console.log(d3.event.active);
    if (!d3.event.active) {
    simulation.alphaTarget(1) // 設定衰減係數,對節點位置移動過程的模擬,數值越高移動越快,數值範圍[0,1]
      .restart();  // 拖拽節點後,重新啟動模擬
    }
    d.fx = d.x;    // d.x是當前位置,d.fx是靜止時位置
    d.fy = d.y;
}
function dragging(d) {
    d.fx = d3.event.x;
    d.fy = d3.event.y;
}
function onDragEnd(d) {
    if (!d3.event.active) simulation.alphaTarget(0);
    d.fx = null;       // 解除dragged中固定的座標
    d.fy = null;
}
const drag = d3.drag()
    .on('start', onDragStart)
    .on('drag', dragging) // 拖拽過程
    .on('end', onDragEnd);
複製程式碼

13.縮放

function onZoomStart(d) {
    // console.log('start zoom');
}
function zooming(d) {
    // 縮放和拖拽整個g
    // console.log('zoom ing', d3.event.transform, d3.zoomTransform(this));
    g.attr('transform', d3.event.transform); // 獲取g的縮放係數和平移的座標值。
}
function onZoomEnd() {
    // console.log('zoom end');
}
const zoom = d3.zoom()
    // .translateExtent([[0, 0], [WIDTH, HEIGHT]]) // 設定或獲取平移區間, 預設為[[-∞, -∞], [+∞, +∞]]
    .scaleExtent([1 / 10, 10]) // 設定最大縮放比例
    .on('start', onZoomStart)
    .on('zoom', zooming)
    .on('end', onZoomEnd);
複製程式碼

三、其它效果

1.單擊節點時讓連線線加粗

nodesCircle.on('click, (node) => {
    edges_line.style("stroke-width",function(line){
        if(line.source.name==node.name || line.target.name==node.name){
            return 4;
        }else{
            return 0.5;
        }
    });
})
複製程式碼

2.被點選的節點變色

nodesCircle.on('click, (node) => {
    nodesCircle.style('fill', (nodeOfSelected) => { // nodeOfSelected:所有節點, node: 選中的節點
    if (nodeOfSelected.id === node.id) { // 被點選的節點變色
        console.log('node')
            return '#36F';
        } else {
            return '#9FF';
        }
    });
})
複製程式碼

順便提一下,react中,prevState是前一個state的值。有了state和prevState,就能進行更多判斷,創造更多變化了。

四、在react中使用注意事項

componentDidMount() {
    this.print();
}
print() {
    let callback = (res) => { // callback獲取後臺返回的資料,並存入state
        let nodeData = res.data.nodes;
        let relationData = res.data.rels;
        this.setState({
        nodeData: res.data.nodes,
        relationData: res.data.rels,
        });
        let nodes = [];
        for (let i = 0; i < nodeData.length; i++) {
            nodes.push({
                id: (nodeData[i] && nodeData[i].id) || '',
                name: (nodeData[i] && nodeData[i].name) || '',
                type: (nodeData[i] && nodeData[i].type) || '',
                definition: (nodeData[i] && nodeData[i].definition) || '',
            });
        }
        let edges = [];
        for (let i = 0; i < relationData.length; i++) {
            edges.push({
                id: (relationData[i] && (relationData[i].id)) || '',
                source: (relationData[i] && relationData[i].start.id) || '',
                target: (relationData[i] && relationData[i].end.id) || '',
                tag: (relationData[i] && relationData[i].name) || '',
            });
        }
        this.forceChart(nodes, edges); // d3力導向圖內容
    };
    this.props.dispatch(getDataFromNeo4J({
        neo4jrun: 'match p=(()-[r]-()) return p limit 300',
    }, callback));
}
複製程式碼

在哪裡構造圖?

因為圖是動態的,如果渲染多次(render執行多次,渲染多次),不會覆蓋前面渲染的圖,反而會造成渲染多次,出現多個圖的現象。把構造圖的函式print()放到componentDidMount()內執行,則只會渲染一次。

對節點和連線資料進行增刪改操作後,需要再次呼叫print()函式,重新構造圖。

從哪裡獲取資料?

資料不從redux獲取,傳送請求後callback直接獲取。

五、乾貨:d3專案查詢網址

如果要找D3js例項,您可以狠狠點選這裡:D3js專案檢索.

各位大佬多指教!!!

轉載請按格式註明出處:

本文作者:BP-Captain

來源:juejin.im/post/5a5755…

相關文章