Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Canvas based scatterPlot implementation #1361

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 243 additions & 47 deletions src/scatter-plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ dc.scatterPlot = function (parent, chartGroup) {
var _nonemptyOpacity = 1;
var _emptyColor = null;
var _filtered = [];
var _canvas = null;
var _context = null;
var _useCanvas = false;

// Calculates element radius for canvas plot to be comparable to D3 area based symbol sizes
function canvasElementSize (d, isFiltered) {
if (!_existenceAccessor(d)) {
return _emptySize / Math.sqrt(Math.PI);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised by Math.sqrt(Math.PI) here. How did you ever figure this out?

I checked in the debugger and yes that's the factor by which the d3.svg.symbol-generated arc differs from the radius.

I always thought they were coming out too small!

I bet the assumption in elementSize that we should square the radius in order to get d3.svg.symbol.size is wrong. So it's easy to see being off by a factor of π but the sqrt is really confusing.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, yeah, I had the same issue initially. I believe it is because the D3 docs mention that the supplied symbol size should specify the area of the symbol, not the radius.

For circles, since the area is Math.PI * (r^2) when we define the radius of the symbol in dc.js, the method elementSize() should actually return Math.pow(_emptySize, 2) * Math.PI. But since it doesn't do this, the actual radius of the D3 symbol that gets plotted in the dc.js chart is off by a factor of sqrt(Math.PI). Hence the correction by that factor.

I also believe that for all the other symbol types, D3 still relies on a circle based area calculation with the symbol extents guaranteed to lie within the circle defined by the provided area parameter. This block seems to support this guess - https://bl.ocks.org/mbostock/6d9d75ee13abbcfea6e0

So if other symbol types get implemented for canvas plots, the method for drawing the canvas symbol would need to make sure that each symbol type respects the perimeter defined by the circle.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That example shows that the radius varies a lot for the same area depending on the shape.

So dc.js is definitely doing this wrong. The API names are correct but it should be using the size directly for d3.svg.symbol and not squaring it as if it's a radius (and getting the calculation wrong).

Would you be willing to fix the original problem and document the API change, so that the new code is not compounding the problem by introducing an obscure calculation in order to be compatible with an incorrect calculation? 😄

API changes are fine in the 2.1 branch.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doh! Didn't notice that the example had identical symbol sizes. I just assumed that the way I had it in my head was how it would be implemented since that makes the most sense as the different symbols would then look to have a similar perceptual size for the same area parameter.

I don't mind implementing a fix that switches to size being defined as the area parameter as per the d3 docs for d3.symbol. Did you envision that being part of this PR or as a separate PR?

} else if (isFiltered) {
return _symbolSize / Math.sqrt(Math.PI);
} else {
return _excludedSize / Math.sqrt(Math.PI);
}
}

function elementSize (d, i) {
if (!_existenceAccessor(d)) {
Expand All @@ -74,48 +88,222 @@ dc.scatterPlot = function (parent, chartGroup) {
return _chart.__filter(dc.filters.RangedTwoDimensionalFilter(filter));
});

_chart.plotData = function () {
var symbols = _chart.chartBodyG().selectAll('path.symbol')
.data(_chart.data());
_chart._resetSvgOld = _chart.resetSvg; // Copy original closure from base-mixin

symbols
.enter()
.append('path')
.attr('class', 'symbol')
.attr('opacity', 0)
.attr('fill', _chart.getColor)
.attr('transform', _locator);
/**
* 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.
* @method resetSvg
* @memberof dc.scatterPlot
* @instance
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement}
* @returns {SVGElement}
*/
_chart.resetSvg = function () {
if (!_useCanvas) {
return _chart._resetSvgOld();
} else {
_chart._resetSvgOld(); // Perform original svgReset inherited from baseMixin
_chart.select('canvas').remove(); // remove old canvas

var svgSel = _chart.svg();
var rootSel = _chart.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
var svgLeft = isNaN(parseInt(svgSel.style('left'), 10)) ? 0 : parseInt(svgSel.style('left'), 10);
var svgTop = isNaN(parseInt(svgSel.style('top'), 10)) ? 0 : parseInt(svgSel.style('top'), 10);
var width = _chart.effectiveWidth();
var height = _chart.effectiveHeight();
var margins = _chart.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
var devicePixelRatio = window.devicePixelRatio || 1;
_canvas = _chart.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
_context = _canvas.node().getContext('2d');
_context.scale(devicePixelRatio, devicePixelRatio);
_context.rect(0, 0, width, height);
_context.clip(); // Setup clipping path
_context.imageSmoothingQuality = 'high';

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

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

symbols.each(function (d, i) {
_filtered[i] = !_chart.filter() || _chart.filter().isFiltered([d.key[0], d.key[1]]);
});
/**
* 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 dc.scatterPlot#useCanvas useCanvas} is set to `true`
* @method canvas
* @memberof dc.scatterPlot
* @instance
* @param {CanvasElement|d3.selection} [canvasElement]
* @return {CanvasElement|d3.selection}
*/
_chart.canvas = function (canvasElement) {
if (!arguments.length) {
return _canvas;
}
_canvas = canvasElement;
return _chart;
};

dc.transition(symbols, _chart.transitionDuration(), _chart.transitionDelay())
.attr('opacity', function (d, i) {
if (!_existenceAccessor(d)) {
return _emptyOpacity;
} else if (_filtered[i]) {
return _nonemptyOpacity;
} else {
return _chart.excludedOpacity();
/**
* Get canvas 2D context. Provides valid context only when
* {@link dc.scatterPlot#useCanvas useCanvas} is set to `true`
* @method context
* @memberof dc.scatterPlot
* @instance
* @return {CanvasContext}
*/
_chart.context = function () {
return _context;
};

// 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
function plotOnCanvas (legendHighlightDatum) {
var context = _chart.context();
context.clearRect(0, 0, (context.canvas.width + 2) * 1, (context.canvas.height + 2) * 1);
var data = _chart.data();

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

// Adjust params for data points if legend is highlighted
if (legendHighlightDatum) {
var isHighlighted = (cColor === legendHighlightDatum.color);
// Calculate opacity for current data point
var fadeOutOpacity = 0.1; // TODO: Make this programmatically setable
if (!isHighlighted) { // Fade out non-highlighted colors + highlighted colors outside filter
cOpacity = fadeOutOpacity;
}
})
.attr('fill', function (d, i) {
if (_emptyColor && !_existenceAccessor(d)) {
return _emptyColor;
} else if (_chart.excludedColor() && !_filtered[i]) {
return _chart.excludedColor();
} else {
return _chart.getColor(d);
if (isHighlighted) { // Set size for highlighted color data points
cSize = _highlightedSize / Math.sqrt(Math.PI);
}
})
.attr('transform', _locator)
.attr('d', _symbol);
}

// Draw point on canvas
context.save();
context.globalAlpha = cOpacity;
context.beginPath();
context.arc(_chart.x()(_chart.keyAccessor()(d)), _chart.y()(_chart.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();
});
}

dc.transition(symbols.exit(), _chart.transitionDuration(), _chart.transitionDelay())
.attr('opacity', 0).remove();
_chart.plotData = function () {
if (_useCanvas) {
plotOnCanvas();
} else {
var symbols = _chart.chartBodyG().selectAll('path.symbol')
.data(_chart.data());

symbols
.enter()
.append('path')
.attr('class', 'symbol')
.attr('opacity', 0)
.attr('fill', _chart.getColor)
.attr('transform', _locator);

symbols.call(renderTitles, _chart.data());

symbols.each(function (d, i) {
_filtered[i] = !_chart.filter() || _chart.filter().isFiltered([d.key[0], d.key[1]]);
});

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

dc.transition(symbols.exit(), _chart.transitionDuration(), _chart.transitionDelay())
.attr('opacity', 0).remove();
}
};

function renderTitles (symbol, d) {
Expand Down Expand Up @@ -357,21 +545,29 @@ dc.scatterPlot = function (parent, chartGroup) {
};

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

_chart.legendReset = function (d) {
resizeSymbolsWhere(function (symbol) {
return symbol.attr('fill') === d.color;
}, _symbolSize);
_chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {
return d3.select(this).attr('fill') !== d.color;
}).classed('fadeout', false);
if (_useCanvas) {
plotOnCanvas();
} else {
resizeSymbolsWhere(function (symbol) {
return symbol.attr('fill') === d.color;
}, _symbolSize);
_chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {
return d3.select(this).attr('fill') !== d.color;
}).classed('fadeout', false);
}
};

function resizeSymbolsWhere (condition, size) {
Expand Down
Loading