Skip to content

Instantly share code, notes, and snippets.

@wwymak
Last active February 26, 2016 15:05
Show Gist options
  • Select an option

  • Save wwymak/95a631e87e55e02b274e to your computer and use it in GitHub Desktop.

Select an option

Save wwymak/95a631e87e55e02b274e to your computer and use it in GitHub Desktop.
reusable d3 chart components, with extra bits and bobs
/**
* Useful stuff for axes
*/
/**
* after drawing the axis, check the label sizes and return the max height and width so you can resize the margins
* if you need to
* @param labelSelArr e.g. d3.selectAll('.y.axis .tick')
*/
function checkLabelSizes(labelSelArr) {
let labels = [];
for (let j = 0; j < labelSelArr[0].length; j++) {
labels.push(labelSelArr[0][j].getBBox())
}
return {
maxWidth: d3.max(labels, d => d.width),
maxHeight: d3.max(labels, d => d.width)
}
}
/**
* for e.g. a timeline chart, when the chart shrinks you probably would want to use less ticks
* to stop them from overlapping
* h/t to @tomcardoso from https://github.com/globeandmail/chart-tool/blob/master/src/js/charts/components/axis.js#L504
* @param tickSelArr e.g. d3.selectAll('.x.axis .tick')
*/
function dropTicks(tickSelArr) {
for (let j = 0; j < tickSelArr[0].length; j++) {
let currentTick = tickSelArr[0][j],
nextTick = tickSelArr[0][j+1];
if (!currentTick || !nextTick || !currentTick.getBoundingClientRect || !nextTick.getBoundingClientRect)
continue;
while (currentTick.getBoundingClientRect().right + 10 > nextTick.getBoundingClientRect().left) {
d3.select(nextTick).remove();
j++;
nextTick = tickSelArr[0][j+1];
if (!nextTick)
break;
}
}
}
export {checkLabelSizes, dropTicks}
/**
* reusable bar chart component
* basically expecting data in the form [{key: "name1", value : [y0, y1..], {key: "name2", value : [y0', y1'..]}
*/
import * as axisUtils from './axisUtils';
function barplot() {
var
width = 1200,
height = 800,
margins = {top: 40, bottom: 120, left: 140, right: 140},
x0Scale = d3.scale.ordinal(), //for spacing the groups
x1Scale = d3.scale.ordinal(), //for the series in each group
yScale = d3.scale.linear(),
xAxis = d3.svg.axis().scale(x0Scale).orient("bottom"),
yAxis = d3.svg.axis().scale(yScale).orient("left"),
//"normal" for both grouped and single vertical bar charts, "stacked" for stacked bars
//"horizontal" for well, horizontal bars
type = "normal";
function chart(selection) {
let svg, barSVG, xAxisSVG, yAxisSVG;
selection.each((data) => {
function drawBars(data, svg) {
//if it's a grouped bars or just normal barchart then each bargroup correspond to 1 bar or 1 group
//if it's a stacked bar chart then it correspon to 1 bar with the stacks inside
let bargroups = svg.selectAll('.bargroup').data(data, d=> d.key );
bargroups.enter().append('g').attr('class', 'bargroup');
if(type == "horizontal") {
bargroups.attr("transform", (d) => { return "translate(0, " + x0Scale(d.key) + ")"; });
} else {
bargroups.attr("transform", (d) => { return "translate(" + x0Scale(d.key) + ",0)"; });
}
bargroups.exit().remove();
let bar = bargroups.selectAll('.bar').data(d => d.value);
if(type == "normal") {
bar.enter().append('rect').attr('class', 'bar')
.attr("x", (d, i) => {return x1Scale(i)})
.attr("y", d => yScale(d))
.attr("height", d => height - margins.bottom - yScale(d))
.attr("width", x1Scale.rangeBand())
.attr("fill", data.colors);
bar.attr("x", (d, i) => x1Scale(i))
.attr("y", d => yScale(d))
.attr("height", d => height - margins.bottom - yScale(d))
.attr("width", x1Scale.rangeBand())
.attr("fill", (d, i) => data.colors[i]);
bar.exit().remove();
}
if(type == "stacked") {
bar.enter().append('rect').attr('class', 'bar');
bar.attr("x", 0)
.attr("y", d => yScale(d.y0 + d.y))
.attr("height", (d, i) => - yScale(d.y0 + d.y) + yScale(d.y0))
.attr("width", x0Scale.rangeBand())
.attr("fill", (d, i) => data.colors[i]);
bar.exit().remove();
}
if(type == "horizontal") {
bar.enter().append('rect').attr('class', 'bar');
bar.attr("y", (d, i) => x1Scale(i))
.attr("x", d => {
if(d < 0) {return yScale(d)} else {return yScale(0)}})
.attr("width", d => {
return Math.abs(yScale(d) - yScale(0) );
})
.attr("height", x1Scale.rangeBand())
.attr("fill", (d, i) => {
//+ve colors, or if -ve color not specified
if( d > 0 || data.colors.length == 1 ) {
return data.colors[0];
}else {
//negative color
return data.colors[1];
}});
bar.exit().remove();
}
}
function drawLegends(data, svg) {
//drawing the legend with d3.legend
if(! d3.legend) {
return
}
let colorScale = d3.scale.ordinal().domain(data.labels).range(data.colors);
newSVG.append("g")
.attr("class", "legend")
let legendG = d3.select('g.legend').attr("transform", `translate(30, ${height - 30})`);
let legend = d3.legend.color()
.shape('rect')
.orient('horizontal')
.labelOffset(-20)
.shapeWidth(20).shapeHeight(20).shapePadding(80)
.labelAlign('start')
.scale(colorScale);
svg.select(".legend")
.call(legend);
//a bit of a hack to get the horizontal legend labels to be on the right of the color
//squares as there isn't an option of doing so in d3.legend
//todo possibly add the extra option to d3.legend and submit a pull request to the lib?
d3.selectAll('.cell .label').attr('transform', "translate(30,15)")
}
//select the svg if it exists
svg = this.selectAll("svg.barchart").data([data]);
//or add a new one if not
var newSVG = svg.enter().append('svg').attr('class', 'barchart');
svg.attr("width" , width)
.attr("height", height);
let xAxisLabel = data.xAxisLabel,
yAxisLabel = data.yAxisLabel;
let x1Domain = d3.range(data[0].value.length),
x0Domain = data.map(item => item.key),
yMinVal = d3.min(data.map( d => d3.min(d.value))),
yMaxVal = d3.max(data.map( d => d3.max(d.value)));
if(yMinVal > 0) {yMinVal = 0}
if(yMaxVal < 0) {yMaxVal = 0}
let yDomain = [yMinVal, yMaxVal];
if(type == "stacked") {
yDomain = [0, d3.max(data.map( d => d3.sum(d.value, e => e.y)))];
}
if(type == "horizontal") {
//reset the axis position if horizontal bars
xAxis.orient('left');
yAxis.orient('bottom');
yScale.range([0, (width - margins.left - margins.right)]).domain(yDomain);
x0Scale.rangeRoundBands([height - margins.bottom, margins.top], 0.15).domain(x0Domain);
x1Scale.rangeRoundBands([0, x0Scale.rangeBand()]).domain(x1Domain);
} else {
x0Scale.rangeRoundBands([0, (width - margins.left - margins.right)], 0.2).domain(x0Domain);
x1Scale.rangeRoundBands([0, x0Scale.rangeBand()]).domain(x1Domain);
yScale.range([height - margins.bottom, margins.top]).domain(yDomain);
}
barSVG = newSVG.append("g").attr('class', 'bars')
.attr('transform', `translate(${margins.left} ,0)`);
xAxisSVG = newSVG.append('g').attr('class', 'x axis')
.attr('transform', `translate( ${margins.left} , ${height - margins.bottom})`);
yAxisSVG = newSVG.append('g').attr('class', 'y axis')
.attr('transform', `translate( ${margins.left} , 0)`);
if(xAxisLabel){
xAxisSVG.append('text').attr('dy', 0.5 * margins.bottom).attr('x', 50).text(xAxisLabel);
}
if(yAxisLabel) {
yAxisSVG.append('text')
.attr('text-align', "right")
.attr('transform', 'rotate(-90)')
.attr('x', -0.3 * height)
.attr('dy', -0.5 * margins.left)
.text(yAxisLabel);
}
let xTicks, yTicks;
//basically if it's a horizontal chart you are flipping the x and yaxis so you call yAxis on the .x.axis
if(type == "horizontal") {
xTicks = svg.select('.x.axis').call(yAxis).selectAll('.tick');
yTicks = svg.select('.y.axis').call(xAxis).selectAll('.tick');
} else {
xTicks = svg.select('.x.axis').call(xAxis).selectAll('.tick');
yTicks = svg.select('.y.axis').call(yAxis).selectAll('.tick');
}
//change the left margins the graph if labels are too wide
let maxYLabelWidth = axisUtils.checkLabelSizes(yTicks).maxWidth;
let maxXLabelHeight = axisUtils.checkLabelSizes(xTicks).maxHeight;
if (maxYLabelWidth > margins.left) {
margins.left = maxYLabelWidth * 1.05;
}
if (maxXLabelHeight > margins.bottom) {
margins.bottom = maxXLabelHeight * 1.05;
//todo different logic needed for horizontal chart since x and y swapped
yScale.range([height - margins.bottom - margins.top, margins.top]).domain(yDomain);
}
//reset the positions if margins has changed
//todo, actually this isn't the full thing, cos you actually need to resize the chart els
barSVG.attr('transform', `translate(${margins.left} ,0)`);
this.selectAll(".x.axis").attr('transform', `translate( ${margins.left} , ${height - margins.bottom})`);//.attr('transform', `translate( ${margins.left} , ${height - margins.bottom})`);
this.selectAll(".y.axis").attr('transform', `translate( ${margins.left} , 0)`);
//do the repositioning of the xaxis as necessary after resizing
//this.selectAll(".x.axis").attr('transform', `translate( ${margins.left} , ${height - margins.bottom})`);
drawBars(data, svg.selectAll('g.bars'));
drawLegends(data, svg);
})
}
//getters and setters
chart.width = function(val) {
if (!arguments.length) {
return width;
}
width = val;
return chart;
};
chart.height = function(val) {
if (!arguments.length) {
return height;
}
height = val;
return chart;
};
chart.margins = function(val) {
if (!arguments.length) {
return margins;
}
margins = val;
return chart;
};
chart.type = function(val) {
if (!arguments.length) {
return type;
}
type = val;
return chart;
};
return chart
}
export {barplot}
/**
* module for reusable linechart
* good ref at http://bost.ocks.org/mike/chart/time-series-chart.js
* (blog post is at http://bost.ocks.org/mike/chart
*/
import * as axisUtils from './axisUtils';
function lineplot() {
var width = 1200,
height = 800,
margins = {top: 40, bottom: 80, left:140, right:140},
line = d3.svg.line().x(X).y(Y),
xScale = d3.scale.ordinal(),
yScale = d3.scale.linear(),
xAxis = d3.svg.axis().scale(xScale).orient("bottom"),
yAxis = d3.svg.axis().scale(yScale).orient("left"),
xValue = function(d) { return d[0]; },
yValue = function(d) { return d[1]; },
datapointRadius = 0;
// The x-accessor for the path generator; xScale ∘ xValue.
function X(d) {
return xScale(d[0]) + xScale.rangeBand()/2
}
function Y(d) {
return yScale(d[1])
}
function chart(selection) {
var svg, lineSVG, xAxisSVG, yAxisSVG, xAxisText, yAxisText;
selection.each((data) => {
let dataType = "single";
let xAxisLabel = data.xAxisLabel,
yAxisLabel = data.yAxisLabel;
if(Array.isArray(data[0])) {
data = data.map(d => {
let outArr = dataMapping(d);
// if the array has a property 'name' then add that to the mapped array to label the lines by
if(d.name) outArr.name = d.name;
//if array has a property 'color' add that to mapped array for linecolor
if(d.color) outArr.color = d.color;
if(d.pointColor) outArr.pointColor = d.pointColor;
if(d.pointStrokeColor) outArr.pointStrokeColor = d.pointStrokeColor;
return outArr
});
dataType = "multi"
}else {
data = dataMapping(data);
}
// Convert data to standard [[x1,y1], [x2, y2], [x3, y3]...] per line
// this is needed for nondeterministic accessors.
//also have the extra d[2] el to contain any annotations for that datapoint
function dataMapping(data) {
return data.map((d, i) => {
return [xValue.call(data, d, i), yValue.call(data, d, i), d.annotation || ""];
});
}
//for an array of arrays, get all the possible unique x values (data parsed as per dataMapping func)
function getOrdinalRange(data) {
var outArr = [];
data.forEach(d => {
d.forEach(i => {
if(outArr.indexOf(i[0]) < 0) {
outArr.push(i[0])
}
})
});
outArr.sort((a,b) => (a-b));
return outArr
}
function drawLineGroup(data, svg) {
function mapPropertyTopoint(item, d) {
item.color = d.color;
item.pointColor = d.pointColor;
item.pointStrokeColor = d.pointStrokeColor;
return item
}
let plottingData;
if(dataType == "single") {
plottingData = [data];
}else {
plottingData = data;
}
let lineFunc = d3.svg.line().x(X).y(Y);
let lineG = svg.selectAll('g.data-lineG').data(plottingData);
let newG = lineG.enter().append('g').attr('class', 'data-lineG');
let line = lineG.selectAll('path.data-line').data(d => [d]);
line.enter().append('path').attr('id', (d,i) => d.name || "linechart-line")
.attr('class', 'data-line');
line.attr('d', lineFunc).attr("fill", "none")
.attr("stroke", (d,i) => d.color || "black");
line.exit().remove();
let datapoints = lineG.selectAll('.data-point')
.data(d => {
d.forEach(item => {return mapPropertyTopoint(item , d)});
return d
});
datapoints.enter().append("circle")
.attr("r", datapointRadius).attr("class", "data-point")
.attr('fill', (d) => {
return d.pointColor || "black"}).attr("stroke", (d,i) => d.pointStrokeColor || "black");
datapoints.attr("cx", (d, i) => {return xScale(d[0]) + xScale.rangeBand()/2})
.attr("cy", d=> yScale(d[1])).attr("r", datapointRadius)
.attr('fill', (d) => d.pointColor || "black").attr("stroke", (d,i) => d.pointStrokeColor || "black");
datapoints.exit().remove();
lineG.exit().remove();
}
let xDomain, yDomain, dataMax, dataMin;
//dataType === "multi" for multi series line charts
if(dataType === "multi"){
xDomain = getOrdinalRange(data);
//add in a bit of padding to the yMax so the lines don't bang against the top of the graph, same logic for dataMin
dataMax = d3.max(data.map(d => 1.05 * d3.max(d, i => i[1])));
//This basically set the minimum to zero if all the data is +ve or the the min value if some of the data is also -ve
//same logioc for the single series chart
dataMin = d3.min([0, d3.min(data.map(d => 1.05 * d3.min(d, i => i[1]))) ]);
yDomain = [dataMin, dataMax];
}else {
xDomain = data.map( d=> d[0] ).sort((a,b) => (a-b));
dataMax = 1.05 * d3.max(data, d => d[1]);
dataMin = d3.min([0 , 1.05 * d3.min(data, d => d[1])]);
yDomain = [dataMin, dataMax];
}
xScale
.domain(xDomain)
.rangeBands([0, (width - margins.left - margins.right)]);
// Update the y-scale.
yScale
.domain(yDomain)
.range([height - margins.bottom, margins.top]);
//select the svg if it exists
svg = this.selectAll("svg.linechart").data([data]);
//or add a new one if not
var newSVG = svg.enter().append('svg').attr('class', 'linechart');
newSVG.append("g").attr('class', 'lines');
newSVG.append('g').attr('class', 'x axis');
newSVG.append('g').attr('class', 'y axis');
lineSVG = d3.selectAll('g.lines').attr('transform', `translate(${margins.left} ,0)`);
//basically the translateY of the xaxis should be at yScale(0) to take care of
//cases where the data is -ve
xAxisSVG = d3.selectAll(".x.axis").attr('transform', `translate( ${margins.left} , ${yScale(0)})`);
yAxisSVG = d3.selectAll('.y.axis')
.attr('transform', `translate( ${margins.left} , 0)`);
if(xAxisLabel) {
newSVG.select('g.x.axis').append('text').attr('class', 'x axis-label').text(xAxisLabel);
xAxisText = d3.selectAll('.x.axis-label')
.attr('dy', 0.6 * margins.bottom)
.attr('x', xScale.range().slice(-1)[0] - 50)
.attr('text-align', 'end')
}
if(yAxisLabel) {
newSVG.select('g.y.axis').append('text').attr('class', 'y axis-label').text(yAxisLabel);
yAxisText = d3.selectAll('.y.axis-label')
.attr('text-align', "right")
.attr('transform', 'rotate(-90)')
.attr('x', -0.3 * height)
.attr('dy', -0.5 * margins.left);
}
svg.attr("width" , width)
.attr("height", height);
let plottingData = data;
let xTicks = svg.select('.x.axis').call(xAxis).selectAll('.tick');
let yTicks = svg.select('.y.axis').call(yAxis).selectAll('.tick');
if(!Array.isArray(data[0])) {
plottingData = [data]
}
axisUtils.dropTicks(xTicks);
drawLineGroup(plottingData, svg.select('g.lines'));
})
}
//getters and setters
chart.width = function(val) {
if (!arguments.length) {
return width;
}
width = val;
return chart;
};
chart.height = function(val) {
if (!arguments.length) {
return height;
}
height = val;
return chart;
};
chart.margins = function(val) {
if (!arguments.length) {
return margins;
}
margins = val;
return chart;
};
chart.xAxis = function(axis){
if (!arguments.length) {
return xAxis
}
xAxis = axis;
return chart;
};
chart.yAxis = function(axis){
if (!arguments.length) {
return yAxis
}
yAxis = axis;
return chart;
};
chart.x = function(xAccessor) {
if (!arguments.length) return xValue;
xValue = xAccessor;
return chart;
};
chart.y = function(yAccessor) {
if (!arguments.length) return yValue;
yValue = yAccessor;
return chart;
};
chart.datapointRadius = function(radius) {
if (!arguments.length) return datapointRadius;
datapointRadius = radius;
return chart;
};
return chart
}
export {lineplot}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment