Source: charts/scatter-plot.js

import {symbol} from 'd3-shape';
import {select} from 'd3-selection';
import {brush} from 'd3-brush';
import {ascending} from 'd3-array'

import {CoordinateGridMixin} from '../base/coordinate-grid-mixin';
import {optionalTransition, transition} from '../core/core';
import {filters} from '../core/filters';
import {constants} from '../core/constants';
import {events} from '../core/events';

/**
 * A scatter plot chart
 *
 * Examples:
 * - {@link http://dc-js.github.io/dc.js/examples/scatter.html Scatter Chart}
 * - {@link http://dc-js.github.io/dc.js/examples/multi-scatter.html Multi-Scatter Chart}
 * @mixes CoordinateGridMixin
 */
export class ScatterPlot extends CoordinateGridMixin {
    /**
     * Create a Scatter Plot.
     * @example
     * // create a scatter plot under #chart-container1 element using the default global chart group
     * var chart1 = new ScatterPlot('#chart-container1');
     * // create a scatter plot under #chart-container2 element using chart group A
     * var chart2 = new ScatterPlot('#chart-container2', 'chartGroupA');
     * // create a sub-chart under a composite parent chart
     * var chart3 = new ScatterPlot(compositeChart);
     * @param {String|node|d3.selection} parent - Any valid
     * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying
     * a dom block element such as a div; or a dom element or d3 selection.
     * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.
     * Interaction with a chart will only trigger events and redraws within the chart's group.
     */
    constructor (parent, chartGroup) {
        super();

        this._symbol = symbol();

        this._existenceAccessor = d => d.value;

        const originalKeyAccessor = this.keyAccessor();
        this.keyAccessor(d => originalKeyAccessor(d)[0]);
        this.valueAccessor(d => originalKeyAccessor(d)[1]);
        this.colorAccessor(() => this._groupName);

        // this basically just counteracts the setting of its own key/value accessors
        // see https://github.com/dc-js/dc.js/issues/702
        this.title(d => `${this.keyAccessor()(d)},${this.valueAccessor()(d)}: ${this.existenceAccessor()(d)}`);

        this._highlightedSize = 7;
        this._symbolSize = 5;
        this._excludedSize = 3;
        this._excludedColor = null;
        this._excludedOpacity = 1.0;
        this._emptySize = 0;
        this._emptyOpacity = 0;
        this._nonemptyOpacity = 1;
        this._emptyColor = null;
        this._filtered = [];
        this._canvas = null;
        this._context = null;
        this._useCanvas = false;


        // Use a 2 dimensional brush
        this.brush(brush());

        this._symbol.size((d, i) => this._elementSize(d, i));

        this.anchor(parent, chartGroup);
    }

    // Calculates element radius for canvas plot to be comparable to D3 area based symbol sizes
    _canvasElementSize (d, isFiltered) {
        if (!this._existenceAccessor(d)) {
            return this._emptySize / Math.sqrt(Math.PI);
        } else if (isFiltered) {
            return this._symbolSize / Math.sqrt(Math.PI);
        } else {
            return this._excludedSize / Math.sqrt(Math.PI);
        }
    }

    _elementSize (d, i) {
        if (!this._existenceAccessor(d)) {
            return Math.pow(this._emptySize, 2);
        } else if (this._filtered[i]) {
            return Math.pow(this._symbolSize, 2);
        } else {
            return Math.pow(this._excludedSize, 2);
        }
    }

    _locator (d) {
        return `translate(${this.x()(this.keyAccessor()(d))},${ 
            this.y()(this.valueAccessor()(d))})`;
    }

    filter (filter) {
        if (!arguments.length) {
            return super.filter();
        }

        return super.filter(filters.RangedTwoDimensionalFilter(filter));
    }

