D3.js join function breaks the donut chart SVG structure

133 Views Asked by At

I'm trying to implement something like "Gauge graph" (or donut chart) using D3.js for dataset that represents binding between statuses of network devices and quantity of devices having such status:

{
  "Operational": 1,
  "Warning": 4,
  "Offline": 2,
  ...
}

Dataset may change over time: some other statuses can be added, some of the statuses can be removed, and/or quantity numbers can be also changed.

I've implemented the logic to do that, but sometimes on some particular datasets it breaks the structure of the root SVG element.

Steps to reproduce

To reproduce it open this JSFiddle: as you see, everything works fine: each <path> is under their parent <g>.

<svg width="400" height="200" viewBox="-200,-150,400,200" style="display: block; max-width: 100%; height: auto; margin: 0 auto;">
  <g>
    <path d="M-98.99819570566463,-0.5977014496607642A99,99,0,0,1,-80.2110753965349,-58.02743647389048L-54.39727983232389,-39.11439564717636A67,67,0,0,0,-66.99733392439637,-0.5977014496607781Z" fill="green"></path>
  </g>
  <g>
    <path d="M-79.50457242815314,-58.991719444482634A99,99,0,0,1,-0.10270506233973228,-98.9999467255926L-0.2627087045622808,-66.99948495426325A67,67,0,0,0,-53.69077686394212,-40.078678617768546Z" fill="lightblue"></path>
  </g>
  <g>
    <path d="M1.0926828944767046,-98.99396973600017A99,99,0,0,1,98.99819570566463,-0.5977014496607955L66.99733392439637,-0.5977014496607884A67,67,0,0,0,0.9326792522541477,-66.99350796467084Z" fill="red"></path>
  </g>
</svg>

Screenshot: JSFiddle - correct example

But when I change the dataset by clicking on "Use 2nd dataset" button, I see that 2 of the <path>s are under the same <g>, and one of the <g>s is empty:

<svg width="400" height="200" viewBox="-200,-150,400,200" style="display: block; max-width: 100%; height: auto; margin: 0 auto;">
  <g>
    <path d="M-98.99819570566463,-0.5977014496607642A99,99,0,0,1,-70.25076521837927,-69.75550147645815L-47.67937956857289,-47.07097581265926A67,67,0,0,0,-66.99733392439637,-0.5977014496607781Z" fill="lightblue"></path>
    <path d="M-69.4033771719701,-70.59866313978799A99,99,0,0,1,90.99166365362825,-39.006629507623266L61.5038381189291,-26.575889385692513A67,67,0,0,0,-46.83199152216371,-47.914137475989136Z" fill="green"></path>
  </g>
  <g>
  </g>
  <g>
    <path d="M91.45601811020627,-37.905101918153754A99,99,0,0,1,98.99819570566463,-0.5977014496607516L66.99733392439637,-0.5977014496607587A67,67,0,0,0,61.9681925755071,-25.474361796223004Z" fill="red"></path>
  </g>
</svg>

Screenshot: JSFiddle - broken layout

It still looks fine, but this incorrect structure breaks the logic when data changes again.

Question

My question is: what I'm doing wrong? I suppose that the problem is in the data join key function, but I cannot understand the root cause.

Playground

See my JSFiddle.

2

There are 2 best solutions below

1
toadv1ne On

I think it has to do with modifying the same svg each time. I'm not sure exactly why your error is happening, but it seems like the pieces of the pie reach a point where they no longer adjust between the two dataset values. I was able to fix the issue by resetting the svg each time the button is clicked. The code below removes the old svg and creates a new one.

function useFirstDataset() {
  d3.selectAll("svg > *").remove();
  updateGaugeData(FIRST_DATASET);
}

function useSecondDataset() {
  d3.selectAll("svg > *").remove();
  updateGaugeData(SECOND_DATASET);
}
4
Mark On

New answer:

