broken legend and tooltip in zoomable sunburst cha

2019-08-19 09:09发布

问题:

I have a zoomable sunburst chart with the following issues:

  1. Legend is displaying vertically instead of horizontal. I thought that float:left on class legend would do the trick but labels display on new line instead.

  2. Allow users to disable categories in the legend to recalculate the sunburst chart.

  3. Tooltip is not showing up. What in the world am I missing?

  4. I want to append the grand total in the middle of the doughnut which dynamically changes upon zoom transitions. How does one go about that?

I apologize for the messy code. I started learning D3 about 2 weeks ago and the code below is a mix of a bunch of different tutorials and stack overflow forums.

Thank you in advance!

// define json object
var root = {
 "name": "TOTAL",
 "children": [
  {
   "name": "UNASSIGNED",
   "children": [
    {"name": "high", "size": 170},
    {"name": "med", "size": 701},
    {"name": "low", "size": 410}
   ]
  },
  {
   "name": "CLOSED",
   "children": [
    {"name": "high", "size": 1701},
    {"name": "med", "size": 584},
    {"name": "low", "size": 606}
   ]
  },
  {
   "name": "ATTACHED",
   "children": [
    {"name": "high", "size": 220},
    {"name": "med", "size": 179},
    {"name": "low", "size": 322}
   ]
  },
  {
   "name": "NOTIFIED",
   "children": [
    {"name": "high", "size": 883},
    {"name": "med", "size": 132},
    {"name": "low", "size": 1066}
   ]
  },
  {
   "name": "INTEGRATED",
   "children": [
    {"name": "high", "size": 883},
    {"name": "med", "size": 132},
    {"name": "low", "size": 416}
   ]
  },
  {
   "name": "DELIVERED",
   "children": [
    {"name": "high", "size": 170},
    {"name": "med", "size": 701},
    {"name": "low", "size": 410}
   ]
  },
  {
   "name": "ESCALATED",
   "children": [
    {"name": "high", "size": 170},
    {"name": "med", "size": 701},
    {"name": "low", "size": 410}
   ]
  },
  {
   "name": "COMMITTED",
   "children": [
    {"name": "high", "size": 170},
    {"name": "med", "size": 701},
    {"name": "low", "size": 410}
   ]
  },
  {
   "name": "VERIFIED",
   "children": [
    {"name": "high", "size": 170},
    {"name": "med", "size": 701},
    {"name": "low", "size": 410}
   ]
  },
  {
   "name": "SUBMITTED",
   "children": [
    {"name": "high", "size": 170},
    {"name": "med", "size": 701},
    {"name": "low", "size": 410}
   ]
  }
 ]
}

// set width, height, and radius
var width = 650,
    height = 475,
    radius = (Math.min(width, height) / 2) - 10; // lowest number divided by 2. Then subtract 10

// legend dimensions
var legendRectSize = 15; // defines the size of the colored squares in legend
var legendSpacing = 6; // defines spacing between squares

var formatNumber = d3.format(",d"); // formats floats

var x = d3.scaleLinear() // continuous scale. preserves proportional differences
    .range([0, 2 * Math.PI]); // setting range from 0 to 2 * circumference of a circle

var y = d3.scaleSqrt() // continuous power scale 
    .range([0, radius]); // setting range from 0 to radius

// setting color scheme
var color = {
    'TOTAL': '#FFF',
    'UNASSIGNED': '#DADFE1',
    'ASSIGNED_TO_EDITOR': '#5BCAFF',
    'ATTACHED': '#87D37C',
    'ASSIGNED_TO_MENTOR': '#F64747',
    'ASSIGNED_TO_REVIEWER': '#7BDDDD',
    'ASSIGNED_TO_APPROVER': '#1e90ff',
    'INTEGRATION_FAILED': '#F1A9A0',
    'DELIVERED': '#4183D7',
    'INTEGRATED': '#90C695',
    'PUBLISHED': '#E4F1FE',
    'COMMIT_FAILED': '#F62459',
    'NOTIFIED': '#4ECDC4',
    'BLOCKED': '#D24D57',
    'ESCALATED': '#DB0A5B',
    'SUBMITTED': '#86a531',
    'REVIEWED': '#bfba00',
    'APPROVED': '#C86DEF',
    'ASSIGNED_TO_VERIFIER': '#D2527F',
    'COMMITTED': '#5AD427',
    'VERIFIED': '#81CFE0',
    'CLOSED': '#CF000F'
};

