Onion Ring Chart with nested elements

Viewed 295

I'm new to D3.js, but have JS programming experience. I am attempting to create a chart and I have the somewhat scaffolding of what I need (using a standard sunburst chart), but I need each layer to have nested elements within it and am struggling to get my head around what I need to do.

This Onion Diagram graphic represents what I am trying to achieve. I have searched for 'onion chart + d3' but haven't come across any examples.

I would really appreciate any pointers, or even better, some pre-existing code base I could build on. Thank you.

Onion Diagram

EDIT 1: I've managed to get something close to what I want. I've used a D3 pack chart with circles. The issue I'm having is aligning the circles. In the attached screenshot, I would like;

  • 'C' and 'A' to hug the circle just like 'B' (so 'C' is above 'B' and 'A' is below 'B'
  • and once done, have 'B' and it's child 'B' circles to align horizontally (rather than the angle they are currently running)

I realise I need to edit the 'transform' attribute of each 'leaf-node' but unsure about how I go about this programmatically. Below is my current code. And below that what I am trying to achieve.

Currently working in D3.js Current working visualisation

What I'm trying to achieve* enter image description here

<!DOCTYPE html>
<html lang="en">
  <head>
    <link href="style.css" rel="stylesheet">
  </head>

  <body>
  <script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script>
    <div class="container">
    </div>
    <script src="main.js"></script>
  </body>
</html>
  var margin = 20,
      diameter = 700;

  var color = d3.scale.linear()
      .domain([-1, 5])
      .range(["hsl(152,80%,80%)", "hsl(228,30%,40%)"])
      .interpolate(d3.interpolateHcl);

  var pack = d3.layout.pack()
      .padding(2)
      .size([diameter - margin, diameter - margin])
      .value(function(d) { return d.size; })

  var svg = d3.select(".container").append("svg")
      .attr("width", diameter)
      .attr("height", diameter)
      .append("g")
      .attr("transform", "translate(" + diameter / 2 + "," + diameter / 2 + ")");

  d3.json("data.json", function(error, root) {
    if (error) return console.error(error);

    var focus = root,
        nodes = pack.nodes(root),
        view;

    var circle = svg.selectAll("circle")
        .data(nodes)
        .enter().append("circle")
        .attr("class", function(d) { return d.parent ? d.children ? "node" : "node node--leaf" : "node node--root"; })
        .style("fill", function(d) { return d.children ? color(d.depth) : null; })
        .on("click", function(d) { if (focus !== d) zoom(d), d3.event.stopPropagation(); });

    var text = svg.selectAll("text")
        .data(nodes)
        .enter().append("text")
        .attr("class", "label")
        .style("fill-opacity", function(d) { return d.parent === root ? 1 : 0; })
        .style("display", function(d) { return d.parent === root ? null : "none"; })
        .text(function(d) { return d.name; });


    // Adding Rect to each child circle
    var bar = svg.selectAll(".bar")
        .data(nodes)
      .enter().append("rect")
        .attr("class", "bar")
        .style("fill", function(d) { return d.children ? color(d.depth) : null; })
        .style("fill-opacity", function(d) { return d.parent === root ? 1 : 0; })
        .style("display", function(d) { return d.parent === root ? null : "none"; })
        .attr("width", 20)
        .attr("x", 100)
        .attr("height", function(d) { return 200; });

    var node = svg.selectAll("circle,text");

    d3.select(".container")
        .style("background", color(-1))
        .on("click", function() { zoom(root); });

    zoomTo([root.x, root.y, root.r * 2 + margin]);

    function zoom(d) {
      var focus0 = focus; focus = d;

      var transition = d3.transition()
          .duration(d3.event.altKey ? 7500 : 750)
          .tween("zoom", function(d) {
            var i = d3.interpolateZoom(view, [focus.x, focus.y, focus.r * 2 + margin]);
            return function(t) { zoomTo(i(t)); };
          });

      transition.selectAll("text")
        .filter(function(d) { return d.parent === focus || this.style.display === "inline"; })
          .style("fill-opacity", function(d) { return d.parent === focus ? 1 : 0; })
          .each("start", function(d) { if (d.parent === focus) this.style.display = "inline"; })
          .each("end", function(d) { if (d.parent !== focus) this.style.display = "none"; });
    }

    function zoomTo(v) {
      var k = diameter / v[2]; view = v;
      node.attr("transform", function(d) { return "translate(" + (d.x - v[0]) * k + "," + (d.y - v[1]) * k + ")"; });
      circle.attr("r", function(d) { return d.r * k; });
    }
  });

  d3.select(self.frameElement).style("height", diameter + "px");
{
  "name": "data",
  "children": 
  [
    {"name": "food", "size": 30,
      "children": 
      [
        {"name": "A", "size": 40},
        {"name": "B", "size": 40},
        {"name": "C", "size": 40},
        {"name": "apple", "size": 40,
          "children": 
          [
            {"name": "A2", "size": 40},
            {"name": "B2", "size": 40},
            {"name": "C3", "size": 40},
            {"name": "green", "size": 40,
              "children": 
              [
                {"name": "A2", "size": 40},
                {"name": "B2", "size": 40},
                {"name": "C3", "size": 40},
                {"name": "granny smith", "size": 40,
                  "children": 
                  [
                    {"name": "A2", "size": 40},
                    {"name": "B2", "size": 40},
                    {"name": "C3", "size": 40},
                    {"name": "price", "size": 40,
                      "children": 
                      [
                        {"name": "A2", "size": 40},
                        {"name": "B2", "size": 40},
                        {"name": "C3", "size": 40},
                        {"name": "1.99", "size": 40,
                          "children": 
                          [
                            {"name": "End1", "size": 40},
                            {"name": "End2", "size": 40},
                            {"name": "End3", "size": 40}
                          ]
                        }
                      ]
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
/* Styles go here */
body {
  padding: 50px;
  font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}

a {
  color: #00B7FF;
}
.container{
  width: 100%;
}

.node {
  cursor: pointer;
}

.node:hover {
  stroke: #000;
  stroke-width: 1.5px;
}

.node--leaf {
  fill: white;
}

.bar {
  display: none;
}
.label {
  font: 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
  text-anchor: middle;
  text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff;
}

.label,
.node--root,
.node--leaf {
  pointer-events: none;
}

1 Answers

Here's an example, as a starting point, with d3.js v5:

(Also in codePen: https://codepen.io/Alexander9111/pen/xxZdNWb)

Note, you don't necessarily need to "nest" svg elements, as they paint in the order they appear in the DOM, so you see here, I just appended each circle after each other and each subsequent one, paints over the previous (i.e. pseudo gets a larger z-index).

Are the little people images important?, if so, we need to add these to the data and then work them in to the answer (shouldn't be too hard).

const myData = [
  { color: "red" },
  { color: "aqua" },
  { color: "orange" },
  { color: "blue" },
  { color: "green" }
];
const width = 100;
const [svg_width, svg_height] = [250, 200];
const len = myData.length;
const padding_bottom = 3;

d3.select("svg")
  .selectAll(".onion")
  .data(myData.reverse())
  .enter()
  .append("circle")
  .attr("class", "onion")
  .attr("fill", (d) => d.color)
  .attr("cx", svg_width / 2)
  .attr("cy", (d, i) => {
    const r = (width / len) * (len - i);
    const diff = svg_height / 2 - r;
    const padd = padding_bottom * i;
    return svg_height / 2 + diff - padd;
  })
  .attr("r", (d, i) => (width / len) * (len - i));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg id="target" width="100%" viewbox="0 0 250 200">
</svg>

Output:

enter image description here


UPDATE 29-Jun-20:

const myData = [
  { color: "red", children: ["A"] },
  { color: "aqua", children: ["A", "B"] },
  { color: "orange", children: ["A", "B", "C"] },
  { color: "blue", children: ["A", "B", "C", "D"] },
  { color: "green", children: ["A", "B", "C", "D", "E"] }
];
const width = 100;
const [svg_width, svg_height] = [250, 200];
const len = myData.length;
const padding_bottom = 3;
const padding_top = 2;
const arc = (120 * Math.PI) / 180;

const layers = d3
  .select("svg")
  .selectAll(".onion")
  .data(myData.reverse())
  .enter()
  .append("g");

layers
  .append("circle")
  .attr("class", "onion")
  .attr("fill", (d) => d.color)
  .attr("cx", svg_width / 2)
  .attr("cy", (d, i) => {
    const r = (width / len) * (len - i);
    const diff = svg_height / 2 - r;
    const padd = padding_bottom * i;
    return svg_height / 2 + diff - padd;
  })
  .attr("r", (d, i) => (width / len) * (len - i));

const persons = layers
  .selectAll(".person")
  .data((d, i) =>
    d.children.map((e) => ({ item: e, i, len: d.children.length }))
  );

persons
  .enter()
  .append("circle")
  .attr("class", "person")
  .attr("fill", "grey")
  .attr("cx", (d, j) => {
    const r = (width / len) * (len - d.i);
    const cr = ((width / len) * (len - d.i + 1) * 1.0) ** 0.5;
    const sep = width / len;
    const diff = svg_height / 2 - r;
    const center = svg_width / 2;
    const r2 = r - cr - padding_top;
    //console.log(d);
    if (d.len == 1) return center;
    const theta = (arc / (d.len - 1)) * j - arc / 2;
    return center + r2 * Math.sin(theta);
  })
  .attr("cy", (d, j) => {
    const r = (width / len) * (len - d.i);
    const cr = ((width / len) * (len - d.i + 1) * 1.0) ** 0.5;
    const sep = width / len;
    const diff = svg_height / 2 - r;
    const padd = padding_bottom * d.i;
    const center = svg_height / 2 + diff - padd;
    const r2 = r - cr - padding_top;
    if (d.len == 1) return center - r2;
    const theta = (arc / (d.len - 1)) * j - arc / 2;
    return center - Math.abs(r2 * Math.cos(theta));
  })
  .attr("r", (d, j) => ((width / len) * (len - d.i + 1) * 1.0) ** 0.5);

persons
  .enter()
  .append("text")
  .attr("class", "person-name")
  .attr("fill", "white")
  .attr("x", (d, j) => {
    const r = (width / len) * (len - d.i);
    const cr = ((width / len) * (len - d.i + 1) * 1.0) ** 0.5;
    const sep = width / len;
    const diff = svg_height / 2 - r;
    const center = svg_width / 2;
    const r2 = r - cr - padding_top;
    //console.log(d);
    if (d.len == 1) return center;
    const theta = (arc / (d.len - 1)) * j - arc / 2;
    return center + r2 * Math.sin(theta);
  })
  .attr("y", (d, j) => {
    const r = (width / len) * (len - d.i);
    const cr = ((width / len) * (len - d.i + 1) * 1.0) ** 0.5;
    const sep = width / len;
    const diff = svg_height / 2 - r;
    const padd = padding_bottom * d.i;
    const center = svg_height / 2 + diff - padd;
    const r2 = r - cr - padding_top;
    if (d.len == 1) return center - r2;
    const theta = (arc / (d.len - 1)) * j - arc / 2;
    return center - Math.abs(r2 * Math.cos(theta));
  })
  .html((d, j) => d.item)
  .attr("alignment-baseline", "middle")
  .attr("text-anchor", "middle")
  .attr("font-size", (d, j) => ((width / len) * (len - d.i + 1) * 1.0) ** 0.5);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg id="target" width="100%" viewbox="0 0 250 200">
</svg>

Output:

enter image description here

Math / Trig:

enter image description here

Related