Last active
February 26, 2016 15:05
-
-
Save wwymak/95a631e87e55e02b274e to your computer and use it in GitHub Desktop.
reusable d3 chart components, with extra bits and bobs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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} | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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