搜索文章

输入关键词开始搜索

【精】如何用D3.js画一个可交互的关系图

数据可视化#数据可视化#D3#AI#数据结构

我们需要实现一个体现人物关联关系的可交互的图形,类似这样:

demo

要求:

1、每个节点都是可以拖动的,且拖动的时候,与该节点关联的线条、箭头,都要随之同步移动;

2、页面要支持缩放和平移;

3、点击节点和线条上的文字,可以在页面底部显示其他相关的信息。

虽然目前有一些开源组件可以实现大致的效果(比如vis.js),但是我们考虑到后续需求可能存在一些比较特殊的个性化内容,为了增加我们的自定义能力,准备考虑用D3.js来自己实现这个图。

接下来就对里面的每一块内容进行拆分,分析其实现方案。

数据结构

关系图的数据结构,基本上都是类似的,主要分为节点和关系线条两部分。

节点(Node)

节点的数据部分,要注意给每一个节点设置一个唯一的id,因为后面其他元素的定位、交互,都要依赖这个唯一id。

另外这个图的层级是预先排版好的,每个节点都有确定的层级,因此每个节点还需要带有一个depth属性。

[{
		id: 1,
		name: '雷军',
		type: 'person',
		depth: 0,
	},
	{
		id: 2,
		name: '天津拾米企业管理合伙企业(有限合伙)',
		type: 'company',
		depth: 1,
	},
	{
		id: 3,
		name: '天津未来新世界传媒有限公司',
		type: 'company',
		depth: 1,
	},
	{
		id: 4,
		name: '马化腾',
		type: 'person',
		depth: 2,
	},
	{
		id: 5,
		name: '深圳市腾讯计算机系统有限公司',
		type: 'company',
		depth: 2,
	},
]

关系(Relationship)

关系数据,需要包含关系线条的起止节点id,以及这个关系的类型(用于后面画关系中间的标签内容):

[{
		from: 1,
		to: 2,
		type: 'invest'
	},
	{
		from: 1,
		to: 3,
		type: 'executive'
	},
	{
		from: 3,
		to: 4,
		type: 'executive'
	},
	{
		from: 4,
		to: 5,
		type: 'invest'
	},
	{
		from: 5,
		to: 2,
		type: 'invest'
	}
]

计算节点的坐标

因为展示的时候是按照层级展示的,因此需要先计算好每个节点在页面上的坐标。

逻辑就是将同一层级的节点筛选出来,然后根据该层级的节点数量以及画布宽度,计算每个节点的位置。

function computeCoordinatesForNodes(nodes) {
	// 垂直高度
	let verticalHeight = 100;
	// 水平间隔
	let horizontalWidth = 100;

	// 先按照层级拆分数据
	let depths = [];
	nodes.forEach(node => {
		let depth = node.depth;
		if ('undefined' === typeof depths[node.depth]) {
			depths[node.depth] = [];
		}
		depths[node.depth].push(node);
	});

	let nodesWithCoordinates = [];

	// 单个节点占据的区域范围高度,这个是全局固定不变的
	let singleNodeAreaHeight = (options.grid.height - options.grid.paddingTop * 2) / depths.length;
	for (let i = 0; i < depths.length; i++) {
		// 单个节点占据的区域范围宽度,这个每一行都不一样;但是同一行的是相同的
		let singleNodeAreaWidth = (options.grid.width - options.grid.paddingLeft * 2) / depths[i].length;
		for (let j = 0; j < depths[i].length; j++) {
			// 这个画个图好理解一些
			let nodeX = options.grid.paddingLeft + (j + 0.5) * singleNodeAreaWidth;
			let nodeY = options.grid.paddingTop + (i + 0.5) * singleNodeAreaHeight;

			depths[i][j]['x'] = nodeX;
			depths[i][j]['y'] = nodeY;
			nodesWithCoordinates.push(depths[i][j]);
		}
	}

	return nodesWithCoordinates;
}

页面元素

由于这个关系在极限数据下,也不会太多,因此我们选择用SVG来作为底层实现方案,这样便于操控。

因此页面上的元素,也是利用的SVG的各种元素来实现的。

圆(circle)

通过SVG的circle元素来作图,比较简单。

function drawNodes(nodeGroups)
{
	nodeGroups.append('circle')
		.attr('r', n => {
			// TODO:临时处理
			return n.type === 'person' ? options.node.person.radius : options.node.company.radius;
		})
		.attr('fill', n => {
			return n.type === 'person' ? options.node.person.backgroundColor : options.node.company
				.backgroundColor;
		})
}

**注意:**因为后面拖拽的时候,圆形和里面的文字是需要同步移动的,因此这里需要将每个node和文字,用一个g包裹起来:

function drawNodesAndTexts(nodes) {
	let nodeGroups = getContainer().selectAll('g')
		.data(nodes)
		.enter()
		.append('g')
		.attr('id', n => `node_${n.id}`)
		.classed('node', true)
		.attr('transform', (d) => {
			return `translate(${d.x}, ${d.y})`;
		})
		// 给整个组绑定拖拽事件
		.call(drag());

		drawNodes(nodeGroups)

		drawTexts(nodeGroups)
}

文本(text、tspan)

如果只是一行文本,通过text来实现即可;但是我们这个需求,因为文字内容不固定,可能有超过一行的情况,因此需要通过tspan来实现换行功能。

function drawTexts(nodeGroups)
{
	nodeGroups.append('text')
		.attr('id', n => 'text' + n.id)
		.attr('font-size', n => {
			return n.type === 'person' ? personFontSize + 'px' : companyFontSize + 'px';
		})
		.attr('fill', n => {
			return n.type === 'person' ? 'white' : 'yellow';
		})
		.selectAll('tspan')
		.data(n => {
			return wrapTextForAbsolute(n)
		})
		.enter()
		.append('tspan')
		.attr('x', s => s.x)
		.attr('y', s => s.y)
		.text(s => s.content)
}

上面的wrapTextForAbsolute(n)就是对文本进行换行处理,并计算换行后每个tspan的横纵坐标的函数:

function wrapTextForAbsolute(node) {
    let fontSize = node.type === 'person' ? personFontSize : companyFontSize;
    let textLength = fontSize * node.name.length;
    let diameter = node.type === 'person' ? options.node.person.radius : options.node.company.radius;
    // 如果一行放得下,就不处理了
    if (diameter * 2 >= textLength) {
        return [{
            content: node.name,
            // 文字要往左偏移
            x: -node.name.length * fontSize / 2,
            y: fontSize / 2
        }];
    }

    // 直径减去1个文字的宽度,作为行宽
    let lineWidth = diameter * 2 - fontSize;
    // 一行能显示几个字
    let wordNumber = Math.floor(lineWidth / fontSize)

    let words = [];
    let level = 0;
    for (let i = 0; i < node.name.length; i += wordNumber) {
        let word = node.name.substr(i, wordNumber);
        words.push({
            content: word,
            x: -word.length * fontSize / 2,
            y: level * fontSize
        });

        level++;
    }

    return words;
}

另外,如果想要做得精细一些,计算本文的长度时,应该将中文和非中文区别对待:中文占据2个宽度单位,非中文占据一个宽度单位:

/**
 * 计算文本的长度
 * 数字和英文算作1个长度单位,中文算作2个长度单位
 * @param {*} str 
 */
export function computeLengthOfContent(str)
{
    let length = 0;
    for (let i = 0; i < str.length; i ++) {
        let ascii = str[i].charCodeAt();
        if (ascii > 127) {
            length += 2;
        } else {
            length ++;
        }
    }
    return length;
}

线条(line)

线条通过SVG的line元素来画的,这个很简单,只需要找到和线条关联的两个节点的坐标即可。

代码如下:

function drawRelationships(relationships) {
    let container = getContainer();

    container.selectAll('line')
        .data(relationships)
        .enter()
        .append('line')
        .attr('x1', r => {
        return nodeMap[r.from].x;
    })
        .attr('y1', r => {
        return nodeMap[r.from].y;
    })
        .attr('x2', r => {
        return nodeMap[r.to].x;
    })
        .attr('y2', r => {
        return nodeMap[r.to].y;
    })
        .attr('from', r => r.from)
        .attr('to', r => r.to)
    // 直线是【一维】的东西,没有任何的【宽度】,所以fill在这里丝毫不适用
    // 所以这里需要用到stroke、stroke-width
        .attr('stroke', 'black')
        .attr('stroke-width', '2px')
}

两个节点之间有多条线怎么画?

// TODO

箭头(path)

这是一个相对较难的部分。

箭头其实是由一个几何三角形构成的,一开始我是准备算出每个箭头的三角形的三个顶点的坐标,然后去作图。后来经过尝试,发现这样做非常麻烦,因此准备换一种实现思路。

由于每个箭头都是固定大小的,因此我们可以预先在(0, 0)这个坐标点画好箭头,然后通过平移+旋转的方式,将其移动到正确的位置进行展示。

画箭头的代码大致如下:

function drawArrows(relationships) {
    let container = getContainer();

    container.selectAll('path')
        .data(relationships)
        .enter()
        .append('path')
        .attr('id', r => `arrow_${r.from}_${r.to}`)
        .attr('d', r => {
            // TODO:待优化提取
            return `M0,20 L-10,-10 L10,-10 L0,20 Z`;
        })
        .attr("fill", "green")
        .attr("fill-opacity", 0.5)
        .attr("stroke", "red")
        .attr("stroke-width", 2.5)
        .style("transform", r => {
            let transform = getCoordinatesForArrows(r);
            return `translate(${transform.translateX}px, ${transform.translateY}px) rotate(${transform.rotate}deg)`;
        })
}

上述代码的关键,是getCoordinatesForArrows(r)这个函数,该函数计算出了箭头移动和旋转所需的数据,其原理是通过将箭头依附的线条平移到原点,然后通过三角函数算出箭头方向和坐标轴的夹角,进而算出所需的数据。

比如箭头依附的线条的真实位置是这样的:

demo

我会先对其进行平移,将线条终点,即图中的点A移动到原点(0, 0),类似这样:

demo

平移后,计算A和X轴的夹角,就非常方便了,直接用B平移后的x、y值就可以算出来。

算出来这个夹角后,我将其应用到箭头三角形,对其旋转,则可以保证箭头的方向和线条是一致的;然后我再给箭头,根据点A的原始坐标值,做一个平移,就可以让箭头位于点A附近了。

具体的代码如下(我为了在后面的拖拽交互中复用代码,因此对代码做了一个抽离,抽出了computePositionForArrow这个复用函数):

function getCoordinatesForArrows(link) {
    let radiusOfNode = nodeMap[link.to].type === 'person' ? options.node.person.radius : options.node.company.radius;

    let fromX = nodeMap[link.from].x;
    let fromY = nodeMap[link.from].y;
    let toX = nodeMap[link.to].x;
    let toY = nodeMap[link.to].y;

    return computePositionForArrow(radiusOfNode, fromX, fromY, toX, toY)
}

function computePositionForArrow(radiusOfNode, fromX, fromY, toX, toY)
{
    // 三角形中心的长度
    // TODO:待计算精确的三角新中心位置
    // TODO:为什么和10相差这么大?
    let centerLengthOfTriangle = 22;

    // 箭头的中心和节点的中心的距离
    let centerBetweenTriangletoNode = centerLengthOfTriangle + radiusOfNode;

    // 线上面的这条边的长度
    let lengthOnTheline = 100;

    let offset = {
        x: toX,
        y: toY
    }

    let totalLineLength = Math.sqrt(Math.pow(fromX - toX, 2) + Math.pow(fromY - toY, 2));
    let xB = onlineX = (fromX - toX) * lengthOnTheline / totalLineLength + toX;
    let yB = onlineY = (fromY - toY) * lengthOnTheline / totalLineLength + toY;

    // 通过将A平移到原点,计算AB与X轴正向轴的夹角,这是三角形需要旋转的角度
    // 为了方便演示,这里重新用其他变量表示
    xB = xB - offset.x;
    yB = yB - offset.y;
    // 平移的角度,比如transform: rotate(45deg);
    let tanB = yB / xB;
    let rotate = Math.atan(tanB) / (Math.PI / 180);
   
    let translateX = (fromX - toX) * centerBetweenTriangletoNode / totalLineLength + toX;
    let translateY = (fromY - toY) * centerBetweenTriangletoNode / totalLineLength + toY;

    // 注意:
    // 1.网页上的坐标系,和平时的二维坐标系有个区别,Y轴是向下为正的
    // 2.rotate的正和负,方向是不一样的:若角度为正,则顺时针方向旋转,否则逆时针方向旋转。
    rotate = rotate >= 0 ? rotate - 90 : rotate + 90;

     // 当线条上的点的Y值大于toNode的Y时(即线在点下方,toY < fromY),就需要对角度做反向处理
     if (toY < fromY) {
        rotate -= 180;
    }

    // rotate += 90;
    return {
        translateX, translateY, rotate
    }
}

