8

I'm quite new to HTML and JavaScript. I'm facing the famous Hierarchical Edge Bundling available here, which is generated by the D3.js library.

My goal is to add a semi-circular label zone in order to obtain something like this: every final node group is labelled with the parent's name. enter image description here

Unfortunately, I have not found any code where I could take inspiration yet, except the code available in the link above: my idea would be to modify that code adding some line in order to generate the labels.

I saw this link with a snippet of code that may do to the trick, but I don't know how to use it (and whether I am in the right direction or not)

node.append("text")
  .attr("dy", ".31em")
  .attr("x", function(d) { return d.x < 180 === !d.children ? 6 : -6; })
  .style("text-anchor", function(d) { return d.x < 180 === !d.children ? "start" : "end"; })
  .attr("transform", function(d) { return "rotate(" + (d.x < 180 ? d.x - 90 : d.x + 90) + ")"; })
  .text(function(d) { return d.id.substring(d.id.lastIndexOf(".") + 1); });

Does someone have any suggestion?

Marco
  • 367
  • 1
  • 10

1 Answers1

2

The basic idea is to draw a series of arcs around the links and shunt the labels outwards by the width of the arc.

V4 solution

A working adaption of the d3 v4 code from the linked block is below:

var flare = "https://gist.githubusercontent.com/robinmackenzie/d01d286d9ac16b474a2a43088c137d00/raw/c53c1eda18cc21636ae52dfffa3e030295916c98/flare.json";
d3.json(flare, function(err, json) {
  if (err) throw err;
  render(json);
});

function render(classes) {
  
  // original code
  var diameter = 960,
      radius = diameter / 2,
      innerRadius = radius - 120;

  var cluster = d3.cluster()
      .size([360, innerRadius]);

  var line = d3.radialLine()
      .curve(d3.curveBundle.beta(0.85))
      .radius(function(d) { return d.y; })
      .angle(function(d) { return d.x / 180 * Math.PI; });

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

  var link = svg.append("g").selectAll(".link"),
      node = svg.append("g").selectAll(".node");

  var root = packageHierarchy(classes)
      .sum(function(d) { return d.size; });

  cluster(root);
  
  // added code -----
  var arcInnerRadius = innerRadius;
  var arcWidth = 30;
  var arcOuterRadius = arcInnerRadius + arcWidth;
  
  var arc = d3.arc()
    .innerRadius(arcInnerRadius)
    .outerRadius(arcOuterRadius)
    .startAngle(function(d) { return d.st; })
    .endAngle(function(d) { return d.et; });
    
  var leafGroups = d3.nest()
    .key(function(d) { return d.parent.data.name.split(".")[1]; })
    .entries(root.leaves())
    
  var arcAngles = leafGroups.map(function(group) {
    return {
      name: group.key,
      min: d3.min(group.values, function(d) { return d.x }),
      max: d3.max(group.values, function(d) { return d.x })
    }
  });
    
  svg
    .selectAll(".groupArc")
    .data(arcAngles)
    .enter()
    .append("path")
    .attr("id", function(d, i) { return`arc_${i}`; })
    .attr("d", function(d) { return arc({ st: d.min * Math.PI / 180, et: d.max * Math.PI / 180}) }) // note use of arcWidth
    .attr("fill", "steelblue");
    
  svg
    .selectAll(".arcLabel")
    .data(arcAngles)
    .enter()
    .append("text")
    .attr("x", 5) //Move the text from the start angle of the arc
    .attr("dy", (d) => ((arcOuterRadius - arcInnerRadius) * 0.8)) //Move the text down
    .append("textPath")
    .attr("class", "arcLabel")
    .attr("xlink:href", (d, i) => `#arc_${i}`)
    .text((d, i) => d.name)
    .style("font", `300 14px "Helvetica Neue", Helvetica, Arial, sans-serif`)
    .style("fill", "#fff");

  // ----------------

  link = link
    .data(packageImports(root.leaves()))
    .enter().append("path")
      .each(function(d) { d.source = d[0], d.target = d[d.length - 1]; })
      .attr("class", "link")
      .attr("d", line);

  node = node
    .data(root.leaves())
    .enter().append("text")
      .attr("class", "node")
      .attr("dy", "0.31em")
      .attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + (d.y + 8 + arcWidth) + ",0)" + (d.x < 180 ? "" : "rotate(180)"); })
      .attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; })
      .text(function(d) { return d.data.key; })
      .on("mouseover", mouseovered)
      .on("mouseout", mouseouted);

  function mouseovered(d) {
    node
        .each(function(n) { n.target = n.source = false; });

    link
        .classed("link--target", function(l) { if (l.target === d) return l.source.source = true; })
        .classed("link--source", function(l) { if (l.source === d) return l.target.target = true; })
      .filter(function(l) { return l.target === d || l.source === d; })
        .raise();

    node
        .classed("node--target", function(n) { return n.target; })
        .classed("node--source", function(n) { return n.source; });
  }

  function mouseouted(d) {
    link
        .classed("link--target", false)
        .classed("link--source", false);

    node
        .classed("node--target", false)
        .classed("node--source", false);
  }

  // Lazily construct the package hierarchy from class names.
  function packageHierarchy(classes) {
    var map = {};

    function find(name, data) {
      var node = map[name], i;
      if (!node) {
        node = map[name] = data || {name: name, children: []};
        if (name.length) {
          node.parent = find(name.substring(0, i = name.lastIndexOf(".")));
          node.parent.children.push(node);
          node.key = name.substring(i + 1);
        }
      }
      return node;
    }

    classes.forEach(function(d) {
      find(d.name, d);
    });

    return d3.hierarchy(map[""]);
  }

  // Return a list of imports for the given array of nodes.
  function packageImports(nodes) {
    var map = {},
        imports = [];

    // Compute a map from name to node.
    nodes.forEach(function(d) {
      map[d.data.name] = d;
    });

    // For each import, construct a link from the source to target node.
    nodes.forEach(function(d) {
      if (d.data.imports) d.data.imports.forEach(function(i) {
        imports.push(map[d.data.name].path(map[i]));
      });
    });

    return imports;
  }


}
.node {
  font: 300 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
  fill: #bbb;
}

.node:hover {
  fill: #000;
}

.link {
  stroke: steelblue;
  stroke-opacity: 0.4;
  fill: none;
  pointer-events: none;
}

.node:hover,
.node--source,
.node--target {
  font-weight: 700;
}

.node--source {
  fill: #2ca02c;
}

.node--target {
  fill: #d62728;
}

.link--source,
.link--target {
  stroke-opacity: 1;
  stroke-width: 2px;
}

.link--source {
  stroke: #d62728;
}

.link--target {
  stroke: #2ca02c;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>

You can see the discrete block of code added to draw and label the arcs - the key bit to calculate the start and end angles for the arc generator are:

  var leafGroups = d3.nest()
    .key(function(d) { return d.parent.data.name.split(".")[1]; })
    .entries(root.leaves())
    
  var arcAngles = leafGroups.map(function(group) {
    return {
      name: group.key,
      min: d3.min(group.values, function(d) { return d.x }),
      max: d3.max(group.values, function(d) { return d.x })
    }
  });

For leafGroups, the nest function is grouping the leaves of the hierarchy by the second item of the key e.g. flare.analytics.cluster = analytics and flare.vis.operator.distortion = vis. There is a choice here for different data sets that you need to have a think about e.g. if the leaves are always at a consistent depth; are the labels always unique. Defining the 'parent group' can either be a top-down or bottom-up definition.

For arcAngles, you just need the min and max of each group then you can go ahead and draw the arcs and label them. I lifted some of the labelling from here which is a great article on labelling arcs in d3. You need to have a think, again, for this bit because if the label is too long for the arc it doesn't look great - see the "Display" label in the example.

The other change is further down here:

  node = node
    .data(root.leaves())
    .enter().append("text")
      .attr("class", "node")
      .attr("dy", "0.31em")
      .attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + (d.y + 8 + arcWidth) + ",0)" + (d.x < 180 ? "" : "rotate(180)"); })
      .attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; })
      .text(function(d) { return d.data.key; })
      .on("mouseover", mouseovered)
      .on("mouseout", mouseouted);