var partition = d3.partition(); // subdivides layers

// define arcs
var arc = d3.arc()
    .startAngle(function(d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x0))); })
    .endAngle(function(d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x1))); })
    .innerRadius(function(d) { return Math.max(0, y(d.y0)); })
    .outerRadius(function(d) { return Math.max(0, y(d.y1)); });

// define tooltip
var tooltip = d3.select('body') // select element in the DOM with id 'chart'
  .append('div') // append a div element to the element we've selected    
  .style("opacity","0")
  .style("position","absolute");

tooltip.append('div') // add divs to the tooltip defined above                            
  .attr('class', 'label'); // add class 'label' on the selection                         

tooltip.append('div') // add divs to the tooltip defined above                     
  .attr('class', 'count'); // add class 'count' on the selection                  

tooltip.append('div') // add divs to the tooltip defined above  
  .attr('class', 'percent'); // add class 'percent' on the selection

// define SVG element
var svg = d3.select("#chart").append("svg")
    .attr("width", width) // set width
    .attr("height", height) // set height
  .append("g") // append g element
    .attr("transform", "translate(" + width / 2 + "," + (height / 2) + ")");
  
root = d3.hierarchy(root);

root.sum(function(d) { return d.size; });// must call sum on the hierarchy first

var path = svg.selectAll("path")
      .data(partition(root).descendants()) // path for each descendant
    .enter().append("path")
      .attr("d", arc) // draw arcs
      .style("fill", function (d) { return color[(d.children ? d : d.parent).data.name]; })
    .on("click", click)
      .append("title")
      .text(function(d) { return d.data.name + "\n" + formatNumber(d.value); 
    });

// mouse event handlers are attached to path so they need to come after its definition
path.on('mouseover', function(d) {  // when mouse enters div
 var total = d.data.size
 var percent = Math.round(1000 * d.value / total) / 10; // calculate percent
 tooltip.select('.label').html(d.data.name); // set current label           
 tooltip.select('.count').html(total); // set current count            
 tooltip.select('.percent').html(percent + '%'); // set percent calculated above          
 tooltip.style('display', 'block'); // set display                     
});                                                           

path.on('mouseout', function() { // when mouse leaves div                        
  tooltip.style('display', 'none'); // hide tooltip for that element
 });

path.on('mousemove', function(d) { // when mouse moves                  
  tooltip.style('top', (d3.event.layerY + 10) + 'px') // always 10px below the cursor
    .style('left', (d3.event.layerX + 10) + 'px'); // always 10px to the right of the mouse
  });

function click(d) {
  svg.transition()
      .duration(750)
      .tween("scale", function() {
        var xd = d3.interpolate(x.domain(), [d.x0, d.x1]),
            yd = d3.interpolate(y.domain(), [d.y0, 1]),
            yr = d3.interpolate(y.range(), [d.y0 ? 20 : 0, radius]);
        return function(t) { x.domain(xd(t)); y.domain(yd(t)).range(yr(t)); };
      })
    .selectAll("path")
      .attrTween("d", function(d) { return function() { return arc(d); }; });
}

d3.select(self.frameElement).style("height", height + "px");

// define legend element

var legendWidth = legendRectSize + legendSpacing; // height of element is the height of the colored square plus the spacing
var width= 500;
var height = 75; // height of element is the height of the colored square plus the spacing
var offset =  80; // vertical offset of the entire legend = height of a single element & 
var svgw = 20;
var svgh = 20;

var legendContainer = d3.select("#legend").append("svg")
    .attr("width", width) // set width
    .attr("height", height) // set height
  .append("g") // append g element
    .attr("transform", function(d, i) {
              return "translate(" + i * 20 + ",0)";
    });

var legend = legendContainer.selectAll('.legend') // selecting elements with class 'legend'
  .data(d3.entries(color)) // refers to an array of labels from our dataset
  .enter() // creates placeholder
  .append('g') // replace placeholders with g elements
  .attr('class', 'legend') // each g is given a legend class
  .style('background-color', 'orange')
  .attr('transform', function(d, i) {             
      return "translate(0," + i * 20 + ")" //return translation       
   });

// adding colored squares to legend
legend.append('rect') // append rectangle squares to legend
  .attr('x', 0)
  .attr('y', 0)
  .attr('width', 10) // width of rect size is defined above                        
  .attr('height', 10) // height of rect size is defined above                      
  .style('fill', function (d) { return color[d.key]; }) // each fill is passed a color

// adding text to legend
legend.append('text')                                    
  .attr('x', 20)
  .attr('y', 10)
  .attr("text-anchor", "start")
  .text(function(d) { return d.key; }); // return label

function getRootmostAncestorByWhileLoop(node) {
    while (node.depth > 1) node = node.parent;
    return node;
}
html, body {
  height: 100%;
}

path {
  stroke: #fff;
}

/* legend */

#legend {
  background-color:yellow;
}
.legend {
  font-size: 14px;
  float: left;
  margin-right:1em;
}
rect {
  stroke-width: 2;
}

/* #tooltip {
	position: absolute;
	width: 200px;
	height: auto;
	padding: 10px;
	background-color: white;
	-webkit-border-radius: 10px;
	-moz-border-radius: 10px;
	border-radius: 10px;
	-webkit-box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4);
	-moz-box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4);
	box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4);
	pointer-events: none;
}
			
#tooltip.hidden {
	display: none;
}
			
#tooltip p {
	margin: 0;
	font-family: sans-serif;
	font-size: 16px;
	line-height: 20px;
} */

.tooltip {
  opactiy: 0;
  positon: absolute;
}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>D3.js Donut Chart</title>
        <link href="https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300|Pacifico" rel="stylesheet">
        <link href="styles.css" rel="stylesheet">
    </head>
    <body>

      <div id="chart"></div>

<div id="tooltip" class="hidden">
	<p><span id="category"><strong>Important Label Heading</strong></span></p>
	<p><span id="value">100</span></p>
</div>

<div id="legend"></div>


        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.11.0/d3.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.11.0/d3.min.js"></script>
        <script src="script.js"></script> <!-- remove if no javascript -->
    </body>
</html>

回答1:

Here's a working demo:

SUNBURST CHART WITH HTML LEGENDS AND TOOLTIP

  1. CSS properties like float, background-color do not work for SVG elements. To achieve that using SVG, you gotta use 'transform('+(i*100)+',0)' which will transform from the left. Have a look at the docs for the transform attribute.

    BUT there would be two major problems:

    a) The value 100 which would not align the texts correctly based on the respective widths (i.e. they would overlap which looks bad). A solution to that would be to position legends based on calculation of width of every key.

    b) You'll have to calculate the positions based on the page width as well i.e. legend wrapping has to be done manually. SVG elements DO NOT mind going off the page :P

    To overcome the above the issues, converting to HTML makes sense here. It's all <div>s and <span>s now.

    And accordingly CSS would be on the lines of the following (check the code for details):

    div#legend .rect {
      width: 10px;
      height: 10px;
      margin-right: 4px;
      display: inline-block;
    }
    
  2. For the data filtering based on legends click, that'd be a bit of code to write. I'd suggest you try it first and get back if you face any issues with it. I'm sorry it's not advisable to write the entire code for a question. But I've given you the starting point for it:

    legend.on('click', function(d) {
        if(d3.select(this).classed('clicked')) {
        d3.select(this).classed('clicked', false)
           .style('background-color', function(d) { return color[d.key]; });   
       // filter data and rerender
    } else {
        d3.select(this).classed('clicked', true)
           .style('background-color', 'transparent');
      // filter data and rerender
    }
    

    You'll notice how this works out by clicking on the legends. Take a look at it and try filtering the data based on this key and rerender the chart. That should be fairly simple as well. If not, get back with a new question.

  3. You had multiple tooltips on your page. I fixed that bug and added a few classes and CSS.

    var tooltip = d3.select('body') // select element in the DOM with id 'chart'
       .append('div').classed('tooltip', true);
    

    And I got rid of the <div class="tooltip"></div> from the HTML code.

  4. Can you elaborate more on this point? I'm not sure of what exactly is the requirement here. Maybe you can try on the fiddle I created and let me know if you're stuck appending that label at the center.

Hope this helps (and yes, please fix the minor bugs, if any). :)