Source: brush.js

import { extent, max } from 'd3-array';
import { axisBottom } from 'd3-axis';
import { brushX } from 'd3-brush';
import { scaleLinear, scaleTime } from 'd3-scale';
import { curveBasis, area } from 'd3-shape';
import { dispatch } from 'd3-dispatch';
import { select, event } from 'd3-selection';
import { timeFormat } from 'd3-time-format';
import 'd3-transition';

import colorHelper from './helpers/color';
import timeAxisHelper from './helpers/axis';
import { axisTimeCombinations, timeIntervals } from './helpers/constants';
import { uniqueId } from './helpers/number';
import { line } from './helpers/load';

/**
 * @typedef BrushChartData
 * @type {Object[]}
 * @property {Number} value        Value to chart (required)
 * @property {Date} date           Date of the value in ISO8601 format (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
 * @requires d3-axis
 * @requires d3-brush
 * @requires d3-ease
 * @requires d3-scale
 * @requires d3-shape
 * @requires d3-dispatch
 * @requires d3-selection
 * @requires d3-time
 * @requires d3-transition
 * @requires d3-time-format
 *
 * @example
 * let brushChart = brush();
 *
 * brushChart
 *     .height(500)
 *     .width(800);
 *
 * d3Selection.select('.css-selector')
 *     .datum(dataset)
 *     .call(brushChart);
 */

