chartjs / Chart.js

Simple HTML5 Charts using the <canvas> tag
https://www.chartjs.org/
MIT License
63.92k stars 11.89k forks source link

Data-labels not rendering. Bar Element element coordinate properties 'y' and 'base' are assigned 'NaN' when all values for that dataset are zero. #11723

Closed toduyemi closed 3 months ago

toduyemi commented 3 months ago

Expected behavior

-Data-labels (from data-labels plugin) should appear at configured position in relation to bars. If there are no bars, they should appear above the x-axis.

Screenshot_2024-03-25_at_10 54 24_PM

-When all values are zero (thus no bars rendered) Metadata for BarElements should have y and base properties assigned as they would if there are was a non-zero value.

Screenshot_2024-03-27_at_12 56 04_AM Screenshot_2024-03-27_at_12 57 06_AM

(note that most of the y values are the same indicating that the relevant value for those are 0)

Current behavior

Using the data-labels plugin to set additional information, if every value for my dataset returns as 0 and thus no bars rendered, none of the corresponding data labels render.

Screenshot_2024-03-25_at_11 08 55_PM

The values for the bar graph correspond to the mm label (seen above in expected behaviour). The description at the bottom plus percentages are extra information that do not render, if every bar graph value is 0.

When you look at the top right of the defect sc, you can see a bunch of text smashed into the corner. These are the missing labels. It seems with no bars, the plugin doesn't know how to anchor.

Investigating via the call stack, I found:

If no bars are rendered, the properties are recieved in the plugin , y: NaN and base: NaN of the BarElements

Screenshot_2024-03-27_at_12 44 30_AM

which cascasdes into breaking the computation of positioning the label in the plugin

Screenshot 2024-03-26 at 11 37 09 PM Screenshot 2024-03-26 at 11 35 03 PM

I've confirmed that even when the data-value is 0, the properties y & base are assigned a non-falsy value as long as there is one non-zero value in the data-set (see expected behaviour images).

Looking in the local scope of _updateDataSet() within the Chart class, the y property for each Bar Element has been assigned as NaN.

Screenshot 2024-03-27 at 2 20 57 AM.

Optional extra steps/info to reproduce

Edge case: Dataset has to return 0 for every value

Is there way for me to create one dummy value point, that isn't displayed or rendered on the chart? one that will go through the calculation process and allow the property calculations to behave as specified?

Possible solution

-Falsy values such as NaN for coordinate related properties should be type guarded against. -Falsy values such as NaN should be coerced to the appropriate value that would result from a 0 value in the dataset.

Context

My chart calls to an api so my data is dynamic. It does not render as specified for valid data.

chart.js version

v4.4.2

Browser name and version

Chrome

Link to your project

https://github.com/toduyemi/weather-app/tree/OpenWeatherApi

LeeLenaleee commented 3 months ago

Please add a reproducable sample as required by the issue template, because it seems to work fine with 0 values: https://jsfiddle.net/2gcorjx5/

toduyemi commented 3 months ago

Here's a deployment of project: https://toduyemi.github.io/weather-app/. I've included the afterDatasetUpdate plugin from your sandbox in the deployed code. I have also set the data: callback to log every value before it returns it.

It strange because I included a new dataset array of just 0s and it failed to return NaN.

Steps to reproduce:

Yet the problem remains persistent for that dataset. I'd appreciate any insight you may have.

Screenshot 2024-03-27 at 9 19 00 PM
//chart 1 ===================>
let chart1: Chart, chart2: Chart;
export async function renderChart(forecast: ForecastObj[]) {
  const getMaxValueWithPadding = () => {
    return (
      Math.max(...forecast.map((row) => (row.rain ?? 0) + (row.snow ?? 0))) *
      1.1
    );
  };
  const chartCtr = document.querySelector('#temp-chart1') as HTMLCanvasElement;
  if (chart1) chart1.destroy();
  if (chart2) chart2.destroy();

  chart1 = new Chart(chartCtr, {
    type: 'line',
    plugins: [
      ChartDataLabels,
      {
        afterDatasetUpdate: (chart, args) => {
          console.log(
            args.meta.data.map((d) => d.y),
            args.index,
          );
        },
      },
    ],
    options: {
      layout: {
        padding: {
          bottom: 47.15,
        },
      },

      maintainAspectRatio: false,
      animation: false,
      plugins: {
        legend: {
          display: false,
        },
      },
      scales: {
        x: {
          adapters: {
            date: {
              locale: enUS,
            },
          },
          grid: {
            display: false,
          },
          type: 'time',
          ticks: {
            stepSize: 3,
            major: {
              enabled: true,
            },
          },
          time: {
            unit: 'hour',
            tooltipFormat: 'HH:mm',
          },
          position: 'top',
        },
        yTemp: {
          ticks: {
            display: false,
          },
          grid: {
            display: false,
          },
          border: {
            display: false,
          },
        },
        yPop: {
          display: false,
          max: getMaxValueWithPadding(),
        },
        yLev: {
          display: false,
        },
      },
    },
    data: {
      labels: forecast.map((row) => row.date),
      datasets: [
         //test data for NaN
         {
          label: '# of Points',
          data: new Array(40).fill(0),
          borderWidth: 1,
        },
        {
          type: 'line',
          label: 'temp every 3 hrs',
          data: forecast.map((row) => row.temp),
          yAxisID: 'yTemp',
          datalabels: {
            display: false,
          },
        },
        {
          label: '3h rain level',
          data: forecast.map((row) => {
            const value = (row.rain ?? 0) + (row.snow ?? 0);
            console.log(value);
            return value;
            // return 0;
          }),
          yAxisID: 'yPop',
          type: 'bar',
          datalabels: {
            labels: {
              description: {
                anchor: 'start',
                align: 'start',
                font: {
                  size: 8.5,
                },
                formatter: (value, context) => {
                  const bar = forecast[context.dataIndex];
                  const words = bar.weather.description.split(' ');
                  return [...words];
                },
              },
              precipitation: {
                anchor: 'end',
                align: 'end',
                offset: 15,
                font: {
                  size: 8.3,
                  weight: 'bold',
                },
                formatter: (value, context) => {
                  if (value) return `${value} mm/h`;
                  else return '';
                },
                textAlign: 'center',
              },
              probability: {
                anchor: 'end',

                align: 'end',
                font: {
                  size: 8.3,
                },
                formatter: (value, context) => {
                  const bar = forecast[context.dataIndex];
                  return `${(bar.pop * 100).toFixed()}%`;
                },
                textAlign: 'center',
              },
            },
          },
        },
      ],
    },
  });
  // Chart2 ===================>
  const chartCtr2 = document.querySelector('#temp-chart2') as HTMLCanvasElement;
  chart2 = new Chart(chartCtr2, {
    type: 'line',
    options: {
      maintainAspectRatio: false,
      layout: {
        padding: {
          top: 30,
          bottom: 41,
        },
      },
      animation: false,
      plugins: {
        legend: {
          display: false,
        },
      },
      scales: {
        x: {
          ticks: {
            display: false,
          },
        },
        y: {
          afterFit: (ctx) => {
            ctx.width = 35;
          },
          ticks: {
            callback: (value) => `${value} C`,
          },
        },
      },
    },
    data: {
      labels: forecast.map((row) => row.date),
      datasets: [
        {
          label: 'temp every 3 hrs',
          data: forecast.map((row) => row.temp),
        },
      ],
    },
  });
}
toduyemi commented 3 months ago

Figured out the source of the problem! It was due to my max configuration being based on the highest value! Fixed it with an OR coercion.