为了账号安全,请及时绑定邮箱和手机立即绑定

D3 关系图示例(拖动缩放、文字换行居中、hover 高亮)

D3 关系图示例

前段时间实现一个关系图功能,初学 D3,开发中遇到很多问题。经过不断的和改进,终于完成了主体功能:关系节点拖动、整体拖动缩放、节点文字换行、连线文字居中,高亮显示相关节点和连线。

后来,发现很多像我一样 D3 新手在开发类似功能时,都遇到了以上问题,在一步步艰难的摸索。于是,决定将主要代码摘取下来,并加入了详细注释。希望为大家解决部分问题,帮大家少加一会班。也感谢在开发过程中,帮助我解答过问题的朋友们。

  • index.html
<!DOCTYPE html>
<html>

<head>
    <meta charset='utf-8'>
    <title>D3 关系图示例</title>
    <link rel="stylesheet" href="style.css">
    <script src='http://d3js.org/d3.v3.min.js' charset='utf-8'></script>
</head>

<body>
    <div id='canvas'></div>
    <script src="./force.js"></script>
</body>

</html>
  • style.css
html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
}

#canvas {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
}

svg {
  background: rgb(245, 245, 245);
}

text {
  font: Microsoft YaHei;
  pointer-events: none;
  user-select: none;
}

.link {
  stroke-opacity: 1;
  stroke-width: 0.8;
}

.link-active {
  stroke-opacity: 1;
  stroke-width: 3;
}

.linetext {
  font-family: Microsoft YaHei;
  text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}

  • force.js

const api = 'data.json';

const width = 800;
const height = 600;
const initScale = 0.6;
let draging = false;

const nodeConf = {
    fillColor: {
        Human: 'rgb(255, 76, 10)',
        Company: 'rgb(35, 148, 206)'
    },
    strokeColor: {
        Human: 'rgb(244,56,0)',
        Company: 'rgb(35, 148, 206)'
    },
    strokeWidth: {
        Human: 3,
        Company: 0
    },
    textFillColor: {
        Human: '#fff',
        Company: '#fff'
    },
    radius: {
        Human: 36,
        Company: 56
    }
};

const lineConf = {
    strokeColor: {
        SERVE: 'rgb(128, 194, 216)',
        OWN: 'rgb(204, 225, 152)',
        INVEST_C: 'rgb(242, 90, 41)'
    }
};

const nodeTextFontSize = 16;
const lineTextFontSize = 12;

let nodesMap = {};
let linkMap = {};

// 力导向图
const force = d3.layout.force()
    .size([width, height]) // 画布的大小
    .linkDistance(400) // 连线长度
    .charge(-2000); // 排斥/吸引,值越小越排斥

// 全图缩放器
const zoom = d3.behavior.zoom()
    .scaleExtent([0.25, 2])
    .on('zoom', zoomFn);

// 节点拖拽器(使用 d3.behavior.drag 节点拖动失效)
const drag = force.drag()
    .origin(d => d)
    .on('dragstart', dragstartFn)
    .on('drag', dragFn)
    .on('dragend', dragendFn);

// SVG
const svg = d3.select('#canvas').append('svg')
    .attr('width', width)
    .attr('height', height)
    .append('g')
    .call(zoom)
    .on('dblclick.zoom', null);

// 缩放层(位置必须在 container 之前)
const zoomOverlay = svg.append('rect')
    .attr('width', width)
    .attr('height', height)
    .style('fill', 'none')
    .style('pointer-events', 'all');

const container = svg.append('g')
    .attr('transform', 'scale(' + initScale + ')')
    .attr('class', 'container');

// 请求数据,绘制图表
d3.json(api, (error, resp) => {
    if (error) {
        return console.error(error);
    }

    // 初始化
	setTimeout(function() {
		initialize(resp);
	}, 10);
});

