Source: brush.js

define(function(require) {
    'use strict';

    const d3Array = require('d3-array');
    const d3Axis = require('d3-axis');
    const d3Brush = require('d3-brush');
    const d3Ease = require('d3-ease');
    const d3Scale = require('d3-scale');
    const d3Shape = require('d3-shape');
    const d3Selection = require('d3-selection');
    const d3Time = require('d3-time');
    const d3Transition = require('d3-transition');

    const colorHelper = require('./helpers/colors');
    const timeAxisHelper = require('./helpers/timeAxis');

    const {axisTimeCombinations} = require('./helpers/constants');


    /**
     * @typedef BrushChartData
     * @type {Object[]}
     * @property {Number} value        Value to chart (required)
     * @property {Date} date           Date of the value (required)
     *
     * @example
     * [
     *     {
     *         value: 1,
     *         date: '2011-01-06T00:00:00Z'
     *     },
     *     {
     *         value: 2,
     *         date: '2011-01-07T00:00:00Z'
     *     }
     * ]
     */

    /**
     * Brush Chart reusable API class that renders a
     * simple and configurable brush chart.
     *
     * @module Brush
     * @tutorial brush
     * @requires d3-array, d3-axis, d3-brush, d3-ease, d3-scale, d3-shape, d3-selection, d3-time, d3-time-format
     *
     * @example
     * let brushChart = brush();
     *
     * brushChart
     *     .height(500)
     *     .width(800);
     *
     * d3Selection.select('.css-selector')
     *     .datum(dataset)
     *     .call(brushChart);
     *
     */

    return function module() {

        let margin = {
                top: 20,
                right: 20,
                bottom: 30,
                left: 20
            },
            width = 960,
            height = 500,
            data,
            svg,

            ease = d3Ease.easeQuadOut,

            dateLabel = 'date',
            valueLabel = 'value',

            dateRange = [null, null],

            chartWidth, chartHeight,
            xScale, yScale,
            xAxis,

            defaultAxisSettings = axisTimeCombinations.DAY_MONTH,
            forceAxisSettings = null,

            brush,
            chartBrush,
            handle,

            tickPadding = 5,

            onBrush = null,

            gradient = colorHelper.colorGradients.greenBlueGradient,

            // extractors
            getValue = ({value}) => value,
            getDate = ({date}) => date;


        /**
         * This function creates the graph using the selection as container
         * @param  {D3Selection} _selection A d3 selection that represents
         *                                  the container(s) where the chart(s) will be rendered
         * @param {BrushChartData} _data The data to attach and generate the chart
         */
        function exports(_selection){
            _selection.each(function(_data){
                chartWidth = width - margin.left - margin.right;
                chartHeight = height - margin.top - margin.bottom;
                data = cleanData(cloneData(_data));

                buildScales();
                buildAxis();
                buildSVG(this);
                buildGradient();
                buildBrush();
                drawArea();
                drawAxis();
                drawBrush();
                drawHandles();
            });
        }

        /**
         * Creates the d3 x axis, setting orientation
         * @private
         */
        function buildAxis(){

            let {minor, major} = timeAxisHelper.getXAxisSettings(data, xScale, width, forceAxisSettings || defaultAxisSettings);

            xAxis = d3Axis.axisBottom(xScale)
                .ticks(minor.tick)
                .tickSize(10, 0)
                .tickPadding([tickPadding])
                .tickFormat(minor.format);
        }

        /**
         * Creates the brush element and attaches a listener
         * @return {void}
         */
        function buildBrush() {
            brush = d3Brush.brushX()
                .extent([[0, 0], [chartWidth, chartHeight]])
                .on('brush', handleBrush)
                .on('end', handleBrushEnded);
        }

        /**
         * Builds containers for the chart, the axis and a wrapper for all of them
         * Also applies the Margin convention
         * @private
         */
        function buildContainerGroups(){
            let 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: gradient[0]},
                    {offset: '100%', color: gradient[1]}
                ])
              .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 = d3Scale.scaleTime()
                .domain(d3Array.extent(data, getDate ))
                .range([0, chartWidth]);

            yScale = d3Scale.scaleLinear()
                .domain([0, d3Array.max(data, getValue)])
                .range([chartHeight, 0]);
        }

        /**
         * Builds the SVG element that will contain the chart
         * @param  {HTMLElement} container DOM element that will work as the container of the graph
         * @private
         */
        function buildSVG(container){
            if (!svg) {
                svg = d3Selection.select(container)
                    .append('svg')
                    .classed('britechart brush-chart', true);

                buildContainerGroups();
            }

            svg
                .transition()
                .ease(ease)
                .attr('width', width)
                .attr('height', height);
        }

        /**
         * Cleaning data adding the proper format
         *
         * @param  {BrushChartData} data Data
         */
        function cleanData(data) {
            return data.map(function (d) {
                d.date = new Date(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
         *
         * @private
         */
        function drawAxis(){
            svg.select('.x-axis-group')
              .append('g')
                .attr('class', 'x axis')
                .attr('transform', `translate(0, ${chartHeight})`)
                .call(xAxis);
        }

        /**
         * Draws the area that is going to represent the data
         *
         * @return {void}
         */
        function drawArea() {
            // Create and configure the area generator
            let area = d3Shape.area()
                .x(({date}) => xScale(date))
                .y0(chartHeight)
                .y1(({value}) => yScale(value))
                .curve(d3Shape.curveBasis);

            // 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', chartHeight);
        }

        /**
         * Draws a handle for the Brush section
         * @return {void}
         */
        function drawHandles() {
            let handleFillColor = colorHelper.colorSchemasHuman.britechartsGreySchema[1];

            // Styling
            handle = chartBrush
                        .selectAll('.handle.brush-rect')
                        .style('fill', handleFillColor);
        }

        /**
         * When a brush event happens, we can extract info from the extension
         * of the brush.
         *
         * @return {void}
         */
        function handleBrush() {
            let s = d3Selection.event.selection,
                dateExtent = s.map(xScale.invert);

            if (typeof onBrush === 'function') {
                onBrush.call(null, dateExtent);
            }

            // updateHandlers(dateExtent);
        }

        /**
         * Processes the end brush event, snapping the boundaries to days
         * as showed on the example on https://bl.ocks.org/mbostock/6232537
         * @return {void}
         * @private
         */
        function handleBrushEnded() {
            if (!d3Selection.event.sourceEvent) return; // Only transition after input.
            if (!d3Selection.event.selection) return; // Ignore empty selections.

            let d0 = d3Selection.event.selection.map(xScale.invert),
                d1 = d0.map(d3Time.timeDay.round);

            // If empty when rounded, use floor & ceil instead.
            if (d1[0] >= d1[1]) {
                d1[0] = d3Time.timeDay.floor(d0[0]);
                d1[1] = d3Time.timeDay.offset(d1[0]);
            }

            d3Selection.select(this)
                .transition()
                .call(d3Selection.event.target.move, d1.map(xScale));
        }

        /**
         * 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
         * @example
         *     setBrushByPercentages(0.25, 0.5)
         */
        function setBrushByPercentages(a, b) {
            let x0 = a * chartWidth,
                x1 = b * chartWidth;

            brush
                .move(chartBrush, [x0, x1]);
        }

        /**
         * Sets a new brush extent within the passed dates
         * @param {String | Date} dateA Initial Date
         * @param {String | Date} dateB End Date
         */
        function setBrushByDates(dateA, dateB) {
            let x0 = xScale(new Date(dateA)),
                x1 = xScale(new Date(dateB));

            brush
                .move(chartBrush, [x0, x1]);
        }

        /**
         * Updates visibility and position of the brush handlers
         * @param  {Number[]} dateExtent Date range
         * @return {void}
         */
        function updateHandlers(dateExtent) {
            if (dateExtent == null) {
                handle.attr('display', 'none');
            } else {
                handle
                    .attr('display', null)
                    .attr('transform', function(d, i) {
                        return `translate(${dateExtent[i]},${chartHeight / 2})`;
                    });
            }
        }

        // API

        /**
         * Gets or Sets the dateRange for the selected part of the brush
         * @param  {String[]} _x Desired dateRange for the graph
         * @return { dateRange | module} Current dateRange or Chart module to chain calls
         * @public
         */
        exports.dateRange = function(_x) {
            if (!arguments.length) {
                return dateRange;
            }
            dateRange = _x;

            if (Array.isArray(dateRange)) {
                setBrushByDates(...dateRange);
            }

            return this;
        };

        /**
         * Exposes the ability to force the chart to show a certain x axis grouping
         * @param  {String} _x Desired format
         * @return { (String|Module) }    Current format or module to chain calls
         */
        exports.forceAxisFormat = function(_x) {
            if (!arguments.length) {
              return forceAxisSettings || defaultAxisSettings;
            }
            forceAxisSettings = _x;
            return this;
        };

        /**
         * constants to be used to force the x axis to respect a certain granularity
         * current options: HOUR_DAY, DAY_MONTH, MONTH_YEAR
         * @example line.forceAxisFormat(line.axisTimeCombinations.HOUR_DAY)
         */
        exports.axisTimeCombinations = axisTimeCombinations;

        /**
         * Gets or Sets the gradient of the chart
         * @param  {String[]} _x Desired gradient for the graph
         * @return { gradient | module} Current gradient or Chart module to chain calls
         * @public
         */
        exports.gradient = function(_x) {
            if (!arguments.length) {
                return gradient;
            }
            gradient = _x;
            return this;
        };

        /**
         * Gets or Sets the height of the chart
         * @param  {number} _x Desired width for the graph
         * @return { height | module} Current height or Chart module to chain calls
         * @public
         */
        exports.height = function(_x) {
            if (!arguments.length) {
                return height;
            }
            height = _x;
            return this;
        };

        /**
         * Gets or Sets the margin of the chart
         * @param  {object} _x Margin object to get/set
         * @return { margin | module} Current margin or Chart module to chain calls
         * @public
         */
        exports.margin = function(_x) {
            if (!arguments.length) {
                return margin;
            }
            margin = _x;
            return this;
        };

        /**
         * Gets or Sets the callback that will be called when the user brushes over the area
         * @param  {Function} _x Callback to call
         * @return {Function | module}    Current callback function or the Chart Module
         */
        exports.onBrush = function(_x) {
            if (!arguments.length) return onBrush;
            onBrush = _x;

            return this;
        };

        /**
         * Gets or Sets the width of the chart
         * @param  {number} _x Desired width for the graph
         * @return { width | module} Current width or Chart module to chain calls
         * @public
         */
        exports.width = function(_x) {
            if (!arguments.length) {
                return width;
            }
            width = _x;
            return this;
        };

        return exports;
    };

});