最近接到一个需求,需要使用d3js来绘制一个树状的关系图,大概样子就是一棵从上往下的树,但是其中的节点连线倒是不确定,比如有从第一层指向第二层的,也有第二层指向第一层的,还有同层之间互相指向的。
怎么能尽快完成一个完全不熟悉的功能呢?
是的,我的第一想法就是这个,因为之前做的功能需求,都会被要求在一个很短的时间内完成并上线,哪怕这个东西之前完全没做过的。尽管这一次跟产品说的是我研究一下,但是我感觉该来的终究还是会来的。
所以第一时间想到:要不问一下AI?
说干就干,截图,编辑了一大段我能想到的需求,让 AI 帮我生成一个 demo。
AI 生成之后,这效果,,,完全对不上啊。瞬间感觉,我俩之间必有一个是智障。反正肯定不是我!!!而且这么一长串代码,想改也无从下手。
于是就找产品商量:这玩意。。。。不好搞啊,文档也是英文的,可能需要点时间。
产品说:没事你先研究,反正是领导要求做的。
啊~~!那我就懂了呀。不要求时间。那我就随便来呗,每天汇报一下工作进度就行了。
可万万没想到,仅仅将这个需求做出初步成果就消耗了五天时间。
第一天
看文档是不可能看文档的,太耗时间了。(绝对不是因为我看英文文档看的头疼),那还得借助 AI。不过得一步一步来。
画一棵树画不出来,一口吃不成个胖子,那我们一个点一个点来总可以吧。
1. 帮我绘制一个矩形
svg.append("rect").attr("width", 20).attr("height", 20).style("fill", node.color);
哎哎哎,等等,这种代码,虽然我没见过一模一样的啊,但是我写过 jQuery,也了解过 svg,你是不是在用 jquery 的形式在操作 svg 啊?
2. 帮我绘制一个圆
svg.append("circle").attr("r", 10).style("fill", node.color);
果然,你就是换了个名字的 jQuery。
3. 帮我画一条直线
svg
.append("line")
.attr("x1", 0) // 起点X坐标
.attr("y1", 0) // 起点Y坐标
.attr("x2", 100) // 终点X坐标
.attr("y2", 100) // 终点Y坐标
.style("stroke", "#e3e3e3") // 颜色
.style("stroke-width", 2); // 宽度
哎呀,这不就是换了个形式再写 jQuery 呗,简单。
那还有个问题,我之前学的没学到怎么画箭头,再问一下AI
4. 绘制一个箭头放到线的一端
svg
.append("defs")
.append("marker")
.attr("id", id)
.attr("viewBox", "0 0 12 12")
.attr("refX", 5)
.attr("refY", 3)
.attr("markerWidth", 10)
.attr("markerHeight", 10)
.attr("orient", "auto")
.append("path")
.attr("d", "M1,1 L5,3 L1,5 L1,1")
.attr("fill", color);
marker标签用于在一个线的起点或者终点放置一个标签,这些标记通常用于装饰,比如箭头,圆点等。比如:
<!-- 定义红色箭头 -->
<defs>
<marker id="red" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="red" />
</marker>
</defs>
所以一步一步来的话,AI还是没有那么拉胯的,感觉类库这东西,知道了使用方法,再有相关知识,还是不难的。
接下来就是将今天研究的成果封装成通用方法,就给产品汇报一下进度吧。
之前这个需求是在一个vue3的项目中的,所以为了跟原来功能接洽,也使用了vue3。另外该项目js和ts都支持,为了让这些通用方法在调用的时候方便一点(主要怕自己写着写着忘了),所以使用了ts:
/**
* 使用d3js绘制一个宽度固定,高度根据文字多少绘制的矩形,
* 该矩形判断是否是跟当前搜索条件直接关联
* 直接关联的矩形是一个实心蓝色白字的矩形
* 不是直接关联的矩形是一个蓝色边框,浅蓝色底色,黑字的矩形
* @param {SvgSelection} svg d3js 的选择器
* @param {string} text 矩形内部的文字
* @param {NodeTranslatePosition} postion 矩形绘制的位置
* @param {CompanyRelationType} relation 是否跟当前搜索条件直接关联
* @returns {GraphInfo} 矩形中心点
*/
export function drawRectNode(svg: SvgSelection | SvgGroupSelection, text: string, postion: Position, relation: NodeRelationType) {
// 将文字进行切割, 一行最多RECT_MAX_CHARS个文字
const lines = splitStringByWidth(text, RECT_MAX_CHARS);
// 计算矩形的高度
// 高度 = 文字y轴偏移量 + 行数 * 行高 + 上下边距
let rectHeight = RECT_TEXT_BASE_OFFSET + (lines.length - 1) * RECT_LINE_HEIGHT + RECT_PADDING;
if (lines.length == 1) { rectHeight = RECT_MIN_HEIHGT; }
// 每个节点进行分组
const rectGroup = svg.append<SVGGElement>("g").attr("transform", `translate(${postion.x}, ${postion.y})`).attr("class", "node").attr("cursor", "pointer");
let nodeStyle = getNodeStyle(relation);
let fillColor = nodeStyle.fill;
let borderColor = nodeStyle.stroke;
let textFillColor = nodeStyle.textFill;
rectGroup
.append("rect")
.attr("class", "node-graph")
.attr("x", 0)
.attr("y", 0)
.attr("width", RECT_WIDTH)
.attr("height", rectHeight)
.attr("fill", fillColor)
.attr("stroke", borderColor)
.attr("stroke-width", 1)
.attr("rx", RECT_RADIUS)
.attr("ry", RECT_RADIUS);
const textElement = rectGroup
.append("text")
.attr("transform", `translate(${RECT_WIDTH / 2}, ${RECT_TEXT_TOP})`)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("fill", textFillColor)
.style("font-size", `${FONT_SIZE}px`);
lines.forEach((line, index) => {
let ty = 8 + index * RECT_LINE_HEIGHT;
if (lines.length == 1) {
ty = 16;
}
textElement.append("tspan").attr("dy", ty).attr("x", 0).attr("y", 0).text(line);
});
// 绘制矩形
const rectInfo = { width: RECT_WIDTH, height: rectHeight, };
return rectInfo;
}
export function drawCircleNode(svg: SvgSelection | SvgGroupSelection, text: string, postion: Position) {
// 每个节点进行分组
const circleGroup = svg.append<SVGGElement>("g").attr("transform", `translate(${postion.x}, ${postion.y})`).attr("class", "node").attr("cursor", "pointer");
circleGroup.append("circle").attr("class", "node-graph").attr("r", CIRCLE_RADIUS).attr("cx", 0).attr("cy", 0).attr("fill", MAIN_ORANGE);
circleGroup.append("text").attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", "#fff").attr("y", 1).attr("font-size", FONT_SIZE).text(text);
}
export function drawRelationLine(svg: SvgSelection | SvgGroupSelection, { start, end, text, relation }: RelationLineType) {
let relationStyle = getLineStyle(relation);
drawLineWithText(svg, { start, end, text, dashArray: relationStyle.lineStyle, arrow: relationStyle.arrowColor });
}
第二天
今天要解决的问题,我主要聚焦在,使用一个什么样的数据才能让节点形成一个树状的排布,并且节点间的连线还不能影响他的排列。
问了一下豆包,他说用d3.tree(),这玩意昨天我试过,并没有达到预期的效果,昨天都问你呢,今天咋还倔呢?
还得用 deepseek,服务器繁忙,请稍后再试。 啊?不是哥们?
最终 kimi 给出了一个好像能用的数据结构:
const data = {
nodes: [
{ id: 1, layer: 0, x: null, y: null }, // 第一层
{ id: 2, layer: 1, x: null, y: null }, // 第二层
{ id: 3, layer: 1, x: null, y: null }, // 第二层
{ id: 4, layer: 2, x: null, y: null }, // 第三层
],
links: [
{ source: 2, target: 1 }, // 节点2指向节点1
{ source: 1, target: 3 }, // 节点1指向节点3
{ source: 2, target: 4 }, // 节点2指向节点4
],
};
仔细想想,好像没啥问题,节点是节点,线是线,跟昨天封装的绘制方法也对得上,不过links中应该再加上start和end才能表示一个线段,nodes也需要一个形状来表明节点形状。
找到了数据结构,也就该思考如何布局了。查看产品给出的示例,可以看出,中间一个节点始终是在中线上的, 所以,对于每一层的节点,我决定从中间开始向两边挨个的计算它的位置。第一天的成果中可以看到,每一个节点的都是根据位置来绘制的。
/**
* 根据节点的位置,对节点进行布局
*/
export function layoutNode(layerMap: Map<number, Node[]>) {
layerMap.forEach((layerNodes, layer) => {
if (layerNodes.length <= 0) return;
let baseYOffset = (layer - 1) * LAYOUT_Y_OFFSET;
if (layerNodes.length == 1) {
if (layerNodes[0].shape == "rect") {
layerNodes[0].position.x = -RECT_WIDTH / 2;
layerNodes[0].position.y = baseYOffset;
} else {
layerNodes[0].position.y = baseYOffset + CIRCLE_RADIUS;
}
return;
}
if (layerNodes.length == 2) {
layerCenterTowNodes(layerNodes, 0, baseYOffset);
return;
}
// 从中间向两边布局
// 中间节点的index,中间节点是一个还是两个
const middleIndex = Math.floor((layerNodes.length - 1) / 2);
console.log("layerNodes[middleIndex]", layerNodes[middleIndex]);
// 1 2 3 4
if (layerNodes.length > 1 && layerNodes.length % 2 == 0) {
// 中间是两个节点
layerCenterTowNodes(layerNodes, middleIndex, baseYOffset);
} else {
if (layerNodes[middleIndex].shape == "rect") {
layerNodes[middleIndex].position.x = -RECT_WIDTH / 2;
layerNodes[middleIndex].position.y = baseYOffset;
} else {
layerNodes[middleIndex].position.y = baseYOffset + CIRCLE_RADIUS;
}
}
const lastIndex = layerNodes.length - 1;
for (let i = middleIndex - 1; i >= 0; i--) {
computeNodePosition(layerNodes[i + 1], layerNodes[i], baseYOffset, "left");
let rightIndex = lastIndex - i;
computeNodePosition(layerNodes[rightIndex - 1], layerNodes[rightIndex], baseYOffset, "right");
}
});
}
造一组数据调用这个方法,看一下效果,哎,完美(不一定真完美)。
剩下的时间干点啥呢?看一下时间,三点, 饮茶先了。
也不能真啥也不干。 研究一下怎么缩放吧
// 定义缩放
const zoom: any = d3
.zoom()
.scaleExtent([0.5, 5]) // 缩放范围
.on("zoom", (event) => {
svgZoomG.attr("transform", event.transform);
});
// 应用缩放行为到 SVG
svg.call(zoom);
还不下班,再研究一下怎么拖拽节点:
/**
* 节点拖拽事件
* @param event
* @param d
*/
function dragged(event: any, d: any) {
d.position.x += event.dx;
d.position.y += event.dy;
// @ts-ignore
d3.select(this).attr("transform", `translate(${d.position.x},${d.position.y})`);
updateLinks(linksGroup as SvgGroupSelection, linkList, nodesMap);
}
/**
* 拖动节点时更新箭头位置
*/
export function updateLinks(linksGroup: SvgGroupSelection, linkList: LineArrow[], nodesMap: Map<string, Node>) {
linksGroup
?.selectAll(".link")
.data(linkList)
.each(function (d: any) {
let sourceNode = nodesMap.get(d.source);
let targetNode = nodesMap.get(d.target);
let { start, end } = getLinePosition(sourceNode as Node, targetNode as Node);
// @ts-ignore
d3.select(this).select(".linkLine").attr("x1", start.x).attr("y1", start.y).attr("x2", end.x).attr("y2", end.y);
// 计算线段方向,使文字使用在线的左边
const { angle, textX, textY } = getTextDirection(start, end);
// 更新当前分组中的文本标签的位置
d3.select(this).select(".lineText").attr("x", textX).attr("y", textY).attr("transform", `rotate(${angle} ${textX},${textY})`);
});
}
差不多了,进度完美。
第三天
有了标准的数据,就可以考虑怎么将接口获取的数据转换成这个标准数据了。
所以第三天主要完成的工作就是与产品沟通,使用什么数据,沟通好了之后,研究怎么生成需要的标准数据。
与业务相关,代码就不贴了(^-^)。总之就是一个方法将元数据转换成目标数据。
第四天
发现一个很严重的问题,有几个节点在不同层级,拧住了。但是他本不应该出现交叉的,根本原因是每一层添加了节点的顺序,跟其计算的位置相关,所以本应该在前面的节点出现在了后面。所以每一层的节点需要一点小小的排序。要实现这个排序,将存储的数据结构也要修改。
// 新的存储数据结构
// 对已经生成的Node进行缓存
const nodesMap = new Map<string, Node>();
// 每一层的节点进行保存,由于是保存的一层一层的数据,方便进行排序,计算每一层节点的位置等
const layerMap = new Map<number, Node[]>();
// 所有节点列表,在绘制的时候,直接使用数组遍历会方便很多
let nodeList: Node[] = [];
// 所有关系链列表。
let linkList: LineArrow[] = [];
/**
* 对节点进行排序
*/
export function sortLayer(layerMap: Map<number, Node[]>) {
layerMap.forEach((layerNodes, layer) => {
if (layer <= 2) return;
let lastLayer = layerMap.get(layer - 1) as Node[];
let sortLayerIds = lastLayer.reduce((resIds: string[], curNode, index) => {
return resIds.concat([...curNode.childIds]);
}, []);
let sortedLayerNodes: Node[] = [];
// {siblingId: curNode}
// 产品有个需求是在一定条件下,下一层的节点移动到上一层,所以对兄弟节点也需要处理一下
let siblingNodeMap = new Map<string, Node>();
let layerNodesMap = new Map<string, Node>();
layerNodes.forEach((node, index) => {
if (node.sibling) {
siblingNodeMap.set(node.sibling.id, node);
} else {
layerNodesMap.set(node.id, node);
}
});
sortLayerIds.forEach((id, index) => {
let node = layerNodesMap.get(id);
if (node) {
if (siblingNodeMap.has(id)) {
// 先添加兄弟节点,再添加当前节点
sortedLayerNodes.push(siblingNodeMap.get(id) as Node);
siblingNodeMap.delete(id);
sortedLayerNodes.push(node);
} else {
sortedLayerNodes.push(node);
}
}
});
layerMap.set(layer, sortedLayerNodes);
});
}
核心思想是根据上一层节点的位置,计算出下一层节点的顺序。
剩余的时间先研究一下怎么给节点添加事件,为明天的工作铺好路
nodesGroup &&
nodesGroup
.selectAll(".node")
.data(nodeList)
// @ts-ignore
.call(d3.drag().on("drag", dragged))
.on("mouseover", nodeMouseover)
.on("mouseout", nodeMouseout)
.on("click", onSelectContectNodeAndLink);
linksGroup && linksGroup.selectAll(".link").data(linkList).on("mouseover", lineMouseover).on("mouseout", lineMouseout);
svg && svg.on("click", onResetLines);
也是在这里添加事件的时候,发现都是需要一个数组形式的节点列表,所以在排序的时候还是保留了之前的 nodeList,目的就是为了这里使用。
第五天
要研究一下节点的各种事件,为昨天声明的各种方法添加最终实现。
首先是节点的各种事件:
/**
* 节点移入事件
* @param event
* @param d
*/
function nodeMouseover(event: any, d: any) {
if (hasSelectNode) return;
// 有.direct-node类名的node要变颜色
// @ts-ignore
const rect = d3.select(this).select(".node-graph");
if (rect.classed("direct-node")) {
rect.attr("fill", MAIN_BLUE_HOVER);
}
colorfulLine(d.id, linksGroup as SvgGroupSelection, linkList);
}
/**
* 节点移出事件
* @param event
* @param d
*/
function nodeMouseout(event: any, d: any) {
if (hasSelectNode) return;
// 有.direct-node类名的node要变颜色
// @ts-ignore
const rect = d3.select(this).select(".node-graph");
if (rect.classed("direct-node")) {
rect.attr("fill", MAIN_BLUE);
}
resetLineColor(linksGroup as SvgGroupSelection, linkList);
}
// 节点点击事件
function onSelectContectNodeAndLink(event: any, d: any) {
const selectNodeId = d.id;
const selectedLinks = linkList.filter((link) => {
return link.source === selectNodeId || link.target === selectNodeId;
});
const selectedNodeIds = selectedLinks.map((link) => {
return link.source === selectNodeId ? link.target : link.source;
});
selectedNodeIds.push(selectNodeId);
highlightSelectedNodes(selectedNodeIds);
highlightSelectedLines(selectedLinks);
resetLineColor(linksGroup as SvgGroupSelection, linkList);
colorfulLine(selectNodeId, linksGroup as SvgGroupSelection, linkList);
hasSelectNode = true;
}
线段事件:
/**
* 线段移入事件
* @param event
* @param d
*/
function lineMouseover(event: any, d: LineArrow) {
if (hasSelectNode) return;
// @ts-ignore
const lineGroup = d3.select(this);
const line = lineGroup.select("line");
const text = lineGroup.select("text");
const lineStyle = getLineStyle(d.relationType);
let lineColor = lineStyle.lineColor;
line.attr("stroke", lineColor);
text.attr("fill", lineColor);
}
/**
* 线段移出事件
* @param event
* @param d
*/
function lineMouseout(event: any, d: LineArrow) {
if (hasSelectNode) return;
// @ts-ignore
const lineGroup = d3.select(this);
const line = lineGroup.select("line");
const text = lineGroup.select("text");
line.attr("stroke", DEFAULT_GRAPH);
text.attr("fill", DEFAULT_TEXT);
}
完成啦!!!
总结
对于这种需要调研,研究的功能需求,在以前,没有这种充足的时间去调研,所以往往做起来很赶时间,为了尽可能快的完成需要求,往往都是看有没有类似的案例去改一下,或者问一下AI。但是这次没有找到类似的案例,问AI也没有起到很好的效果的情况下,一步一步的实现这个功能,最终达到一个比较符合预期的效果。
在这个期间,虽然没有规定在特定时间内必须完成,但是每一步进度都会及时的给产品汇报。我觉得这就是一个比较好的工作状态。
另外,也希望产品同学能给予开发同学更多的信任,尤其是对于一个需要调研的功能的时候,大家统一战线了,才能更快更好地完成任务。不用一直追着问为什么这个功能需要这么久呀。
最后,虽然接到需要的时候对于d3还是一头懵,但是通过一步一步的拆解需求,逐步的加深对他的理解,再复杂的需求,最终也都可以完成的。总之,这次的开发体验也是非常棒的一次开发体验,之后也希望有更多类似的成长。