// 初始化
function initialize(resp) {
    let {
        nodes,
        relations
    } = resp;

    const nodesLength = nodes.length;

    // 生成 nodes map
    nodesMap = genNodesMap(nodes);

    // 构建 nodes(不能直接使用 api 中的 nodes)
    nodes = d3.values(nodesMap);

    // 起点和终点相同的关系映射
    linkMap = genLinkMap(relations);

    // 构建 links(source 属性必须从 0 开始)
    const links = genLinks(relations);

    // 绑定力导向图数据
    force
        .nodes(nodes) // 设定节点数组
        .links(links); // 设定连线数组

    // 开启力导向布局
    force.start();

    // 手动快速布局
    for (let i = 0, n = 1000; i < n; ++i) {
        force.tick();
    }

    // 停止力布局
    force.stop();

    // 固定所有节点
    nodes.forEach(node => {
        node.fixed = true;
    });

    // 箭头
    const marker = container.append('svg:defs').selectAll('marker')
        .data(force.links())
        .enter().append('svg:marker')
        .attr('id', link => 'marker-' + link.id)
        .attr('markerUnits', 'userSpaceOnUse')
        .attr('viewBox', '0 -5 10 10')
        .attr('refX', nodeConf.radius.Company)
        .attr('refY', 0)
        .attr('markerWidth', 12)
        .attr('markerHeight', 12)
        .attr('orient', 'auto')
        .attr('stroke-width', 2)
        .append('svg:path')
        .attr('d', 'M2,0 L0,-3 L9,0 L0,3 M2,0 L0,-3')
        .attr('fill', link => lineConf.strokeColor[link.type]);

    // 节点连线    
    const linkLine = container.selectAll('.link')
        .data(force.links())
        .enter()
        .append('path')
        .attr('class', 'link')
        .attr({
            'marker-end': link => 'url(#' + 'marker-' + link.id + ')', // 标记箭头
            'd': link => genLinkPath(link),
            'id': link => 'link-' + link.id,
        })
        .style('stroke', link => lineConf.strokeColor[link.type]);

    // 连线的文字
    const lineText = container.append('g').selectAll('.linetext')
        .data(force.links())
        .enter()
        .append('text')
        .style('font-size', lineTextFontSize)
        .attr({
            'class': 'linetext',
            'id': link => 'linktext' + link.id,
            'dx': link => getLineTextDx(link),
            'dy': 5
        });

    lineText.append('textPath')
        .attr('xlink:href', link => '#link-' + link.id)
        // .text(link => link.label);
        .text(link => link.labels);

    // 节点(圆)
    const nodeCircle = container.append('g')
        .selectAll('.node')
        .data(force.nodes())
        .enter()
        .append('g')
        .style('cursor', 'pointer')
        .attr('class', 'node')
        .attr('cx', node => node.x)
        .attr('cy', node => node.y)
        .call(drag); // 节点可拖动

    nodeCircle.append('circle')
        // .style('fill-opacity', .3)
        .style('fill', node => nodeConf.fillColor[node.ntype])
        .style('stroke', node => nodeConf.strokeColor[node.ntype])
        .style('stroke-width', node => nodeConf.strokeWidth[node.ntype])
        .attr('class', 'node-circle')
        .attr('id', node => 'node-circle-' + node.id)
        .attr('r', node => nodeConf.radius[node.ntype]);

    // 鼠标交互
    nodeCircle.on('mouseenter', function (currNode) {
            if (draging) {
                return;
            }
            toggleNode(nodeCircle, currNode, true);
            toggleLine(linkLine, currNode, true);
            toggleMarker(marker, currNode, true);
            toggleLineText(lineText, currNode, true);
        })
        .on('mouseleave', function (currNode) {
            if (draging) {
                return;
            }
            toggleNode(nodeCircle, currNode, false);
            toggleLine(linkLine, currNode, false);
            toggleMarker(marker, currNode, false);
            toggleLineText(lineText, currNode, false);
        });

    // 节点文字
    const nodeText = nodeCircle.append('text')
        .attr('class', 'nodetext')
        .attr('id', node => 'node-text-' + node.id)
        .style('font-size', nodeTextFontSize)
        .style('font-weight', 400)
        .style('fill', ({
            ntype
        }) => nodeConf.textFillColor[ntype])
        .attr('text-anchor', 'middle')
        .attr('dy', '.35em')
        .attr('x', function ({
            name
        }) {
            return textBreaking(d3.select(this), name);
        });

    // 更新力导向图
    function tick() {
        // 节点位置
        nodeCircle.attr('transform', node => 'translate(' + node.x + ',' + node.y + ')');
        // 连线路径
        linkLine.attr('d', link => genLinkPath(link));
        // 连线文字位置
        lineText.attr('dx', link => getLineTextDx(link));
        // 连线文字角度 
        lineText.attr('transform', function (link) {
            return getLineTextAngle(link, this.getBBox());
        });
    }

    // 更新力导向图
    // 注意1:必须调用一次 tick (否则,节点会堆积在左上角)
    // 注意2:调用位置必须在 nodeCircle, nodeText, linkLine, lineText 后
    setTimeout(function() {
        tick();
    }, 10);

    // 监听力学图运动事件,更新坐标
    force.on('tick', tick);

}