Noting you need to add arcWidth when setting the transform attribute - this moves the node labels outward to accommodate the arcs.

V6+ solution

There's a newer version of hierarchical edge bundling in this Observable HQ page using d3 v6 (and also by Mike Bostok). We can add similar code to identify groups, get the min/ max of the angles and push the labels outward a bit to accomodate the arcs.

const flare = "https://gist.githubusercontent.com/robinmackenzie/d01d286d9ac16b474a2a43088c137d00/raw/c53c1eda18cc21636ae52dfffa3e030295916c98/flare.json";
const colorin = "#00f";
const colorout = "#f00";
const colornone = "#ccc";
const width = 960;
const radius = width / 2;

d3.json(flare).then(json => render(json));

function render(data) {

  const line = d3.lineRadial()
    .curve(d3.curveBundle.beta(0.85))
    .radius(d => d.y)
    .angle(d => d.x);

  const tree = d3.cluster()
    .size([2 * Math.PI, radius - 100]);

  const root = tree(bilink(d3.hierarchy(hierarchy(data))
    .sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.name, b.data.name))));

  const svg = d3.select("body")
    .append("svg")
    .attr("width", width)
    .attr("height", width)
    .append("g")
    .attr("transform", `translate(${radius},${radius})`);

  // NEW CODE BELOW ----------------------------------------
  // add arcs with labels
  const arcInnerRadius = radius - 100;
  const arcWidth = 30;
  const arcOuterRadius = arcInnerRadius + arcWidth;
  const arc = d3
    .arc()
    .innerRadius(arcInnerRadius)
    .outerRadius(arcOuterRadius)
    .startAngle((d) => d.start)
    .endAngle((d) => d.end);

  const leafGroups = d3.groups(root.leaves(), d => d.parent.data.name);
  const arcAngles = leafGroups.map(g => ({
    name: g[0],
    start: d3.min(g[1], d => d.x),
    end: d3.max(g[1], d => d.x)
  }));

  svg
    .selectAll(".arc")
    .data(arcAngles)
    .enter()
    .append("path")
    .attr("id", (d, i) => `arc_${i}`)
    .attr("d", (d) => arc({start: d.start, end: d.end}))
    .attr("fill", "blue")
    .attr("stroke", "blue");
  svg
    .selectAll(".arcLabel")
    .data(arcAngles) 
    .enter()
    .append("text")
    .attr("x", 5) //Move the text from the start angle of the arc
    .attr("dy", (d) => ((arcOuterRadius - arcInnerRadius) * 0.8)) //Move the text down
    .append("textPath")
    .attr("class", "arcLabel")
    .attr("xlink:href", (d, i) => `#arc_${i}`)
    .text((d, i) => ((d.end - d.start) < (6 * Math.PI / 180)) ? "" : d.name); // 6 degrees min arc length for label to apply
    
  // --------------------------------------------------------



  // add nodes
  const node = svg.append("g")
      .attr("font-family", "sans-serif")
      .attr("font-size", 10)
    .selectAll("g")
    .data(root.leaves())
    .join("g")
      .attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y}, 0)`)
    .append("text")
      .attr("dy", "0.31em")
      .attr("x", d => d.x < Math.PI ? (arcWidth + 5) : (arcWidth + 5) * -1) // note use of arcWidth
      .attr("text-anchor", d => d.x < Math.PI ? "start" : "end")
      .attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null)
      .text(d => d.data.name)
      .each(function(d) { d.text = this; })
      .on("mouseover", overed)
      .on("mouseout", outed)
      .call(text => text.append("title").text(d => `${id(d)} ${d.outgoing.length} outgoing ${d.incoming.length} incoming`));

  // add edges
  const link = svg.append("g")
      .attr("stroke", colornone)
      .attr("fill", "none")
    .selectAll("path")
    .data(root.leaves().flatMap(leaf => leaf.outgoing))
    .join("path")
      .style("mix-blend-mode", "multiply")
      .attr("d", ([i, o]) => line(i.path(o)))
      .each(function(d) { d.path = this; });

  function overed(event, d) {
    link.style("mix-blend-mode", null);
    d3.select(this).attr("font-weight", "bold");
    d3.selectAll(d.incoming.map(d => d.path)).attr("stroke", colorin).raise();
    d3.selectAll(d.incoming.map(([d]) => d.text)).attr("fill", colorin).attr("font-weight", "bold");
    d3.selectAll(d.outgoing.map(d => d.path)).attr("stroke", colorout).raise();
    d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr("fill", colorout).attr("font-weight", "bold");
  }

  function outed(event, d) {
    link.style("mix-blend-mode", "multiply");
    d3.select(this).attr("font-weight", null);
    d3.selectAll(d.incoming.map(d => d.path)).attr("stroke", null);
    d3.selectAll(d.incoming.map(([d]) => d.text)).attr("fill", null).attr("font-weight", null);
    d3.selectAll(d.outgoing.map(d => d.path)).attr("stroke", null);
    d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr("fill", null).attr("font-weight", null);
  }  

  function id(node) {
    return `${node.parent ? id(node.parent) + "." : ""}${node.data.name}`;
  }

  function bilink(root) {
    const map = new Map(root.leaves().map(d => [id(d), d]));
    for (const d of root.leaves()) d.incoming = [], d.outgoing = d.data.imports.map(i => [d, map.get(i)]);
    for (const d of root.leaves()) for (const o of d.outgoing) o[1].incoming.push(o);
    return root;
  }

  function hierarchy(data, delimiter = ".") {
    let root;
    const map = new Map;
    data.forEach(function find(data) {
      const {name} = data;
      if (map.has(name)) return map.get(name);
      const i = name.lastIndexOf(delimiter);
      map.set(name, data);
      if (i >= 0) {
        find({name: name.substring(0, i), children: []}).children.push(data);
        data.name = name.substring(i + 1);
      } else {
        root = data;
      }
      return data;
    });
    return root;
  }
  
}
.node {
  font: 300 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
  fill: #fff;
}

.arcLabel {
  font: 300 14px "Helvetica Neue", Helvetica, Arial, sans-serif;
  fill: #fff;
}

.node:hover {
  fill: #000;
}

.link {
  stroke: steelblue;
  stroke-opacity: 0.4;
  fill: none;
  pointer-events: none;
}

.node:hover,
.node--source,
.node--target {
  font-weight: 700;
}

.node--source {
  fill: #2ca02c;
}

.node--target {
  fill: #d62728;
}

.link--source,
.link--target {
  stroke-opacity: 1;
  stroke-width: 2px;
}

.link--source {
  stroke: #d62728;
}

.link--target {
  stroke: #2ca02c;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.0.0/d3.min.js"></script>

Some differences to note:

  • The hierarchy function differs from the packageHierarchy function in the original block - seemingly we no longer have the full path of the hierarchy and therefore there's ambiguity for flare.vis.data.EdgeSprite vs flare.data.DataField i.e. two leaves can have the same 'parent' in different branches of the hierarchy.
  • I've fixed the input to accomodate that but it changes how the 'parent group' is identified i.e. bottom-up vs top-down in the original.
  • nest has gone so you can use groups instead
  • The v4 seems to have objects defined with angles in degrees, but in v6 they are in radians - so you will see a few * Math.PI / 180 in the v4 version and not in the v6 - but it's just degrees/ radians conversion.
  • for long labels, I use a threshold such that an arc has to be minimum 6 degrees long otherwise the label won't place (.text((d, i) => ((d.end - d.start) < (6 * Math.PI / 180)) ? "" : d.name);)
Robin Mackenzie
  • 18,801
  • 7
  • 38
  • 56