    /**
     * Method that replaces original resetSvg and appropriately inserts canvas
     * element along with svg element and sets their CSS properties appropriately
     * so they are overlapped on top of each other.
     * Remove the chart's SVGElements from the dom and recreate the container SVGElement.
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement}
     * @returns {SVGElement}
     */
    resetSvg () {
        if (!this._useCanvas) {
            return super.resetSvg();
        } else {
            super.resetSvg(); // Perform original svgReset inherited from baseMixin
            this.select('canvas').remove(); // remove old canvas

            const svgSel = this.svg();
            const rootSel = this.root();

            // Set root node to relative positioning and svg to absolute
            rootSel.style('position', 'relative');
            svgSel.style('position', 'relative');

            // Check if SVG element already has any extra top/left CSS offsets
            const svgLeft = isNaN(parseInt(svgSel.style('left'), 10)) ? 0 : parseInt(svgSel.style('left'), 10);
            const svgTop = isNaN(parseInt(svgSel.style('top'), 10)) ? 0 : parseInt(svgSel.style('top'), 10);
            const width = this.effectiveWidth();
            const height = this.effectiveHeight();
            const margins = this.margins(); // {top: 10, right: 130, bottom: 42, left: 42}

            // Add the canvas element such that it perfectly overlaps the plot area of the scatter plot SVG
            const devicePixelRatio = window.devicePixelRatio || 1;
            this._canvas = this.root().append('canvas')
                .attr('x', 0)
                .attr('y', 0)
                .attr('width', (width) * devicePixelRatio)
                .attr('height', (height) * devicePixelRatio)
                .style('width', `${width}px`)
                .style('height', `${height}px`)
                .style('position', 'absolute')
                .style('top', `${margins.top + svgTop}px`)
                .style('left', `${margins.left + svgLeft}px`)
                .style('z-index', -1) // Place behind SVG
                .style('pointer-events', 'none'); // Disable pointer events on canvas so SVG can capture brushing

            // Define canvas context and set clipping path
            this._context = this._canvas.node().getContext('2d');
            this._context.scale(devicePixelRatio, devicePixelRatio);
            this._context.rect(0, 0, width, height);
            this._context.clip(); // Setup clipping path
            this._context.imageSmoothingQuality = 'high';

            return this.svg(); // Respect original return param for this.resetSvg;
        }
    }

    _resizeCanvas () {
        const width = this.effectiveWidth();
        const height = this.effectiveHeight();

        const devicePixelRatio = window.devicePixelRatio || 1;
        this._canvas
            .attr('width', (width) * devicePixelRatio)
            .attr('height', (height) * devicePixelRatio)
            .style('width', `${width}px`)
            .style('height', `${height}px`);
        this._context.scale(devicePixelRatio, devicePixelRatio);
    }


    /**
     * Set or get whether to use canvas backend for plotting scatterPlot. Note that the
     * canvas backend does not currently support
     * {@link ScatterPlot#customSymbol customSymbol} or
     * {@link ScatterPlot#symbol symbol} methods and is limited to always plotting
     * with filled circles. Symbols are drawn with
     * {@link ScatterPlot#symbolSize symbolSize} radius. By default, the SVG backend
     * is used when `useCanvas` is set to `false`.
     * @param {Boolean} [useCanvas=false]
     * @return {Boolean|d3.selection}
     */
    useCanvas (useCanvas) {
        if (!arguments.length) {
            return this._useCanvas;
        }
        this._useCanvas = useCanvas;
        return this;
    }

    /**
     * Set or get canvas element. You should usually only ever use the get method as
     * dc.js will handle canvas element generation.  Provides valid canvas only when
     * {@link ScatterPlot#useCanvas useCanvas} is set to `true`
     * @param {CanvasElement|d3.selection} [canvasElement]
     * @return {CanvasElement|d3.selection}
     */
    canvas (canvasElement) {
        if (!arguments.length) {
            return this._canvas;
        }
        this._canvas = canvasElement;
        return this;
    }

    /**
     * Get canvas 2D context. Provides valid context only when
     * {@link ScatterPlot#useCanvas useCanvas} is set to `true`
     * @return {CanvasContext}
     */
    context () {
        return this._context;
    }

