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