Gist to serve as template for future TDD D3 blocks
forked from Golodhros's block: TDD D3 Template
| license: mit |
Gist to serve as template for future TDD D3 blocks
forked from Golodhros's block: TDD D3 Template
| var graphs = graphs || {}; | |
| graphs.dataManager = function module() { | |
| var exports = {}, | |
| dispatch = d3.dispatch('dataReady', 'dataLoading', 'dataError'), | |
| data; | |
| d3.rebind(exports, dispatch, 'on'); | |
| exports.loadJsonData = function(_file, _cleaningFn) { | |
| var loadJson = d3.json(_file); | |
| loadJson.on('progress', function(){ | |
| dispatch.dataLoading(d3.event.loaded); | |
| }); | |
| loadJson.get(function (_err, _response){ | |
| if (!_err){ | |
| _response.data.forEach(function(d){ | |
| _cleaningFn(d); | |
| }); | |
| data = _response.data; | |
| dispatch.dataReady(_response.data); | |
| } else { | |
| dispatch.dataError(_err.statusText); | |
| } | |
| }); | |
| }; | |
| exports.loadTsvData = function(_file, _cleaningFn) { | |
| var loadTsv = d3.tsv(_file); | |
| loadTsv.on('progress', function() { | |
| dispatch.dataLoading(d3.event.loaded); | |
| }); | |
| loadTsv.get(function (_err, _response) { | |
| if (!_err){ | |
| _response.forEach(function(d){ | |
| _cleaningFn(d); | |
| }); | |
| data = _response; | |
| dispatch.dataReady(_response); | |
| } else { | |
| dispatch.dataError(_err.statusText); | |
| } | |
| }); | |
| }; | |
| exports.loadCsvData = function(_file, _cleaningFn) { | |
| var loadCsv = d3.csv(_file); | |
| loadCsv.on('progress', function() { | |
| dispatch.dataLoading(d3.event.loaded); | |
| }); | |
| loadCsv.get(function (_err, _response) { | |
| if (!_err){ | |
| _response.forEach(function(d){ | |
| _cleaningFn(d); | |
| }); | |
| data = _response; | |
| dispatch.dataReady(_response); | |
| } else { | |
| dispatch.dataError(_err.statusText); | |
| } | |
| }); | |
| }; | |
| // If we need more types of data geoJSON, etc. we will need | |
| // to create methods for them | |
| exports.getCleanedData = function(){ | |
| return data; | |
| }; | |
| return exports; | |
| }; |
| <!DOCTYPE html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <link type="text/css" rel="stylesheet" href="style.css"/> | |
| </head> | |
| <body> | |
| <h2 class="block-title">TDD Brushing Demo</h2> | |
| <div class="graph"></div> | |
| <p class="js-date-range date-range is-hidden">Selected from <span class="js-start-date"></span> to <span class="js-end-date"></span></p> | |
| <p>Forked from:</p> | |
| <ul> | |
| <li><a href="http://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81">Micah Stubbs block programmatic control of a d3 brush</a></li> | |
| <li><a href="http://bl.ocks.org/Golodhros/dfe7c0c8be07a461e6ba">My own TDD Template</a></li> | |
| </ul> | |
| <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script> | |
| <script src="http://d3js.org/d3.v3.min.js"></script> | |
| <script src="dataManager.js"></script> | |
| <script src="src.js"></script> | |
| <script type="text/javascript"> | |
| // Code that instantiates the graph and uses the data manager to load the data | |
| var app = { | |
| // D3 Reusable API Chart | |
| graph: { | |
| dataManager: null, | |
| config: { | |
| margin : { | |
| top : 20, | |
| bottom: 40, | |
| right : 20, | |
| left : 20 | |
| }, | |
| aspectRatio: 0.18, | |
| dataURL: 'mock_data.csv' | |
| }, | |
| init: function(ele){ | |
| this.$el = ele; | |
| this.requestNewData(); | |
| this.addEvents(); | |
| }, | |
| addEvents: function(){ | |
| //Callback triggered by browser | |
| window.onresize = this.drawGraph.bind(this); | |
| }, | |
| calculateRatioHeight: function(width) { | |
| var config = this.config; | |
| return Math.ceil(width * config.aspectRatio); | |
| }, | |
| dataCleaningFunction: function(d){ | |
| d.date = d.date; | |
| d.value = +d.value; | |
| }, | |
| drawGraph: function(){ | |
| var config = this.config, | |
| width = this.$el.width(), | |
| height = this.calculateRatioHeight(width); | |
| this.resetGraph(); | |
| this.chart = graphs.chart() | |
| .width(width) | |
| .height(height) | |
| .margin(config.margin) | |
| .onBrush(function(brushExtent) { | |
| var format = d3.time.format('%m/%d/%Y'); | |
| $('.js-start-date').text(format(brushExtent[0])); | |
| $('.js-end-date').text(format(brushExtent[1])); | |
| $('.js-date-range').removeClass('is-hidden'); | |
| }); | |
| this.container = d3.select(this.$el[0]) | |
| .datum(this.data) | |
| .call(this.chart); | |
| }, | |
| handleReceivedData: function(result){ | |
| this.data = result; | |
| this.drawGraph(); | |
| }, | |
| requestNewData: function(){ | |
| this.dataManager = graphs.dataManager(); | |
| this.dataManager.on('dataError', function(errorMsg){ | |
| console.log('error:', errorMsg); | |
| }); | |
| this.dataManager.on('dataReady', $.proxy(this.handleReceivedData, this)); | |
| this.dataManager.loadCsvData(this.config.dataURL, this.dataCleaningFunction); | |
| }, | |
| resetGraph: function(){ | |
| this.$el.find('svg').remove(); | |
| } | |
| } | |
| }; | |
| $(function(){ | |
| app.graph.init($('.graph')); | |
| }); | |
| </script> | |
| </body> |
| value | date | |
|---|---|---|
| 16 | 9/15/2015 | |
| 79 | 9/19/2015 | |
| 22 | 12/5/2015 | |
| 45 | 1/4/2016 | |
| 11 | 1/8/2016 | |
| 20 | 1/16/2016 | |
| 66 | 1/25/2016 | |
| 44 | 2/1/2016 | |
| 81 | 2/17/2016 | |
| 33 | 3/16/2016 | |
| 81 | 4/21/2016 | |
| 73 | 5/22/2016 | |
| 82 | 6/11/2016 | |
| 52 | 6/12/2016 | |
| 50 | 6/30/2016 | |
| 35 | 7/3/2016 | |
| 43 | 7/20/2016 | |
| 74 | 7/22/2016 | |
| 79 | 7/24/2016 | |
| 28 | 9/2/2016 |
| var graphs = graphs || {}; | |
| graphs.chart = function module(){ | |
| var margin = {top: 20, right: 20, bottom: 40, left: 20}, | |
| width = 960, | |
| height = 500, | |
| data, | |
| ease = 'quad-out', | |
| dateLabel = 'date', | |
| valueLabel = 'value', | |
| chartW, chartH, | |
| xScale, yScale, | |
| xAxis, | |
| brush, | |
| chartBrush, | |
| onBrush = null, | |
| gradientColorSchema = { | |
| left: '#39C7EA', | |
| right: '#4CDCBA' | |
| }, | |
| defaultTimeFormat = '%m/%d/%Y', | |
| xTickMonthFormat = d3.time.format('%b'), | |
| svg; | |
| /** | |
| * This function creates the graph using the selection and data provided | |
| * | |
| * @param {D3Selection} _selection A d3 selection that represents | |
| * the container(s) where the chart(s) will be rendered | |
| * @param {Object} _data The data to attach and generate the chart | |
| */ | |
| function exports(_selection) { | |
| _selection.each(function(_data){ | |
| chartW = width - margin.left - margin.right; | |
| chartH = height - margin.top - margin.bottom; | |
| data = cleanData(cloneData(_data)); | |
| buildScales(); | |
| buildAxis(); | |
| buildSVG(this); | |
| buildGradient(); | |
| buildBrush(); | |
| drawArea(); | |
| drawAxis(); | |
| drawBrush(); | |
| // This last step is optional, just needed when | |
| // a given selection would need to be shown | |
| setBrush(0, 0.5); | |
| }); | |
| } | |
| /** | |
| * Creates the d3 x and y axis, setting orientations | |
| */ | |
| function buildAxis() { | |
| xAxis = d3.svg.axis() | |
| .scale(xScale) | |
| .orient('bottom') | |
| .tickFormat(xTickMonthFormat); | |
| } | |
| /** | |
| * Creates the brush element and attaches a listener | |
| * @return {void} | |
| */ | |
| function buildBrush() { | |
| brush = d3.svg.brush() | |
| .x(xScale) | |
| .on('brush', handleBrush); | |
| } | |
| /** | |
| * Builds containers for the chart, the axis and a wrapper for all of them | |
| * NOTE: The order of drawing of this group elements is really important, | |
| * as everything else will be drawn on top of them | |
| * @private | |
| */ | |
| function buildContainerGroups() { | |
| var container = svg.append('g') | |
| .classed('container-group', true) | |
| .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); | |
| container | |
| .append('g') | |
| .classed('chart-group', true); | |
| container | |
| .append('g') | |
| .classed('metadata-group', true); | |
| container | |
| .append('g') | |
| .classed('x-axis-group', true); | |
| container | |
| .append('g') | |
| .classed('brush-group', true); | |
| } | |
| /** | |
| * Creates the gradient on the area | |
| * @return {void} | |
| */ | |
| function buildGradient() { | |
| let metadataGroup = svg.select('.metadata-group'); | |
| metadataGroup.append('linearGradient') | |
| .attr('id', 'brush-area-gradient') | |
| .attr('gradientUnits', 'userSpaceOnUse') | |
| .attr('x1', 0) | |
| .attr('x2', xScale(data[data.length - 1].date)) | |
| .attr('y1', 0) | |
| .attr('y2', 0) | |
| .selectAll('stop') | |
| .data([ | |
| {offset: '0%', color: gradientColorSchema.left}, | |
| {offset: '100%', color: gradientColorSchema.right} | |
| ]) | |
| .enter().append('stop') | |
| .attr('offset', ({offset}) => offset) | |
| .attr('stop-color', ({color}) => color); | |
| } | |
| /** | |
| * Creates the x and y scales of the graph | |
| * @private | |
| */ | |
| function buildScales() { | |
| xScale = d3.time.scale() | |
| .domain(d3.extent(data, function(d) { return d.date; } )) | |
| .range([0, chartW]); | |
| yScale = d3.scale.linear() | |
| .domain([0, d3.max(data, function(d) { return d.value; })]) | |
| .range([chartH, 0]); | |
| } | |
| /** | |
| * Builds the SVG element that will contain the chart | |
| * | |
| * @param {HTMLElement} container DOM element that will work as the container of the graph | |
| */ | |
| function buildSVG(container) { | |
| if (!svg) { | |
| svg = d3.select(container) | |
| .append('svg') | |
| .classed('chart brush-chart', true); | |
| buildContainerGroups(); | |
| } | |
| svg | |
| .transition() | |
| .ease(ease) | |
| .attr({ | |
| width: width, | |
| height: height | |
| }); | |
| } | |
| /** | |
| * Cleaning data adding the proper format | |
| * | |
| * @param {array} data Data | |
| */ | |
| function cleanData(data) { | |
| var parseDate = d3.time.format(defaultTimeFormat).parse; | |
| return data.map(function (d) { | |
| d.date = parseDate(d[dateLabel]); | |
| d.value = +d[valueLabel]; | |
| return d; | |
| }); | |
| } | |
| /** | |
| * Clones the passed array of data | |
| * @param {Object[]} dataToClone Data to clone | |
| * @return {Object[]} Cloned data | |
| */ | |
| function cloneData(dataToClone) { | |
| return JSON.parse(JSON.stringify(dataToClone)); | |
| } | |
| /** | |
| * Draws the x axis on the svg object within its group | |
| */ | |
| function drawAxis() { | |
| svg.select('.x-axis-group') | |
| .append('g') | |
| .attr('class', 'x axis') | |
| .attr('transform', 'translate(0,' + chartH + ')') | |
| .call(xAxis); | |
| } | |
| /** | |
| * Draws the area that is going to represent the data | |
| * | |
| * @return {void} | |
| */ | |
| function drawArea() { | |
| // Create and configure the area generator | |
| var area = d3.svg.area() | |
| .x(function(d) { return xScale(d.date); }) | |
| .y0(chartH) | |
| .y1(function(d) { return yScale(d.value); }) | |
| .interpolate('basis'); | |
| // Create the area path | |
| svg.select('.chart-group') | |
| .append('path') | |
| .datum(data) | |
| .attr('class', 'brush-area') | |
| .attr('d', area); | |
| } | |
| /** | |
| * Draws the Brush components on its group | |
| * @return {void} | |
| */ | |
| function drawBrush() { | |
| chartBrush = svg.select('.brush-group') | |
| .call(brush); | |
| // Update the height of the brushing rectangle | |
| chartBrush.selectAll('rect') | |
| .classed('brush-rect', true) | |
| .attr('height', chartH); | |
| } | |
| /** | |
| * When a brush event happens, we can extract info from the extension | |
| * of the brush. | |
| * | |
| * @return {void} | |
| */ | |
| function handleBrush() { | |
| var brushExtent = d3.event.target.extent(); | |
| if (typeof onBrush === 'function') { | |
| onBrush.call(null, brushExtent); | |
| } | |
| } | |
| /** | |
| * Sets a new brush extent within the passed percentage positions | |
| * @param {Number} a Percentage of data that the brush start with | |
| * @param {Number} b Percentage of data that the brush ends with | |
| */ | |
| function setBrush(a, b) { | |
| var transitionDuration = 500, | |
| transitionDelay = 1000, | |
| x0 = xScale.invert(a * chartW), | |
| x1 = xScale.invert(b * chartW); | |
| brush.extent([x0, x1]); | |
| // now draw the brush to match our extent | |
| brush(d3.select('.brush-group').transition().duration(transitionDuration)); | |
| // now fire the brushstart, brushmove, and brushend events | |
| // set transition the delay and duration to 0 to draw right away | |
| brush.event(d3.select('.brush-group').transition().delay(transitionDelay).duration(transitionDuration)); | |
| } | |
| exports.margin = function(_x) { | |
| if (!arguments.length) return margin; | |
| margin = _x; | |
| return this; | |
| }; | |
| exports.width = function(_x) { | |
| if (!arguments.length) return width; | |
| width = _x; | |
| return this; | |
| }; | |
| exports.height = function(_x) { | |
| if (!arguments.length) return height; | |
| height = _x; | |
| return this; | |
| }; | |
| exports.onBrush = function(_x) { | |
| if (!arguments.length) return onBrush; | |
| onBrush = _x; | |
| return this; | |
| }; | |
| return exports; | |
| }; |
| describe('Reusable Brush Chart Test Suite', function() { | |
| var brushChart, dataset, containerFixture; | |
| beforeEach(function() { | |
| dataset = [ | |
| { | |
| 'value': 94, | |
| 'date': '7/22/2016' | |
| }, | |
| { | |
| 'value': 92, | |
| 'date': '6/11/2016' | |
| }, | |
| { | |
| 'value': 33, | |
| 'date': '7/20/2016' | |
| }, | |
| { | |
| 'value': 50, | |
| 'date': '6/30/2016' | |
| }, | |
| { | |
| 'value': 52, | |
| 'date': '6/12/2016' | |
| }, | |
| { | |
| 'value': 81, | |
| 'date': '4/21/2016' | |
| }, | |
| { | |
| 'value': 33, | |
| 'date': '3/16/2016' | |
| }, | |
| { | |
| 'value': 99, | |
| 'date': '7/24/2016' | |
| }, | |
| { | |
| 'value': 16, | |
| 'date': '9/15/2015' | |
| }, | |
| { | |
| 'value': 28, | |
| 'date': '9/2/2016' | |
| } | |
| ]; | |
| brushChart = graphs.chart(); | |
| $('body').append($('<div class="test-container"></div>')); | |
| containerFixture = d3.select('.test-container'); | |
| containerFixture.datum(dataset).call(brushChart); | |
| }); | |
| afterEach(function() { | |
| containerFixture.remove(); | |
| }); | |
| it('should render a chart with minimal requirements', function() { | |
| expect(containerFixture.select('.brush-chart').empty()).toEqual(false); | |
| }); | |
| it('should render container, axis and chart groups', function() { | |
| expect(containerFixture.select('g.container-group').empty()).toEqual(false); | |
| expect(containerFixture.select('g.chart-group').empty()).toEqual(false); | |
| expect(containerFixture.select('g.metadata-group').empty()).toEqual(false); | |
| expect(containerFixture.select('g.x-axis-group').empty()).toEqual(false); | |
| expect(containerFixture.select('g.brush-group').empty()).toEqual(false); | |
| }); | |
| it('should render an X axis', function() { | |
| expect(containerFixture.select('.x.axis').empty()).toEqual(false); | |
| }); | |
| it('should render an area', function() { | |
| expect(containerFixture.selectAll('.brush-area').empty()).toEqual(false); | |
| }); | |
| it('should render the brush elements', function() { | |
| expect(containerFixture.selectAll('.background.brush-rect').empty()).toEqual(false); | |
| expect(containerFixture.selectAll('.extent.brush-rect').empty()).toEqual(false); | |
| }); | |
| describe('the API', function() { | |
| it('should provide margin getter and setter', function() { | |
| var defaultMargin = brushChart.margin(), | |
| testMargin = {top: 4, right: 4, bottom: 4, left: 4}, | |
| newMargin; | |
| brushChart.margin(testMargin); | |
| newMargin = brushChart.margin(); | |
| expect(defaultMargin).not.toBe(testMargin); | |
| expect(newMargin).toBe(testMargin); | |
| }); | |
| it('should provide width getter and setter', function() { | |
| var defaultWidth = brushChart.width(), | |
| testWidth = 200, | |
| newWidth; | |
| brushChart.width(testWidth); | |
| newWidth = brushChart.width(); | |
| expect(defaultWidth).not.toBe(testWidth); | |
| expect(newWidth).toBe(testWidth); | |
| }); | |
| it('should provide height getter and setter', function() { | |
| var defaultHeight = brushChart.height(), | |
| testHeight = 200, | |
| newHeight; | |
| brushChart.height(testHeight); | |
| newHeight = brushChart.height(); | |
| expect(defaultHeight).not.toBe(testHeight); | |
| expect(newHeight).toBe(testHeight); | |
| }); | |
| }); | |
| }); |
| @import url("//fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,700italic,400,300,700"); | |
| body { | |
| font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Helvetica, Arial, sans-serif; | |
| color: ADB0B6; | |
| font-size: 14px; | |
| } | |
| a { | |
| color: #39C7EA; | |
| } | |
| .block-title { | |
| color: #ADB0B6; | |
| font-size: 44px; | |
| font-style: normal; | |
| font-weight: 300; | |
| text-rendering: optimizelegibility; | |
| } | |
| .brush-area { | |
| fill: url(#brush-area-gradient); | |
| } | |
| .brush-area:hover { | |
| opacity: 0.8; | |
| } | |
| .extent.brush-rect { | |
| fill: #EFF2F5; | |
| opacity: 0.4; | |
| } | |
| .axis text { | |
| font-size: 14px; | |
| fill: #ADB0B6; | |
| } | |
| .axis path, | |
| .axis line { | |
| fill: none; | |
| stroke: #ADB0B6; | |
| shape-rendering: crispEdges; | |
| } | |
| .x.axis path { | |
| display: none; | |
| } | |
| .date-range { | |
| font-size: 18px; | |
| margin-bottom: 40px; | |
| } | |
| .is-hidden { | |
| display: none; | |
| } |
| <!DOCTYPE HTML> | |
| <html lang="en-US"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Jasmine Spec Runner</title> | |
| <link rel="stylesheet" type="text/css" href="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/jasmine.css"> | |
| <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script> | |
| <script src="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/jasmine.js"></script> | |
| <script src="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/jasmine-html.js"></script> | |
| <script src="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/boot.js"></script> | |
| <!-- Favicon --> | |
| <link rel="shortcut icon" type="image/png" href="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.0.0/jasmine_favicon.png" /> | |
| <!-- End Favicon --> | |
| <!-- source files... --> | |
| <script src="http://d3js.org/d3.v3.min.js"></script> | |
| <script src="dataManager.js"></script> | |
| <script src="src.js"></script> | |
| <!-- spec files... --> | |
| <script src="src.spec.js"></script> | |
| </head> | |
| <body> | |
| </body> | |
| </html> |