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> |