Skip to content

Instantly share code, notes, and snippets.

@a0viedo
Last active January 20, 2017 20:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save a0viedo/6b11c6ed81d3b52409a66f72d1d4ad7b to your computer and use it in GitHub Desktop.
Save a0viedo/6b11c6ed81d3b52409a66f72d1d4ad7b to your computer and use it in GitHub Desktop.
Utility wrapper of D3.js that favors configuration to increase code reuse and gain simplicity to create charts.

I wrote a bunch of utility helpers for graphing data with pie charts, bar charts, scatter plots and histograms.

It reutilices many examples from d3.org, so many that I lost track of them all. I have only tested on d3's v3.

  • Bar charts:
    • Properties like width, height, X and Y axis domains, X and Y axis transforms, tips and margins can be passed through a configuration object
    • tips
    • helper to update graph from new data through animations
    • opt-in interpolation
  • Pie charts:
    • Properties like width, height, tips and margins can be passed through a configuration object
    • tips
    • helper to update graph from new data through animations
  • Histograms:
    • Properties like width, height, X and Y domains, number of bins, tips and margins can be passed through a configuration object
    • tips
    • helper to update graph from new data through animations
    • opt-in interpolation
  • Scatter plots:
    • Properties like width, height and margins can be passed through a configuration object

Examples

  graphBarChart(data, parentElement, {
    xAxis: {
      text: 'Provincia',
      transform: 'rotate(-45)',
      domain: function(d) {return d.name},
      value: function(d) {return d.name}
    },
    yAxis: {
      text: 'Salario promedio',
      domain: [8000, 24000],
      value: function(d) {return d.value}
    },
    tips: {
      parse: Math.round.bind()
    },
    width: 640,
    height: 450,
    margins: {
      top: 20,
      bottom: 80,
      left: 10,
      right: 0
    },
    padding: 80,
    textLabel: 'Salario promedio total: 16118',
    display: true
  });

would produce the next bar chart

  graphHistogram(data, parentElement, {
    width: 640,
    height: 450,
    padding: 100,
    margins: {
      top: 20,
      right: 20,
      bottom: 60,
      left: 20
    },
    bins: 30,
    xAxis: {
      text: 'Salario',
      domain: [5000, 100000]
    },
    id: 'histogramaSalarios',
    display: true,
    barTextSize: '13px'
  });
  graphPieChart(data, domElement, {
    width: 720,
    height: 450,
    title: 'Profesiones',
    tip: {
      offset: [30, 0]
    },
    id: 'pieChartProfesiones',
    display: true,
    padding: 100,
    xOffset: 40,
    value: function(d){return d.value},
    name: function(d){return d.name},
    colorsArray: ['#5E2971', '#5A9E9B', '#A50C00']
  });
function graphBarChart(data, parentNode, options) {
var margins = options.margins || {
top: 20,
right: 20,
bottom: 20,
left: 20
},
width = options.width,
height = options.height - margins.top - margins.bottom,
padding = options.padding;
var line;
var x = d3.scale.ordinal()
.rangeRoundBands([padding, width - padding], 0.1);
var y = d3.scale.linear()
.range([height, 0]);
var xAxis = d3.svg.axis()
.scale(x)
.orient('bottom');
var yAxis = d3.svg.axis()
.scale(y)
.orient('left');
var svg = d3.select(parentNode).append('svg')
.attr('width', width)
.attr('height', height + margins.top + margins.bottom)
.style('display', options.display ? '' : 'none')
.append('g')
.attr('transform', 'translate(' + margins.left + ',' + margins.top + ')');
configureAxesDomains(data, options, x, y);
var tip = d3.tip()
.attr('class', 'd3-tip')
.offset([-10, 0])
.html(function(d) {
var fn = typeof options.tips.parse === 'function' ?
options.tips.parse : Math.round.bind();
var tip = '{{options.yAxis.text}}: <span style="color:white">' + fn(options.yAxis.value(d)) + '</span>';
if(d.count) {
tip += '<br>Cantidad: <span> ' + d.count + '</span>';
}
tip = tip.replace('{{options.yAxis.text}}', options.yAxis.text);
return tip;
});
svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis)
.selectAll('text')
.style('text-anchor', 'end')
.attr('transform', function() {
if(options.xAxis && options.xAxis.transform) {
d3.select(this).attr('x', -5).attr('y', 5);
return options.xAxis.transform;
}
});
svg.append('g')
.attr('class', 'y axis')
.attr('transform', 'translate('+padding+',0)')
.call(yAxis)
.selectAll('text')
.attr('transform', function() {
if(options.yAxis && options.yAxis.transform) {
return options.yAxis.transform;
}
});
svg.selectAll('.bar')
.data(data)
.enter().append('rect')
.attr('class', 'bar')
.attr('x', compose(x, options.xAxis.value))
.attr('width', x.rangeBand())
.attr('y', compose(y, options.yAxis.value))
.attr('height', function(d) {
return height - y(options.yAxis.value(d));
})
.on('mouseover', tip.show)
.on('mouseout', tip.hide);
svg.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'translate('+ (padding/3) +','+(height/2)+')rotate(-90)')
.text(options.yAxis.text);
svg.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'translate('+ (width/2) +','+(height + margins.top + margins.bottom-(padding/3))+')')
.text(options.xAxis.text);
svg.call(tip);
if(options.textLabel) {
addText({
elem: svg,
x: (width - 80),
y: (margins.bottom / 2),
textAnchor: 'end',
fontSize: '16px',
text: options.textLabel
});
}
// add interpolation
if(options.interpolation) {
line = d3.svg.line()
.x(function(d, i) {return x(options.xAxis.value(d)) + i;}) // composing twice is more pure but who cares?
.y(compose(y, options.yAxis.value))
.interpolate(options.interpolation);
svg.append('path')
.datum(data)
.attr('class', 'line')
.attr('d', line);
}
}
function addText(obj) {
if(!obj || !obj.elem || typeof obj.elem.append !== 'function') {
console.log('Invalid call to addText.');
return;
}
obj.elem.append('text')
.attr('x', obj.x)
.attr('y', obj.y)
.attr('text-anchor', obj.textAnchor)
.style('font-size', obj.fontSize)
.text(obj.text);
}
function configureAxesDomains(data, options, x, y) {
function configureAxisDomain(axis, axisFn) {
if(options && options[axis] && options[axis].domain) {
if(typeof options[axis].domain === 'function') {
axisFn.domain(data.map(options[axis].domain));
} else {
axisFn.domain(options[axis].domain);
}
} else {
axisFn.domain([d3.min(data), d3.max(data)]);
}
}
x && configureAxisDomain('xAxis', x);
y && configureAxisDomain('yAxis', y);
}
function graphHistogram(values, parentNode, options) {
function addBarText(data) {
bar.append('text')
.style('cursor', 'default')
.attr('dy', '.75em')
.attr('y', 4)
.attr('x', Math.ceil((x(data[0].dx) - x(0)) / 2))
.attr('text-anchor', 'middle')
.style('font-size', options.barTextSize)
.attr('class', 'histogram-text')
.text(function(d) {
if(d.height < 17) {
d3.select(this)
.attr('class', null)
.attr('y', -15);
}
return formatCount(d.y);
});
}
var formatCount = d3.format(',.0f');
var margin = options.margins || {
top: 20,
right: 20,
bottom: 100,
left: 60
},
width = options.width - margin.left - margin.right,
height = options.height - margin.top - margin.bottom,
numOfBins = options.bins || 20;
var x = d3.scale.linear()
.range([0, width]);
configureAxesDomains(values, options, x);
var line;
var data = d3.layout.histogram()
.bins(x.ticks(numOfBins))(values);
var y = d3.scale.linear()
.domain([0, d3.max(data, pick('y'))])
.range([height, 0]);
var xAxis = d3.svg.axis()
.scale(x)
.orient('bottom');
var svg = d3.select(parentNode).append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.attr('id', options.id)
.style('display', options.display ? '' : 'none')
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
saveGraphInfo(options.id, {
height: height,
width: width,
options: options,
addBarText: addBarText,
configureAxesDomains: configureAxesDomains,
x: x
});
var bar = svg.selectAll('.bar')
.data(data)
.enter().append('g')
.attr('class', 'bar')
.attr('transform', function(d) {
return 'translate(' + x(d.x) + ',' + y(d.y) + ')';
});
bar.append('rect')
.attr('x', 1)
.attr('width', x(data[0].dx) - x(0) - 1)
.attr('height', function(d) {d.height= height - y(d.y); return height - y(d.y);});
addBarText(data);
svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis);
svg.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'translate('+ (width/2) +','+(height + margin.top + (20/100 *margin.bottom))+')')
.text(options.xAxis.text);
// add interpolation
if(options.interpolation) {
line = d3.svg.line()
.x(function(d, i) {return x(d[0]) + i;}) // composing twice is more pure but who cares?
.y(compose(y, options.yAxis.value))
.interpolate(options.interpolation);
svg.append('path')
.datum(data)
.attr('class', 'line')
.attr('d', line);
}
}
function graphPieChart(data, parentNode, options) {
function addSlices(data, color) {
var slice = svg.select('.slices').selectAll('path.slice')
.data(pie(data), key);
slice.enter()
.insert('path')
.attr('fill', compose(color, compose(options.name, pick('data'))))
.attr('class', 'slice')
.transition().duration(1000)
.attrTween('d', arcTween);
slice.exit().remove();
}
function removeSlices() {
svg.select('.slices').selectAll('path.slice')
.remove();
}
function arcTween(d) {
var interpolate;
interpolate = d3.interpolate({startAngle: 0, endAngle: 0}, d);
return compose(arc, interpolate);
}
var width = options.width,
height = options.height,
radius = Math.min(width, height) / 2,
svg,
pie,
arc,
tip,
outerArc,
key,
color,
xOffset = options.xOffset || 0;
svg = d3.select(parentNode)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('id', options.id)
.style('display', options.display ? '' : 'none')
.append('g');
svg.append('g')
.attr('class', 'slices');
svg.append('g')
.attr('class', 'labels');
svg.append('g')
.attr('class', 'lines');
pie = d3.layout.pie()
.sort(null) // this avoids further sorting, we assume data it's already sorted in the right way
.value(options.value);
arc = d3.svg.arc()
.outerRadius(radius * 0.8)
.innerRadius(radius * 0.4);
tip = d3.tip()
.attr('class', 'd3-tip')
.offset(options.tip ? options.tip.offset : [0, 0])
.html(function(d) {
return options.name(d.data) + ': ' + options.value(d.data);
});
outerArc = d3.svg.arc()
.innerRadius(radius * 0.9)
.outerRadius(radius * 0.9);
svg.attr('transform', 'translate(' + ((width / 2) + xOffset) + ',' + height / 2 + ')');
key = compose(options.name, pick('data'));
color = d3.scale.ordinal()
.domain(data.map(options.name))
.range(options.colorsArray);
/* ------- PIE SLICES -------*/
svg.select('.slices').selectAll('path.slice')
.data(pie(data), key);
var addLabelsParams = {
tip: tip,
outerArc: outerArc,
key: key,
options: options,
pie: pie,
svg: svg,
radius: radius,
arc: arc
};
saveGraphInfo(options.id, {
height: height,
width: width,
options: options,
pie: pie,
arcTween: arcTween,
addLabelsParams: addLabelsParams,
removeSlices: removeSlices,
addSlices: addSlices,
arc: arc
});
addSlices(data, color);
addLabelsParams.data = data;
addPieChartLabels(addLabelsParams, options);
if(options.title) {
addText({
elem: svg,
x: 0,
y: 8,
textAnchor: 'middle',
fontSize: '21px',
text: options.title
});
}
return svg;
}
function addPieChartLabels(params, options) {
var svg = params.svg;
var pie = params.pie;
var data = params.data;
var key = params.key;
var tip = params.tip;
var outerArc = params.outerArc;
var radius = params.radius;
var arc = params.arc;
var text = svg.select('.labels').selectAll('text')
.data(pie(data), key);
text.enter()
.append('text')
.attr('dy', '.35em')
.text(compose(options.name, pick('data')));
function midAngle(d) {
return d.startAngle + (d.endAngle - d.startAngle)/2;
}
text.transition().duration(1000)
.attrTween('transform', function(d) {
var interpolate;
this._current = this._current || d;
interpolate = d3.interpolate(this._current, d);
this._current = interpolate(0);
return function(t) {
var d2 = interpolate(t);
var pos = outerArc.centroid(d2);
pos[0] = radius * (midAngle(d2) < Math.PI ? 1 : -1);
pos[0] += pos[0] < 0 ? 20 : -20;
return 'translate('+ pos +')';
};
})
.styleTween('text-anchor', function(d) {
var interpolate;
this._current = this._current || d;
interpolate = d3.interpolate(this._current, d);
this._current = interpolate(0);
return function(t) {
var d2 = interpolate(t);
return midAngle(d2) < Math.PI ? 'start':'end';
};
});
text.exit()
.remove();
/* ------- SLICE TO TEXT POLYLINES -------*/
var polyline = svg.select('.lines').selectAll('polyline')
.data(pie(data), key);
polyline.enter()
.append('polyline');
polyline.transition().duration(1000)
.attrTween('points', function(d) {
var interpolate;
this._current = this._current || d;
interpolate = d3.interpolate(this._current, d);
this._current = interpolate(0);
return function(t) {
var d2 = interpolate(t);
var pos = outerArc.centroid(d2);
pos[0] = radius * 0.85 * (midAngle(d2) < Math.PI ? 1 : -1);
return [arc.centroid(d2), outerArc.centroid(d2), pos];
};
});
polyline.exit()
.remove();
svg.call(tip);
svg.select('.slices').selectAll('path.slice')
.on('mouseover', tip.show)
.on('mouseout', tip.hide);
}
function graphScatterPlot(data, parentNode, options) {
var margin = options.margins || {top: 20, right: 20, bottom: 40, left: 60},
width = options.width - margin.left - margin.right,
height = options.height - margin.top - margin.bottom;
/*
* value accessor - returns the value to encode for a given data object.
* scale - maps value to a visual display encoding, such as a pixel position.
* map function - maps from data value to display value
* axis - setsup axis
*/
// setup x
var xValue = pick('xValue'), // data -> value
xScale = d3.scale.linear().range([0, width]), // value -> display
xMap = compose(xScale, xValue), // data -> display
xAxis = d3.svg.axis().scale(xScale).orient('bottom');
// setup y
var yValue = pick('yValue'), // data -> value
yScale = d3.scale.linear().range([height, 0]), // value -> display
yMap = compose(yScale, yValue), // data -> display
yAxis = d3.svg.axis().scale(yScale).orient('left');
// add the graph canvas to the body of the webpage
var svg = d3.select(parentNode).append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// add the tooltip area to the webpage
d3.select('body').append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
// don't want dots overlapping axis, so add in buffer to data domain
if(options.xAxis.domain) {
xScale.domain(options.xAxis.domain);
} else {
xScale.domain([d3.min(data, xValue) -1, d3.max(data, xValue)+1]);
}
if(options.yAxis.domain) {
yScale.domain(options.yAxis.domain);
} else {
yScale.domain([d3.min(data, yValue)-1, d3.max(data, yValue)+1]);
}
// x-axis
svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis)
.append('text')
.attr('class', 'label')
.attr('x', width)
.attr('y', -6)
.style('text-anchor', 'end')
.text(options.xAxis.text);
// y-axis
svg.append('g')
.attr('class', 'y axis')
.call(yAxis)
.append('text')
.attr('class', 'label')
.attr('transform', 'rotate(-90)')
.attr('y', 6)
.attr('dy', '.71em')
.style('text-anchor', 'end')
.text(options.yAxis.text);
// draw dots
svg.selectAll('.dot')
.data(data)
.enter().append('circle')
.attr('class', 'dot')
.attr('r', 3.5)
.attr('cx', xMap)
.attr('cy', yMap)
.style('fill', 'black');
}
function saveGraphInfo(id, info) {
var selector;
if(!info || !info.options) {
console.log('Invalid call to saveGraphInfo.');
return;
}
selector = '#' + id;
if(document.querySelector(selector)) {
document.querySelector(selector).graph = info;
} else {
console.log('Invalid selector:', id);
}
}
function updateHistogram(domElement, data) {
var svg,
width,
height,
options,
histData,
x,
y;
if(!domElement || !data) {
console.log('Invalid call to updateHistogram.');
return;
}
svg = d3.select(domElement);
width = domElement.graph.width;
height = domElement.graph.height;
options = domElement.graph.options;
x = domElement.graph.x;
configureAxesDomains(data, options, x);
if(!data.length) {
svg
.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('class', 'no-data')
.text('No hay datos');
svg.selectAll('.bar rect')
.transition()
.duration(400)
.ease('linear')
.attr('height', 0);
svg.selectAll('.bar text').remove();
return;
}
svg.selectAll('.no-data').remove();
histData = d3.layout.histogram()
.bins(x.ticks(options.bins))(data);
y = d3.scale.linear()
.domain([0, d3.max(histData, pick('y'))])
.range([height, 0]);
svg.selectAll('.bar')
.data(histData)
.attr('transform', function(d) {
return 'translate(' + x(d.x) + ',' + y(d.y) + ')';
})
.select('rect')
.attr('height', 0)
.transition()
.duration(400)
.ease('linear')
.attr('height', function(d) {d.height= height - y(d.y); return height - y(d.y);});
svg.selectAll('.bar text').remove();
domElement.graph.addBarText(histData);
}
function updatePieChart(domElement, data) {
var svg,
pie,
path,
addLabelsParams,
allZeros,
color;
if(!domElement || !data) {
console.log(domElement, data);
console.log('Invalid call to updatePieChart.');
return;
}
svg = d3.select(domElement);
allZeros = data.filter(function(e) {
return domElement.graph.options.value(e) !== 0;
}).length === 0;
if(!data.length || allZeros) {
domElement.graph.removeSlices();
svg
.selectAll('.labels text')
.transition()
.duration(700)
.style('fill', 'white')
.remove();
svg
.selectAll('.lines polyline')
.transition()
.duration(700)
.style('stroke', 'white')
.remove();
svg.select('g')
.append('text')
.attr('x', 0)
.attr('y', 20)
.attr('text-anchor', 'middle')
.style('font-size', '16px')
.attr('class', 'no-data')
.text('(No hay datos)');
return;
}
svg.selectAll('g .no-data').remove();
// here I would sort the data so not too many small slices are placed together
// in order to improve readability but it's optional
pie = domElement.graph.pie;
path = svg.datum(data).selectAll('path');
path = path.data(pie);
color = d3.scale.ordinal()
.domain(data.map(domElement.graph.options.name))
.range(domElement.graph.options.colorsArray);
domElement.graph.removeSlices();
domElement.graph.addSlices(data, color);
addLabelsParams = domElement.graph.addLabelsParams;
addLabelsParams.data = data;
addPieChartLabels(addLabelsParams, domElement.graph.options);
}
function pick(prop) {
if(typeof prop !== 'string' && typeof prop !== 'number') {
throw Error('Properties should be able to be coersed to string properly.');
}
return function(obj) {
var dots, curObj, curKey;
if(typeof obj !== 'object') {
throw Error('Invalid parameter received to partial application, should have type of object');
}
dots = (prop + '').split('.');
curObj = obj;
while (dots.length) {
curKey = dots.shift();
curObj = curObj[curKey];
}
return curObj;
};
}
function compose(x, y) {
if(typeof x !== 'function' || typeof y !== 'function') {
throw Error('x and y parameters should be functions');
}
if(arguments.length > 2) {
throw Error('compose(fn1,fn2...fn) not implemented');
}
return function() {
return x(y.apply(null, arguments));
};
}
.bar {
fill: steelblue;
}
.bar:hover {
fill: #195E08;
}
.axis {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.d3-tip {
line-height: 1;
font-weight: bold;
padding: 12px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 2px;
}
/* Creates a small triangle extender for the tooltip */
.d3-tip:after {
box-sizing: border-box;
display: inline;
font-size: 10px;
width: 100%;
line-height: 1;
color: rgba(0, 0, 0, 0.8);
content: "\25BC";
position: absolute;
text-align: center;
}
/* Style northward tooltips differently */
.d3-tip.n:after {
margin: -1px 0 0 0;
top: 100%;
left: 0;
}
.histogram-text {
fill:white;
}
path.slice {
stroke-width:2px;
}
polyline {
opacity: .3;
stroke: black;
stroke-width: 2px;
fill: none;
}
.hidden {
display:none;
}
.line {
fill: none;
stroke: #444;
stroke-width: 1.5px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment