Image here Currently using chart.js plus a custom tooltip that's worked great so far, but a new use case is to now show the tooltip even when there is no data. (See the image attached).
In this case, the chart labels
contain 12 different dates, and datasets
contains data
as follows: [0.22, 0.22, 0.18, 0.24, 0.16, 0.24, 0.18, 0.26, 0.22, 0.16, 0.26, undefined]
, with the last item being undefined. I've noticed the tooltip dataIndex
does not change when hovering over the last label, given there is no data. What is the best way to show a tooltip for an undefined datapoint? Some more additional information about our chart config:
interaction: {
// false - tooltip will render in the optimal position when mouse is near to the point
// true - tooltip will render only when mouse is at the point
intersect: false,
mode: 'index',
},
plugins: {
colors: {
enabled: false,
},
legend: {
display: false,
},
tooltip: {
// Disable the on-canvas tooltip
enabled: false,
external: onShowTooltip,
position: 'average',
},
annotation: {
clip: false,
annotations,
},
},
Thanks in advance, I've done quite a lot of research but haven't found a good solution.
Here is a reproducible example: codesandbox.io/p/sandbox/react-fiddle-forked-4963l2
I am using the default tooltip in this example, but the idea is that I want the tooltip to appear for the last two labels, with something like "No data available". As you can see, it seems you need an actual data point for this to happen. Our custom tooltip is just a custom div, so I don't think the implementation details are that important.
There isn't a simple option-based solution for this problem, but, as usual with chart.js a solution might be found using its customizations.
First a tricky solution, that might appear fragile, but it has the advantage of being simple:
The new dataset will be cloned from the existing dataset but replace its
non-missing data points with null
s and the missing points with
true values - I chose to position the missing data tooltip at the y
value corresponding to the average of the existing values; other
variants like min
, max
or max/2
can be considered.
Then the dataset properties are set such that the existing points and indeed the
whole "ghost dataset" are completely invisible. Also, a $ghost
custom
property is added so that the dataset is easily identified, for instance
to set the tooltip labels text.
The dataset is added to the chartData
before the chart is built:
(() => {
const dataset0 = chartData.datasets[0],
data0 = dataset0.data,
nullIdx = data0.map((v, idx) => (!v && v!==0) ? idx: -1).filter(x => x>=0),
dataset1 = {...dataset0};
dataset1.label = '';
dataset1.spanGaps = false;
dataset1.showLine = false;
dataset1.pointRadius = 0;
dataset1.pointHoverRadius = 0;
dataset1.$ghost = true; // to recognize "ghost" datasets
const avg = data0.reduce((acc, val) => acc + (val ?? 0), 0) / (data0.length - nullIdx.length);
// set the y position of the tooltip at the average of the non-null points
dataset1.data = Array.from({length: data0.length}, (_, idx) => nullIdx.includes(idx) ? avg : null);
chartData.datasets.push(dataset1);
})();
There should also be a label
callback for the tooltip setting the
text displayed for those missing data points; one should also add a
labels.filter
callback for the legend, to avoid showing the ghost
dataset if the legend would be enabled:
const chartConfig = {
// ..... other options
plugins: {
tooltip: {
position: "average",
callbacks: {
label: function({dataset}){
if(dataset.$ghost){
return 'No data available';
}
}
}
},
legend: {
display: false,
labels: {
filter({datasetIndex}, {datasets}){ // if legend enabled, filter out "ghost" datasets
return datasets[datasetIndex].$ghost;
}
}
},
// ..... other plugin options
}
}
Here's a stack snippet based on that idea. I added two intermediate missing data points for the purpose to test the generality of the solution.
const chartConfig = {
layout: {
padding: {
left: 8,
right: 8,
},
},
maintainAspectRatio: false,
responsive: true,
animation: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
colors: {
enabled: false,
},
tooltip: {
position: "average",
callbacks: {
label: function({dataset}){
if(dataset.$ghost){
return 'No data available';
}
}
}
},
legend: {
display: false,
labels: {
filter({datasetIndex}, {datasets}){ // if legend enabled, filter out "ghost" datasets
return datasets[datasetIndex].$ghost;
}
}
},
annotation: {
clip: false,
},
},
scales: {
x: {
offset: true,
grid: {
offset: false,
display: true,
drawOnChartArea: false,
tickWidth: 1,
tickLength: 8,
tickColor: "rgba(118, 118, 118, 1)",
},
ticks: {
align: "center",
maxTicksLimit: 12,
maxRotation: 0,
font: {
size: 14,
},
},
},
y: {
beginAtZero: true,
border: {
display: false,
dash: [3, 4],
},
min: 0,
ticks: {
autoSkip: true,
maxTicksLimit: 6,
font: {
size: 14,
},
padding: 8,
},
grid: {
drawTicks: false,
},
},
},
};
const chartData = {
labels: [
"2024-10-07",
"2024-10-14",
"2024-10-21",
"2024-10-28",
"2024-11-04",
"2024-11-11",
"2024-11-18",
"2024-11-25",
"2024-12-02",
"2024-12-09",
"2024-12-16",
"2024-12-23",
],
datasets: [
{
label: "DAY",
borderColor: "#2AA4A9",
backgroundColor: "#2AA4A9",
borderWidth: 3,
borderRadius: 4,
pointHoverBorderColor: "#2AA4A9",
fill: false,
pointRadius: 0,
tension: 0,
pointHoverRadius: 4,
pointHoverBorderWidth: 3,
pointBackgroundColor: "#ffffff",
data: [
0.22,
0.22,
null, //0.18,
null, //0.24,
0.16,
0.24,
0.18,
0.26,
0.22,
0.16,
null, //0.26,
undefined,
],
}
],
};
(() => {
const dataset0 = chartData.datasets[0],
data0 = dataset0.data,
nullIdx = data0.map((v, idx) => (!v && v !== 0) ? idx : -1).filter(x => x >= 0),
dataset1 = {...dataset0};
dataset1.label = '';
dataset1.spanGaps = false;
dataset1.showLine = false;
dataset1.pointRadius = 0;
dataset1.pointHoverRadius = 0;
dataset1.$ghost = true;
const avg = data0.reduce((acc, val) => acc + (val ?? 0), 0) / (data0.length - nullIdx.length);
// set the y position of the tooltip at the average of the non-null points
dataset1.data = Array.from({length: data0.length}, (_, idx) => nullIdx.includes(idx) ? avg : null);
chartData.datasets.push(dataset1);
})();
new Chart("myChart", {type: "line", data: chartData, options: chartConfig});
<div style="height:300px">
<canvas id="myChart"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
A solution that achieves the same behavior as the above one, without adding a dataset that might become apparent in some unforeseen context, is using three customizations:
PointElement
for the tooltip
at the missing points; the element is cloned from the default
PointElelemnt
but it is positioned at the y average and sets its properties so
it doesn't appear as a circle when selected (the point doesn't appear at all).
function getXIndexFromPixel(datasetMeta, xPixels){
const metaDeltaX = datasetMeta.data.map(({x}) => Math.abs(x-xPixels));
const deltaMin = Math.min(...metaDeltaX);
return metaDeltaX.indexOf(deltaMin);
}
function newElementForNoData(datasetMeta, xPixels){
const xIndex = getXIndexFromPixel(datasetMeta, xPixels);
const datasetElement = datasetMeta.data[xIndex];
const newElement = new PointElement(datasetElement);
const nonNullY = datasetMeta.data.map(({y, skip}) => skip ? null : y).filter(y => y !== null);
// set the y position of the tooltip at the average of the non-null points
newElement.y = nonNullY.reduce((acc, value) => acc+value, 0)/nonNullY.length;
newElement.options = {...datasetElement.options};
newElement.options.radius = 0;
newElement.options.hoverRadius = 0;
newElement.options.borderWidth = 0;
newElement.options.hoverBorderWidth = 0;
newElement.$noData = true;
return newElement;
}
Interaction.modes.indexPlusMissingData = function(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
const items = [];
Interaction.evaluateInteractionItems(chart, 'x', position, (element, datasetIndex, index) => {
const xDec = chart.scales.x.getDecimalForPixel(position.x);
if(xDec < 0 || xDec > 1){
return;
}
const datasetMeta = chart.getDatasetMeta(datasetIndex);
const xIndex = getXIndexFromPixel(datasetMeta, position.x);
const yValue = chart.data.datasets[datasetIndex].data[xIndex];
const datasetElement = datasetMeta.data[xIndex];
if(xIndex === index){
items.push({element: datasetElement ?? element, datasetIndex, index});
return items;
}
else if(!yValue && yValue !== 0 && datasetElement){
items.push({element: newElementForNoData(datasetMeta, position.x), datasetIndex, index: xIndex});
return items;
}
});
return items;
};
Tooltip.positioners.positionerForMissingData = function(elements, eventPosition){
if(!elements[0]?.element){
elements.pop();
}
if(elements.length === 0){
const chart = eventPosition.chart;
const xDec = chart.scales.x.getDecimalForPixel(eventPosition.x);
if(xDec < 0 || xDec > 1){
return {x: null, y: null};
}
const datasetMeta = chart.getDatasetMeta(0);
const metaDeltaX = datasetMeta.data.map(({x}) => Math.abs(x-eventPosition.x));
const deltaMin = Math.min(...metaDeltaX);
const xIndex = metaDeltaX.indexOf(deltaMin);
const newElement = {...datasetMeta.data[xIndex], $noData: true};
const nonNullY = datasetMeta.data.map(({y, skip}) => skip ? null : y).filter(y => y !== null);
// set the y position of the tooltip at the average of the non-null points
newElement.y = nonNullY.reduce((acc, value) => acc+value, 0)/nonNullY.length;
elements.push({element: newElement, datasetIndex: 0, index: xIndex});
}
return {
x: elements[0].element.x,
y: elements[0].element.y
};
};
label
callback, in options.plugins.tooltip
, that takes
advantage of the fact that the PointElements
created for the
purpose of positioning the tooltip on no data have been set a
$noData
property (see newElementForNoData
function) to allow us
to recognize them easily:
tooltip: {
position: "positionerForMissingData",
callbacks: {
label: function({element}){
if(element.$noData){
return 'No data available';
}
}
}
},
Here's a stack snippet with these customization
const Interaction = Chart.Interaction;
const Tooltip = Chart.Tooltip;
const PointElement = Chart.PointElement;
const getRelativePosition = Chart.helpers.getRelativePosition;
function getXIndexFromPixel(datasetMeta, xPixels){
const metaDeltaX = datasetMeta.data.map(({x}) => Math.abs(x - xPixels));
const deltaMin = Math.min(...metaDeltaX);
return metaDeltaX.indexOf(deltaMin);
}
function newElementForNoData(datasetMeta, xPixels){
const xIndex = getXIndexFromPixel(datasetMeta, xPixels);
const datasetElement = datasetMeta.data[xIndex];
const newElement = new PointElement(datasetElement);
const nonNullY = datasetMeta.data.map(({y, skip}) => skip ? null : y).filter(y => y !== null);
// set the y position of the tooltip at the average of the non-null points
newElement.y = nonNullY.reduce((acc, value) => acc + value, 0) / nonNullY.length;
newElement.options = {...datasetElement.options};
newElement.options.radius = 0;
newElement.options.hoverRadius = 0;
newElement.options.borderWidth = 0;
newElement.options.hoverBorderWidth = 0;
newElement.$noData = true;
return newElement;
}
Interaction.modes.indexPlusMissingData = function(chart, e, options, useFinalPosition){
const position = getRelativePosition(e, chart);
const items = [];
Interaction.evaluateInteractionItems(chart, 'x', position, (element, datasetIndex, index) => {
const xDec = chart.scales.x.getDecimalForPixel(position.x);
if(xDec < 0 || xDec > 1){
return;
}
const datasetMeta = chart.getDatasetMeta(datasetIndex);
const xIndex = getXIndexFromPixel(datasetMeta, position.x);
const yValue = chart.data.datasets[datasetIndex].data[xIndex];
const datasetElement = datasetMeta.data[xIndex];
if(xIndex === index){
items.push({element: datasetElement ?? element, datasetIndex, index});
return items;
}
else if(!yValue && yValue !== 0 && datasetElement){
items.push({element: newElementForNoData(datasetMeta, position.x), datasetIndex, index: xIndex});
return items;
}
});
return items;
};
Tooltip.positioners.positionerForMissingData = function(elements, eventPosition){
if(!elements[0]?.element){
elements.pop();
}
if(elements.length === 0){
const chart = eventPosition.chart;
const xDec = chart.scales.x.getDecimalForPixel(eventPosition.x);
if(xDec < 0 || xDec > 1){
return {x: null, y: null};
}
const datasetMeta = chart.getDatasetMeta(0);
const metaDeltaX = datasetMeta.data.map(({x}) => Math.abs(x - eventPosition.x));
const deltaMin = Math.min(...metaDeltaX);
const xIndex = metaDeltaX.indexOf(deltaMin);
const newElement = {...datasetMeta.data[xIndex], $noData: true};
const nonNullY = datasetMeta.data.map(({y, skip}) => skip ? null : y).filter(y => y !== null);
// set the y position of the tooltip at the average of the non-null points
newElement.y = nonNullY.reduce((acc, value) => acc + value, 0) / nonNullY.length;
elements.push({element: newElement, datasetIndex: 0, index: xIndex});
}
return {
x: elements[0].element.x,
y: elements[0].element.y
};
};
const chartConfig = {
layout: {
padding: {
left: 8,
right: 8,
},
},
maintainAspectRatio: false,
responsive: true,
animation: false,
interaction: {
intersect: false,
mode: "indexPlusMissingData",
},
plugins: {
colors: {
enabled: false,
},
legend: {
display: false
},
tooltip: {
position: "positionerForMissingData",
callbacks: {
label: function({element}){
if(element.$noData){
return 'No data available';
}
}
}
},
annotation: {
clip: false,
},
},
scales: {
x: {
offset: true,
//type: 'time',
grid: {
offset: false,
display: true,
drawOnChartArea: false,
tickWidth: 1,
tickLength: 8,
tickColor: "rgba(118, 118, 118, 1)",
},
ticks: {
align: "center",
maxTicksLimit: 12,
maxRotation: 0,
font: {
size: 14,
},
},
},
y: {
beginAtZero: true,
border: {
display: false,
dash: [3, 4],
},
min: 0,
ticks: {
autoSkip: true,
maxTicksLimit: 6,
font: {
size: 14,
},
padding: 8,
},
grid: {
drawTicks: false,
},
},
},
};
const chartData = {
labels: [
"2024-10-07",
"2024-10-14",
"2024-10-21",
"2024-10-28",
"2024-11-04",
"2024-11-11",
"2024-11-18",
"2024-11-25",
"2024-12-02",
"2024-12-09",
"2024-12-16",
"2024-12-23",
],
datasets: [
{
label: "DAY",
borderColor: "#2AA4A9",
backgroundColor: "#2AA4A9",
borderWidth: 3,
borderRadius: 4,
pointHoverBorderColor: "#2AA4A9",
fill: false,
pointRadius: 0,
tension: 0,
pointHoverRadius: 4,
pointHoverBorderWidth: 3,
pointBackgroundColor: "#ffffff",
data: [
0.22,
0.22,
0.18,
null, //0.24,
null, //0.16,
0.24,
0.18,
0.26,
0.22,
0.16,
null,//0.26,
undefined,
],
}
],
};
new Chart("myChart", {type: "line", data: chartData, options: chartConfig});
<div style="height:300px">
<canvas id="myChart"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
and a fork of the codesandbox from the original post.