从0到0.5学习d3

91 阅读8分钟

最近接到一个需求,需要使用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中应该再加上startend才能表示一个线段,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还是一头懵,但是通过一步一步的拆解需求,逐步的加深对他的理解,再复杂的需求,最终也都可以完成的。总之,这次的开发体验也是非常棒的一次开发体验,之后也希望有更多类似的成长。