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