Skip to content

Instantly share code, notes, and snippets.

@ryanlsimms
Last active July 27, 2017 19:37
Show Gist options
  • Save ryanlsimms/6092ddf8bce46189bb328c5b88e09739 to your computer and use it in GitHub Desktop.
Save ryanlsimms/6092ddf8bce46189bb328c5b88e09739 to your computer and use it in GitHub Desktop.
Rotating Donut
license: gpl-3.0
height: 600

Demo for modular d3 charts

Goal

Reduce coupling between chart components and delegate responsibility. Build components based on Mike Bostock's example

  • Use mediator pattern for communication between components.
  • Embrace method chaining.
  • component should make no assumptions about which properties to use in the data. Instead, use accessor functions
  • A component is function which returns inner function.
    This inner function contains the accessible methods.
  • Dispatch custom events from each component using d3.dispatch.
  • Allow binding callbacks to component events using an on method.
  • Set parameters using methods instead of an object literal.
    Should return value if not given value (getter/setter).
  • Use d3.local to save component instance state between renders.
  • Allow component to be called on either transition or selection.
  • Allow component to be called on multiple or single selection.
  • this should always point to a dom element.
  • Don't re-render unless explicitly called.
  • Try to keep the core component slim.

Basic Structure

function barChartWithTransitions() {
  // outer scope available to public methods
  var events = d3.dispatch('click');
  var transitionDuration = 600; // default
  var label = d3.local();
  
  function chart(group) {
    // scope available to all contexts
    
    group.each(function(data) {
      // context-specific scope
      var context = d3.select(this),
          transition;
      
      // use transition if that's what was passed in, other wise create a new one
      if (group instanceof d3.transition) {
        transition = context.transition(group);
      } else {
        transition = context.transition().duration(o.animationDuration);
      }
      
      /* render chart */
    })
  }
    
  // component-scope event listerner binding
  chart.on = function(evt, callback) {
    events.on(evt, callback);
    return chart;
  };
  
  // component-scope getter/setter
  chart.transitionDuration = function(value) {
    if (!arguments.length) {return transitionDuration;}
    transitionDuration = value;
    return chart;
  };
  
  // context-specific getter/setter
  chart.label = function(context, value) {
    if (typeof value === 'undefined' ) {
      return context.nodes().map(function(node) {return label.get(node);});
    }
    context.each(function() {label.set(this, _);});
    return chart;
  };
  
  return chart;
}
var barchart = barChartWithTransitions()
    .transitionDuration(400)
    .on('click', function(d) {console.log(d.name + ' clicked');});
    
d3.select('#chart1')
        .datum(someDataArray)
        .call(barchart.label, 'Quarterly Profits')
        .call(barchart);

Features of This Example

  • Donut Chart
  • Pie segemnts animate when data is updated
  • Resizable with animation
  • Selected pie segment rotates to alignment angle
  • Icons are shown for each segment
  • Legend
  • Legend highlights when item is selected
  • Description is shown for selected segment
  • Description is not shown if selected segment is not contained in the data
  • All elements interact through mediator app.js

Things Not Addressed

  • ECMAScript20**
  • Custom d3 bundles via rollup or similar
  • Most of these interactions require re-rendering more components than may be necessary. While this is fine in this example, it may be worth considering less heavy-handed options.
  • Directory Structure
  • Yes, I know this chart probably isn't the best way to display this data, but that's not the point right now

Blog Posts

  • Manifesto / Overview
  • Basic Donut, app.js
  • Animation
  • Legend and Description, events
  • Rotation
  • Icons
  • Tests
  • Refactoring / Retesting / Final thoughts
document.addEventListener('DOMContentLoaded', function() {
'use strict';
var donut,
legend,
description,
events;
function build() {
donut = APP.rotatingDonut()
.alignmentAngle(90)
.iconSize(0.5)
.thickness(0.5)
.value(function(d) {return d.value;})
.icon(function(d) {return d.icon;})
.color(function(d) {return d.color;})
.key(function(d) {return d.id;})
.sort(function(a, b) {return a.id - b.id;});
legend = APP.basicLegend()
.label(function(d) {return d.label;})
.color(function(d) {return d.color;})
.key(function(d) {return d.id;});
description = APP.descriptionWithArrow()
.label(function(d) {return formatDollar((d || {}).value);})
.text(function(d) {return d ? d.description : 'no data for selection';});
}
function addToDom() {
d3.select('#donut1')
.datum(APP.generateData())
.call(donut.label, 'Smith')
.transition()
.duration(0) // don't animate the initial load
.call(donut);
d3.select('#donut2')
.datum(APP.generateData())
.call(donut.label, 'Jones')
.transition()
.duration(0)
.call(donut);
d3.select('#legend')
.datum(APP.generateData())
.call(legend);
}
function addListeners() {
donut.on('click', events.donutClick);
legend.on('click', events.legendClick);
d3.select('button').on('click', events.dataButtonClick);
d3.selectAll('.donut-size').on('change', events.resizeSliderChange);
}
function setDescriptions() {
d3.select('#description1')
.datum(donut.selectedSegment(d3.select('#donut1'))[0])
.call(description);
d3.select('#description2')
.datum(donut.selectedSegment(d3.select('#donut2'))[0])
.call(description);
}
function formatDollar(num) {
return typeof num === 'number' ? '$' + num.toFixed(2) : '';
}
events = {
dataButtonClick: function() {
// bind new data to selections and call donut to re-render
// the donut module can be passed a transition rather than a selection
// and will use that transition instead of creating a new transition
d3.select('#donut1')
.datum(APP.generateData(true))
.transition()
.duration(600)
.call(donut);
d3.select('#donut2')
.datum(APP.generateData(true))
.transition()
.delay(400)
.duration(200)
.call(donut);
setDescriptions();
},
donutClick: function(d) {
var container = this;
// select segment on all the other donuts
d3.selectAll('.donut')
.filter(function() {return this !== container;})
.call(donut.selectedSegment, d)
.call(donut);
// select item on legend
d3.select('#legend')
.call(legend.selectedItem, d)
.call(legend);
setDescriptions();
},
legendClick: function(d) {
// select segment on all donuts
d3.selectAll('.donut')
.call(donut.selectedSegment, d)
.call(donut);
setDescriptions();
},
resizeSliderChange: function() {
var target = d3.select(this).attr('data-target'),
value = this.value * 2;
d3.selectAll(target)
.call(donut.dimensions, {width: value, height: value})
.call(donut)
.transition()
.duration(donut.animationDuration())
.style('width', value + 'px')
.style('height', value + 'px')
.each(transitionDonutDescription);
function transitionDonutDescription() {
d3.select('.description[data-target="' + target + '"]')
.transition()
.duration(donut.animationDuration())
.style('height', value + 'px');
}
}
};
build();
addToDom();
addListeners();
});
if(typeof APP === 'undefined') {APP = {};}
APP.basicLegend = function () {
'use strict';
// scope available to public methods
var events = d3.dispatch('mouseenter', 'mouseleave', 'click'),
selectedItem = d3.local();
var o = {
label: null,
key: null,
color: null
};
function legend(group) {
// scope which isn't selection-specific
group.each(function(data) {
render.call(this, data, group)
});
}
function render(data, group) {
// selection-specific scope
var context = d3.select(this),
t,
labels,
labelsEnter;
if (group instanceof d3.transition) {
t = context.transition(group);
} else {
t = context.transition().duration(o.animationDuration);
}
context
.selectAll('ul')
.data([data])
.enter()
.append('ul')
.attr('class', 'legend');
// bind new data to label selection
// Object is shortcut function to return argument as an object
labels = context
.selectAll('ul') // sets parent for selection where elements will be added
.selectAll('li.legend-label')
.data(Object, o.key);
// update existing labels which are in new data set
labels.transition(t)
.call(labelFinalAttributes);
// add new labels which weren't in previous data set
labelsEnter = labels.enter()
.append('li')
.attr('class', 'legend-label')
.attr('data-id', o.key)
.on('mouseenter mouseleave', onMouseMovement)
.on('click', onLabelClick)
.call(labelInitialAttributes);
labelsEnter
.transition(t)
.call(labelFinalAttributes);
labelsEnter
.append('svg')
.attr('width', 22)
.attr('height', 22)
.append('rect')
.attr('fill', o.color)
.attr('width', 20)
.attr('height', 20)
.attr('x', 1)
.attr('y', 1);
labelsEnter
.append('span')
.text(o.label);
// remove labels which aren't in new data set
labels.exit()
.transition(t)
.call(labelInitialAttributes)
.remove();
labelsEnter.merge(labels)
.classed('selected', isSelected);
function onLabelClick(d) {
selectedItem.set(context.node(), d);
context.call(legend);
events.call('click', context.node(), d);
}
function onMouseMovement(d) {
context.call(highlight, d, d3.event.type);
events.call(d3.event.type, context.node(), d);
}
}
function highlight(selection, d, action) {
selection
.selectAll('li[data-id="' + o.key(d) + '"]')
.classed('hovered', action === 'mouseenter');
}
function labelInitialAttributes(selection) {
selection
.style('left', '-12px')
.style('opacity', 0);
}
function labelFinalAttributes(selection) {
selection
.style('top', function(d, i) {return (i * 22) + 'px';})
.style('opacity', 1)
.style('left', '12px');
}
function isSelected(d) {
return selectedItem.get(this) && o.key(d) === o.key(selectedItem.get(this));
}
// value accessor functions
APP.addOptionMethods(legend, o);
APP.addLocalMethods(legend, 'selectedItem', selectedItem);
APP.addEventsListener(legend, events);
legend.highlight = function(selection, d) {
selection.call(highlight, d, 'mouseenter');
return legend;
};
legend.unhighlight = function(selection, d) {
selection.call(highlight, d, 'mouseleave');
return legend;
};
return legend;
};
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
if(typeof APP === 'undefined') {APP = {};}
APP.generateData = function(splice) {
'use strict';
// Icons from Freepik at http://www.flaticon.com/packs/miscellaneous-elements
var icons = ['car.svg', 'idea.svg', 'phone-call.svg', 'shopping-cart.svg', 'cutlery.svg'],
labels = ['travel', 'electricity', 'phone', 'shopping', 'food'],
descriptions = [
'Including car payments, fuel, tolls', 'Electric Bill',
'Cell phone, cell plan, land-line',
'Any non-food shopping items such as clothing, gifts, etc.',
'Groceries and restaurant expenses'
],
colors = d3.scaleOrdinal(d3.schemeCategory10),
arr = [],
i;
for (i = 1; i <= 5; i++) {
arr.push({
id: i,
value: 5 + Math.random() * 15,
color: colors(i),
icon: icons[i - 1],
label: labels[i - 1],
description: descriptions[i - 1]
});
}
if (splice) {
arr.sort(function() {return 0.5 - Math.random();})
.splice(0, Math.random() * 5);
}
return arr;
};
if(typeof APP === 'undefined') {APP = {};}
APP.descriptionWithArrow = function() {
'use strict';
var o = {
label: null,
text: null
};
function description(group) {
group.each(render);
}
function render(data) {
var context = d3.select(this),
right;
context
.html('')
.classed('no-data', !data)
.append('div')
.attr('class', 'desc-left arrow')
.html(data ? '&larr;' : '');
right = context.append('div')
.attr('class', 'desc-right');
right.append('div')
.attr('class', 'label')
.text(o.label);
right.append('div')
.attr('class', 'text')
.text(o.text);
}
APP.addOptionMethods(description, o);
return description;
};
if(typeof APP === 'undefined') {APP = {};}
APP.addOptionMethods = function(component, options) {
'use strict';
Object.keys(options).forEach(function (key) {
component[key] = function (_) {
if (!arguments.length) {
return options[key];
}
options[key] = _;
return component;
};
});
};
APP.addLocalMethods = function(component, methodName, local) {
'use strict';
function isList(context) {
return context._groups[0] instanceof NodeList;
}
function getLocalValue(node) {
return local.get(node);
}
component[methodName] = function(context, _) {
var returnArray;
// if there's do data passed, then we'll return the current selection instead of setting it.
if (typeof _ === 'undefined' ) {
returnArray = context.nodes().map(getLocalValue);
return isList(context) ? returnArray : returnArray[0];
}
context.each(function() {local.set(this, _);});
return component;
};
};
APP.addEventsListener = function(component, events) {
'use strict';
component.on = function(evt, callback) {
events.on(evt, callback);
return component;
};
};
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Rotating Donut</title>
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<script src="https://d3js.org/d3.v4.min.js"></script>
<!--<script src="node_modules/d3/build/d3.js"></script>-->
<script src="helpers.js"></script>
<script src="data.js"></script>
<script src="rotating_donut.js"></script>
<script src="pie_selection_rotation.js"></script>
<script src="pie_transitions.js"></script>
<script src="pie_icons.js"></script>
<script src="basic_legend.js"></script>
<script src="description_with_arrow.js"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="donuts">
<div class="donut" id="donut1"></div>
<div class="slider">
<label for="donut-size-1">Size</label>
<input type="range"
class="donut-size"
id="donut-size-1"
title="donut-size"
data-target="#donut1"
value="100"
max="150">
</div>
<div class="donut" id="donut2"></div>
<div class="slider">
<label for="donut-size-2">Size</label>
<input type="range"
class="donut-size"
id="donut-size-2"
title="donut-size"
data-target="#donut2"
max="150"
value="150">
</div>
</div>
<div class="descriptions">
<button>Randomize Data</button>
<div class="description" id="description1" data-target="#donut1"></div>
<div class="description" id="description2" data-target="#donut2"></div>
</div>
<div id="legend"></div>
</body>
<script src="app.js"></script>
</html>
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
if(typeof APP === 'undefined') {APP = {};}
APP.pieIcons = function() {
'use strict';
var local = {
icon: d3.local(),
radius: d3.local()
};
var o = {
iconPath: null,
imageWidth: null,
radius: null,
interpolate: null,
container: function (selection) {return d3.select(selection._parents[0]);}
};
function icons(group) {
var container = o.container(group);
group.each(function(data) {
render.call(this, data, container);
});
}
function render(data, container) {
var thisIcon = container
.append('image')
.attr('class', 'icon')
.attr('xlink:href', o.iconPath.bind(null, data))
.attr('width', o.imageWidth)
.attr('height', o.imageWidth)
.style('opacity', 0);
local.icon.set(this, thisIcon);
local.radius.set(this, o.radius);
}
icons.tween = function (transition, isExiting) {
transition.on('start', function () {
local.icon.get(this)
.transition(transition)
.attr('width', o.imageWidth)
.attr('height', o.imageWidth)
.style('opacity', Number(!isExiting))
.attrTween('transform', iconTween(this))
.on('end', removeIfParentIsGone(this));
local.radius.set(this, o.radius);
});
};
function iconTween(pieSegment) {
var i = o.interpolate(pieSegment),
iRadius = d3.interpolate(local.radius.get(pieSegment), o.radius);
return function () {
return iconTranslate.bind(this, i, iRadius);
};
}
function iconTranslate(i, iRadius, t) {
var dimensions = this.getBoundingClientRect(),
curr = i(t),
angle = d3.mean([curr.startAngle, curr.endAngle]),
r = iRadius(t),
coords = polarToCartesian(r, angle - Math.PI / 2),
adjustedCoords = [
coords.x - dimensions.width / 2,
coords.y - dimensions.height / 2
];
return 'translate(' + adjustedCoords.join(',') + ')';
}
function polarToCartesian(radius, angle) {
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius
};
}
function removeIfParentIsGone(segment) {
return function() {
if (!document.body.contains(segment)) {
this.remove();
}
};
}
icons.exitTween = function(transition) {
icons.tween(transition, true);
};
APP.addOptionMethods(icons, o);
return icons;
};
if(typeof APP === 'undefined') {APP = {};}
APP.pieSelectionRotation = function() {
'use strict';
var tau = Math.PI * 2;
var local = {
angle: d3.local(),
selectedSegment: d3.local(),
selectedKey: d3.local()
};
var o = {
key: null,
alignmentAngle: 0
};
function rotation(group) {
group.each(calculateAngle);
}
function calculateAngle(data) {
var alignmentAngeRadians = o.alignmentAngle * tau / 360,
selectedData,
selectedAngle,
newUnoffsetAngle,
newAngle;
local.angle.set(this, local.angle.get(this) || 0);
// find the new angle if the selectedSegment is in the data set
selectedData = data.filter(getSelectedData.bind(this))[0];
local.selectedSegment.set(this, selectedData ? selectedData.data : null);
if (selectedData) {
selectedAngle = d3.mean([selectedData.startAngle, selectedData.endAngle]);
// we need to consider the rotation from the previous angle, not just the offset from zero.
// however, we want to cancel that out before we return the offset
newUnoffsetAngle = alignmentAngeRadians - selectedAngle - local.angle.get(this);
newAngle = shorterRotation(newUnoffsetAngle) + local.angle.get(this);
local.angle.set(this, newAngle);
}
}
// if the distance the donut has to travel is more than half a turn,
// then rotate the other way instead
function shorterRotation(offset) {
offset = offset % tau;
return (Math.abs(offset) > tau / 2) ? offset + tau * Math.sign(-offset) : offset;
}
function getSelectedData(d) {
return o.key(d.data) === local.selectedKey.get(this);
}
APP.addOptionMethods(rotation, o);
rotation.selectedSegment = function(selection, d) {
// if there's do data passed, then we'll return the current selection instead of setting it.
if (typeof d === 'undefined' ) {
return selection.nodes()
.map(function(node) {return local.selectedSegment.get(node);});
}
selection.each(function() {
local.selectedKey.set(this, o.key(d));
});
return rotation;
};
rotation.getAngle = function(selection) {
return selection.nodes()
.map(function(node) {return local.angle.get(node) || 0;});
};
return rotation;
};
if(typeof APP === 'undefined') {APP = {};}
APP.pieTransition = function() {
'use strict';
var allNodes,
firstPreviousNode,
firstCurrentNode,
enteringSegments,
transitioningSegments;
var previousSegmentData = d3.local();
var o = {
arc: null,
sort: null,
offset: 0
};
var methods = {
enter: function(transition) {
transition
.each(setEnterAngle)
.call(render);
},
transition: render,
exit: function(transition) {
transition
.each(setExitAngle)
.call(render);
},
interpolate: function (segment) {
// grabbing the data this way is easier that trying to pass it in at every call
var d = d3.select(segment).datum();
var newData = {
startAngle: d.startAngle + o.offset,
endAngle: d.endAngle + o.offset,
innerRadius: o.arc.innerRadius()(),
outerRadius: o.arc.outerRadius()()
};
return d3.interpolate(previousSegmentData.get(segment), newData);
}
};
// finds the endAngle (either current or previous) of the arc next to the node
function adjacentAngle(node, state) {
var index = allNodes.indexOf(node);
if (index && state === 'previous') {
return previousSegmentData.get(allNodes[index - 1]).endAngle;
} else if (index && state === 'current') {
return nodeData(allNodes[index - 1]).endAngle;
} else if (state === 'previous' && firstPreviousNode) {
return previousSegmentData.get(firstPreviousNode).startAngle;
} else if (state === 'current' && firstCurrentNode) {
return nodeData(firstCurrentNode).startAngle;
} else {
return nodeData(node).startAngle;
}
}
function updateNodes() {
if (!transitioningSegments || !enteringSegments) {return;}
allNodes = transitioningSegments.nodes()
.concat(transitioningSegments.exit().nodes())
.concat(enteringSegments.nodes())
.sort(sortNodes);
firstPreviousNode = transitioningSegments.nodes()
.concat(transitioningSegments.exit().nodes())
.sort(sortNodes)[0];
firstCurrentNode = transitioningSegments.nodes()
.concat(enteringSegments.nodes())
.sort(sortNodes)[0];
function sortNodes(a, b) {
return o.sort(nodeData(a).data, nodeData(b).data);
}
}
function nodeData(node) {
return d3.select(node).datum();
}
// for enter segments, we want to get the previous endAngle of the adjacent arc
// and animate in from there
function setEnterAngle() {
var enterAngle = adjacentAngle(this, 'previous');
previousSegmentData.set(this, {
startAngle: enterAngle,
endAngle: enterAngle,
innerRadius: o.arc.innerRadius()(),
outerRadius: o.arc.outerRadius()()
});
}
// for exit segments, we have to find the adjacent segment
// and transition to the angle where that one is going to be.
// This way, the arc will shrink to nothing in the appropriate place.
function setExitAngle(d) {
var exitAngle = adjacentAngle(this, 'current');
d.startAngle = exitAngle;
d.endAngle = exitAngle;
}
function render(transition) {
transition.attrTween('d', arcTween);
}
// returns a function which accepts t(0-1) and returns a "d" attribute for a path
// currentSegment keeps the currentSegment up-to-date as the transition changes
// so if the tranistion is cancelled (by new data) then it will animate smoothly
function arcTween() {
var i = methods.interpolate(this);
previousSegmentData.set(this, i(0));
return function(t) {
o.arc
.innerRadius(i(t).innerRadius)
.outerRadius(i(t).outerRadius);
return o.arc(i(t));
};
}
methods.enteringSegments = function (_) {
enteringSegments = _;
updateNodes();
return methods;
};
methods.transitioningSegments = function (_) {
transitioningSegments = _;
updateNodes();
return methods;
};
APP.addOptionMethods(methods, o);
return methods;
};
if(typeof APP === 'undefined') {APP = {};}
// use descriptive name instead of something generic like 'chart'
APP.rotatingDonut = function() {
'use strict';
// outer scope available to public methods
var o,
events,
local,
rotation;
// accessor functions can be overridden from the public methods
o = {
animationDuration: 600,
alignmentAngle: 90,
iconSize: 0.7,
thickness: 0.4,
value: null,
icon: null,
color: null,
key: null,
sort: null
};
events = d3.dispatch('click');
// context-dependant variables which require persistance between renders
// must be set with d3 local so that each can have its own value,
// and be easily accessible by node
// label stores the label in the center of the donut
// animate stores the state of a donut's animation
// icons stores the reference to the donut's icon generator
// dimenstions stores the donut's externally-set dimensions
local = {
label: d3.local(),
animate: d3.local(),
icons: d3.local(),
dimensions: d3.local()
};
rotation = APP.pieSelectionRotation()
.key(function(d) {return o.key(d);});
// this inner function can have a generic name as it is not public
function donut(group) {
// scope available to all contexts
// we need to run this here because this o.alignmentAngle may have changed since the last render
rotation.alignmentAngle(o.alignmentAngle);
// even if there is only one selection, this is an easy way to pass bound data
group.each(function(data) {
// this is individual item of the group, in this case the svg
render.call(this, data, group);
});
}
function render(data, group) {
// context-specific scope
var context,
t,
dim,
pie,
arc,
thisAnimate,
thisIcons,
segments,
segmentEnter;
if (!data) {return;}
context = d3.select(this);
// Using a single transition ensures that all child animations remain in sync
// and are cancelled properlyif donut call called on a transition instead of a selection,
// we'll use that transition instead of creating a new one.
if (group instanceof d3.transition) {
t = context.transition(group);
} else {
t = context.transition().duration(o.animationDuration);
}
dim = getDimensions(context);
// although pie and arc are context-specific, they do not need to be persistent between calls,
// so we don't need to store them in a d3.local()
pie = d3.pie()
.value(o.value)
.sort(null);
arc = d3.arc()
.outerRadius(dim.outerRadius)
.innerRadius(dim.innerRadius);
// the instance of these needs to persist between renders,
// so we only instantiate it the first time and stash it in a local
// after the first run, we grab it from the local
thisAnimate = local.animate.get(this) || local.animate.set(this, APP.pieTransition());
thisIcons = local.icons.get(this) || local.icons.set(this, APP.pieIcons());
thisAnimate
.arc(arc)
.sort(o.sort);
thisIcons
.container(function() {return context.select('g.group');})
.iconPath(dataAccess('icon'))
.imageWidth(dim.outerRadius * o.thickness * o.iconSize)
.radius(d3.mean([dim.innerRadius, dim.outerRadius]))
.interpolate(thisAnimate.interpolate);
// add svg and g if they don't yet exist
// we need to bind the data after it's been processed by pie()
// binding it here instead of on the segment groups allows us to
// use enter() to add the svg and g if not already there
// ideally, data should not have to be bound directly anywhere else down the hierarchy
context.selectAll('svg')
.data([pie(data.sort(o.sort))])
.call(rotation)
.enter()
.append('svg')
.append('g')
.attr('class', 'group')
.append('text')
.attr('class', 'donut-label')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle');
context.selectAll('svg')
.transition(t)
.attr('width', dim.width)
.attr('height', dim.height)
.selectAll('g.group')
.attr('transform', 'translate(' + dim.width / 2 + ',' + dim.height / 2 + ')');
context.select('text.donut-label')
.text(local.label.get(context.node()));
// using the Object constructor is a shortcut for function(d) {return d} when d is an object
// String, Number, Function, and Boolean can also be used
// when the type of the returned object is known.
segments = context.selectAll('svg') // selection with bound data
.select('g.group') // sets parent of segments selection
.selectAll('path.segment')
.data(Object, dataAccess('key'));
// segmentEnter is what is returned from this chain, which is the group
segmentEnter = segments.enter()
.append('path')
.attr('class', 'segment')
.attr('fill', dataAccess('color'))
.on('click', onPathClick(context));
thisAnimate
.enteringSegments(segmentEnter)
.transitioningSegments(segments)
.offset(rotation.getAngle(context.selectAll('svg'))[0]);
segmentEnter
.call(thisIcons)
.transition(t)
.call(thisAnimate.enter)
.call(thisIcons.tween);
segments
.transition(t)
.call(thisAnimate.transition)
.call(thisIcons.tween);
segments.exit()
.transition(t)
.call(thisAnimate.exit)
.call(thisIcons.exitTween)
.remove();
}
function onPathClick(context) {
return function(d) {
rotation.selectedSegment(context.selectAll('svg'), d.data);
context.call(donut); // re-render with new context
events.call('click', context.node(), d.data); // trigger event for external listeners
};
}
function dataAccess(key) {
return function(d) {
return o[key](d.data);
};
}
function getDimensions(context) {
var thisDimensions = local.dimensions.get(context.node()) || {},
width = thisDimensions.width || context.node().getBoundingClientRect().width,
height = thisDimensions.height || context.node().getBoundingClientRect().height,
outerRadius = Math.min(width, height) / 2,
innerRadius = outerRadius * (1 - o.thickness);
return {
width: width,
height: height,
outerRadius: outerRadius,
innerRadius: innerRadius
};
}
// publicly accessible methods
donut.selectedSegment = function(context, d) {
// if there's do data passed, then we'll return the current selection instead of setting it.
if (typeof d === 'undefined' ) {
return rotation.selectedSegment(context.selectAll('svg'));
}
rotation.selectedSegment(context.selectAll('svg'), d);
return donut; // return the donut to allow chaining
};
// adds 'on' method
APP.addEventsListener(donut, events);
// value accessor functions
// this chart shouldn't have too much assumed knowledge of the data structure.
// rather, it should be told how to access each of these values
APP.addOptionMethods(donut, o);
// context-specific methods
APP.addLocalMethods(donut, 'dimensions', local.dimensions);
APP.addLocalMethods(donut, 'label', local.label);
return donut;
};
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
body {
display: flex;
font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
font-weight: 300;
height: 600600px;
margin: 8px;
}
path,
image {
cursor: pointer;
}
image {
pointer-events: none;
}
.donuts, .arrows {
display: inline-block;
position: relative;
}
.donuts {
width: 300px;
text-align: right;
overflow: hidden;
}
.donut {
margin-left: auto;
}
#donut1 {
width: 200px;
}
#donut2 {
width: 300px;
height: 300px;
}
#donut1,
#description1 {
height: 200px;
}
#donut2,
#description2 {
height: 300px;
}
.donut-label {
font-weight: bold;
}
.arrow {
font-size: 24px;
line-height: 24px;
padding: 8px;
}
.descriptions {
width: 300px;
position: relative;
}
.description {
display: flex;
flex-direction: row;
margin-bottom: 40px;
}
.desc-left,
.desc-right {
display: flex;
flex-direction: column;
justify-content: center;
}
.no-data {
color: #bbb;
}
#legend {
position: relative;
margin-top: 20px;
width: 140px;
}
li.legend-label {
display: flex;
align-items: center;
position: absolute;
padding: 2px 8px;
width: calc(100% - 20px);
cursor: pointer;
}
li.legend-label svg {
margin-right: 8px;
}
.hovered {
stroke: black;
z-index: 1;
}
rect.hovered {
stroke-width: 2px;
}
li.hovered,
li.selected {
background-color: #e2e8ff;
}
li.hovered svg rect {
stroke-width: 1px;
}
button {
background-color: #eee;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 11px;
padding: 6px 10px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: pointer;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
button:hover {
background-color: #ddd;
border-color: #ccc
}
button:active {
background-color: #ccc;
}
button:focus {
outline:0;
}
.slider {
display: inline-block;
height: 20px;
margin: 4px auto 16px;
}
#donut-size {
margin-left: 8px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment