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

如何基于D3.js将三级层级气泡图改造为四级可点击版本?

问题:如何将D3.js层级气泡图改造为四级可点击交互?

我用D3.js做了一个基于法国行政层级(国家→大区→省→城市)的气泡图,数据是四级结构的JSON:

{ "name": "France", "children": [ 
    { "name": "Auvergne-Rhône-Alpes" }, 
    { "name": "Bourgogne-Franche-Comté" }, 
    { "name": "Bretagne", "children": [ 
        {"name": "Côtes-d'Armor", "children": [ 
            {"name": "ville1", "population": 31960}, 
            {"name": "ville2", "population": 44021}, 
            {"name": "ville3", "population": 23821}, 
            {"name": "ville4", "population": 5819}, 
            {"name": "ville5", "population": 30729}, 
            {"name": "ville6", "population": 64913} 
        ]} 
    ]} 
]}

目前我只实现了三级层级,且第二级和第三级会同时显示,现在需要改成四级可点击切换的交互:点击上级气泡展开下一级,同时支持返回上一级。现有代码如下:

const diameter = 700, // Rendre diameter adaptater à l'écran
format = d3.format(",d");
const svg = d3.select("#chart").append("svg")
 .attr("width", diameter)
 .attr("height", diameter)
 .attr("id", "svg");
const g = svg.append("g").attr("transform", "translate(2,2)");
const pack = d3.pack()
 .size([diameter - 4, diameter - 4]) // Related to previous translation
 .padding(1.5);
d3.json("donnee2.json").then(function(root) {
 root = d3.hierarchy(root)
 .sum(function(d) { return d.population; }) // La variable du JSON (à remplacer pour comparer la variable voulu)
 .sort(function(a, b) { return b.value - a.value; })
 var node = g.selectAll("g")
 .data(pack(root).descendants())
 .enter().append("g");
 // Attribuer les classes aux "g" des cercles
 node.attr("class", function(d, i) { if (i > 0) return d.children ? "node" : "leaf_node"; })
 .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; })
 .attr("text-anchor", "middle")
 .attr('fill', 'steelblue')
 .style("font", "10px sans-serif")
 .style("cursor", "pointer")
 .style("opacity", "0.6")
 .on("mouseover", function() { d3.select(this).attr("stroke", "white").style("stroke-width", "2px")})
 .on("mouseout", function() { d3.select(this).attr("stroke", null)});
 /********** HIERARCHY ***********/
 // Attributes of circles in terms of hierarchy
 function first_scale(){ // Example: France
 node.filter(function(d, i) { if(i == 0) return d.children; }).append("circle")
 .attr("r", function(d) { return d.r; })
 .attr("id", function(d, i) {return "b_"+i })
 .on("dblclick", function() { second_scale()});
 node.filter(function(d, i) { if(i == 0) return d.children; }).append("title") //afficher les données du JSON
 .text(function(d) { return d.data.name + "\n" + format(d.value) })
 node.filter(function(d, i) { if(i == 0) return d.children; }).append("text")
 .attr("dy", "0.3em")
 .style('fill', 'white')
 .style("font", "30px sans-serif")
 .text(function(d) { return d.data.name.substring(0, d.r / 3); });
 }
 first_scale();
 function second_scale(){ // Example: les Régions
 node.filter(function(d, i) { if(i > 0) return d.children; }).append("circle")
 .attr("r", function(d) { return d.r; })
 .attr("id", function(d, i) {return "b_"+i })
 .on("dblclick", function() { third_scale()});
 node.filter(function(d, i) { if(i > 0) return d.children; }).append("title") //afficher les données du JSON
 .text(function(d) { return d.data.name + "\n" + format(d.value) })
 node.filter(function(d, i) { if(i > 0) return d.children; }).append("text")
 .attr("dy", "0.3em")
 .style('fill', 'white')
 .text(function(d) { return d.data.name.substring(0, d.r / 3); });
 }
 function third_scale(){ // Example: les Départements
 node.filter(function(d) { return !d.children; }).append("circle")
 .attr("r", function(d) { return d.r; })
 .attr("id", function(d, i) {return "b_"+i })
 .on("click", function() { d3.select(this).attr("fill", "white")});
 node.filter(function(d) { return !d.children; }).append("title") // Afficher les données du JSON
 .text(function(d) { return d.data.name + "\n" + format(d.value) })
 node.filter(function(d) { return !d.children; }).append("text")
 .attr("dy", "0.3em")
 .style('fill', 'white')
 .text(function(d) { return d.data.name.substring(0, d.r / 3); });
 }
 /* function fourth_scale(){} */
 });

当前气泡图效果

请问如何修改代码实现四级可点击的层级气泡图?


回答:

我来帮你重构代码解决这个问题——你的现有方案最大的问题是把层级切换拆成了多个独立函数,导致逻辑混乱,而且没有处理第四级的交互。我们可以用统一的状态管理+延迟渲染的方式,实现流畅的四级点击切换,还能支持返回上一级的功能,完美适配你的法国行政层级数据。

第一步:重构节点初始化逻辑

首先,我们先一次性创建所有节点的容器(g元素),但默认全部隐藏,只在需要显示的时候再渲染气泡和文字。这样避免了重复创建元素,也让层级切换更灵活:

const diameter = 700,
      format = d3.format(",d");

const svg = d3.select("#chart").append("svg")
  .attr("width", diameter)
  .attr("height", diameter);

const g = svg.append("g").attr("transform", "translate(2,2)");

const pack = d3.pack()
  .size([diameter - 4, diameter - 4])
  .padding(1.5);

d3.json("donnee2.json").then(function(root) {
  // 构建层级结构并计算pack布局
  root = d3.hierarchy(root)
    .sum(d => d.population)
    .sort((a, b) => b.value - a.value);

  const allNodes = pack(root).descendants();

  // 创建所有节点的g容器,默认隐藏
  const node = g.selectAll(".node")
    .data(allNodes)
    .enter()
    .append("g")
    .attr("class", d => d.children ? "parent-node" : "leaf-node")
    .attr("transform", d => `translate(${d.x}, ${d.y})`)
    .style("opacity", 0)
    .style("cursor", d => d.children ? "pointer" : "default")
    .on("mouseover", function() {
      d3.select(this).select("circle")
        .attr("stroke", "white")
        .style("stroke-width", 2);
    })
    .on("mouseout", function() {
      d3.select(this).select("circle").attr("stroke", null);
    });

  // 给所有节点添加tooltip(title元素)
  node.append("title")
    .text(d => `${d.data.name}\n${format(d.value)}`);

第二步:实现核心层级切换逻辑

我们用一个变量currentRoot记录当前显示的层级根节点,然后写一个通用的渲染函数,负责显示当前根节点和它的直接子节点,同时绑定点击事件展开下一级:

// 当前层级的根节点,初始为整个法国
  let currentRoot = root;

  // 渲染指定层级的通用函数
  function renderCurrentLevel() {
    // 先隐藏所有节点
    node.style("opacity", 0);

    // 显示当前根节点(比如初始的法国,或者点击后的某个大区)
    node.filter(d => d === currentRoot)
      .style("opacity", 1)
      .each(function(d) {
        // 如果节点还没有气泡和文字,就创建
        if (!d3.select(this).select("circle").node()) {
          d3.select(this).append("circle")
            .attr("r", d.r)
            .attr("fill", "steelblue");
          
          d3.select(this).append("text")
            .attr("dy", "0.3em")
            .style("fill", "white")
            .style("font", d.depth === 0 ? "30px sans-serif" : "10px sans-serif")
            .text(d => d.data.name.substring(0, d.r / 3));
        }
      });

    // 显示当前根的子节点(下一级)
    const childNodes = currentRoot.children || [];
    node.filter(d => childNodes.includes(d))
      .style("opacity", 1)
      .each(function(d) {
        if (!d3.select(this).select("circle").node()) {
          const circle = d3.select(this).append("circle")
            .attr("r", d.r)
            .attr("fill", "steelblue");

          // 如果是父节点(还有下一级),绑定点击事件展开
          if (d.children) {
            circle.on("click", () => {
              currentRoot = d;
              renderCurrentLevel();
              addBackButton(); // 显示返回按钮
            });
          }

          d3.select(this).append("text")
            .attr("dy", "0.3em")
            .style("fill", "white")
            .text(d => d.data.name.substring(0, d.r / 3));
        }
      });
  }

第三步:添加返回上一级功能

为了让用户可以回溯层级,我们添加一个返回按钮,只有当不在最顶层时显示:

// 添加返回上一级按钮
  function addBackButton() {
    // 先移除之前的按钮,避免重复创建
    svg.select(".back-btn").remove();

    if (currentRoot.depth > 0) {
      svg.append("text")
        .attr("class", "back-btn")
        .attr("x", 20)
        .attr("y", 30)
        .style("fill", "black")
        .style("font-size", "16px")
        .style("cursor", "pointer")
        .text("← 返回上一级")
        .on("click", () => {
          currentRoot = currentRoot.parent;
          renderCurrentLevel();
          // 如果回到最顶层,移除返回按钮
          if (currentRoot.depth === 0) svg.select(".back-btn").remove();
        });
    }
  }

  // 初始化渲染最顶层(法国)
  renderCurrentLevel();
});

为什么这个方案能解决你的问题?

  1. 适配四级结构:不管是国家→大区→省→城市,只要你的JSON是标准的层级结构,这个逻辑会自动识别所有层级,不需要为每一级写单独的函数
  2. 流畅的交互:点击父节点展开下一级,点击返回按钮回到上一级,逻辑清晰直观
  3. 性能优化:只有需要显示的节点才会渲染气泡和文字,避免一次性生成大量DOM元素
  4. 可扩展性:如果以后要添加更多层级,或者修改样式,只需要调整通用函数即可

额外的小优化建议

  • 给不同层级的气泡设置不同颜色,比如国家用#2c3e50,大区用#3498db,省用#1abc9c,城市用#ecf0f1,增强视觉区分度
  • 调整pack.padding()的值,让同级气泡之间的间距更合理
  • 优化文本截断逻辑,比如用自定义函数确保文字不会超出气泡边界

内容的提问来源于stack exchange,提问作者Gabriel Chevallier

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.12 05:38:43