My original answer, as you pointed out, just masked the root cause. The real issue here is missing values - meaning, you have two datasets that'll produce data-binding with missing keys based on your key function. This causes d3 to "miscalculate" the enter vs update selections.

Here's a new runnable snippet with comments to explain what I changed:

<html>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.9.0/d3.min.js"></script>
  <figure id="gauge_graph"></figure>
  <button onclick="useFirstDataset()">Use 1<sup>st</sup> dataset</button>
  <button onclick="useSecondDataset()">Use 2<sup>nd</sup> dataset</button>

  <script>
/**
 * Datasets
 */
const FIRST_DATASET = {
  Operational: 2,
  Unknown: 3,
  Error: 5
};
const SECOND_DATASET = {
  Unknown: 2,
  Operational: 5,
  Error: 1,
};

/**
 * Constants
 */
const GAUGE_WIDTH_PX = 400;
const GAUGE_HEIGHT_PX = 200;
const GAUGE_ARC_RADIUS_PX = Math.min(GAUGE_WIDTH_PX, GAUGE_HEIGHT_PX) / 2;
const COLORS = {
  Operational: 'green',
  Error: 'red',
  Warning: 'orange',
  Offline: 'grey',
  Unknown: 'lightblue',
};

/**
 * Creating svg element
 */
d3.select('figure#gauge_graph')
  .append('svg')
  .attr('width', GAUGE_WIDTH_PX)
  .attr('height', GAUGE_HEIGHT_PX)
  .attr('viewBox', [
    -GAUGE_WIDTH_PX / 2,
    -GAUGE_HEIGHT_PX / 2 - GAUGE_ARC_RADIUS_PX / 2,
    GAUGE_WIDTH_PX,
    GAUGE_HEIGHT_PX,
  ])
  .attr(
    'style',
    'display: block; max-width: 100%; height: auto; margin: 0 auto;'
  );

/**
 * Defining elements: d3.pie, that distributes values over the circle, and d3.arc, that actually draws the line
 */
const pie = d3
  .pie()
  .startAngle(-Math.PI / 2)
  .endAngle(Math.PI / 2)
  .padAngle(1 / GAUGE_ARC_RADIUS_PX)
  .sort(null)
  .value(d => d.value); //<-- simplified value func based on new data structure

const arc = d3
  .arc()
  .innerRadius(GAUGE_ARC_RADIUS_PX * 0.67)
  .outerRadius(GAUGE_ARC_RADIUS_PX - 1);

/**
 * Function that sets/updates data
 */
function updateGaugeData(dataset) {

  // normalize the data on each call
  // this will produce the kind of data d3 likes - an array of objects
  // without potential missing values
  const normData = pie(
    Object.keys(COLORS).map(key => ({key: key, value: dataset[key] || 0}))
  );

  d3
    .select('figure#gauge_graph svg')
    .selectAll('g')
    .data(normData, d => d.data.key) //<-- simplified key func based on new data structure
    .join(
      function(enter) {
        return enter
          .append('g')
          .append('path')
          .attr('d', arc)
          .attr('fill', d => COLORS[d.data.key]); //<-- simplified color selection
      },
      function(update) {
        return update
          .select('path')
          .attr('d', arc);
      },
      function(exit) {
        return exit.remove()
      }
    );
}

updateGaugeData(FIRST_DATASET);

/**
 * Click handlers
 */
function useFirstDataset() {
  updateGaugeData(FIRST_DATASET);
}

function useSecondDataset() {
  updateGaugeData(SECOND_DATASET);
}

  </script>
</html>

Old sub-par answer:

You need to hold a reference to you selection instead of re-selecting it each time you call your update function:

const slices = d3
  .select('figure#gauge_graph svg')
  .selectAll('g');

function updateGaugeData(dataset) {

  slices
    .data(
      pie(Object.entries(dataset)),
      function(datum) {
        return datum.data[0]; // <-- using status name (e. g. "Operational", "Warning") as a key
      }
    )
...