    /*eslint complexity: [2,11] */
    // Plots data on canvas element. If argument provided, assumes legend is
    // currently being highlighted and modifies opacity/size of symbols accordingly
    // @param {Object} [legendHighlightDatum] - Datum provided to legendHighlight method
    _plotOnCanvas (legendHighlightDatum) {
        this._resizeCanvas();
        const context = this.context();
        context.clearRect(0, 0, (context.canvas.width + 2) * 1, (context.canvas.height + 2) * 1);
        const data = this.data();

        // Draw the data on canvas
        data.forEach((d, i) => {
            const isFiltered = !this.filter() || this.filter().isFiltered([d.key[0], d.key[1]]);
            // Calculate opacity for current data point
            let cOpacity = 1;
            if (!this._existenceAccessor(d)) {
                cOpacity = this._emptyOpacity;
            } else if (isFiltered) {
                cOpacity = this._nonemptyOpacity;
            } else {
                cOpacity = this.excludedOpacity();
            }
            // Calculate color for current data point
            let cColor = null;
            if (this._emptyColor && !this._existenceAccessor(d)) {
                cColor = this._emptyColor;
            } else if (this.excludedColor() && !isFiltered) {
                cColor = this.excludedColor();
            } else {
                cColor = this.getColor(d);
            }
            let cSize = this._canvasElementSize(d, isFiltered);

            // Adjust params for data points if legend is highlighted
            if (legendHighlightDatum) {
                const isHighlighted = (cColor === legendHighlightDatum.color);
                // Calculate opacity for current data point
                const fadeOutOpacity = 0.1; // TODO: Make this programmatically setable
                if (!isHighlighted) { // Fade out non-highlighted colors + highlighted colors outside filter
                    cOpacity = fadeOutOpacity;
                }
                if (isHighlighted) { // Set size for highlighted color data points
                    cSize = this._highlightedSize / Math.sqrt(Math.PI);
                }
            }

            // Draw point on canvas
            context.save();
            context.globalAlpha = cOpacity;
            context.beginPath();
            context.arc(this.x()(this.keyAccessor()(d)), this.y()(this.valueAccessor()(d)), cSize, 0, 2 * Math.PI, true);
            context.fillStyle = cColor;
            context.fill();
            // context.lineWidth = 0.5; // Commented out code to add stroke around scatter points if desired
            // context.strokeStyle = '#333';
            // context.stroke();
            context.restore();
        });
    }

    _plotOnSVG () {

        const data = this.data();

        if (this._keyboardAccessible) {
            // sort based on the x value (key)
            data.sort((a, b) => ascending(this.keyAccessor()(a), this.keyAccessor()(b)));
        }

        let symbols = this.chartBodyG().selectAll('path.symbol')
            .data(data);

        transition(symbols.exit(), this.transitionDuration(), this.transitionDelay())
            .attr('opacity', 0).remove();

        symbols = symbols
            .enter()
            .append('path')
            .attr('class', 'symbol')
            .classed('dc-tabbable', this._keyboardAccessible)
            .attr('opacity', 0)
            .attr('fill', this.getColor)
            .attr('transform', d => this._locator(d))
            .merge(symbols);

        // no click handler - just tabindex for reading out of tooltips
        if (this._keyboardAccessible) {
            this._makeKeyboardAccessible();
            symbols.order();
        }

        symbols.call(s => this._renderTitles(s, data));

        symbols.each((d, i) => {
            this._filtered[i] = !this.filter() || this.filter().isFiltered([this.keyAccessor()(d), this.valueAccessor()(d)]);
        });

        transition(symbols, this.transitionDuration(), this.transitionDelay())
            .attr('opacity', (d, i) => {
                if (!this._existenceAccessor(d)) {
                    return this._emptyOpacity;
                } else if (this._filtered[i]) {
                    return this._nonemptyOpacity;
                } else {
                    return this.excludedOpacity();
                }
            })
            .attr('fill', (d, i) => {
                if (this._emptyColor && !this._existenceAccessor(d)) {
                    return this._emptyColor;
                } else if (this.excludedColor() && !this._filtered[i]) {
                    return this.excludedColor();
                } else {
                    return this.getColor(d);
                }
            })
            .attr('transform', d => this._locator(d))
            .attr('d', this._symbol);
    }

    plotData () {
        if (this._useCanvas) {
            this._plotOnCanvas();
        } else {
            this._plotOnSVG();
        }
    }

    _renderTitles (_symbol, _d) {
        if (this.renderTitle()) {
            _symbol.selectAll('title').remove();
            _symbol.append('title').text(d => this.title()(d));
        }
    }

    /**
     * Get or set the existence accessor.  If a point exists, it is drawn with
     * {@link ScatterPlot#symbolSize symbolSize} radius and
     * opacity 1; if it does not exist, it is drawn with
     * {@link ScatterPlot#emptySize emptySize} radius and opacity 0. By default,
     * the existence accessor checks if the reduced value is truthy.
     * @see {@link ScatterPlot#symbolSize symbolSize}
     * @see {@link ScatterPlot#emptySize emptySize}
     * @example
     * // default accessor
     * chart.existenceAccessor(function (d) { return d.value; });
     * @param {Function} [accessor]
     * @returns {Function|ScatterPlot}
     */
    existenceAccessor (accessor) {
        if (!arguments.length) {
            return this._existenceAccessor;
        }
        this._existenceAccessor = accessor;
        return this;
    }

