2

I have a tree visualisation in which I am trying to display paths between nodes that represent a distribution with multiple classes. I want to split the path lengthwise into multiple colours to represent the frequency of each distribution.

For example: say we have Class A (red) and Class B (black), that each have a frequency of 50. Then I would like a path that is half red and half black between the nodes. The idea is to represent the relative frequencies of the classes, so the frequencies would be normalised.

My current (naive) attempt is to create a separate path for each class and then use an x-offset. It looks like this.

However, as shown in the image, the lines do not maintain an equal distance for the duration of the path.

The relevant segment of code:

linkGroup.append("path").attr("class", "link")
              .attr("d", diagonal)
              .style("stroke", "red")
              .style("stroke-width", 5)
              .attr("transform", function(d) {
                  return "translate(" + -2.5 + "," + 0.0 + ")"; });

linkGroup.append("path").attr("class", "link")
              .attr("d", diagonal)
              .style("stroke", "black")
              .style("stroke-width", 5)
              .attr("transform", function(d) {
                  return "translate(" + 2.5 + "," + 0.0 + ")"; });

It would be great if anyone has some advice.

Thanks!

  • What if both of them have frequency > 50 ? Say red 80 and black 75. – Kosh Aug 05 '18 at 02:02
  • 2
    The idea is to represent the relative size of each class, so they would be normalised. In your example, the values would be normalised to 80 / (80+75) and 75 / (80+75). – Isaac Monteath Aug 05 '18 at 02:45

2 Answers2

2

A possible solution is to calculate the individual paths and fill with the required color.

Using the library svg-path-properties from geoexamples.com you can calculate properties (x,y,tangent) of a path without creating it first like it is done in this SO answer (this does not calculate the tangent).

The code snippet does it for 2 colors but it can be easy generalized for more.

You specify the colors, percentage and width of the stroke with a dictionary

var duoProp = { color: ["red", "black"], percent: 0.30, width: 15 };

percent is the amount color[0] takes from the stroke width.

var duoPath = pathPoints("M30,30C160,30 150,90 250,90S350,210 250,210", 10, duoProp);
duoPath.forEach( (d, i) => {
    svg.append("path")
       .attr("d", d)
       .attr("fill", duoProp.color[i])
       .attr("stroke", "none");
});

enter image description here

The pathPoints parameters

  1. path that needs to be stroked, can be generated by d3.line path example from SO answer

    var lineGenerator = d3.line().x(d=>d[0]).y(d=>d[1]).curve(d3.curveNatural);
    var curvePoints = [[0,0],[0,10],[20,30]];
    var duoPath = pathPoints(lineGenerator(curvePoints), 10, duoProp);
    
  2. path length interval at which to sample (unit pixels). Every 10 pixels gives a good approximation

  3. dictionary with the percent and width of the stroke

It returns an array with the paths to be filled, 1 for each color.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/svg-path-properties@0.4.4/build/path-properties.min.js"></script>
</head>
<body>
<svg id="chart" width="350" height="350"></svg>
<script>
var svg = d3.select("#chart");

function pathPoints(path, stepLength, duoProp) {
    var props = spp.svgPathProperties(path);
    var length = props.getTotalLength();
    var tList = d3.range(0, length, stepLength);
    tList.push(length);
    var tProps = tList.map(d => props.getPropertiesAtLength(d));
    var pFactor = percent => (percent - 0.5) * duoProp.width;
    tProps.forEach(p => {
        p.x0 = p.x - pFactor(0) * p.tangentY;
        p.y0 = p.y + pFactor(0) * p.tangentX;
        p.xP = p.x - pFactor(duoProp.percent) * p.tangentY;
        p.yP = p.y + pFactor(duoProp.percent) * p.tangentX;
        p.x1 = p.x - pFactor(1) * p.tangentY;
        p.y1 = p.y + pFactor(1) * p.tangentX;
    });
    var format1d = d3.format(".1f");
    var createPath = (forward, backward) => {
        var fp = tProps.map(p => forward(p));
        var bp = tProps.map(p => backward(p));
        bp.reverse();
        return 'M' + fp.concat(bp).map(p => `${format1d(p[0])},${format1d(p[1])}`).join(' ') + 'z';
    }
    return [createPath(p => [p.x0, p.y0], p => [p.xP, p.yP]), createPath(p => [p.xP, p.yP], p => [p.x1, p.y1])]
}

