赞
踩
最近项目需要写一个d3的力导向图,之前没接触过d3.js 所以吧这次开发的经历写一下
友情提示:不要让设计设计的华丽呼哨,点多了很卡,而且svg 有些标签是不支持css 控制 某些样式的,也不是很好实现
如果之前没写过d3 的旁友 还不熟悉d3 的话。可以吧d3. js 理解为1个帮助你处理数据的库。
把点与线 给到d3, d3 会根据你传入的长宽 自动给你分配x,y 的位置,自己再通过 js 去点的位置想干嘛干嘛(画点)
###基本的展示
####基本配置
// 生成力
const force = d3
.forceSimulation()
.force('link',d3.forceLink().id((d) => d.id),)
.force('collide', d3.forceCollide(72).strength(0.1))
.force('charge',d3.forceManyBody().strength(-400),)
.force('center', d3.forceCenter());
处理一下线的数据, 两个点可能出现多条线的情况
export const setLinkNumber = (group, type) => { if (group.length === 0) return; const linksA = []; const linksB = []; for (let i = 0; i < group.length; i++) { const link = group[i]; // 对该分组内的关系按照方向进行分类,此处根据连接的实体ASCII值大小分成两部分 if (link.source.id < link.target.id) { linksA.push(link); } else { linksB.push(link); } } // 确定关系最大编号。为了使得连接两个实体的关系曲线呈现对称,根据关系数量奇偶性进行平分。 // 特殊情况:当关系都是连接到同一个实体时,不平分 let maxLinkNumber = 0; if (type === 'self') { maxLinkNumber = group.length; } else { maxLinkNumber = group.length % 2 === 0 ? group.length / 2 : (group.length + 1) / 2; } // 如果两个方向的关系数量一样多,直接分别设置编号即可 if (linksA.length === linksB.length) { let startLinkNumber = 1; for (let i = 0; i < linksA.length; i++) { linksA[i].linknum = startLinkNumber++; } startLinkNumber = 1; for (let i = 0; i < linksB.length; i++) { linksB[i].linknum = startLinkNumber++; } } else { // 当两个方向的关系数量不对等时,先对数量少的那组关系从最大编号值进行逆序编号,然后在对另一组数量多的关系从编号1一直编号到最大编号,再对剩余关系进行负编号 // 如果抛开负号,可以发现,最终所有关系的编号序列一定是对称的(对称是为了保证后续绘图时曲线的弯曲程度也是对称的) let biggerLinks; let smallerLinks; if (linksA.length > linksB.length) { biggerLinks = linksA; smallerLinks = linksB; } else { biggerLinks = linksB; smallerLinks = linksA; } let startLinkNumber = maxLinkNumber; for (let i = 0; i < smallerLinks.length; i++) { smallerLinks[i].linknum = startLinkNumber--; } const tmpNumber = startLinkNumber; startLinkNumber = 1; let p = 0; while (startLinkNumber <= maxLinkNumber) { biggerLinks[p++].linknum = startLinkNumber++; } // 开始负编号 startLinkNumber = 0 - tmpNumber; for (let i = p; i < biggerLinks.length; i++) { biggerLinks[i].linknum = startLinkNumber++; } } }; function getKey(target, source) { const result = target > source ? `${target}:${source}` : `${source}:${target}`; return result; } export const operationData = (chartData, clickType) => { const linkmap = {}; const linkGroup = {}; const { links, dots } = chartData; for (let i = 0; i < links.length; i++) { const link = links[i]; const { target, source } = link; const key = getKey(target, source); if (linkGroup[key]) { linkGroup[key].push(link); linkmap[key] += 1; } else { linkGroup[key] = [links[i]]; } } Object.keys(linkGroup).forEach((groupKey) => { linkmap[groupKey] = linkGroup[groupKey].length; }); // 关联线与点 JSON.parse(JSON.stringify(links)).forEach((e) => { const sourceNode = dots.filter((n) => n.id === e.source)[0]; const targetNode = dots.filter((n) => n.id === e.target)[0]; const nowIndex = links.findIndex( (n) => n.source === e.source && n.target === e.target, ); if (!sourceNode || !targetNode) { links.splice(nowIndex, 1); } else { links[nowIndex].source = sourceNode; links[nowIndex].target = targetNode; } }); for (let i = 0; i < links.length; i++) { let { target, source } = links[i]; target = target.id; source = source.id; const link = links[i]; const key = getKey(target, source); link.size = linkmap[key]; const group = linkGroup[key]; const type = 'noself'; // 标示该组关系是指向两个不同实体还是同一个实体 setLinkNumber(group, type); } return { links, dots }; };
处理好的数据丢到d3里面去
// tick 渲染时执行的方法
force.nodes(dots).alpha(0.01).on('tick', this.tick).restart();
force.force('link').links(links).distance(150);
丢进去 会吐出来有x,y 的数据 如:
数据处理完了 接下来创建dom
线: 线是一个 g 标签包含着N 条线(a)
线内包含着2条线以及线相关的箭头
两条线的目的是因为1条线很细的情况下会很不好hover 到。所以1条粗线 一条细线 ,直接把透明度(opacity) 属性 设置为0 即可
所有箭头状态的属性,因为箭头和线不是"一体"的,所以当hover 的时候,圆点(dot) 的大小会发生变化,箭头的refX,refY,也会发生变化,甚至 箭头的大小变化 refX 和refY也得改变。
export const styleSize = { normal: { refX: 30, markerHeight: 8, markerWidth: 8, }, hover: { refX: 28, markerHeight: 10, markerWidth: 10, }, click: { refX: 19, markerHeight: 17.5, markerWidth: 17.5, }, dotnormal: { refX: 35, markerHeight: 8, markerWidth: 8, }, dothover: { refX: 43, markerHeight: 10, markerWidth: 10, }, dotclick: { refX: 48, markerHeight: 10, markerWidth: 10, }, dotlineclick: { refX: 28, markerHeight: 25, markerWidth: 20, }, }; // isThumb 是否是缩略图 export const drawLine = (svg, type, links) => { const isThumb = type === 'thumb'; const warp = isThumb ? svg.insert('g', '.dragThumb') : svg.append('g'); const lineWarp = warp .attr('class', `${isThumb ? 'thumbG' : 'forceLines forceMainG'}`) .selectAll('g') .data(links) .enter() .append('a') const { refX, markerWidth, markerHeight, } = styleSize.normal; const markerId = (d) => `marker-${(d.id)}`; lineWarp .append('marker') .attr('id', markerId) .attr('markerUnits', 'userSpaceOnUse') .attr('viewBox', '0 -5 10 10') // 坐标系的区域 .attr('refX', refX) // 箭头坐标 .attr('markerWidth', markerWidth) // 标识的大小 .attr('markerHeight', markerHeight) .attr('orient', 'auto') // 绘制方向,可设定为:auto(自动确认方向)和 角度值 .attr('stroke-width', 2) // 箭头宽度 .append('path') .attr('d', 'M0,-5L10,0L0,5') // 箭头的路径 .attr('fill', 'inherit'); //箭头的颜色, 设置箭头的颜色 不可以直接找到箭头然后更改fill 因为真正有颜色的是 箭头里面的dom // 展示的线 const line = lineWarp.append('path') //实线虚线自己控制 .attr('stroke-dasharray', (d) => (虚线 ? '8,5' : '')) .attr('marker-end', (d) => { if (isThumb) return ''; return `url(#${(markerId(d))})`; }); // 实际hover 以及 点击的线 const bkLine = lineWarp.append('path') .attr('stroke-width', 10) .attr('stroke', 'red') .attr('fill', 'none') .attr('opacity', '0') return { lineWarp, line, bkLine, }; };
此处只创建1个圆点
export const drawCircle = (svg, nodes, type) => { const dotWarp = svg .append('g') .attr('class', 'forceNodes forceMainG') .selectAll('g') .data(nodes) .enter() .append('a') .attr('xlink:href', 'javascript:void(0)'); const circle = dotWarp.append('circle') .attr('class', 'forceNode regionNode'); return { circle, dotWarp }; };
在之前讲的tick 中去改变 path 的d属性 以及 点的 位置
this.paths.attr('d', function (data) {
return pathD(data, this);
});
this.bkLine.attr('d', function (data) {
return pathD(data, this);
});
this.dotWarp.attr('transform', setTransform); // 圆圈
export const pathD = (d, dom) => { const { x: sx, y: sy } = d.source; const { x: tx, y: ty } = d.target; let dr; // 如果连接线连接的是同一个实体,则对path属性进行调整,绘制的圆弧属于长圆弧,同时对终点坐标进行微调,避免因坐标一致导致弧无法绘制 if (d.target === d.source) { dr = 30 / d.linknum; return ( `M${ sx },${ sy }A${ dr },${ dr } 0 1,1 ${ tx },${ ty + 1}` ); } if (d.size % 2 !== 0 && d.linknum === 1) { // 如果两个节点之间的连接线数量为奇数条,则设置编号为1的连接线为直线,其他连接线会均分在两边 return `M ${sx},${sy},L ${tx},${ty}`; } // 根据连接线编号值来动态确定该条椭圆弧线的长半轴和短半轴,当两者一致时绘制的是圆弧 // 注意A属性后面的参数,前两个为长半轴和短半轴,第三个默认为0, // 第四个表示弧度大于180度则为1,小于则为0,这在绘制连接到相同节点的连接线时用到; // 第五个参数,0表示正角,1表示负角,即用来控制弧形凹凸的方向。本文正是结合编号的正负情况来控制该条连接线的凹凸方向,从而达到连接线对称的效果 const curve = 1.5; const homogeneous = 2; const dx = d.target.x - d.source.x; const dy = d.target.y - d.source.y; dr = (Math.sqrt(dx * dx + dy * dy) * (d.linknum + homogeneous)) / (curve * homogeneous); // 当节点编号为负数时,对弧形进行反向凹凸,达到对称效果 if (d.linknum < 0) { if (dom) { d3.select(dom.previousElementSibling).attr('refY', 4).attr('oldRefY', 4); } dr = (Math.sqrt(dx * dx + dy * dy) * (-1 * d.linknum + homogeneous)) / (curve * homogeneous); return ( `M${sx},${sy}A${dr},${dr} 0 0,0 ${tx},${ty}` ); } if (dom) { d3.select(dom.previousElementSibling).attr('refY', -4).attr('oldRefY', -4); } return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`; };
export const setTransform = (node) => { const { x, y, k } = node; let result = ''; if (x && y)result += `translate(${x},${y})`; if (k)result += ` scale(${k})`; return result; };
####点的拖拽
固定节点的方法就是 设置fx fy=null
// 创建完dotWarp的时候 可以直接绑定 dotWarp.call( d3 .drag() .on('start', this.started) .on('drag', this.dragged) .on('end', this.ended), ); started(d) { const { force } = this; if (!d3.event.active) { force.alphaTarget(0.2).restart(); // 设置衰减系数,对节点位置移动过程的模拟,数值越高移动越快,数值范围[0,1] } d.fx = d.x; d.fy = d.y; }, dragged(d) { const { x, y } = d3.event; if (this.inBoundaries(x, y).isIn) { d.fx = x; d.fy = y; } d.fx = d3.event.x; d.fy = d3.event.y; }, ended(d) { const { force } = this; if (!d3.event.active) { force.alphaTarget(0); } d.fx = null; d.fy = null; },
####zoom 画布的拖拽 以及放大缩小
/** * @params * zoomMin 最小缩小倍数 * zoomMax 最大放大倍数 */ const zoom = d3 .zoom() .scaleExtent([zoomMin, zoomMax]) .on('zoom', () => { const transInfo = d3.event.transform; //绘制框选的时候需要用到 mainSvg.selectAll('g').attr('transform', transInfo); this.transInfo = transInfo; this.$emit('zoom', transInfo); // 告诉外层 发生了拖拽 }); mainSvg.call(zoom).on('dblclick.zoom', null); // 注销双击缩放
放大 缩小
//svg.transition().duration(750).call(zoom.scaleBy,放大的倍数);
// 缩小 0.9倍直到 缩小到最小倍数
svg.transition().duration(750).call(zoom.scaleBy, 0.9);
// 放大 1.1倍直到 放大到最大倍数
svg.transition().duration(750).call(zoom.scaleBy, 1.1);
####点的框选
拖拽中创建一个矩形框,拖拽后判断中心点是否在矩形框中则为被框选中 (位置需要与缩放的scale 配合计算)
####删除
点的删除实际上 就是把 相关点与线全部删除, 并且清空画布后, 重新用删除后的数据重新绘制。
####缩略图
缩略图目前的逻辑是主图的最大倍数作为背景,主图的宽高作为缩略图视野(蓝框)的宽高。
因为缩略图的dom 的宽高是css 定死的,所以给定主图(正常)的宽高 会自动缩放。
主图的拖拽与缩略图背景图的关系会在下面一节说
/** * @params * width 缩略图宽度 * height 缩略图高度 * mainWidth 主图的宽度 * mainHeight 主图的高度 * zoomMax 最大缩放比例 * */ thumbSvg = d3 .select('#thumbWarp') .append('svg'); dragThumb = thumbSvg.append('rect') .attr('class', 'dragThumb') .attr('fill', 'none'); let w; let h; let x = 0; let y = 0; thumbSvg.attr('width', width) .attr('height', height) .attr('id', 'thumbSvg') .attr('viewBox', () => { // 缩略图的宽高为 主图的 最大缩略比例 w = mainWidth * zoomMax; h = mainHeight * zoomMax; // 设置偏移 让背景图移至中心,缩略图与主图的差/ 2 就是需要移动的距离 x = -(w - mainWidth) / 2; y = -(h - mainHeight) / 2; return `${x} ${y} ${w} ${h}`; }); dragThumb.attr('width', mainWidth) .attr('height', mainHeight);
####主图的拖拽、缩放与缩略图
主图的拖拽与缩放 在调用上面的缩放的时候会调用zoom 的on zoom 方法
并将缩放以及拖拽的距离传给 缩略图
因为缩放会造成 主图的 translate 发生变化 与手动拖拽造成的translate 会有差 所以 要扣除缩放造成的偏移
if (!mainTransform.x && !mainTransform.y && mainTransform.k === 1) { this.initSvg(); return; } const { innerZoomInfo, mainWidth, mainHeight, } = this; // 如果传入的 缩放值与之前记录的缩放值不一致 则认为发生了缩放 记录发生缩放后偏移值 if (!innerZoomInfo || innerZoomInfo.k !== mainTransform.k) { this.moveDiff = { x: (mainWidth - innerZoomInfo.k * mainWidth) / 2, y: (mainHeight - innerZoomInfo.k * mainHeight) / 2, }; } const { x: diffX, y: diffY } = this.moveDiff; const { x, y, k } = mainTransform; // 主图偏移以及缩放数据 this.dragThumb .attr('width', mainWidth / k) .attr('height', mainHeight / k) .attr('transform', () => setTransform({ x: -((x - diffX) / k), // 这个地方应该不能直接 除 k 这里的x,y 应该是放大后的x,y应该减去缩放的差值 再 除K y: -((y - diffY) / k), }));
###自己实现一个简单的拓扑图
####碰撞检测
一开始的逻辑,两个正方形任意正方形包裹住另外一个任意一点则为碰撞 如下图。如果画的真正是个圆形的话则存在精度不足的问题
但是这种情况不适于 两个长方形只相交,如:
最后还是需要改为两个圆进行检测,逻辑为任意两个圆形的圆心距离是否小于两圆半径之和,若小于则为碰撞。
Math.sqrt(Math.pow(circleA.x - circleB.x, 2) +
Math.pow(circleA.y - circleB.y, 2))
< circleA.radius + circleB.radius
详情见 aotu实验室 碰撞专栏
####点的分配
点的位置的分配 就是确定中心点后,将关系最多的点作为中心点,其关系点向四周分散,没有关系的同级点,则向中心点四周进行分散,其关系点以确定后位置的点的坐标向周围分散。
根据三角形的正玄、余弦来得值;
假设一个圆的圆心坐标是(a,b),半径为r,角度为d
则圆上每个点的坐标可以通过下面的公式得到
/*
* @params
* d 角度
* r 半径长度
*/
X = a + Math.cos(((Math.PI * 2) / 360) * d) * r;
Y = b + Math.sin(((Math.PI * 2) / 360) * d) * r;
角度可以通过 关系边进行得到. d = 360/关系边的数量,确定第一圈点的角度。
拿到角度后 ,维持一个所有点坐标的对象,再结合碰撞上门的碰撞检测,我们就可以遍历 获取所有点的坐标了
/* * @params * dotsLocations 所有点的坐标信息 */ initNodes() { const { x: centerX, y: centerY } = this.center; const { distance } = this; const getDeg = (all, now) => 360 / (all - (now || 0)); // 把中心点分配给线最多的点 const centerdot = this.dots[0]; centerdot.x = centerX; centerdot.y = centerY; this.dotsLocations[centerdot.id] = { x: centerX, y: centerY }; this.dots.forEach((dot) => { const { x: outx, y: outy } = dot; if (!outx && !outy) { // 兄弟点 (无关系的点) 默认以中心店的10度进行遍历 dot = this.getLocation(dot, centerX, centerY,10, distance).dot; } const { x: cx, y: cy } = dot; const dotsLength = dot.relationDots.length; let { distance: innerDistance } = this; // 获取剩余点的角度 let addDeg = getDeg(dotsLength); dot.relationDots.forEach((relationId, index) => { let relationDot = this.findDot(relationId); if (!relationDot.x && !relationDot.y) { const { dot: resultDot, isPlus, outerR, } = this.getLocation(relationDot, cx, cy, addDeg, innerDistance); if (isPlus) { // 如果第一圈遍历完毕,则开始以 半径 * 2 为第二圈开始遍历 innerDistance = outerR; addDeg = getDeg(dotsLength, index); addDeg += randomNumber(5, 9); //防止第一圈与第二圈的点所生成的角度一致 造成链接的线重叠在一起 } relationDot = resultDot; } }); }); }
// 分配位置 getLocation(dot, cx, cy, addDeg, distance) { // 由第一张图 得知 -90度为最上面 从最上面开始循环 let outerDeg = -90; let outerR = distance; const { distance: addDistance } = this; let firsted; // 用于分布完后一周 while (Object.keys(this.checkDotLocation(dot)).length !== 0) { outerDeg += addDeg; if (outerDeg > 360) { // 转完一圈 随机生成第二圈的角度再开始对当前点进行定位 addDeg = randomNumber(10, 35); outerDeg = addDeg; if (firsted) { outerR += addDistance; } firsted = true; } const innerLocation = getDegXy(cx, cy, outerDeg, outerR); dot = Object.assign(dot, innerLocation); } this.dotsLocations[dot.id] = { x: dot.x, y: dot.y }; return { dot, isPlus: firsted, outerR, }; }
// 碰撞检测 checkDotLocation(circleA) { let repeat = false; if (!circleA.x || !circleA.y) return true; const { forceCollide } = this; console.log(this.dotsLocations) Object.keys(this.dotsLocations).forEach((key) => { if (key === circleA.id) { return; } const circleB = this.dotsLocations[key]; let isRepeat = Math.sqrt(Math.pow(circleA.x - circleB.x, 2) + Math.pow(circleA.y - circleB.y, 2)) < forceCollide * 2; if(isRepeat)repeat = true; }); return repeat; } }
生成时间与D3 的差不多
####碰撞后点的移动 (力?)
碰撞后的逻辑呢 简单的就是已拖动点为圆点,计算碰撞点与圆点的夹角,再通过角度与距离得出碰撞后被碰撞点的x,y的坐标
changeLocation(data, x, y, eliminate) { // 先对原来的点进行赋值 data.x = x; data.y = y; // 对点的坐标进行赋值,使之后的碰撞使用新值进行计算 this.dotsLocations[data.id] = { x, y }; let crashDots = this.checkDotLocation(data); // 获得所有被碰撞的点 Object.keys(crashDots).forEach((crashId) => { if (eliminate === crashId) return; // 碰撞后的碰撞防止 更改当前拖拽元素 const crashDot = this.findDot(crashId); // 获取被碰撞的x,y 值 const { x: crashX, y: crashY } = crashDot; // 此处的角度是要移动的方向的角度 let deg = getDeg(crashDot.x,crashDot.y,data.x,data.y); // - 180 的目的是为了 与上面的黑图角度一致 // 2是碰撞后 移动2个像素的半径 const {x:endX,y:endY} = getDegXy(crashDot.x, crashDot.y, deg - 180, 2); // 讲被碰撞的点作为圆点 改变值 并进行碰撞点的碰撞的碰撞检测(禁止套娃 ) this.changeLocation(crashDot, endX, endY, data.id); }); }
获取夹角角度
function getDeg(x1,y1,x2,y2){ //中心点 let cx = x1; let cy = y1; //2个点之间的角度获取 let c1 = Math.atan2(y1 - cy, x1 - cx) * 180 / (Math.PI); let c2 = Math.atan2(y2 - cy, x2 - cx) * 180 / (Math.PI); let angle; c1 = c1 <= -90 ? (360 + c1) : c1; c2 = c2 <= -90 ? (360 + c2) : c2; //夹角获取 angle = Math.floor(c2 - c1); angle = angle < 0 ? angle + 360 : angle; return angle; }
到此实现一个简单的拓扑图就搞定了。
使用我们自己的force 代替 d3.js 的效果,后期想要什么效果就可以自己再加了 如 拖动主点相关点动,其他关联点不动的需求。
tick方法需要自己手动去调用了
let force = new Force({ x: svgW / 2, y: svgH / 2, distance: 200, forceCollide:30, }); force.nodes(dot); force.initLines(line);
####拖动
这边的tick 是当 点的xy 发生变化的时候 自己去重新构建点和线。再实际项目中每一次拖动就会构建,会比较卡,可以丢到requestAnimationFrame 去调用
dotDoms.on("mousedown", function (d) { dragDom = { data: d, dom: this, }; }); d3.select("svg").on("mousemove", function (d) { if (!dragDom) return; const { offsetX: x, offsetY: y } = d3.event; if (x < -1 || y < -1 || x >= svgH - 10 || y >= svgH - 10) { //边界 dragDom = null; return; } force.changeLocation(dragDom.data, x, y); tick(); }); d3.select("svg").on("mouseup", function (d) { dragDom = null; });
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。