Search code examples
chart.js

Chart.js, show tooltip even if there is no data available for the label


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.


Solution

  • 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:

    Automatically create an invisible dataset for the missing points

    The new dataset will be cloned from the existing dataset but replace its non-missing data points with nulls 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>

    Standard customizations solution

    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:

    • a custom interaction mode, that creates a 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;
      };
      
    • a custom tooltip positioner that is required to generate positions for x pixel positions between two consecutive missing data points:
      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
         };
      };
      
    • a custom tooltip 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.