ChartJS line chart calls plugin each time I hover a point - performance

I am using ChartJS v2.9.3.
I am trying to add to the time line chart a conditional vertical background color that is displayed depending on if dataset's y-value is above a threshold.
The thread Background colour of line charts in chart.js gave me the idea to use the following plugin
const backgroundPlugin = {
beforeDatasetDraw(chart, easing) {
const ctx = chart.chart.ctx;
const chartArea = chart.chartArea;
ctx.save();
const colors = [
'red',
'green',
'yellow',
'blue'
];
const randomNumber = Math.floor(Math.random() * colors.length);
// Set random background color to show how many times this function is called
ctx.fillStyle = colors[randomNumber];
console.log('refreshing background');
chart.getDatasetMeta(0).data.forEach((data, index, all) => {
// Check if value > threshold (here 350)
if (
chart.config.data.datasets[0].data[index].y > 350
) {
const nextPointX =
index === all.length - 1 ?
chartArea.left :
all[index + 1]._model.x;
// Create vertical rectangle of color between current point and next point
ctx.fillRect(
nextPointX,
chartArea.top,
Math.abs(all[index]._model.x - nextPointX),
chartArea.bottom - chartArea.top
);
}
});
ctx.restore();
}
};
The problem I observed is that every time I hover on one point of the line chart, the plugin's beforeDatasetDraw function gets called a huge number of time.
The problem can be seen on this JSFiddle : https://jsfiddle.net/avocado1/hgpukame/88/#
This is a problem for me because in my real case that causes a performance issue (I work with a more than 20.000 points datasets and I would like to have 5 different background colors depending on 5 different thresholds, meaning there could be 5 comparisons for each point of my dataset).
Would anyone have any clue on how to prevent hovering events to cause calls to my plugin ? (Or have any workaround to create a conditional background once and for all ?)

Related

Histogram based on "reduceSummed" groups

