Source: base/bubble-mixin.js

import { ascending, descending, min, max } from 'd3-array';
import { scaleLinear } from 'd3-scale';

import {ColorMixin} from './color-mixin';
import {transition} from '../core/core';
import {events} from '../core/events';
import {d3compat} from '../core/config';

/**
 * This Mixin provides reusable functionalities for any chart that needs to visualize data using bubbles.
 * @mixin BubbleMixin
 * @mixes ColorMixin
 * @param {Object} Base
 * @returns {BubbleMixin}
 */
export const BubbleMixin = Base => class extends ColorMixin(Base) {
    constructor () {
        super();

        this._maxBubbleRelativeSize = 0.3;
        this._minRadiusWithLabel = 10;
        this._sortBubbleSize = false;
        this._elasticRadius = false;
        this._excludeElasticZero = true;

        // These cane be used by derived classes as well, so member status
        this.BUBBLE_NODE_CLASS = 'node';
        this.BUBBLE_CLASS = 'bubble';
        this.MIN_RADIUS = 10;

        this.renderLabel(true);

        this.data(group => {
            const data = group.all();

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

            if (this._sortBubbleSize) {
                // sort descending so smaller bubbles are on top
                const radiusAccessor = this.radiusValueAccessor();
                data.sort((a, b) => descending(radiusAccessor(a), radiusAccessor(b)));
            }
            return data;
        });

        this._r = scaleLinear().domain([0, 100]);
    }

    _rValueAccessor (d) {
        return d.r;
    }

    /**
         * Get or set the bubble radius scale. By default the bubble chart uses
         * {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleLinear d3.scaleLinear().domain([0, 100])}
         * as its radius scale.
         * @memberof BubbleMixin
         * @instance
         * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}
         * @param {d3.scale} [bubbleRadiusScale=d3.scaleLinear().domain([0, 100])]
         * @returns {d3.scale|BubbleMixin}
         */
    r (bubbleRadiusScale) {
        if (!arguments.length) {
            return this._r;
        }
        this._r = bubbleRadiusScale;
        return this;
    }

    /**
         * Turn on or off the elastic bubble radius feature, or return the value of the flag. If this
         * feature is turned on, then bubble radii will be automatically rescaled to fit the chart better.
         * @memberof BubbleMixin
         * @instance
         * @param {Boolean} [elasticRadius=false]
         * @returns {Boolean|BubbleChart}
         */
    elasticRadius (elasticRadius) {
        if (!arguments.length) {
            return this._elasticRadius;
        }
        this._elasticRadius = elasticRadius;
        return this;
    }

    calculateRadiusDomain () {
        if (this._elasticRadius) {
            this.r().domain([this.rMin(), this.rMax()]);
        }
    }

    /**
         * Get or set the radius value accessor function. If set, the radius value accessor function will
         * be used to retrieve a data value for each bubble. The data retrieved then will be mapped using
         * the r scale to the actual bubble radius. This allows you to encode a data dimension using bubble
         * size.
         * @memberof BubbleMixin
         * @instance
         * @param {Function} [radiusValueAccessor]
         * @returns {Function|BubbleMixin}
         */
    radiusValueAccessor (radiusValueAccessor) {
        if (!arguments.length) {
            return this._rValueAccessor;
        }
        this._rValueAccessor = radiusValueAccessor;
        return this;
    }

    rMin () {
        let values = this.data().map(this.radiusValueAccessor());
        if(this._excludeElasticZero) {
            values = values.filter(value => value > 0);
        }
        return min(values);
    }

    rMax () {
        return max(this.data(), e => this.radiusValueAccessor()(e));
    }

    bubbleR (d) {
        const value = this.radiusValueAccessor()(d);
        let r = this.r()(value);
        if (isNaN(r) || value <= 0) {
            r = 0;
        }
        return r;
    }

    _labelFunction (d) {
        return this.label()(d);
    }

    _shouldLabel (d) {
        return (this.bubbleR(d) > this._minRadiusWithLabel);
    }

    _labelOpacity (d) {
        return this._shouldLabel(d) ? 1 : 0;
    }

    _labelPointerEvent (d) {
        return this._shouldLabel(d) ? 'all' : 'none';
    }

    _doRenderLabel (bubbleGEnter) {
        if (this.renderLabel()) {
            let label = bubbleGEnter.select('text');

            if (label.empty()) {
                label = bubbleGEnter.append('text')
                        .attr('text-anchor', 'middle')
                        .attr('dy', '.3em')
                        .on('click', d3compat.eventHandler(d => this.onClick(d)));
            }

            label
                    .attr('opacity', 0)
                    .attr('pointer-events', d => this._labelPointerEvent(d))
                    .text(d => this._labelFunction(d));
            transition(label, this.transitionDuration(), this.transitionDelay())
                    .attr('opacity', d => this._labelOpacity(d));
        }
    }

    doUpdateLabels (bubbleGEnter) {
        if (this.renderLabel()) {
            const labels = bubbleGEnter.select('text')
                    .attr('pointer-events', d => this._labelPointerEvent(d))
                    .text(d => this._labelFunction(d));
            transition(labels, this.transitionDuration(), this.transitionDelay())
                    .attr('opacity', d => this._labelOpacity(d));
        }
    }

    _titleFunction (d) {
        return this.title()(d);
    }

    _doRenderTitles (g) {
        if (this.renderTitle()) {
            const title = g.select('title');

            if (title.empty()) {
                g.append('title').text(d => this._titleFunction(d));
            }
        }
    }

    doUpdateTitles (g) {
        if (this.renderTitle()) {
            g.select('title').text(d => this._titleFunction(d));
        }
    }

    /**
         * Turn on or off the bubble sorting feature, or return the value of the flag. If enabled,
         * bubbles will be sorted by their radius, with smaller bubbles in front.
         * @memberof BubbleChart
         * @instance
         * @param {Boolean} [sortBubbleSize=false]
         * @returns {Boolean|BubbleChart}
         */
    sortBubbleSize (sortBubbleSize) {
        if (!arguments.length) {
            return this._sortBubbleSize;
        }
        this._sortBubbleSize = sortBubbleSize;
        return this;
    }

    /**
         * Get or set the minimum radius. This will be used to initialize the radius scale's range.
         * @memberof BubbleMixin
         * @instance
         * @param {Number} [radius=10]
         * @returns {Number|BubbleMixin}
         */
    minRadius (radius) {
        if (!arguments.length) {
            return this.MIN_RADIUS;
        }
        this.MIN_RADIUS = radius;
        return this;
    }

    /**
         * Get or set the minimum radius for label rendering. If a bubble's radius is less than this value
         * then no label will be rendered.
         * @memberof BubbleMixin
         * @instance
         * @param {Number} [radius=10]
         * @returns {Number|BubbleMixin}
         */

    minRadiusWithLabel (radius) {
        if (!arguments.length) {
            return this._minRadiusWithLabel;
        }
        this._minRadiusWithLabel = radius;
        return this;
    }

    /**
         * Get or set the maximum relative size of a bubble to the length of x axis. This value is useful
         * when the difference in radius between bubbles is too great.
         * @memberof BubbleMixin
         * @instance
         * @param {Number} [relativeSize=0.3]
         * @returns {Number|BubbleMixin}
         */
    maxBubbleRelativeSize (relativeSize) {
        if (!arguments.length) {
            return this._maxBubbleRelativeSize;
        }
        this._maxBubbleRelativeSize = relativeSize;
        return this;
    }

    /**
     * Should the chart exclude zero when calculating elastic bubble radius?
     * @memberof BubbleMixin
     * @instance
     * @param  {Boolean} [excludeZero=true]
     * @returns {Boolean|BubbleMixin}
     */
    excludeElasticZero (excludeZero) {
        if (!arguments.length) {
            return this._excludeElasticZero;
        }
        this._excludeElasticZero = excludeZero;
        return this;
    }

    fadeDeselectedArea (selection) {
        if (this.hasFilter()) {
            const chart = this;
            this.selectAll(`g.${chart.BUBBLE_NODE_CLASS}`).each(function (d) {
                if (chart.isSelectedNode(d)) {
                    chart.highlightSelected(this);
                } else {
                    chart.fadeDeselected(this);
                }
            });
        } else {
            const chart = this;
            this.selectAll(`g.${chart.BUBBLE_NODE_CLASS}`).each(function () {
                chart.resetHighlight(this);
            });
        }
    }

    isSelectedNode (d) {
        return this.hasFilter(d.key);
    }

    onClick (d) {
        const filter = d.key;
        events.trigger(() => {
            this.filter(filter);
            this.redrawGroup();
        });
    }
};