线条上的文字(text)

一开始我又陷入误区了,准备去计算这个文字标签的详细的偏移量,然后感觉我这个方案似乎不对,和之前刚开始画三角形箭头的时候一样,我又走入歧途了。

其实这个文字标签,完全可以利用三角形的思维,先把方框画好,然后再将这个方框偏移到原点,然后计算线条中点到原点的偏移量,对方框进行偏移,就可以实现所需的效果了。 我把这个叫做中心点思维

画text标签的代码如下:

/**
 * 画关系线条上的文本标签
 */
function drawRelationTags(relationships) {
	let container = getContainer();

	// 不能直接selectAll('text'),因为页面上已经有其他的text了
	container.append('g').selectAll('text')
		.data(relationships)
		.enter()
		.append('text')
		.attr('id', r => `tag_${r.from}_${r.to}`)
		.attr('font-size', n => {
			return options.relationship.text.fontSize;
		})
		.attr('fill', n => {
			return options.relationship.text.color;
		})
		.attr('x', r => {
			let position = initializeTag(r);
			return position.x;
		})
		.attr('y', r => {
			let position = initializeTag(r);
			return position.y;
		})
		.attr('transform', r => {
			let transform = computeCoordinateForRelationTag(r)
			return `translate(${transform.x}, ${transform.y}) rotate(${transform.rotate})`;
		})
		.style('background-color', '#ADD8E6')
		.text(n => {
			let text;
			switch (n.type) {
				case 'invest':
					text = '投资';
					break;
				case 'executive':
					text = "董事";
					break;
				default:
					text = "其他";
					break;
			}
			console.log(text);

			return text;
		})
}

可以看到上面的代码,有两个关键的函数,分别是:

计算文本方框初始位置(即把方框的中心偏移到原点)的initializeTag()

/**
 * 初始化标签,使其中心位于原点(0, 0),方便后面计算偏移量
 * @param {*} link 
 */
function initializeTag(link)
{
    let text;
    switch (link.type) {
        case 'invest':
            text = '投资';
            break;
        case 'executive':
            text = "董事";
            break;
        default:
            text = "其他";
            break;
    }
    let fontSize = options.relationship.text.fontSize;
    let widthOfText = fontSize * text.length;
    return {
        x: -1 * widthOfText / 2,
        // DEBUG:实际行高要比font-size大一些,因此这里人工做了一点补丁操作,即加了3个像素的Y轴偏移
        y: fontSize / 2 - 3
    }
}

以及计算文本框偏移量(即计算线条中点到原点的偏移量,对方框进行偏移)的computeCoordinateForRelationTag()

function computeCoordinateForRelationTag(link) {
    // 获取线条2个端点的坐标
    let from = getNodePositionById(link.from);
    let to = getNodePositionById(link.to);

    // 线条的中点坐标
    let middle = {
        x: (from.x + to.x) / 2,
        y: (from.y + to.y) / 2
    }

    // 确定旋转的角度
    let relativeLinePosition = {
        x: from.x - to.x,
        y: from.y - to.y
    }

    let tan = relativeLinePosition.y / relativeLinePosition.x
    // 和X轴的夹角
    let angle = Math.atan(tan) / (Math.PI / 180)

    return {
        x: middle.x,
        y: middle.y,
        rotate: angle
    }
}

节点嵌入图片

网上找了一个例子:

<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">
    <defs>
        <pattern id="avatar" width="100%" height="100%" patternContentUnits="objectBoundingBox">
            <image width="1" height="1" xlink:href="http://userimg.yingyonghui.com/head/24/1458708838143/5426424.png-thumb"/>
        </pattern>
        <style>
            circle, rect {
                stroke: #ff9900;
                stroke-width: 5px;
            }
        </style>
    </defs>
    <g>
        <circle cx="100" cy="100" r="50" fill="url(#avatar)"/>
        <rect x="200" y="50" rx="20" ry="20" width="100" height="100" fill="url(#avatar)"/>
    </g>