I have CSV data with the following pattern:
Quarter,productCategory,unitsSold
2018-01-01,A,21766
2018-01-01,B,10076
2018-01-01,C,4060
2018-04-01,A,27014
2018-04-01,B,12219
2018-04-01,C,4740
2018-07-01,A,29503
2018-07-01,B,13020
2018-07-01,C,5549
2018-10-01,A,3796
2018-10-01,B,15110
2018-10-01,C,6137
2019-01-01,A,25008
2019-01-01,B,11655
2019-01-01,C,4630
2019-04-01,A,31633
2019-04-01,B,14837
2019-04-01,C,5863
2019-07-01,A,33813
2019-07-01,B,15442
2019-07-01,C,6293
2019-10-01,A,35732
2019-10-01,B,19482
2019-10-01,C,6841
As you can see, there are 3 product categories sold every day. I can make a histogram and count how many Quarters are involved per bin of unitsSold. The problem here is that every Quarter is counted separately. What I would like is a histogram where the bins of unitsSold are already grouped with a reduceSum on the Quarter.
This would result in something like this:
Quarter, unitsSold
2018-01-01,35902
2018-04-01,43973
2018-07-01,48072
2018-10-01,25043
2019-01-01,41293
2019-04-01,52333
2019-07-01,55548
2019-10-01,62055
Where, based on the bins of unitsSold, a number of Quarters would fall into. For example a bin of 50.000 - 70.000 would count 3 Quarters (2019-04-01, 2019-07-01 and 2019-10-01)
Normally I would do something like this:
const histogramChart = new dc.BarChart('#histogram');
const histogramDim = ndx.dimension(d => Math.round(d.unitsSold / binSize) * binSize);
const histogramGroup = histogramDim.group().reduceCount();
But in the desired situation the histogram is kind of created on something that has already been "reducedSummed". Ending up in a barchart histogram like this (data does not match with this example):
How can this be done with dc.js/crossfilter.js?
Regrouping the data by value
I think the major difference between your question and this previous question is that you want to bin the data when you "regroup" it. (Sometimes this is called a "double reduce"... no clear names for this stuff.)
Here's one way to do that, using an offset and width:
function regroup(group, width, offset = 0) {
return {
all: function() {
const bins = {};
group.all().forEach(({key, value}) => {
const bin = Math.floor((value - offset) / width);
bins[bin] = (bins[bin] || 0) + 1;
});
return Object.entries(bins).map(
([bin, count]) => ({key: bin*width + offset, value: count}));
}
}
}
What we do here is loop through the original group and
map each value to its bin number
increment the count for that bin number, or start at 1
map the bins back to original numbers, with counts
Testing it out
I displayed your original data with the following chart (too lazy to figure out quarters, although I think it's not hard with recent D3):
const quarterDim = cf.dimension(({Quarter}) => Quarter),
unitsGroup = quarterDim.group().reduceSum(({unitsSold}) => unitsSold);
quarterChart.width(300)
.height(200)
.margins({left: 50, top: 0, right: 0, bottom: 20})
.dimension(quarterDim)
.group(unitsGroup)
.x(d3.scaleTime().domain([d3.min(data, d => d.Quarter), d3.timeMonth.offset(d3.max(data, d => d.Quarter), 3)]))
.elasticY(true)
.xUnits(d3.timeMonths);
and the new chart with
const rg = regroup(unitsGroup, 10000);
countQuartersChart.width(500)
.height(200)
.dimension({})
.group(rg)
.x(d3.scaleLinear())
.xUnits(dc.units.fp.precision(10000))
.elasticX(true)
.elasticY(true);
(Note the empty dimension, which disables filtering. Filtering may be possible but you have to map back to the original dimension keys so I’m skipping that for now.)
Here are the charts I get, which look correct at a glance:
Demo fiddle.
Adding filtering to the chart
To implement filtering on this "number of quarters by values" histogram, first let's enable filtering between the by-values chart and the quarters chart by putting the by-values chart on its own dimension:
const quarterDim2 = cf.dimension(({Quarter}) => Quarter),
unitsGroup2 = quarterDim2.group().reduceSum(({unitsSold}) => unitsSold);
const byvaluesGroup = regroup(unitsGroup2, 10000);
countQuartersChart.width(500)
.height(200)
.dimension(quarterDim2)
.group(byvaluesGroup)
.x(d3.scaleLinear())
.xUnits(dc.units.fp.precision(10000))
.elasticX(true)
.elasticY(true);
Then, we implement filtering with
countQuartersChart.filterHandler((dimension, filters) => {
if(filters.length === 0)
dimension.filter(null);
else {
console.assert(filters.length === 1 && filters[0].filterType === 'RangedFilter');
const range = filters[0];
const included_quarters = unitsGroup2.all()
.filter(({value}) => range[0] <= value && value < range[1])
.map(({key}) => key.getTime());
dimension.filterFunction(k => included_quarters.includes(k.getTime()));
}
return filters;
});
This finds all quarters in unitsGroup2 that have a value which falls in the range. Then it sets the dimension's filter to accept only the dates of those quarters.
Odds and ends
Quarters
D3 supports quarters with interval.every:
const quarterInterval = d3.timeMonth.every(3);
chart.xUnits(quarterInterval.range);
Eliminating the zeroth bin
As discussed in the comments, when other charts have filters active, there may end up being many quarters with less than 10000 units sold, resulting in a very tall zero bar which distorts the chart.
The zeroth bin can be removed with
delete bins[0];
before the return in regroup()
Rounding the by-values brush
If snapping to the bars is desired, you can enable it with
.round(x => Math.round(x/10000)*10000)
Otherwise, the filtered range can start or end inside of a bar, and the way the bars are colored when brushed is somewhat inaccurate as seen below.
Here's the new fiddle.

d3 synchronizing 2 separate zoom behaviors

I have the following d3/d3fc chart
https://codepen.io/parliament718/pen/BaNQPXx
The chart has a zoom behavior for the main area and a separate zoom behavior for the y-axis.
The y-axis can be dragged to rescale.
The problem I'm having trouble solving is that after dragging the y-axis to rescale and then subsequently panning the chart, there is a "jump" in the chart.
Obviously the 2 zoom behaviors have a disconnect and need to be synchronized but I'm racking my brain trying to fix this.
const mainZoom = zoom()
.on('zoom', () => {
xScale.domain(t.rescaleX(x2).domain());
yScale.domain(t.rescaleY(y2).domain());
});
const yAxisZoom = zoom()
.on('zoom', () => {
const t = event.transform;
yScale.domain(t.rescaleY(y2).domain());
render();
});
const yAxisDrag = drag()
.on('drag', (args) => {
const factor = Math.pow(2, -event.dy * 0.01);
plotArea.call(yAxisZoom.scaleBy, factor);
});
The desired behavior is for zooming, panning, and/or rescaling the axis to always apply the transformation from wherever the previous action finished, without any "jumps".
OK, so I've had another go at this - as mentioned in my previous answer, the biggest issue you need to overcome is that the d3-zoom only permits symmetrical scaling. This is something that has been widely discussed, and I believe Mike Bostock is addressing this in the next release.
So, in order to overcome the issue, you need to use multiple zoom behaviour. I have created a chart that has three, one for each axis and one for the plot area. The X & Y zoom behaviours are used to scale the axes. Whenever a zoom event is raised by the X & Y zoom behaviours, their translation values are copied across to the plot area. Likewise, when a translation occurs on the plot area, the x & y components are copied to the respective axis behaviours.
Scaling on the plot area is a little more complicated as we need to maintain the aspect ratio. In order to achieve this I store the previous zoom transform and use the scale delta to work out a suitable scale to apply to the X & Y zoom behaviours.
For convenience, I've wrapped all of this up into a chart component:
const interactiveChart = (xScale, yScale) => {
const zoom = d3.zoom();
const xZoom = d3.zoom();
const yZoom = d3.zoom();
const chart = fc.chartCartesian(xScale, yScale).decorate(sel => {
const plotAreaNode = sel.select(".plot-area").node();
const xAxisNode = sel.select(".x-axis").node();
const yAxisNode = sel.select(".y-axis").node();
const applyTransform = () => {
// apply the zoom transform from the x-scale
xScale.domain(
d3
.zoomTransform(xAxisNode)
.rescaleX(xScaleOriginal)
.domain()
);
// apply the zoom transform from the y-scale
yScale.domain(
d3
.zoomTransform(yAxisNode)
.rescaleY(yScaleOriginal)
.domain()
);
sel.node().requestRedraw();
};
zoom.on("zoom", () => {
// compute how much the user has zoomed since the last event
const factor = (plotAreaNode.__zoom.k - plotAreaNode.__zoomOld.k) / plotAreaNode.__zoomOld.k;
plotAreaNode.__zoomOld = plotAreaNode.__zoom;
// apply scale to the x & y axis, maintaining their aspect ratio
xAxisNode.__zoom.k = xAxisNode.__zoom.k * (1 + factor);
yAxisNode.__zoom.k = yAxisNode.__zoom.k * (1 + factor);
// apply transform
xAxisNode.__zoom.x = d3.zoomTransform(plotAreaNode).x;
yAxisNode.__zoom.y = d3.zoomTransform(plotAreaNode).y;
applyTransform();
});
xZoom.on("zoom", () => {
plotAreaNode.__zoom.x = d3.zoomTransform(xAxisNode).x;
applyTransform();
});
yZoom.on("zoom", () => {
plotAreaNode.__zoom.y = d3.zoomTransform(yAxisNode).y;
applyTransform();
});
sel
.enter()
.select(".plot-area")
.on("measure.range", () => {
xScaleOriginal.range([0, d3.event.detail.width]);
yScaleOriginal.range([d3.event.detail.height, 0]);
})
.call(zoom);
plotAreaNode.__zoomOld = plotAreaNode.__zoom;
// cannot use enter selection as this pulls data through
sel.selectAll(".y-axis").call(yZoom);
sel.selectAll(".x-axis").call(xZoom);
decorate(sel);
});
let xScaleOriginal = xScale.copy(),
yScaleOriginal = yScale.copy();
let decorate = () => {};
const instance = selection => chart(selection);
// property setters not show
return instance;
};
Here's a pen with the working example:
https://codepen.io/colineberhardt-the-bashful/pen/qBOEEGJ
There are a couple of issues with your code, one which is easy to solve, and one which is not ...
Firstly, the d3-zoom works by storing a transform on the selected DOM element(s) - you can see this via the __zoom property. When the user interacts with the DOM element, this transform is updated and events emitted. Therefore, if you have to different zoom behaviours both of which are controlling the pan / zoom of a single element, you need to keep these transforms synchronised.
You can copy the transform as follows:
selection.call(zoom.transform, d3.event.transform);
However, this will also cause zoom events to be fired from the target behaviour also.
An alternative is to copy directly to the 'stashed' transform property:
selection.node().__zoom = d3.event.transform;
However, there is a bigger problem with what you are trying to achieve. The d3-zoom transform is stored as 3 components of a transformation matrix:
https://github.com/d3/d3-zoom#zoomTransform
As a result, the zoom can only represent a symmetrical scaling together with a translation. Your asymmetrical zoom as a applied to the x-axis cannot be faithfully represented by this transform and re-applied to the plot-area.
This is an upcoming feature, as already noted by #ColinE. The original code is always doing a "temporal zoom" that is un-synced from the transform matrix.
The best workaround is to tweak the xExtent range so that the graph believes that there are additional candles on the sides. This can be achieved by adding pads to the sides. The accessors, instead of being,
[d => d.date]
becomes,
[
() => new Date(taken[0].date.addDays(-xZoom)), // Left pad
d => d.date,
() => new Date(taken[taken.length - 1].date.addDays(xZoom)) // Right pad
]
Sidenote: Note that there is a pad function that should do that but for some reason it works only once and never updates again that's why it is added as an accessors.
Sidenote 2: Function addDays added as a prototype (not the best thing to do) just for simplicity.
Now the zoom event modifies our X zoom factor, xZoom,
zoomFactor = Math.sign(d3.event.sourceEvent.wheelDelta) * -5;
if (zoomFactor) xZoom += zoomFactor;
It is important to read the differential directly from wheelDelta. This is where the unsupported feature is: We can't read from t.x as it will change even if you drag the Y axis.
Finally, recalculate chart.xDomain(xExtent(data.series)); so that the new extent is available.
See the working demo without the jump here: https://codepen.io/adelriosantiago/pen/QWjwRXa?editors=0011
Fixed: Zoom reversing, improved behaviour on trackpad.
Technically you could also tweak yExtent by adding extra d.high and d.low's. Or even both xExtent and yExtent to avoid using the transform matrix at all.
A solution is given here https://observablehq.com/#d3/x-y-zoom
It uses a main zoom behavior that gets the gestures, and two ancillary zooms that store the transforms.

Using CreateJS to apply an alpha tween to a drawn line

I'm trying to take a users mouse/touch drawn line and then have it alpha fade out the result using a tween. The problem is when cap and joint style are set to rounded then joint point fades behind the rest of the line. It looks fine when set to miter or bevel.
What I want is a smooth solid fade of the shape. Any ideas?
Fiddle: http://jsfiddle.net/mcfarljw/ZNGK2/
Function for drawing the line based on user input:
function handleMouseMove(event) {
var midPt = new createjs.Point(oldPt.x + stage.mouseX >> 1, oldPt.y + stage.mouseY >> 1);
drawingCanvas.graphics.setStrokeStyle(stroke, 'round', 'round').beginStroke(color).moveTo(midPt.x, midPt.y).curveTo(oldPt.x, oldPt.y, oldMidPt.x, oldMidPt.y);
oldPt.x = stage.mouseX;
oldPt.y = stage.mouseY;
oldMidPt.x = midPt.x;
oldMidPt.y = midPt.y;
stage.update();
}
Tween applied to the shape after line is finished:
createjs.Tween.get(drawingCanvas).to({
alpha: 0
}, 2000).call(function() {
drawingCanvas.alpha = 1;
drawingCanvas.graphics.clear();
});
You'll want to cache the whole shape before fading it out. See the updates I have made to the fiddle. Mainly, take a look at line 52 on the handleMouseUp event.
drawingCanvas.cache(0, 0, 800, 800);
Then, when your fade is complete. Make sure to uncache before showing the object again. Otherwise your graphics.clear() won't work.
drawingCanvas.uncache();

Adding a filter in dc.js / Crossfilter not updating the chart

jsFiddle:
http://jsfiddle.net/PYeFP/
I have a bar chart set up that graphs a users number of trips by day
tripVolume = dc.barChart("#trip-volume")
.width(980) // (optional) define chart width, :default = 200
.height(75) // (optional) define chart height, :default = 200
.transitionDuration(0) // (optional) define chart transition duration, :default = 500
.margins({ top: 10, right: 50, bottom: 30, left: 40 })
.dimension(tripsByDateDimension) // set dimension
.group(tripsByDateGroup) // set group
// (optional) whether chart should rescale y axis to fit data, :default = false
.elasticY(false)
// (optional) whether chart should rescale x axis to fit data, :default = false
.elasticX(false)
// define x scale
.x(d3.time.scale().domain([tripsByDateDimension.bottom(1)[0].startDate, tripsByDateDimension.top(1)[0].startDate ]))
// (optional) set filter brush rounding
.round(d3.time.day.round)
// define x axis units
.xUnits(d3.time.days)
// (optional) whether bar should be center to its x value, :default=false
.centerBar(true)
// (optional) render horizontal grid lines, :default=false
.renderHorizontalGridLines(true)
// (optional) render vertical grid lines, :default=false
.renderVerticalGridLines(true)
.brushOn(false);
The graph displays fine but I would like to filter it using some jQuery controls.
When the user selects the date I am trying to add a filter to the chart, the filter gets added but the chart does not change, even if I redraw() or render().
This is how the crossfilter is setup:
tripsCx = crossfilter(data.rows);
var allTripsGroup = tripsCx.groupAll();
var tripsByDateDimension = tripsCx.dimension(function (d) { return d.startDate; });
var tripsByDateGroup = tripsByDateDimension.group(d3.time.day);
The following are some of the methods I have used to try to apply a filter:
This should use the filterRange:
d.filter(d.dimension().top(20)[19], d.dimension().top(20)[0]);
FilterFunction:
d.filter(function (d) {
return d.getTime() > start.valueOf() && d.getTime() < end.valueOf();
});
FilterExact:
d.filter(d.dimension().top(20)[0]);
I also tried bypassing the chart and applying the filter directly on the dimension:
d.dimension().filterFunction(function (d) {
return d.getTime() > start.valueOf() && d.getTime() < end.valueOf()
});
Nothing I have done causes the chart to change.
I am beginning to think that I have the wrong expectation of what the filter function should do?
How can I manually filter the data in the dimension to have the chart updated?
I don't want to use a brush.
I will be filtering the data based on different criteria, I'm just trying to get the simple case working first.
I've spent a couple of days on this now and I'm at a loss as to what to try next.
Any help would be greatly appreciated.
Have you tried to reset your x property of the graph after setting the crossfilter filter
I have a somewhat similar case and what I do after each action that changes the filtered values is something along the lines of
.x(..).dimension(...).group(...)
after creating/setting the filters
Tried to do something like that
$('#filter').on('click', function(){
var minDate = tripsByDateDimension.top(5)[4].startDate;
var maxDate = tripsByDateDimension.top(5)[0].startDate;
console.log(tripVolume.filters());
tripVolume.filter([minDate, maxDate]);
tripVolume.x(d3.time.scale().domain([minDate,maxDate]));
console.log(tripVolume.filters());
dc.redrawAll()
});
http://jsfiddle.net/PYeFP/5/
Better answer per the discussion in the comment is to add the filter to the dimension, not the chart
Finally, one needs to realize what is mentioned in https://github.com/square/crossfilter/wiki/API-Reference#group-map-reduce
Note: a grouping intersects the crossfilter's current filters, except for the associated dimension's filter. Thus, group methods consider only records that satisfy every filter except this dimension's filter. So, if the crossfilter of payments is filtered by type and total, then group by total only observes the filter by type.
(also see https://groups.google.com/d/msg/dc-js-user-group/UFxvUND7hmY/btbAjqIIzl8J)

Is an NVD3 Line Plot with Markers Possible?

I'm making an NVD3 line plot that will have significantly improved clarity if I can get markers to show for each data point instead of just the line itself. Unfortunately, I haven't been able to find an easy way to do this with NVD3 yet. I also considered using a scatter plot, but I couldn't figure out how to show connecting lines between the points. A third option I considered was to overlay a line and scatter plot, but this would show each series twice in the legend and may cause other unnecessary visual complications.
Is there a way to elegantly pull this off yet? Sample code of my formatting technique is listed below, but the 'size' and 'shape' attributes in test_data have no effect on the line plot with the current code.
test_data = [ { key: 'series1',
values: [
{ x: 1, y: 2.33, size:5, shape:"circle" },
{ x: 2, y: 2.34, size:5, shape:"circle" },
{ x: 3, y: 2.03, size:5, shape:"circle" },
] } ];
nv.addGraph(function() {
var test_chart = nv.models.lineChart();
test_chart.xAxis.axisLabel('Sample Number');
test_chart.yAxis
.axisLabel('Voltage (V)')
.tickFormat(d3.format('.02f'));
d3.select('#test_plot')
.datum(test_data)
.transition().duration(500)
.call(test_chart);
nv.utils.windowResize(test_chart.update);
return test_chart;
});
I also wanted to add markers in a project I was working on. Here is a solution my partner and I found.
First, you have to select all of the points in your chart and set the fill-opacity to 1:
#my-chart .nv-lineChart circle.nv-point
{
fill-opacity: 1;
}
Now your points will be visible. To adjust the size of each point you need to modify each one's "r" (for radius) attribute. This isn't a style so you can't do it with css. Here is some jQuery code that does the job. The 500 millisecond delay is so the code will not run before the chart is rendered. This snippet sets the radius to 3.5:
setTimeout(function() {
$('#my-chart .nv-lineChart circle.nv-point').attr("r", "3.5");
}, 500);
This puzzled me until I got help from the community:
css styling of points in figure
So here is my solution, based on css:
.nv-point {
stroke-opacity: 1!important;
stroke-width: 5px!important;
fill-opacity: 1!important;
}
If anyone has come here from rCharts, below is a rmarkdown template to create an nPlot with both lines and markers:
```{r 'Figure'}
require(rCharts)
load("data/df.Rda")
# round data for rChart tooltip display
df$value <- round(df$value, 2)
n <- nPlot(value ~ Year, group = 'variable', data = df, type = 'lineChart')
n$yAxis(axisLabel = 'Labor and capital income (% national income)')
n$chart(margin = list(left = 100)) # margin makes room for label
n$yAxis(tickFormat = "#! function(d) {return Math.round(d*100*100)/100 + '%'} !#")
n$xAxis(axisLabel = 'Year')
n$chart(useInteractiveGuideline=TRUE)
n$chart(color = colorPalette)
n$addParams(height = 500, width = 800)
n$setTemplate(afterScript = '<style>
.nv-point {
stroke-opacity: 1!important;
stroke-width: 6px!important;
fill-opacity: 1!important;
}
</style>'
)
n$save('figures/Figure.html', standalone = TRUE)
```
The current version of nvd3 use path instead of circle to draw markers. Here is a piece of css code that i used to show markers.
#chart g.nv-scatter g.nv-series-0 path.nv-point
{
fill-opacity: 1;
stroke-opacity: 1;
}
And I also write something about this in https://github.com/novus/nvd3/issues/321, you could find that how i change the shape of makers.
I don't know how to change the size of markers. Trying to find a solution.
Selectively enable points to some series using the following logic in nvd3.
//i is the series number; starts with 0
var selector = 'g.nv-series-'+i+' circle';
d3.selectAll(selector).classed("hover",true);
However an additional parameter( like say 'enable_points':'true') in the data would make better sense. I will hopefully push some changes to nvd3 with this idea.
For current version of NVD3 (1.8.x), I use this D3-based solution (scripting only, no CSS file or style block required):
nv.addGraph(function() {
// ...
return chart;
},
function() {
// this function is called after the chart is added to document
d3.selectAll('#myChart .nv-lineChart .nv-point').style("stroke-width",
"7px").style("fill-opacity", ".95").style("stroke-opacity", ".95");
}
);
The styles used are exactly the styles added by NVD3 by applying the "hover" class to each point (when hovered). Adjust them to your needs.

Resources