function genLinks(relations) {
    const indexHash = {};
    
    return relations.map(function ({
        id,
        startNode,
        endNode,
        label,
        type
    }, i) {
        const linkKey = startNode + '-' + endNode;
        const count = linkMap[linkKey];
        if (indexHash[linkKey]) {
            indexHash[linkKey] -= 1;
        } else {
            indexHash[linkKey] = count - 1;
        }
        
        return {
            id,
            source: nodesMap[startNode],
            target: nodesMap[endNode],
            label,
            type,
            labels: linkMap[linkKey + '-label'],
            count: linkMap[linkKey],
            index: indexHash[linkKey]
        }
    })
}

function genLinkMap(relations) {
    const hash = {};
    relations.map(function ({
        startNode,
        endNode,
        label
    }) {
        const key = startNode + '-' + endNode;
        if (hash[key]) {
            hash[key] += 1;
            hash[key + '-label'] += '、' + label;
        } else {
            hash[key] = 1;
            hash[key + '-label'] = label;
        }
    });
    return hash;
}

function genNodesMap(nodes) {
    const hash = {};
    nodes.map(function ({
        id,
        name,
        ntype
    }) {
        hash[id] = {
            id,
            name,
            ntype
        };
    });
    return hash;
}

// 生成关系连线路径
function genLinkPath(link) {
    const sr = nodeConf.radius.Human;
    const tr = nodeConf.radius.Company;

    const count = link.count;
    const index = link.index;

    let sx = link.source.x;
    let tx = link.target.x;
    let sy = link.source.y;
    let ty = link.target.y;
    
    return 'M' + sx + ',' + sy + ' L' + tx + ',' + ty;
}

function getLineAngle(sx, sy, tx, ty) {
    // 两点 x, y 坐标偏移值
    const x = tx - sx;
    const y = ty - sy;
    // 斜边长度
    const hypotenuse = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) | 1;
    // 求出弧度
    const cos = x / hypotenuse;
    const radian = Math.acos(cos);
    // 用弧度算出角度   
    let angle = 180 / (Math.PI / radian);
    if (y < 0) {
        angle = -angle;
    } else if ((y == 0) && (x < 0)) {
        angle = 180;
    }
    return angle;
}

function zoomFn() {
    const {
        translate,
        scale
    } = d3.event;
    container.attr('transform', 'translate(' + translate + ')scale(' + scale * initScale + ')');
}

function dragstartFn(d) {
    draging = true;
    d3.event.sourceEvent.stopPropagation();
    force.start();
}

function dragFn(d) {
    draging = true;
    d3.select(this)
        .attr('cx', d.x = d3.event.x)
        .attr('cy', d.y = d3.event.y);
}

function dragendFn(d) {
    draging = false;
    force.stop();  
}

function isLinkLine(node, link) {
    return link.source.id == node.id || link.target.id == node.id;
}

function isLinkNode(currNode, node) {
    if (currNode.id === node.id) {
        return true;
    }
    return linkMap[currNode.id + '-' + node.id] || linkMap[node.id + '-' + currNode.id];
}

function textBreaking(d3text, text) {
    const len = text.length;
    if (len <= 4) {
        d3text.append('tspan')
            .attr('x', 0)
            .attr('y', 2)
            .text(text);
    } else {
        const topText = text.substring(0, 4);
        const midText = text.substring(4, 9);
        let botText = text.substring(9, len);
        let topY = -22;
        let midY = 8;
        let botY = 34;
        if (len <= 10) {
            topY += 10;
            midY += 10;
        } else {
            botText = text.substring(9, 11) + '...';
        }

        d3text.text('');
        d3text.append('tspan')
            .attr('x', 0)
            .attr('y', topY)
            .text(function () {
                return topText;
            });
        d3text.append('tspan')
            .attr('x', 0)
            .attr('y', midY)
            .text(function () {
                return midText;
            });
        d3text.append('tspan')
            .attr('x', 0)
            .attr('y', botY)
            .text(function () {
                return botText;
            });
    }
}

function getLineTextDx(d) {

    const sr = nodeConf.radius[d.source.ntype];
    const sx = d.source.x;
    const sy = d.source.y;
    const tx = d.target.x;
    const ty = d.target.y;

    const distance = Math.sqrt(Math.pow(tx - sx, 2) + Math.pow(ty - sy, 2));

    // const textLength = d.label.length;
    const textLength = d.labels.length;
    const deviation = 8; // 调整误差
    const dx = (distance - sr - textLength * lineTextFontSize) / 2 + deviation;

    return dx;
}

