You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Power BI自定义可折叠树:解决点击节点整树重复渲染的方案

Power BI自定义可视化:修复可折叠树节点切换问题

问题说明

开发Power BI自定义可视化的可折叠树组件时,点击节点无法正常实现展开/收起切换。当前点击节点会触发renderTree方法重新渲染整树,且传入子节点作为新根,导致树结构被替换,切换效果异常。

现有核心代码

接口与布局定义

// Declare an interface for the links/connections
interface LinkCoords {
  x: number;
  y: number;
}

// Declare an interface for a tree node
interface TreeNode {
  name: string;
  children?: TreeNode[];
  // Dynamic addition of any incoming columns from analyze by data role
  [key: string]: number | string | undefined | TreeNode[];
}

// Create a tree layout
private tree = d3
  .tree()
  // Height and width of the separations
  .nodeSize([50, 300])
  // Wether or not the nodes have same parent, there should be a separation of 2 units between nodes
  .separation(function separation(a, b) {
    return a.parent == b.parent ? 2 : 2;
  });

// Links properties
private diagonal = d3
  .linkHorizontal<{}, LinkCoords>()
  .x((d) => d.y)
  .y((d) => d.x);

渲染与点击逻辑

// Function that would render the tree on the canvas
private renderTree(measuredData: TreeNode) {
  // Compute the hierarchy
  const root = d3.hierarchy(measuredData);

  // Call the tree function
  this.tree(root);

  // Node variable
  const node = this.treeContainer
    .selectAll(".node")
    .data(root.descendants())
    .enter()
    .append("g")
    .attr("transform", function (d: any) {
      return "translate(" + (d.y + 20) + "," + (d.x + 307) + ")";
    })
    .attr("font-size", 10)
    .on("click", (event, d) => this.click(d.data));

  // Node properties
  node
    .append("rect")
    .attr("width", 150)
    .attr("height", 50)
    .style("stroke", function (d) {
      return d.children ? "#5CCE8A" : "#D45F5F";
    })
    .attr("fill", "grey");

  // Node text properties
  node
    .append("text")
    .attr("x", 70)
    .attr("y", 25)
    .attr("text-anchor", "middle")
    .text(
      (d) => `name: ${d.data.name}, value: ${d.data.V}, import: ${d.data.I}`
    )
    .attr("fill", "white");

  // Create links
  const link = this.treeContainer
    .selectAll(".link")
    .data(root.links())
    .enter();

  // Define the path source and target for the links
  link
    .append("path")
    .attr("class", "link")
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("d", (d) => {
      // Define the source
      const source: LinkCoords = {
        x: (d.source as any).x + 332,
        y: (d.source as any).y + 170,
      };
      // Define the target
      const target: LinkCoords = {
        x: (d.target as any).x + 332,
        y: (d.target as any).y + 20,
      };
      return this.diagonal({ source, target });
    })
    .attr("stroke-width", "1px")
    .attr("stroke-dasharray", "1, 0");
}

// Toggle the node
private click(d: any) {
  if (d.children) {
    // If the clicked node has children
    d._children = d.children; // Hide its children
    d.children = null;
  } else {
    // If the clicked node does not have children
    d.children = d._children; // Show its children
    d._children = null;
  }
  this.renderTree(d);
}

解决方案

1. 保存根节点引用,避免替换树结构

在Visual类中添加全局根节点属性,始终基于原始根节点进行渲染,而非点击的子节点:

private root: d3.HierarchyNode<TreeNode> | null = null;

2. 实现D3完整的enter/update/exit更新模式

当前代码仅使用enter()添加元素,未处理更新和移除,导致重复渲染。修改renderTree方法,实现三阶段更新:

private renderTree() {
  if (!this.root) return;

  // 重新计算树布局
  this.tree(this.root);

  // --------------------------
  // 节点更新逻辑
  // --------------------------
  const node = this.treeContainer.selectAll(".node")
    .data(this.root.descendants(), d => d.data.name); // 使用节点name作为唯一标识绑定数据

  // 移除不需要的节点
  node.exit().remove();

  // 添加新节点
  const nodeEnter = node.enter()
    .append("g")
    .attr("class", "node")
    .attr("font-size", 10)
    .on("click", (event, d) => this.click(d)); // 传入D3层级节点对象

  // 节点矩形
  nodeEnter.append("rect")
    .attr("width", 150)
    .attr("height", 50)
    .attr("fill", "grey");

  // 节点文本
  nodeEnter.append("text")
    .attr("x", 70)
    .attr("y", 25)
    .attr("text-anchor", "middle")
    .attr("fill", "white");

  // 合并enter和update,更新位置和样式
  const nodeMerge = nodeEnter.merge(node);
  nodeMerge
    .attr("transform", d => `translate(${d.y + 20}, ${d.x + 307})`)
    .select("rect")
    .style("stroke", d => d.children ? "#5CCE8A" : "#D45F5F");

  nodeMerge.select("text")
    .text(d => `name: ${d.data.name}, value: ${d.data.V}, import: ${d.data.I}`);

  // --------------------------
  // 连线更新逻辑
  // --------------------------
  const link = this.treeContainer.selectAll(".link")
    .data(this.root.links(), d => `${d.source.data.name}-${d.target.data.name}`);

  // 移除不需要的连线
  link.exit().remove();

  // 添加新连线
  const linkEnter = link.enter()
    .append("path")
    .attr("class", "link")
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("stroke-width", "1px")
    .attr("stroke-dasharray", "1, 0");

  // 合并enter和update,更新路径
  linkEnter.merge(link)
    .attr("d", d => {
      const source: LinkCoords = {
        x: d.source.x + 332,
        y: d.source.y + 170,
      };
      const target: LinkCoords = {
        x: d.target.x + 332,
        y: d.target.y + 20,
      };
      return this.diagonal({ source, target });
    });
}

3. 修正点击事件逻辑,操作层级节点

修改click方法,直接操作D3的HierarchyNode对象,切换其展开状态后重新渲染整树:

private click(d: d3.HierarchyNode<TreeNode>) {
  // 切换子节点的显示/隐藏
  if (d.children) {
    d._children = d.children;
    d.children = null;
  } else {
    d.children = d._children;
    d._children = null;
  }
  // 重新渲染整棵树
  this.renderTree();
}

4. 初始化根节点

在数据加载或更新时,初始化全局根节点并调用渲染:

// 数据更新时调用此方法
private updateData(measuredData: TreeNode) {
  this.root = d3.hierarchy(measuredData);
  this.renderTree();
}

关键说明

  • 始终基于原始根节点渲染,避免点击子节点时替换树结构
  • 使用D3的enter/update/exit模式,确保只更新需要变化的元素,避免重复渲染
  • 操作D3层级节点而非原始数据对象,确保布局计算正确

内容的提问来源于stack exchange,提问作者Hammad Ahmed

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.25 13:49:57