I’ve created a line chart in Chart.js that updates every second with new values. However, there's a small issue: when a new value is added, the animation starts from the bottom and moves up to the correct line position. How can I prevent the end of the line from dropping down before reaching the new value?
Video demo: https://jam.dev/c/a928a2f1-d467-4ed5-b7ec-339b480eabc9
You can do that by setting animations.x.from
and animations.y.from
callbacks; they are called for each point of the chart
so case has to be taken to identify the last datapoint that was just added. A good starting point is the position of the
pre-animation last point, which is in now the penultimate point since the new point was already added to the dataset:
const fromX = function(context){
if(!context.element){
return;
}
if(context.dataIndex === context.dataset.data.length -1){
const prevValue = context.chart.data.labels.length - 2;
// penultimate element; for a category axis, "value" is the index
return context.chart.getDatasetMeta(context.datasetIndex)['xScale'].getPixelForValue(prevValue);
}
return context.element.x;
};
const fromY = function(context){
if(!context.element){
return;
}
if(context.dataIndex === context.dataset.data.length -1){
const [prevValue] = context.dataset.data.slice(-2); // penultimate element
return context.chart.getDatasetMeta(context.datasetIndex)['yScale'].getPixelForValue(prevValue);
}
return context.element.y;
};
with the configuration:
options: {
....... other options
animations: {
x: {
from: fromX
},
y: {
from: fromY
}
}
}
This works fine as long as the new data is inside the existing chart area; if it is not, and the chart will rescale with the new point, things get more complicated. If we want to put the initial position of the new point to coincide with the current position of the previous point, we'll have to compute the pre-animation position of this previous point, using the previous scales of the axes, which is no longer available, since the scale was already updated. Or, we can get the penultimate point pixel position directly from the dataset meta, which still involves a little bit of gymnastics:
const fromX = function(context){
if(!context.element){
return;
}
if(context.dataIndex === context.dataset.data.length - 1){
const pointElements = context.chart.getDatasetMeta(context.datasetIndex).data;
return pointElements[pointElements.length - 2].x;
}
return context.element.x;
}
This is implemented in the following demo snippet, that also manipulates the duration
property of the
animations
, to make the scaling duration slightly shorter than that of the whole animation in order to avoid
the last point to temporarily get out of the chartArea
:
let labels = [1, 2, 3, 4, 5, 6, 7];
let data = [65, 59, 80, 81, 56, 55, 40];
const chartData = {
labels: labels,
datasets: [
{
label: "My First dataset",
backgroundColor: "rgba(75,192,192,0.4)",
borderColor: "rgba(75,192,192,1)",
data: data,
},
],
};
const TRescale = 600, TTotalAnimation = 800;
const duration = function(context){
if(!context.element){
return;
}
if(context.dataIndex === context.dataset.data.length - 1){
return TTotalAnimation;
}
return TRescale;
}
const fromX = function(context){
if(!context.element){
return;
}
if(context.dataIndex === context.dataset.data.length - 1){
const pointElements = context.chart.getDatasetMeta(context.datasetIndex).data;
return pointElements[pointElements.length - 2].x;
}
return context.element.x;
}
const fromY = function(context){
if(!context.element){
return;
}
if(context.dataIndex === context.dataset.data.length -1){
const pointElements = context.chart.getDatasetMeta(context.datasetIndex).data;
return pointElements[pointElements.length - 2].y;
}
return context.element.y;
};
const options = {
responsive: true,
scales: {
x: {
display: true,
title: {
display: true,
text: "Time",
},
},
y: {
display: true,
title: {
display: true,
text: "Value",
},
},
},
animations: {
x: {
duration,
from: fromX
},
y: {
duration,
from: fromY
}
},
};
const chart = new Chart("canvas", {
type: "line",
data: chartData,
options: options,
});
let interval;
function startInterval(){
interval = setInterval(() => {
data.push(Math.random() * data.length + 1);
labels.push(labels.length + 1);
chart.update();
}, 2000);
}
addEventListener("DOMContentLoaded", () => {startInterval();});
document.querySelector('#reset').onclick = () => {
clearInterval(interval);
chart.stop();
chart.data.datasets[0].data = [1, 2];
chart.data.labels = [1, 2];
data = chart.data.datasets[0].data;
labels = chart.data.labels;
chart.update();
startInterval();
};
document.querySelector('#stop').onclick = () => {
clearInterval(interval);
};
<div>
<button id="reset">reset</button> <button id="stop">stop</button>
<canvas id="canvas"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
and codesandbox fork
Note The code in this post is based on some code of mine from what I think must have been a similar answer I posted some time ago, but for some reason I don't find it on SO; by the dates of this initial version, it must have been for this question, which I also upvoted (and also the one to suggest the previous point as the starting point of the animation), but for some reason I must've not posted it. If that's wrong and I already posted something similar before, please let me know.
There are also more elaborate versions of this, as for instance this one that splits the animation in two separate stages: the rescale followed by the new point animation:
const TNewPointAnimation = 800, TRescaleAnimation = 500,
fracRescale = TRescaleAnimation/(TNewPointAnimation + TRescaleAnimation);
let resizeAnimation = true, updateAnimation = false, rescale = false;
const prev = {x: null, y: null}, last = {x: null, y: null}, prevLast = {x: null, y: null};
const duration = function(context){
if(!updateAnimation){
return; // use the default duration for this type of animation
}
if(!context.chart.$rescaleComputed){
const meta = context.chart.getDatasetMeta(context.datasetIndex);
if(!resizeAnimation){
rescale = false;
const prevValueX = context.chart.data.labels.length - 2;
const [prevValueY] = context.dataset.data.slice(-2); // penultimate element
prev.x = meta.xScale.getPixelForValue(prevValueX);
prev.y = meta.yScale.getPixelForValue(prevValueY);
if(Math.sqrt((prev.x - last.x) ** 2 + (prev.y - last.y) ** 2) > 1e-5){
rescale = true;
prevLast.x = last.x;
prevLast.y = last.y;
}
}
const prevValueX = context.chart.data.labels.length - 1;
const [prevValueY] = context.dataset.data.slice(-1); // last element
last.x = meta.xScale.getPixelForValue(prevValueX);
last.y = meta.yScale.getPixelForValue(prevValueY);
context.chart.$rescaleComputed = true;
}
if(context.dataIndex === context.dataset.data.length - 1){
if(!resizeAnimation){
return rescale ? TRescaleAnimation + TNewPointAnimation : TNewPointAnimation;
}
return TNewPointAnimation;
}
else{
if(!resizeAnimation){
return rescale ? TRescaleAnimation : TNewPointAnimation;
}
return TNewPointAnimation;
}
};
const fromXY_gen = (x_or_y) => function(context){
if(resizeAnimation || !updateAnimation || !context.element){
return;
}
if(context.dataIndex === context.dataset.data.length -1){
return rescale ? 1/0 : prev[x_or_y]; // use +Infinity to identify the spacial case
}
return context.element[x_or_y];
};
const fnXY_gen = (x_or_y) => function(from, to, factor){
if(from === 1/0){ // the spacial interpolation of the last point with rescaling
if(!this.$resetEasing){
this._easing = t => t <= fracRescale ? -fracRescale*((t/fracRescale - 1) ** 4 - 1) :
fracRescale + -(1-fracRescale)*(((t - 1)/(1 - fracRescale)) ** 4 - 1);
this.$resetEasing = true;
}
const p1 = prev[x_or_y], p0 = prevLast[x_or_y], p2 = last[x_or_y];
if(factor < fracRescale){
return p0 + (p1 - p0) * (factor/fracRescale);
}
else{
return p1 + (p2 - p1) * (factor - fracRescale) / (1 - fracRescale);
}
}
return from + (to - from) * factor;
}
let labels = [1, 2, 3, 4, 5, 6, 7];
let data = [65, 59, 80, 81, 56, 55, 40];
const chartData = {
labels: labels,
datasets: [
{
label: "My First dataset",
backgroundColor: "rgba(75,192,192,0.4)",
borderColor: "rgba(75,192,192,1)",
data: data,
},
],
};
const options = {
responsive: true,
scales: {
x: {
display: true,
title: {
display: true,
text: "Time",
},
},
y: {
display: true,
title: {
display: true,
text: "Value",
},
},
},
animation:{
duration: TNewPointAnimation,
onComplete: function(context){
context.chart.$rescaleComputed = false;
resizeAnimation = false;
updateAnimation = false;
}
},
animations: {
x: {
duration,
easing: 'easeOutQuart',
from: fromXY_gen('x'),
fn: fnXY_gen('x')
},
y: {
duration,
easing: 'easeOutQuart',
from: fromXY_gen('y'),
fn: fnXY_gen('y')
}
},
};
const chart = new Chart("canvas", {
type: "line",
data: chartData,
options: options,
plugins:[{
resize(){
resizeAnimation = true;
},
beforeUpdate(){
updateAnimation = true;
// used to ignore non-update animations, e.g., when a tooltip is shown
}
}]
});
let interval;
function startInterval(){
interval = setInterval(() => {
data.push(Math.random() * data.length + 1);
labels.push(labels.length + 1);
chart.update();
}, 2000);
}
addEventListener("DOMContentLoaded", () => {startInterval();});
document.querySelector('#reset').onclick = () => {
clearInterval(interval);
chart.stop();
chart.data.datasets[0].data = [1, 2];
chart.data.labels = [1, 2];
data = chart.data.datasets[0].data;
labels = chart.data.labels;
chart.update();
startInterval();
};
document.querySelector('#stop').onclick = () => {
clearInterval(interval);
};
<div>
<button id="reset">reset</button> <button id="stop">stop</button>
<canvas id="canvas"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
or as codesandbox fork