function getLineTextAngle(d, bbox) {
    if (d.target.x < d.source.x) {
        const {
            x,
            y,
            width,
            height
        } = bbox;
        const rx = x + width / 2;
        const ry = y + height / 2;
        return 'rotate(180 ' + rx + ' ' + ry + ')';
    } else {
        return 'rotate(0)';
    }
}

function toggleNode(nodeCircle, currNode, isHover) {
    if (isHover) {
        // 提升节点层级 
        nodeCircle.sort((a, b) => a.id === currNode.id ? 1 : -1);
        // this.parentNode.appendChild(this);
        nodeCircle
            .style('opacity', .1)
            .filter(node => isLinkNode(currNode, node))
            .style('opacity', 1);

    } else {
        nodeCircle.style('opacity', 1);
    }

}

function toggleLine(linkLine, currNode, isHover) {
    if (isHover) {
        // 加重连线样式
        linkLine
            .style('opacity', .1)
            .filter(link => isLinkLine(currNode, link))
            .style('opacity', 1)
            .classed('link-active', true);
    } else {
        // 连线恢复样式
        linkLine
            .style('opacity', 1)
            .classed('link-active', false);
    }
}

function toggleLineText(lineText, currNode, isHover) {
    if (isHover) {
        // 只显示相连连线文字
        lineText
            .style('fill-opacity', link => isLinkLine(currNode, link) ? 1.0 : 0.0);
    } else {
        // 显示所有连线文字
        lineText
            .style('fill-opacity', '1.0');
    }
}

function toggleMarker(marker, currNode, isHover) {
    if (isHover) {
        // 放大箭头
        marker.filter(link => isLinkLine(currNode, link))
            .style('transform', 'scale(1.5)');
    } else {
        // 恢复箭头
        marker
            .attr('refX', nodeConf.radius.Company)
            .style('transform', 'scale(1)');
    }
}

function round(num, pow = 2) {
    const multiple = Math.pow(10, pow);
    return Math.round(num * multiple) / multiple;
}

  • data.json
{
    "nodes": [
        {
            "id": 1,
            "name": "刘琪",
            "ntype": "Human"
        },
        {
            "id": 3,
            "name": "许开辰",
            "ntype": "Human"
        },
        {
            "id": 4,
            "name": "便利蜂企业管理咨询(上海)有限公司",
            "ntype": "Company"
        },
        {
            "id": 2,
            "name": "邻久企业管理服务(天津)有限公司",
            "ntype": "Company"
        }
    ],
    "relations": [
        {
            "id": 10,
            "startNode": 1,
            "endNode": 2,
            "label": "法人",
            "type": "OWN"
        },
        {
            "id": 11,
            "startNode": 1,
            "endNode": 4,
            "label": "法人",
            "type": "OWN"
        },
        {
            "id": 12,
            "startNode": 1,
            "endNode": 4,
            "label": "执行董事",
            "type": "SERVE"
        },
        {
            "id": 13,
            "startNode": 1,
            "endNode": 4,
            "label": "监事",
            "type": "SERVE"
        },
        {
            "id": 14,
            "startNode": 1,
            "endNode": 4,
            "label": "总经理",
            "type": "SERVE"
        },
        {
            "id": 25, 
            "startNode": 3,
            "endNode": 4,
            "label": "总经理",
            "type": "SERVE"
        },
        {
            "id": 26, 
            "startNode": 3,
            "endNode": 4,
            "label": "执行董事",
            "type": "SERVE"
        },
        {
            "id": 27, 
            "startNode": 2,
            "endNode": 4,
            "label": "参股",
            "type": "INVEST_C"
        },{
            "id": 22, 
            "startNode": 3,
            "endNode": 2,
            "label": "总经理",
            "type": "SERVE"
        },
        {
            "id": 23, 
            "startNode": 3,
            "endNode": 2,
            "label": "执行董事",
            "type": "SERVE"
        },
        {
            "id": 24, 
            "startNode": 3,
            "endNode": 4,
            "label": "监事",
            "type": "SERVE"
        }
    ]
}
  • 代码中需要异步请求 JSON 数据,无法直接在浏览器打开运行。需要起一个简单的本地服务,在 127.0.0.1 或 localhost 下访问
  • 或者,直接在 Chrome 访问 示例 查看运行效果
  • 代码有很多优化提高的地方,欢迎指出改进
点击查看更多内容
11人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
JS工程师
手记
粉丝
104
获赞与收藏
631

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消