    /**
     * Get or set the symbol type used for each point. By default the symbol is a circle (d3.symbolCircle).
     * Type can be a constant or an accessor.
     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_type symbol.type}
     * @example
     * // Circle type
     * chart.symbol(d3.symbolCircle);
     * // Square type
     * chart.symbol(d3.symbolSquare);
     * @param {Function} [type=d3.symbolCircle]
     * @returns {Function|ScatterPlot}
     */
    symbol (type) {
        if (!arguments.length) {
            return this._symbol.type();
        }
        this._symbol.type(type);
        return this;
    }

    /**
     * Get or set the symbol generator. By default `ScatterPlot` will use
     * {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol d3.symbol()}
     * to generate symbols. `ScatterPlot` will set the
     * {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size symbol size accessor}
     * on the symbol generator.
     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol d3.symbol}
     * @see {@link https://stackoverflow.com/questions/25332120/create-additional-d3-js-symbols Create additional D3.js symbols}
     * @param {String|Function} [customSymbol=d3.symbol()]
     * @returns {String|Function|ScatterPlot}
     */
    customSymbol (customSymbol) {
        if (!arguments.length) {
            return this._symbol;
        }
        this._symbol = customSymbol;
        this._symbol.size((d, i) => this._elementSize(d, i));
        return this;
    }

    /**
     * Set or get radius for symbols.
     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}
     * @param {Number} [symbolSize=3]
     * @returns {Number|ScatterPlot}
     */
    symbolSize (symbolSize) {
        if (!arguments.length) {
            return this._symbolSize;
        }
        this._symbolSize = symbolSize;
        return this;
    }

    /**
     * Set or get radius for highlighted symbols.
     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}
     * @param {Number} [highlightedSize=5]
     * @returns {Number|ScatterPlot}
     */
    highlightedSize (highlightedSize) {
        if (!arguments.length) {
            return this._highlightedSize;
        }
        this._highlightedSize = highlightedSize;
        return this;
    }

    /**
     * Set or get size for symbols excluded from this chart's filter. If null, no
     * special size is applied for symbols based on their filter status.
     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}
     * @param {Number} [excludedSize=null]
     * @returns {Number|ScatterPlot}
     */
    excludedSize (excludedSize) {
        if (!arguments.length) {
            return this._excludedSize;
        }
        this._excludedSize = excludedSize;
        return this;
    }

    /**
     * Set or get color for symbols excluded from this chart's filter. If null, no
     * special color is applied for symbols based on their filter status.
     * @param {Number} [excludedColor=null]
     * @returns {Number|ScatterPlot}
     */
    excludedColor (excludedColor) {
        if (!arguments.length) {
            return this._excludedColor;
        }
        this._excludedColor = excludedColor;
        return this;
    }

    /**
     * Set or get opacity for symbols excluded from this chart's filter.
     * @param {Number} [excludedOpacity=1.0]
     * @returns {Number|ScatterPlot}
     */
    excludedOpacity (excludedOpacity) {
        if (!arguments.length) {
            return this._excludedOpacity;
        }
        this._excludedOpacity = excludedOpacity;
        return this;
    }

    /**
     * Set or get radius for symbols when the group is empty.
     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}
     * @param {Number} [emptySize=0]
     * @returns {Number|ScatterPlot}
     */
    emptySize (emptySize) {
        if (!arguments.length) {
            return this._emptySize;
        }
        this._emptySize = emptySize;
        return this;
    }

    hiddenSize (emptySize) {
        if (!arguments.length) {
            return this.emptySize();
        }
        return this.emptySize(emptySize);
    }

    /**
     * Set or get color for symbols when the group is empty. If null, just use the
     * {@link ColorMixin#colors colorMixin.colors} color scale zero value.
     * @param {String} [emptyColor=null]
     * @return {String}
     * @return {ScatterPlot}/
     */
    emptyColor (emptyColor) {
        if (!arguments.length) {
            return this._emptyColor;
        }
        this._emptyColor = emptyColor;
        return this;
    }