var duoProp = { color: ["red", "black"], percent: 0.30, width: 15 };

var duoPath = pathPoints("M30,30C160,30 150,90 250,90S350,210 250,210", 10, duoProp);

duoPath.forEach( (d, i) => {
    svg.append("path")
       .attr("d", d)
       .attr("fill", duoProp.color[i])
       .attr("stroke", "none");
});
</script>
</body>
</html>
rioV8
  • 24,506
  • 3
  • 32
  • 49
  • Thank you very much for the detailed answer. I've managed to apply it to my application but I'm struggling to extend it to more than two colours. Ideally, I'd like to specify a dict like { 'red': 0.2, 'green': 0.3, 'blue': 0.5 } (where the values are percentages). Do you have any hints on how I could do this? Thanks! – Isaac Monteath Aug 05 '18 at 22:12
1

As a quick follow-up to rioV8's excellent answer, I was able to get their code working but needed to generalise it to work with more than two colours. In case someone else has a similar requirement, here is the code:

function pathPoints(path, stepLength, duoProp) {
    // get the properties of the path
    var props = spp.svgPathProperties(path);
    var length = props.getTotalLength();

    // build a list of segments to use as approximation points
    var tList = d3.range(0, length, stepLength);
    tList.push(length);
    var tProps = tList.map(function (d) {
        return props.getPropertiesAtLength(d);
    });

    // incorporate the percentage
    var pFactor = function pFactor(percent) {
        return (percent - 0.5) * duoProp.width;
    };

    // for each path segment, calculate offset points
    tProps.forEach(function (p) {
        // create array to store modified points
        p.x_arr = [];
        p.y_arr = [];

        // calculate offset at 0%
        p.x_arr.push(p.x - pFactor(0) * p.tangentY);
        p.y_arr.push(p.y + pFactor(0) * p.tangentX);

        // calculate offset at each specified percent
        duoProp.percents.forEach(function(perc) {
            p.x_arr.push(p.x - pFactor(perc) * p.tangentY);
            p.y_arr.push(p.y + pFactor(perc) * p.tangentX);
        });

        // calculate offset at 100%
        p.x_arr.push(p.x - pFactor(1) * p.tangentY);
        p.y_arr.push(p.y + pFactor(1) * p.tangentX);
    });

    var format1d = d3.format(".1f");
    var createPath = function createPath(forward, backward) {
        var fp = tProps.map(function (p) {
            return forward(p);
        });
        var bp = tProps.map(function (p) {
            return backward(p);
        });
        bp.reverse();
        return 'M' + fp.concat(bp).map(function (p) {
            return format1d(p[0]) + "," + format1d(p[1]);
        }).join(' ') + 'z';
    };

    // create a path for each projected point
    var paths = [];
    for(var i=0; i <= duoProp.percents.length; i++) {
        paths.push(createPath(function (p) { return [p.x_arr[i], p.y_arr[i]]; }, function (p) { return [p.x_arr[i+1], p.y_arr[i+1]]; }));
    }

    return paths;
}

// generate the line 
var duoProp = { color: ["red", "blue", "green"], percents: [0.5, 0.7], width: 15 };
var duoPath = pathPoints("M30,30C160,30 150,90 250,90S350,210 250,210", 10, duoProp);
duoPath.forEach( (d, i) => {
    svg.append("path")
       .attr("d", d)
       .attr("fill", duoProp.color[i])
       .attr("stroke", "none");
});

Note that the percents array specifies the cumulative percentage of the stroke, not the individual percentages of the width. E.g. in the example above, the red stroke will span 0% to 50% width, the blue stroke 50% to 70% width and the green stroke 70% to 100% width.