Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Special donut chart with different rings/arcs for positive and negative values

I am trying to create a special kind of donut chart in D3 which will contain different rings for positive and negative values. The values can be greater than 100% or less than -100% so there will be an arc representing the remaining value. Below is the sample image of the chart:enter image description here

The first positive category (Category_1 - Gray) value is 80, so it is 80% filling the the circle with gray, leaving the 20% for next positive category. The next positive category value (Category_2 - Orange) is 160. So it is first using the 20% left by Category_1 (140 value left now). Then it is filling the next circle (upward) 100% (40 value left now), and for the remaining value (40), it is creating partial-circle upward.

Now, we have Category_3 (dark-red) as negative (-120%), so it if creating an inward circle and filling it 100% (20 value left now), and then it is creating an inward arc for remaining value (20). We have another negative category (Category_4 - red), so it will start from where the previous negative category (Category_3) ended and fill 20% area from there.

Edit 3: I've created a very basic arc-based donut chart and when total value is exceeding 100, I am able to create outer rings for the remaining values. Below is the JSFiddle link:

http://jsfiddle.net/rishabh1990/zmuqze80/

data = [20, 240];

var startAngle = 0;
var previousData = 0;
var exceedingData;
var cumulativeData = 0;
var remainder = 100;
var innerRadius = 60;
var outerRadius = 40;
var filledFlag;

var arc = d3.svg.arc()
  .innerRadius(innerRadius)
  .outerRadius(outerRadius)

for (var i = 0; i < data.length; i++) {

  filledFlag = 0;
  exceedingData = 0;

  console.log("---------- Iteration: " + (i + 1) + "---------");

  if (data[i] > remainder) {
    filledFlag = 1;
    exceedingData = data[i] - remainder;
    console.log("Exceeding: " + exceedingData);
    data[i] = data[i] - exceedingData;
    data.splice(i + 1, 0, exceedingData);
  }

  if( filledFlag === 1) {
    cumulativeData = 0;
  } else {
    cumulativeData += data[i];
  }

  console.log("Previous: " + previousData);
  console.log("Data: " + data, "Current Data: " + data[i]);
  var endAngle = (previousData + (data[i] / 50)) * Math.PI;
  console.log("Start " + startAngle, "End " + endAngle);
  previousData = previousData + data[i] / 50;

  //if(i===1) endAngle = 1.4 * Math.PI;
  //if(i===2) endAngle = 2 * Math.PI;

  var vis = d3.select("#svg_donut");

  arc.startAngle(startAngle).endAngle(endAngle);


  vis.append("path")
    .attr("d", arc)
    .attr("transform", "translate(200,200)")
    .style("fill", function(d) {
      if (i === 0) return "red";
      //if (i === 1) return "green";
      //if (i === 2) return "blue"
      //if (i === 3) return "orange"
      //if (i === 4) return "yellow";
    });

  if (exceedingData > 0) {
    console.log("Increasing Radius From " + outerRadius + " To " + (outerRadius + 40));
    outerRadius = outerRadius + 22;
    innerRadius = innerRadius + 22;
    arc.innerRadius(innerRadius).outerRadius(outerRadius);
    console.log("Outer: ", outerRadius);
  }

  if (remainder === 100) {
    remainder = 100 - data[i];
  } else {
    remainder = 100 - cumulativeData;
  };

  if (filledFlag === 1) {
    remainder = 100;
  }

  console.log("Remainder: " + remainder);

  startAngle = endAngle;

}

Please share some ideas for implementation.

like image 695
Rishabh Avatar asked Jun 17 '16 13:06

Rishabh


1 Answers

Ok, this took some time, but it seems to be working. First, let's identify that what you describe as a donut chart can also be rendered as a series of bars — using the exact same data. So I started from there and eventually worked it into a donut chart, but left the bar implementation in there as well. The other thing is that a generic solution should be able to wrap the segments at any value, not just 100, so I included a slider that lets you vary that wrapping value. Finally — and this is easier to explain in a bars rather than donut implementation — rather than always having the bars wrap left-to-right, like text, it may be desirable to zigzag, i.e. alternate wrapping left-to-right then right-to-left and so on. The effect this has is that when an amount is broken up into two segments on two separate lines, the zigzag approach will keep those two segments next to each other. I added a checkbox to turn on/off this zigzag behavior.

Here's a working jsFiddle and another iteration of it.

Here are the important bits:

There's a function wrap(data, wrapLength) which takes an array of data values and a wrapLength at which to wrap these values. That function figures out which data values have to be split up into sub-segments and returns a new array of them, with each segment's object having x1, x2 and y values. x1 and x2 are the start and end of each bar, and y is the row of the bar. In a donut chart those values are equivalently start angle (x1), end angle (x2) and radius (y) of each arc.

The function wrap() doesn't know how to account for negative vs positive values, so wrap() has to be called twice — once with all the negatives and then all the positives. From there, some processing is applied selectively to just the negatives and then more processing is applied to the combination of the two sets. The entire set of transformations described in the last 2 paragraphs is captured by following snippet. I'm not including the implementation of wrap() here, just the code that calls it; also not including the rendering code, which is pretty straightforward once segments is generated.

// Turn N data points into N + x segments, as dictated by wrapLength. Do this separately
// for positive and negative values. They'll be merged further down, after we apply
// a specific transformation to just the negatives
var positiveSegments = wrap(data.filter(function(d) { return d.value > 0; }), wrapLength);
var negativeSegments = wrap(data.filter(function(d) { return d.value < 0; }), wrapLength);

// Flip and offset-by-one the y-value of every negative segment. I.e. 0 becomes -1, 1 becomes -2
negativeSegments.forEach(function(segment) { segment.y = -(segment.y + 1); });

// Flip the order of the negative segments, so that their sorted from negative-most y-value and up
negativeSegments.reverse()

// Combine negative and positive segments
segments = negativeSegments.concat(positiveSegments);

if(zigzag) {
  segments.forEach(function(segment) {
    if(Math.abs(segment.y) % 2 == (segment.y < 0 ? 0 : 1)) { flipSegment(segment, wrapLength); }
  });
}

// Offset the y of every segment (negative or positive) so that the minimum y is 0
// and goes up from there
var maxNegativeY = negativeSegments[0].y * -1;
segments.forEach(function(segment) { segment.y += maxNegativeY; });
like image 167
meetamit Avatar answered Oct 10 '22 22:10

meetamit