一.背景
d3.js是一个很强大的js可视化工具,Zoomable Circle Packing是d3里面的一种可以展示层级关系的气泡图。
Zoomable Circle Packing地址:https://observablehq.com/@d3/zoomable-circle-packing
效果图:
现在的需求是在气泡图中添加连线,以展示层级关系中节点之间的关系,但是d3库中并没有提供这样的方法。
但是
《2020_IWoR_ClusterViz-Visualizing Architectural Refactoring Opportunities》这篇文章中同样使用了气泡图,并成功实现了连线(着实佩服。。。)
项目地址:https://github.com/FrankHZFang/ClusterViz
效果图:
这个已经基本满足需求了,但是有个问题是,每个连线的两端都是气泡的圆心,在连线很多的情况下,很影响体验,于是我就在原来的基础上,做了一些改进。
这是本人项目中改进前后的效果:
虽然没太大的意义,但是也算是一次学习的机会吧!
二.实现
d3的图都是在一个svg上画出来的,每个气泡都有自己的坐标,所以这个问题的本质其实就是直角坐标中的解方程问题。
原理如下:
只需要求出x_offset和y_offset即可。
思路是通过两个气泡的坐标求出斜率,然后结合两个气泡的半径,通过勾股定理求出。
公示如下:
解得:
接下来就可以分为以下四种情况:
但是由于k是有正负之分的(y_offset也是),所以归根结底只有两种情况,即(x1, y1)和(x2, y2)的左右问题。
伪代码如下:
if(x1 < x2){
x1 += x1_offset;
y1 += y1_offset;
x2 -= x2_offset;
y2 -= y2_offset;
}else{
x1 -= x1_offset;
y1 -= y1_offset;
x2 += x2_offset;
y2 += y2_offset;
}
然后对k = 0和k不存在这样的情况做一下讨论就可以了,这样处理完就可以实现文章开头提到的效果啦!
三.源码
由于项目特殊,有些代码是特殊需要,我在这里把核心的代码贴出来吧,本人初学js,代码有些笨重,又需要改进的地方还望指正!
//存放连线数据中所有处理好后的坐标信息
var circleCoordinate = [];
var links = svg_global.append('g')
.style('stroke', '#aaa')
.attr("class", "packageLink")
.selectAll('line')
.data(jsonLinks) //连线数据
.enter().append('line');
//对连线数据中的每个坐标进行处理
jsonLinks.forEach(function (d){
//获取两个圆(source和target)的transform属性(包含坐标信息)和半径
var source_transform = d3.select("#" + d.source_id).attr("transform");
var target_transform = d3.select("#" + d.target_id).attr("transform");
var r1 = d3.select("#" + d.source_id).attr("r");
var r2 = d3.select("#" + d.target_id).attr("r");
//求初始情况下的两个圆心坐标
var x1 = parseFloat(source_transform.slice(source_transform.indexOf("(") + 1, source_transform.indexOf(",")));
var y1 = parseFloat(source_transform.slice(source_transform.indexOf(",") + 1, source_transform.indexOf(")")));
var x2 = parseFloat(target_transform.slice(target_transform.indexOf("(") + 1, target_transform.indexOf(",")));
var y2 = parseFloat(target_transform.slice(target_transform.indexOf(",") + 1, target_transform.indexOf(")")));
//求斜率(考虑斜率正无穷问题)
if(x1 !== x2){
var k = (y2 - y1) / (x2 - x1);
}else{
var k;
}
if(typeof(k) !== "undefined"){
//求偏移量
var x1_offset = Math.sqrt((r1 * r1) / (k * k + 1));
var y1_offset = Math.sqrt((r1 * r1) / (k * k + 1)) * k;
var x2_offset = Math.sqrt((r2 * r2) / (k * k + 1));
var y2_offset = Math.sqrt((r2 * r2) / (k * k + 1)) * k;
if(x1 > x2){
x1 -= x1_offset;
y1 -= y1_offset;
x2 += x2_offset;
y2 += y2_offset;
}else{
x1 += x1_offset;
y1 += y1_offset;
x2 -= x2_offset;
y2 -= y2_offset;
}
}else{
if(y1 > y2){
y1 -= r1;
y2 += r2;
}else if(y1 < y2){
y1 += r1;
y2 -= r2;
}
}
//存放每个坐标信息
var temp_coordinate = {};
temp_coordinate["id"] = d.source_id + "_" + d.target_id;
temp_coordinate["x1"] = x1;
temp_coordinate["y1"] = y1;
temp_coordinate["x2"] = x2;
temp_coordinate["y2"] = y2;
//添加到数组
circleCoordinate.push(temp_coordinate);
})
//下面四个方法分别取每个连线的x1,y1,x2,y2
function getTranslateX1(source_id, target_id){
var link_id = source_id + "_" + target_id;
return circleCoordinate.find((n) => n.id === link_id).x1;
}
function getTranslateY1(source_id, target_id){
var link_id = source_id + "_" + target_id;
return circleCoordinate.find((n) => n.id === link_id).y1;
}
function getTranslateX2(source_id, target_id){
var link_id = source_id + "_" + target_id;
return circleCoordinate.find((n) => n.id === link_id).x2;
}
function getTranslateY2(source_id, target_id){
var link_id = source_id + "_" + target_id;
return circleCoordinate.find((n) => n.id === link_id).y2;
}
//对连线添加坐标信息,每添加时调用上方的四个方法,向数组中查询这个连线处理后的坐标信息
links.attr("x1", function (d) {
return getTranslateX1(d.source_id, d.target_id) + diameter_global / 2;
})
.attr("y1", function (d) {
return getTranslateY1(d.source_id, d.target_id) + diameter_global / 2;
})
.attr("x2", function (d) {
return getTranslateX2(d.source_id, d.target_id) + diameter_global / 2;
})
.attr("y2", function (d) {
return getTranslateY2(d.source_id, d.target_id) + diameter_global / 2;
});