Skip to content

Instantly share code, notes, and snippets.

@Golodhros
Last active September 8, 2016 05:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Golodhros/e3c2bdf6c022e691f6e7d07a47d51c51 to your computer and use it in GitHub Desktop.
Save Golodhros/e3c2bdf6c022e691f6e7d07a47d51c51 to your computer and use it in GitHub Desktop.
TDD Bushing Demo
license: mit
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment