5

I am trying to color the connections in my hierarchical edge bundling visualization based on the groups they are connecting to. An example of this can be seen here.

enter image description here

Here is my current mouseover function:

    function mouseover(d) {
        svg.selectAll("path.link.target-" + d.key)
            .classed("target", true)
            .each(updateNodes("source", true));

        svg.selectAll("path.link.source-" + d.key)
            .classed("source", true)
            .each(updateNodes("target", true));
    }

And here is the mouseover function from the example I've posted:

function mouseovered(d) 
{
        // Handle tooltip
        // Tooltips should avoid crossing into the center circle

        d3.selectAll("#tooltip").remove();
        d3.selectAll("#vis")
            .append("xhtml:div")
            .attr("id", "tooltip")
            .style("opacity", 0)
            .html(d.title);
        var mouseloc = d3.mouse(d3.select("#vis")[0][0]),
            my = ((rotateit(d.x) > 90) && (rotateit(d.x) < 270)) ? mouseloc[1] + 10 : mouseloc[1] - 35,
            mx = (rotateit(d.x) < 180) ? (mouseloc[0] + 10) :  Math.max(130, (mouseloc[0] - 10 - document.getElementById("tooltip").offsetWidth));
        d3.selectAll("#tooltip").style({"top" : my + "px", "left": mx + "px"});
        d3.selectAll("#tooltip")
            .transition()
            .duration(500)
            .style("opacity", 1);
        node.each(function(n) { n.target = n.source = false; });

        currnode = d3.select(this)[0][0].__data__;

        link.classed("link--target", function(l) { 
                if (l.target === d) 
                { 
                    return l.source.source = true; 
                }
                if (l.source === d) 
                { 
                    return l.target.target = true; 
                }
            })
            .filter(function(l) { return l.target === d || l.source === d; })
            .attr("stroke", function(d){
                if (d[0].name == currnode.name)
                {
                    return color(d[2].cat);
                }
                return color(d[0].cat);
            })
            .each(function() { this.parentNode.appendChild(this); });

        d3.selectAll(".link--clicked").each(function() { this.parentNode.appendChild(this); });

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

I am somewhat new to D3, but I am assuming what I'll need to do is check the group based on the key and then match it to the same color as that group.

My full code is here:

 <script type="text/javascript">
    color = d3.scale.category10(); 

    var w = 840,
        h = 800,
        rx = w / 2,
        ry = h / 2,
        m0,
        rotate = 0
    pi = Math.PI;

    var splines = [];

    var cluster = d3.layout.cluster()
        .size([360, ry - 180])
        .sort(function(a, b) {
            return d3.ascending(a.key, b.key);
        });

    var bundle = d3.layout.bundle();

    var line = d3.svg.line.radial()
        .interpolate("bundle")
        .tension(.5)
        .radius(function(d) {
            return d.y;
        })
        .angle(function(d) {
            return d.x / 180 * Math.PI;
        });

    // Chrome 15 bug: <http://code.google.com/p/chromium/issues/detail?id=98951>
    var div = d3.select("#bundle")
        .style("width", w + "px")
        .style("height", w + "px")
        .style("position", "absolute");

    var svg = div.append("svg:svg")
        .attr("width", w)
        .attr("height", w)
        .append("svg:g")
        .attr("transform", "translate(" + rx + "," + ry + ")");

    svg.append("svg:path")
        .attr("class", "arc")
        .attr("d", d3.svg.arc().outerRadius(ry - 180).innerRadius(0).startAngle(0).endAngle(2 * Math.PI))
        .on("mousedown", mousedown);

    d3.json("TASKS AND PHASES.json", function(classes) {

        var nodes = cluster.nodes(packages.root(classes)),
            links = packages.imports(nodes),
            splines = bundle(links);

        var path = svg.selectAll("path.link")
            .data(links)
            .enter().append("svg:path")
            .attr("class", function(d) {
                return "link source-" + d.source.key + " target-" + d.target.key;
            })
            .attr("d", function(d, i) {
                return line(splines[i]);
            });

        var groupData = svg.selectAll("g.group")
            .data(nodes.filter(function(d) {
                return (d.key == 'Department' || d.key == 'Software' || d.key == 'Tasks' || d.key == 'Phases') && d.children;
            }))
            .enter().append("group")
            .attr("class", "group");

        var groupArc = d3.svg.arc()
            .innerRadius(ry - 177)
            .outerRadius(ry - 157)
            .startAngle(function(d) {
                return (findStartAngle(d.__data__.children) - 2) * pi / 180;
            })
            .endAngle(function(d) {
                return (findEndAngle(d.__data__.children) + 2) * pi / 180
            });        

        svg.selectAll("g.arc")
            .data(groupData[0])
            .enter().append("svg:path")
            .attr("d", groupArc)
            .attr("class", "groupArc")
            .attr("id", function(d, i) {console.log(d.__data__.key); return d.__data__.key;})
            .style("fill", function(d, i) {return color(i);})
            .style("fill-opacity", 0.5)
            .each(function(d,i) {

                var firstArcSection = /(^.+?)L/;

                var newArc = firstArcSection.exec( d3.select(this).attr("d") )[1];

                newArc = newArc.replace(/,/g , " ");

                svg.append("path")
                    .attr("class", "hiddenArcs")
                    .attr("id", "hidden"+d.__data__.key)
                    .attr("d", newArc)
                    .style("fill", "none");
            });



        svg.selectAll(".arcText")
            .data(groupData[0])
            .enter().append("text")
            .attr("class", "arcText")
            .attr("dy", 15)
            .append("textPath")
            .attr("startOffset","50%")
            .style("text-anchor","middle")
            .attr("xlink:href",function(d,i){return "#hidden" + d.__data__.key;})
            .text(function(d){return d.__data__.key;});    

        svg.selectAll("g.node")
            .data(nodes.filter(function(n) {
                return !n.children;
            }))
            .enter().append("svg:g")
            .attr("class", "node")
            .attr("id", function(d) {
                return "node-" + d.key;
            })
            .attr("transform", function(d) {
                return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")";
            })
            .append("svg:text")
            .attr("dx", function(d) {
                return d.x < 180 ? 25 : -25;
            })
            .attr("dy", ".31em")
            .attr("text-anchor", function(d) {
                return d.x < 180 ? "start" : "end";
            })
            .attr("transform", function(d) {
                return d.x < 180 ? null : "rotate(180)";
            })
            .text(function(d) {
                return d.key.replace(/_/g, ' ');
            })
            .on("mouseover", mouseover)
            .on("mouseout", mouseout);

        d3.select("input[type=range]").on("change", function() {
            line.tension(this.value / 100);
            path.attr("d", function(d, i) {
                return line(splines[i]);
            });
        });
    });

    d3.select(window)
        .on("mousemove", mousemove)
        .on("mouseup", mouseup);

    function mouse(e) {
        return [e.pageX - rx, e.pageY - ry];
    }

    function mousedown() {
        m0 = mouse(d3.event);
        d3.event.preventDefault();
    }

    function mousemove() {
        if (m0) {
            var m1 = mouse(d3.event),
                dm = Math.atan2(cross(m0, m1), dot(m0, m1)) * 180 / Math.PI;
            div.style("-webkit-transform", "translate3d(0," + (ry - rx) + "px,0)rotate3d(0,0,0," + dm + "deg)translate3d(0," + (rx - ry) + "px,0)");
        }
    }

    function mouseup() {
        if (m0) {
            var m1 = mouse(d3.event),
                dm = Math.atan2(cross(m0, m1), dot(m0, m1)) * 180 / Math.PI;

            rotate += dm;
            if (rotate > 360) rotate -= 360;
            else if (rotate < 0) rotate += 360;
            m0 = null;

            div.style("-webkit-transform", "rotate3d(0,0,0,0deg)");

            svg.attr("transform", "translate(" + rx + "," + ry + ")rotate(" + rotate + ")")
                .selectAll("g.node text")
                .attr("dx", function(d) {
                    return (d.x + rotate) % 360 < 180 ? 25 : -25;
                })
                .attr("text-anchor", function(d) {
                    return (d.x + rotate) % 360 < 180 ? "start" : "end";
                })
                .attr("transform", function(d) {
                    return (d.x + rotate) % 360 < 180 ? null : "rotate(180)";
                });
        }
    }

    function mouseover(d) {
        svg.selectAll("path.link.target-" + d.key)
            .classed("target", true)
            .each(updateNodes("source", true));

        svg.selectAll("path.link.source-" + d.key)
            .classed("source", true)
            .each(updateNodes("target", true));
    }

    function mouseout(d) {
        svg.selectAll("path.link.source-" + d.key)
            .classed("source", false)
            .each(updateNodes("target", false));

        svg.selectAll("path.link.target-" + d.key)
            .classed("target", false)
            .each(updateNodes("source", false));
    }

    function updateNodes(name, value) {
        return function(d) {
            if (value) this.parentNode.appendChild(this);
            svg.select("#node-" + d[name].key).classed(name, value);
        };
    }

    function cross(a, b) {
        return a[0] * b[1] - a[1] * b[0];
    }

    function dot(a, b) {
        return a[0] * b[0] + a[1] * b[1];
    }

    function findStartAngle(children) {
        var min = children[0].x;
        children.forEach(function(d) {
            if (d.x < min)
                min = d.x;
        });
        return min;
    }

    function findEndAngle(children) {
        var max = children[0].x;
        children.forEach(function(d) {
            if (d.x > max)
                max = d.x;
        });
        return max;
    }
</script>
Menuka Ishan
  • 5,164
  • 3
  • 50
  • 66
  • can you provide some sample data too? The data in then harvard example is quite different to yours. It's difficult to know where `__data__.key` comes from. – Lex Jun 06 '18 at 01:32
  • @Lex here's the dataset I am working with: https://pastebin.com/raw/cZcib22t I've based my visualization on this one http://bl.ocks.org/slattery/52970ba87b5ad6f914804a19df639e3a – TropicalOats Jun 06 '18 at 14:23
  • @TropicalOats did you find a solution to this? I am looking to solve a similar problem – Jehan Dastoor Feb 14 '22 at 23:29
  • can you please share codepen? or any online editor with your code & data – amir_barak Jun 13 '22 at 16:19

1 Answers1

2

Here's an example solution in D3 v6 adapting the Observable example plus my answer to this other question. Basic points:

  • You will to add the 'group' into the input data - for the data you mention in the comments I've defined group as the 2nd element (per dot separation) of the name. The hierarchy function in the Observable appears to strip this.
  • It's probably fortunate that all the name values are e.g. root.parent.child - this makes the leafGroups work quite well for your data (but might not for asymmetric hierarchies).
  • Define a colour range e.g. const colors = d3.scaleOrdinal().domain(leafGroups.map(d => d[0])).range(d3.schemeTableau10); which you can use for arcs, label text (nodes), paths (links)
  • I've avoided using the mix-blend-mode styling with the example as it doesn't look good to me.
  • I'm applying the styles in overed and outed - see below for the logic.

See the comments in overed for styling logic on mouseover:

function overed(event, d) {

  //link.style("mix-blend-mode", null);

  d3.select(this)
    // set dark/ bold on hovered node 
    .style("fill", colordark) 
    .attr("font-weight", "bold"); 

  d3.selectAll(d.incoming.map(d => d.path))
    // each link has data with source and target so you can get group 
    // and therefore group color; 0 for incoming and 1 for outgoing
    .attr("stroke", d => colors(d[0].data.group)) 
    // increase stroke width for emphasis
    .attr("stroke-width", 4)
    .raise();

  d3.selectAll(d.outgoing.map(d => d.path))
    // each link has data with source and target so you can get group 
    // and therefore group color; 0 for incoming and 1 for outgoing
    .attr("stroke", d => colors(d[1].data.group))
    // increase stroke width for emphasis
    .attr("stroke-width", 4)
    .raise()

  d3.selectAll(d.incoming.map(([d]) => d.text))
    // source and target nodes to go dark and bold
    .style("fill", colordark) 
    .attr("font-weight", "bold");    

  d3.selectAll(d.outgoing.map(([, d]) => d.text))
    // source and target nodes to go dark and bold
    .style("fill", colordark) 
    .attr("font-weight", "bold");    
}

See the comments in outed for styling logic on mouseout:

function outed(event, d) {

  //link.style("mix-blend-mode", "multiply");

  d3.select(this)
    // hovered node to revert to group colour on mouseout
    .style("fill", d => colors(d.data.group))
    .attr("font-weight", null); 

  d3.selectAll(d.incoming.map(d => d.path))
    // incoming links to revert to 'colornone' and width 1 on mouseout
    .attr("stroke", colornone)
    .attr("stroke-width", 1);

  d3.selectAll(d.outgoing.map(d => d.path))
    // incoming links to revert to 'colornone' and width 1 on mouseout
    .attr("stroke", colornone)
    .attr("stroke-width", 1);

  d3.selectAll(d.incoming.map(([d]) => d.text))
    // incoming nodes to revert to group colour on mouseout
    .style("fill", d => colors(d.data.group))
    .attr("font-weight", null);    

  d3.selectAll(d.outgoing.map(([, d]) => d.text))
    // incoming nodes to revert to group colour on mouseout
    .style("fill", d => colors(d.data.group))
    .attr("font-weight", null);    
}

Working example with the data you mentioned in the comments:

const url = "https://gist.githubusercontent.com/robinmackenzie/5c5d2af4e3db47d9150a2c4ba55b7bcd/raw/9f9c6b92d24bd9f9077b7fc6c4bfc5aebd2787d5/harvard_vis.json";
const colornone = "#ccc";
const colordark = "#222";
const width = 600;
const radius = width / 2;

d3.json(url).then(json => {
  // hack in the group name to each object
  json.forEach(o => o.group = o.name.split(".")[1]);
  // then render
  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})`);

  const arcInnerRadius = radius - 100;
  const arcWidth = 20;
  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)
  }));
  const colors = d3.scaleOrdinal().domain(leafGroups.map(d => d[0])).range(d3.schemeTableau10);

  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", d => colors(d.name))

  svg
    .selectAll(".arcLabel")
    .data(arcAngles) 
    .enter()
    .append("text")
    .attr("x", 5) 
    .attr("dy", (d) => ((arcOuterRadius - arcInnerRadius) * 0.8)) 
    .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); 

  // 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) 
      .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)
      .style("fill", d => colors(d.data.group))
      .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)
      .style("fill", colordark)
      .attr("font-weight", "bold"); 

    d3.selectAll(d.incoming.map(d => d.path))
      .attr("stroke", d => colors(d[0].data.group))
      .attr("stroke-width", 4)
      .raise();

    d3.selectAll(d.outgoing.map(d => d.path))
      .attr("stroke", d => colors(d[1].data.group))
      .attr("stroke-width", 4)
      .raise()

    d3.selectAll(d.incoming.map(([d]) => d.text))
      .style("fill", colordark)
      .attr("font-weight", "bold");    

    d3.selectAll(d.outgoing.map(([, d]) => d.text))
      .style("fill", colordark)
      .attr("font-weight", "bold");    
  }

  function outed(event, d) {

    //link.style("mix-blend-mode", "multiply");

    d3.select(this)
      .style("fill", d => colors(d.data.group))
      .attr("font-weight", null); 

    d3.selectAll(d.incoming.map(d => d.path))
      .attr("stroke", colornone)
      .attr("stroke-width", 1);

    d3.selectAll(d.outgoing.map(d => d.path))
      .attr("stroke", colornone)
      .attr("stroke-width", 1);

    d3.selectAll(d.incoming.map(([d]) => d.text))
      .style("fill", d => colors(d.data.group))
      .attr("font-weight", null);    

    d3.selectAll(d.outgoing.map(([, d]) => d.text))
      .style("fill", d => colors(d.data.group))
      .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;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.0.0/d3.min.js"></script>
Robin Mackenzie
  • 18,801
  • 7
  • 38
  • 56