    /**
     * Set or get opacity for symbols when the group is empty.
     * @param {Number} [emptyOpacity=0]
     * @return {Number}
     * @return {ScatterPlot}
     */
    emptyOpacity (emptyOpacity) {
        if (!arguments.length) {
            return this._emptyOpacity;
        }
        this._emptyOpacity = emptyOpacity;
        return this;
    }

    /**
     * Set or get opacity for symbols when the group is not empty.
     * @param {Number} [nonemptyOpacity=1]
     * @return {Number}
     * @return {ScatterPlot}
     */
    nonemptyOpacity (nonemptyOpacity) {
        if (!arguments.length) {
            return this._emptyOpacity;
        }
        this._nonemptyOpacity = nonemptyOpacity;
        return this;
    }

    legendables () {
        return [{chart: this, name: this._groupName, color: this.getColor()}];
    }

    legendHighlight (d) {
        if (this._useCanvas) {
            this._plotOnCanvas(d); // Supply legend datum to plotOnCanvas
        } else {
            this._resizeSymbolsWhere(s => s.attr('fill') === d.color, this._highlightedSize);
            this.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {
                return select(this).attr('fill') !== d.color;
            }).classed('fadeout', true);
        }
    }

    legendReset (d) {
        if (this._useCanvas) {
            this._plotOnCanvas(d); // Supply legend datum to plotOnCanvas
        } else {
            this._resizeSymbolsWhere(s => s.attr('fill') === d.color, this._symbolSize);
            this.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {
                return select(this).attr('fill') !== d.color;
            }).classed('fadeout', false);
        }
    }

    _resizeSymbolsWhere (condition, size) {
        const symbols = this.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {
            return condition(select(this));
        });
        const oldSize = this._symbol.size();
        this._symbol.size(Math.pow(size, 2));
        transition(symbols, this.transitionDuration(), this.transitionDelay()).attr('d', this._symbol);
        this._symbol.size(oldSize);
    }
    createBrushHandlePaths () {
        // no handle paths for poly-brushes
    }

    extendBrush (brushSelection) {
        if (this.round()) {
            brushSelection[0] = brushSelection[0].map(this.round());
            brushSelection[1] = brushSelection[1].map(this.round());
        }
        return brushSelection;
    }

    brushIsEmpty (brushSelection) {
        return !brushSelection || brushSelection[0][0] >= brushSelection[1][0] || brushSelection[0][1] >= brushSelection[1][1];
    }

    _brushing (evt) {
        if (this._ignoreBrushEvents) {
            return;
        }

        let brushSelection = evt.selection;

        // Testing with pixels is more reliable
        let brushIsEmpty = this.brushIsEmpty(brushSelection);

        if (brushSelection) {
            brushSelection = brushSelection.map(point => point.map((coord, i) => {
                const scale = i === 0 ? this.x() : this.y();
                return scale.invert(coord);
            }));

            brushSelection = this.extendBrush(brushSelection);

            // The rounding process might have made brushSelection empty, so we need to recheck
            brushIsEmpty = brushIsEmpty && this.brushIsEmpty(brushSelection);
        }

        this.redrawBrush(brushSelection, false);

        const ranged2DFilter = brushIsEmpty ? null : filters.RangedTwoDimensionalFilter(brushSelection);

        events.trigger(() => {
            this.replaceFilter(ranged2DFilter);
            this.redrawGroup();
        }, constants.EVENT_DELAY);
    }

    redrawBrush (brushSelection, doTransition) {
        // override default x axis brush from parent chart
        this._gBrush = this.gBrush();

        if (this.brushOn() && this._gBrush) {
            if (this.resizing()) {
                this.setBrushExtents(doTransition);
            }

            if (!brushSelection) {
                this._withoutBrushEvents(() => {
                    this._gBrush
                        .call(this.brush().move, brushSelection);
                });
            } else {
                brushSelection = brushSelection.map(point => point.map((coord, i) => {
                    const scale = i === 0 ? this.x() : this.y();
                    return scale(coord);
                }));

                const gBrush =
                    optionalTransition(doTransition, this.transitionDuration(), this.transitionDelay())(this._gBrush);

                this._withoutBrushEvents(() => {
                    gBrush
                        .call(this.brush().move, brushSelection);
                });
            }
        }

        this.fadeDeselectedArea(brushSelection);
    }
}

export const scatterPlot = (parent, chartGroup) => new ScatterPlot(parent, chartGroup);