block by Golodhros e3c2bdf6c022e691f6e7d07a47d51c51

TDD Bushing Demo

Full Screen

Gist to serve as template for future TDD D3 blocks

forked from Golodhros‘s block: TDD D3 Template

index.html

<!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="//bl.ocks.org/micahstubbs/3cda05ca68cba260cb81">Micah Stubbs block programmatic control of a d3 brush</a></li>
        <li><a href="//bl.ocks.org/Golodhros/dfe7c0c8be07a461e6ba">My own TDD Template</a></li>
    </ul>

    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="//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>

dataManager.js

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

mock_data.csv

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

src.js

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

src.spec.js

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);
        });
    });
});

style.css

@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;
}

test_runner.html

<!DOCTYPE HTML>
<html lang="en-US">
<head>
    <meta charset="UTF-8">
    <title>Jasmine Spec Runner</title>
    <link rel="stylesheet" type="text/css" href="//cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/jasmine.css">
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/jasmine.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/jasmine-html.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.0/boot.js"></script>

    <!-- Favicon -->
    <link rel="shortcut icon" type="image/png" href="//cdnjs.cloudflare.com/ajax/libs/jasmine/2.0.0/jasmine_favicon.png" />
    <!-- End Favicon -->

    <!-- source files... -->
    <script src="//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>