export default function module() {

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

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

        dateRange = [null, null],

        chartWidth, chartHeight,
        xScale, yScale,
        xAxis,

        xAxisFormat = null,
        xTicks = null,
        xAxisCustomFormat = null,
        locale,

        brush,
        chartBrush,
        brushArea,
        handle,

        tickPadding = 5,

        chartGradientEl,
        gradient = colorHelper.colorGradients.greenBlue,
        gradientId = uniqueId('brush-area-gradient'),

        roundingTimeInterval = 'timeDay',

        // Dispatcher object to broadcast the mouse events
        // @see {@link https://github.com/d3/d3/blob/master/API.md#dispatches-d3-dispatch}
        dispatcher = dispatch('customBrushStart', 'customBrushEnd'),

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

        if (xAxisFormat === 'custom' && typeof xAxisCustomFormat === 'string') {
            minor = {
                tick: xTicks,
                format: timeFormat(xAxisCustomFormat)
            };
        } else {
            ({minor, major} = timeAxisHelper.getTimeSeriesAxis(data, width, xAxisFormat));
        }

        xAxis = 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 = brushX()
            .extent([[0, 0], [chartWidth, chartHeight]])
            .on('brush', handleBrushStart)
            .on('end', handleBrushEnd);
    }

    /**
     * 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('x-axis-group', true)
                .append('g')
                .classed('x axis', true);
        container
            .append('g')
            .classed('brush-group', true);
        container
            .append('g')
            .classed('metadata-group', true);
    }

    /**
     * Creates the gradient on the area
     * @return {void}
     */
    function buildGradient() {
        if (!chartGradientEl) {
            chartGradientEl = svg.select('.metadata-group')
                .append('linearGradient')
                .attr('id', gradientId)
                .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 = scaleTime()
            .domain(extent(data, getDate ))
            .range([0, chartWidth]);

        yScale = scaleLinear()
            .domain([0, 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 = select(container)
                .append('svg')
                .classed('britechart brush-chart', true);

            buildContainerGroups();
        }

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

    /**
     * Cleaning data casting the values and dates to the proper type while keeping
     * the rest of properties on the data
     * @param  {BrushChartData} originalData        Raw data from the container
     * @return {BrushChartData}                     Clean data
     * @private
     */
    function cleanData(originalData) {
        return originalData.reduce((acc, d) => {
            d.date = new Date(d[dateLabel]);
            d.value = +d[valueLabel];

            return [...acc, 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 .axis.x')
            .attr('transform', `translate(0, ${chartHeight})`)
            .call(xAxis);
    }

    /**
     * Draws the area that is going to represent the data
     *
     * @return {void}
     */
    function drawArea() {
        if (brushArea) {
            svg.selectAll('.brush-area').remove();
        }

        // Create and configure the area generator
        brushArea = area()
            .x(({date}) => xScale(date))
            .y0(chartHeight)
            .y1(({value}) => yScale(value))
            .curve(curveBasis);

        // Create the area path
        svg.select('.chart-group')
            .append('path')
            .datum(data)
            .attr('class', 'brush-area')
            .attr('d', brushArea);
    }

    /**
     * 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);

        chartBrush.selectAll('.selection')
            .attr('fill', `url(#${gradientId})`);
    }

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

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

    /**
     * When a brush event starts, we can extract info from the extension
     * of the brush.
     *
     * @return {void}
     */
    function handleBrushStart() {
        const selection = event.selection;

        if (!selection) {
            return;
        }

        dispatcher.call('customBrushStart', this, selection.map(xScale.invert));
    }

    /**
     * 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 handleBrushEnd() {
        if (!event.sourceEvent) {
            return; // Only transition after input.
        }

        let dateExtentRounded = [null, null];
        const selection = event.selection;

        if (selection) {
            let dateExtent = selection.map(xScale.invert);

            dateExtentRounded = dateExtent.map(timeIntervals[roundingTimeInterval].round);

            // If empty when rounded, use floor & ceil instead.
            if (dateExtentRounded[0] >= dateExtentRounded[1]) {
                dateExtentRounded[0] = timeIntervals[roundingTimeInterval].floor(dateExtent[0]);
                dateExtentRounded[1] = timeIntervals[roundingTimeInterval].offset(dateExtentRounded[0]);
            }

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

        dispatcher.call('customBrushEnd', this, dateExtentRounded);
    }

    /**
     * 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 selection = null;

        if (dateA !== null) {
            selection = [
                xScale(new Date(dateA)),
                xScale(new Date(dateB))
            ];
        }

        brush.move(chartBrush, selection);
    }

    // API

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

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

    /**
     * Gets or Sets the gradient of the chart
     * @param  {String[]} [_x=colorHelper.colorGradients.greenBlue]    Desired gradient for the graph
     * @return {String | 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 {Number | 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 loading state of the chart
     * @param  {string} markup              Desired markup to show when null data
     * @return { loadingState | module}     Current loading state markup or Chart module to chain calls
     * @public
     */
    exports.loadingState = function(_markup) {
        if (!arguments.length) {
            return loadingState;
        }
        loadingState = _markup;

        return this;
    };

    /**
     * Pass language tag for the tooltip to localize the date.
     * Feature uses Intl.DateTimeFormat, for compatability and support, refer to
     * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
     * @param  {String} _x              Must be a language tag (BCP 47) like 'en-US' or 'fr-FR'
     * @return { (String|Module) }      Current locale or module to chain calls
     */
    exports.locale = function(_x) {
        if (!arguments.length) {
            return locale;
        }
        locale = _x;

        return this;
    };

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

        return this;
    };

    /**
     * Date range
     * @typedef DateExtent
     * @type {Date[]}
     * @property {Date} 0 Lower bound date selection
     * @property {Date} 1 Upper bound date selection
     * @see {@link https://github.com/d3/d3-brush#brushSelection|d3-brush:brushSelection}
     */

    /**
     * Event indicating when the brush moves
     * @event customBrushStart
     * @type {module:Brush~DateExtent}
     * @see {@link https://github.com/d3/d3-brush#brush_on|d3-brush:on(brush)}
     */

    /**
     * Event indicating the end of a brush gesture
     * @event customBrushEnd
     * @type {module:Brush~DateExtent}
     * @see {@link https://github.com/d3/d3-brush#brush_on|d3-brush:on(end)}
     */

    /**
     * @callback eventCallback
     * @param {module:Brush~DateExtent} dateExtent Date range
     */

    /**
     * Adds, removes, or gets the callback for the specified typenames.
     * @param {String} typenames One or more event type names, delimited by a space
     * @param {module:Brush~eventCallback} [callback] Callback to register
     * @return {module:Brush}
     * @listens customBrushStart
     * @listens customBrushEnd
     * @see {@link https://github.com/d3/d3-dispatch/blob/master/README.md#dispatch_on|d3-dispatch:on}
     * @public
     */
    exports.on = function() {
        let value = dispatcher.on.apply(dispatcher, arguments);

        return value === dispatcher ? exports : value;
    };

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

        return this;
    };

    /**
     * Exposes the ability to force the chart to show a certain x format
     * It requires a `xAxisFormat` of 'custom' in order to work.
     * @param  {String} _x              Desired format for x axis
     * @return {String | Module}        Current format or module to chain calls
     */
    exports.xAxisCustomFormat = function(_x) {
        if (!arguments.length) {
            return xAxisCustomFormat;
        }
        xAxisCustomFormat = _x;

        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
     * @example
     *     brush.xAxisFormat(brush.axisTimeCombinations.HOUR_DAY)
     */
    exports.xAxisFormat = function(_x) {
        if (!arguments.length) {
            return xAxisFormat;
        }
        xAxisFormat = _x;

        return this;
    };

    /**
     * Exposes the ability to force the chart to show a certain x ticks. It requires a `xAxisCustomFormat` of 'custom' in order to work.
     * NOTE: This value needs to be a multiple of 2, 5 or 10. They won't always work as expected, as D3 decides at the end
     * how many and where the ticks will appear.
     *
     * @param  {Number} _x              Desired number of x axis ticks (multiple of 2, 5 or 10)
     * @return {Number | Module}        Current number or ticks or module to chain calls
     */
    exports.xTicks = function(_x) {
        if (!arguments.length) {
            return xTicks;
        }
        xTicks = _x;

        return this;
    };

    /**
     * Gets or Sets the rounding time interval of the selection boundary
     * @param  {roundingTimeInterval} _x Desired time interval for the selection, default 'timeDay'. All options are:
     * timeMillisecond, utcMillisecond, timeSecond, utcSecond, timeMinute, utcMinute, timeHour, utcHour, timeDay, utcDay
     * timeWeek, utcWeek, timeSunday, utcSunday, timeMonday, utcMonday, timeTuesday, utcTuesday, timeWednesday,
     * utcWednesday, timeThursday, utcThursday, timeFriday, utcFriday, timeSaturday, utcSaturday, timeMonth, utcMonth,
     * timeYear and utcYear. Visit https://github.com/d3/d3-time#intervals for more information.
     * @return { (roundingTimeInterval | Module) } Current time interval or module to chain calls
     * @public
     */
    exports.roundingTimeInterval = function(_x) {
        if (!arguments.length) {
            return roundingTimeInterval;
        }
        roundingTimeInterval = _x;

        return this;
    };

    return exports;
};