</svg>

这里有个实战案例,还涉及了图片的自适应高宽问题:

https://blog.csdn.net/weixin_30571465/article/details/95628764

然后项目中具体实现的代码如下(给circle设置fill属性的代码就省略掉了):

function drawImages(nodeGroups) {
	nodeGroups.append('pattern')
	.attr('id', n => {
		return `pattern_${n.id}`;
	})
	.attr('width', n => {
		return '100%';
	})
	.attr('height', n => {
		return '100%';
	})
	// 使pattern内部元素的定位,相对于pattern的边界盒子来定位,这样方便操控
	.attr('patternContentUnits', 'objectBoundingBox')
	.append('svg:image')
	// 高宽匹配,避免图片高宽比例差距较大的时候,出现填充不满的现象
	// 不过这样可能导致图片被拉伸,因此最好还是预先处理好图片,弄成高宽比1:1的
	.attr('preserveAspectRatio', 'none')
	.attr('xlink:href', n => {
		// TODO:测试的时候,先随便写个图片地址
		return 'http://i.thsi.cn/images/zcx/development/CPI.jpg';
	})
	.attr('width', n => {
		return 1;
	})
	.attr('height', n => {
		return 1;
	})
}

交互

页面的缩放与平移(zoom)

缩放和平移用D3的zoom实现,也是比较简单的。

注意要把页面所有元素放入一个g中,即下面的container中。

function initZoom()
{
    let svg = getSVG();
    let container = getContainer();
    let zoomHandler = new d3.zoom()
    // 缩放限制
    .scaleExtent([0.1, 10])
    // 拖拽的范围限制,从左上角坐标到右下角坐标
    .translateExtent([[-100, -100], [options.grid.width + 90, options.grid.width + 100]])
    .on('zoom', () => {        
        container.attr('transform', d3.event.transform) // 添加缩放功能
    })
    
    svg.call(zoomHandler)
}

平移动画

通过CSS的transformtransition来实现。

不过要注意的是,D3对transition做了封装,不能直接用类似attr(‘transition’, ‘all 2s’)的这种写法,而是应该这样:

/**
 * 移动视图
 * @param {*} x 
 * @param {*} y 
 */
function moveView(x, y)
{
    let transition = d3.transition()
    .duration(1500)
    .ease(d3.easeLinear);

    getContainer()
    .transition(transition)
    .attr('transform', `translate(${x}, ${y})`)
}

详见transition的API

拖拽(drag)

这个交互是耗时最久的部分。

虽然D3提供了drag拖拽交互,但是那是针对单个元素的,而我们的拖拽,要保证节点、节点内部的文字、和节点关联的线条、和节点关联的箭头,都同步移动。这样要控制的数据就比较多了。

参考上面画圆(circle)部分的代码,我们是将圆和里面的文本放入了一个g标签中,然后对这个g标签绑定了拖拽事件:

function drawNodesAndTexts(nodes) {
	let nodeGroups = getContainer().selectAll('g')
		.data(nodes)
		.enter()
		.append('g')
		.attr('id', n => `node_${n.id}`)
		.classed('node', true)
		.attr('transform', (d) => {
			return `translate(${d.x}, ${d.y})`;
		})
		// 给整个组绑定拖拽事件
		.call(drag());

		drawNodes(nodeGroups)

		drawTexts(nodeGroups)
}

我们会在这个drag()函数里面,去动态修改相关元素的坐标信息:

function drag()
{
    return d3.drag()
    .on('drag', move)
}

function move(d)
{
    let x = d3.event.x,
        y = d3.event.y;

    if (!inBoundaries(x, y)) {
        return false;
    }
    // 调试用,将当前鼠标坐标显示在页面上
    d3.select('#coordinate').html(`x=${x}, y=${y}`)

    // 设置circle和text的位移
    let element = d3.select(this);
    element.raise()
    .attr('transform', () => {
        return `translate(${d.x = x}, ${d.y = y})`;
    })

    // 设置line的位移
    let id = element.attr('id');
    if ('undefined' === typeof id) {
        return;
    }
    let idInfo = id.split('_')
    if ('undefined' === typeof idInfo[1]) {
        return;
    }
    id = idInfo[1];

    // 【经验教训】通过属性筛选元素,属性的值(即下面的id)必须是一个字符串,不能是数字
    d3.selectAll(`line[from="${id}"]`)
    .attr('x1', x)
    .attr('y1', y)

    d3.selectAll(`line[to="${id}"]`)
    .attr('x2', x)
    .attr('y2', y)


     // 设置三角形箭头的位移
    let radiusOfNode;
    id = parseInt(id)
    data.relationships.forEach(r => {
        // 箭头元素的ID
        let arrowId = `arrow_${r.from}_${r.to}`;
        let transform;
        let needTransform = false;
        if (r.from === id) {
            needTransform = true;
            let toNode = nodeMap[r.to];
            radiusOfNode = nodeMap[r.to].type === 'person' ? options.node.person.radius : options.node.company.radius;
            transform = computePositionForArrow(radiusOfNode, x, y, toNode.x, toNode.y)
        } else if (r.to === id) {
            needTransform = true;
            let fromNode = nodeMap[r.from];
            radiusOfNode = nodeMap[r.to].type === 'person' ? options.node.person.radius : options.node.company.radius;
            transform = computePositionForArrow(radiusOfNode, fromNode.x, fromNode.y, x, y)
        }

        if (needTransform) {
            d3.select(`#${arrowId}`)
            .style("transform", (oldR) => {
                return `translate(${transform.translateX}px, ${transform.translateY}px) rotate(${transform.rotate}deg)`;
            })
        }
    });
}

function inBoundaries(x, y)
{
    // DEBUG:临时写死,后面再修改
    let r = 10;
    return (x >= (0 + r) && x <= (options.grid.width -r))
    && (y >= (0 + r) && y <= (options.grid.height -r))
}

边框透明

直接在g里面添加一个圆弧(arc)即可,且因为节点的偏移是作用在g上面的,这个圆弧画好后,不用单独设置偏移,会自动根据g的位置进行定位,很方便。

边框旋转

灰化

灰化的实现也比较简单,只需要根据逻辑获取对应的数据,然后找到对应的页面元素,修改其样式属性即可。

动画播放

点击

这里会遇到一个问题:当存在元素叠加的时候(比如节点和里面的文字),点击事件如何绑定?

绑定到二者的父元素上面,即g上面。

另外,点击的时候,关联节点和非关联节点的样式都要重新设置过,因为你可能点击多次,之前已经灰化的节点,在下次点击后,可能是需要解除灰化的。

数据驱动的理念现在还是没有一个很好的实现方案。

比如点击节点的时候,和被点击节点相关联的节点和线条不变,其他节点和线条都灰化,这个本来应该通过数据驱动的形式去实现,但是现在其实是手动获取数据,再手动去找到对应元素,然后修改每个元素的样式,这感觉并不是一个好方案。

如何将数据和页面元素解绑?

我之前给页面元素写了很多属性,后面操作数据的时候(比如找到和某个Node相关的线条的数据),无法解绑了。

视觉效果

渐变

渐变是通过defslinearGradient元素来预定义,然后在具体的图形元素(比如circle)中引用这个预定义的渐变来实现的。

定义

/**
 * 预定义渐变色
 * 这里采用线性渐变
 * @param {*} nodes 
 */
export function defineGradients(nodes) {
    // 涨跌幅限制
    let maxRate = 0.1,
        minRate = -0.1;
    nodes.forEach(n => {
        n.rate > maxRate ? maxRate = n.rate : null;
        n.rate < minRate ? minRate = n.rate : null;
    });

    let container = getContainer();
    container.selectAll('defs')
        .data(nodes)
        .enter()
        .append('linearGradient')
        .attr('id', n => `linearGradient_${n.id}`)
        .attr('x1', '0%')
        .attr('y1', '0%')
        .attr('x2', '100%')
        .attr('y2', '100%')
        .selectAll('stop')
        .data(n => {
            return generateGradientColor(n.rate, minRate, maxRate);
        })
        .enter()
        .append('stop')
        .attr('offset', (d, i) => {
            return i === 0 ? '0%' : '100%';
        })
        .attr('stop-color', d => d)
}

/**
 * 计算节点颜色
 * https://www.bbsmax.com/A/D854XNVY5E/
 * @param {*} rate 
 */
export function generateGradientColor(rate, minRate, maxRate) {
    if (null === rate) {
        return [
            data.options.node.normal.backgroundColor.default,
            data.options.node.normal.backgroundColor.default
        ];
    }

    let gradient = data.options.node.normal.backgroundColor.gradient;
    let minRGB = gradient[0].split(',');
    let maxRGB = gradient[1].split(',');
    if (minRGB.length < 3 || maxRGB.length < 3) {
        alert('gradient 配置有误!')
        return false;
    }

    let b = d3.rgb(minRGB[0], minRGB[1], minRGB[2]) // 绿
    let a = d3.rgb(maxRGB[0], maxRGB[1], maxRGB[2]) // 红

    let compute = d3.interpolate(b, a);

    let linear = d3.scaleLinear()
        .domain([minRate, maxRate])
        .range([0, 1])

    // 渐变需要2个颜色,因此对涨跌幅做个处理,生成2个颜色
    // TODO:待移出去
    let fix = rate > 0 ? 0.1 : -0.1;

    let color = [
        compute(linear(rate + fix)),
        compute(linear(rate))
    ]

    return color;
}

引用

d3.select('rect')
  .style('fill', d => {
    return `url(#linearGradient_${d.name})`;
})

布局

力导向

优化

重叠问题

线条上的文本重叠

比如两个线条相交,导致位于中间的文本叠加了。

这个应该可以通过层级来解决,每个元素都有一个不同的层级。

层级问题

比如线条画在了圆圈上面,没有被圆圈遮住。

// TODO

一些技巧

debugger

F11一路跟下去,不要纠结,除非你非常确定问题的原因,否则这就是最高效的调试方法。

辅助线

比如角度的辅助线

比如坐标的辅助线

debug输出函数

开发过程中的输出不要直接删掉,通过调试函数来输出,否则后面出现问题,又要重新添加调试输出,很麻烦。

可以在最终的打包工具中,写个插件,自动把调试代码删除即可。

给样式和id增加一个前缀

这样做的目的是避免和业务方自己定义的样式名/id冲突。

想法

如何理解数据驱动?

这里的关键,是要能够实现数据变更后,页面的绘图层自动随之变更。

这是通过数据监听,即观察者模式来实现的。

那么存储数据的载体是什么?如何对载体进行监听?针对数据驱动的模式,在编写视图层代码时,有哪些要注意的地方呢?

存储数据的载体是什么?

因为页面的元素可能比较多,我们自己创建一个数据结构去存储这些数据是不大现实的。

考虑到视图的变更,都是由CSS样式来决定的,因此我们完全可以利用元素的CSS属性作为数据载体。

SVG元素的样式,大多都会绑定到元素属性(attr)上,因此我们经常通过element.attr(attributeName)去获取数据,比如获取transform的值:

d3.select('#test').attr('transform')

如何对载体进行监听?

可以参考Vue的实现思路:

https://www.jianshu.com/p/23180880d3aa

实现mvvm的双向绑定,是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

由于页面上可能存在多个同类型的元素,因此必须提前确定好规则,给每个元素绑定一个唯一的属性,便于后面根据该属性定位到该元素。

且这个唯一属性必须是和数据本身有关联的,比如数据中的id这种。

编写视图层代码的注意事项

一定要和具体数据解绑,将数据以参数的形式传入进去。

这是为了方便对具体数据监听后,可以传入变更的数据,进而触发页面的重新绘制。

问题

为什么我在第二次拖动节点时,节点会回到初始位置?

这种情况,是在我处理拖拽的时候,给节点所在的g设置transform属性时,没有同时设置节点数据的d.x和d.y时,出现的现象。

这是会出现这个问题的代码:

let element = d3.select(this);
element.raise()
.attr('transform', () => {
	return `translate(${x}, ${y})`;
})

这是没有问题的代码:

let element = d3.select(this);
element.raise()
.attr('transform', () => {
	return `translate(${d.x = x}, ${d.y = y})`;
})

原因就是如果我不设置dx = x和dy = y,那么在拖拽动作触发时,此时节点所在的g,其transform属性的值还是页面初始渲染时的值,因此节点就会跳转到初始位置了。

demo