chartjs / Chart.js

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

Chart with live data source + decimation + zoom plugins not drawing new points when samples exceed decimation threshold #11929

Open twilson90 opened 1 month ago

twilson90 commented 1 month ago

Expected behavior

I have a line chart that updates every second with live data. Every time data is received it is pushed onto a dataset.data. (not re-constructed) I have enabled decimation and zoom because the amount of data points can get into tens of thousands. All works well until I zoom out enough that the decimation threshold is passed, at which point the graph stops adding points to the canvas. The scale continues to grow however which suggests a bug. If I disable decimation then the problem doesn't occur.

Presumably I have to do something to refresh the internal _data buffer but there's nothing in the docs I can find that suggests anything.

Current behavior

What it looks like after zooming out and waiting: chrome_CPt5mrz4pC

Closer to what it should look like: image_3

Optional extra steps/info to reproduce

this.chart = new Chart(this.canvas, {
  type: "line",
  data: {},
  options: {
      normalized: true,
      parsing: false,
      spanGaps: true,
      onHover: (e)=>{
          this.canvas.style.cursor = "crosshair"
      },
      animation: false,
      maintainAspectRatio: false,
      responsive:true,
      scales: {
          x: {
              display: 'auto',
              type: "linear",
              min: 0,
              max: 60*1000,
              ticks: {
                  autoSkip: true,
                  maxRotation: 0,
                  callback: (value, index, values)=>{
                      if (index == 0 || index == values.length - 1) return null;
                      return ms_to_timespan(value);
                  }
              },
          },
          y: {
              display: 'auto',
              type: "linear",
              ticks: {
                  callback: (value, index, values)=>{
                      return this.#format_value(value)
                  }
              },
          }
      },
      plugins: {
          zoom: {

              limits: {
                  x: {
                      minRange: 10*1000
                  },
              },
              pan: {
                  enabled: true,
                  mode: 'x',
                  onPanStart:(c, ...args)=>{
                      this.panning = true;
                      this.update();
                  },
                  onPanComplete:(c)=>{
                      this.panning = false;
                      this.update();
                  }
              },
              zoom: {
                  wheel: {
                      enabled: false,
                  },
                  pinch: {
                      enabled: false
                  },
                  mode: 'x',
                  onZoomStart:()=>{
                      this.zooming = true;
                      this.update();
                  },
                  onZoomComplete:(c)=>{
                      this.zooming = false;
                      this.update();
                  }
              }
          },
          tooltip: {
              callbacks: {
                  title: (ctxs)=>{
                      return ctxs.map(ctx=>ms_to_timespan(ctx.raw.x)).join(", ");
                  },
                  label: (ctx)=>{
                      return `${this.#parse_key(ctx.dataset.label)[0]}: ${this.#format_value(ctx.raw.y)}`;
                  }
              }
          },
          legend: {
              labels: {
                  boxWidth: Chart.defaults.font.size,
                  generateLabels: (c)=>{
                      var items = Chart.defaults.plugins.legend.labels.generateLabels(c);
                      for (var i of items) {
                          i.text = this.#parse_key(i.text)[0];
                      }
                      return items;
                  }
              },
              onHover: ()=>{
                  this.canvas.style.cursor = "pointer";
              },
              onLeave: ()=>{
                  this.canvas.style.cursor = "";
              },
              onClick: (e, legendItem, legend)=>{
                  Chart.defaults.plugins.legend.onClick(e, legendItem, legend);
                  this.update();
              }
          },
          /* decimation: {
              enabled: true,
              algorithm: 'min-max',
              samples: 100,
              threshold: 100
          }, */
      }
  }
});

Whenever I want to add data:

this.chart.data.datasets = Object.entries(raw_data).map(([key,{data,length}], i)=>{
  let dataset = this.chart.data.datasets.find(d=>d.label==key);
  dataset = dataset ?? {
      label: key,
      borderWidth: 1.0,
      pointRadius: 1.5,
      pointHitRadius: 2,
      pointStyle: "rect",
      fill: false,
      tension: 0.5,
      borderJoinStyle: "round",
      data: []
  };
  dataset.borderColor = graph_colors[i%graph_colors.length];
  if (data && length) {
      for (var i = dataset.data.length; i<length; i++) {
          let [x,y] = data[i];
          dataset.data.push({x,y});
      }
  }
  return dataset;
});

Possible solution

Some way of refreshing decimate's _data buffer referenced in the docs?

Has to be efficient though, can't regenerate from scratch with each new sample.

Context

No response

chart.js version

4.4.4

Browser name and version

No response

Link to your project

No response

twilson90 commented 1 month ago

Figured out a solution, when appending samples with decimation enabled I now do:

dataset.data.push(...samples);
if (dataset._data) dataset._data.push(...samples);

And if I'm retrieving a sample from a dataset, I refer to _data if it exists, otherwise data.

etimberg commented 1 month ago

Thanks for reporting this @twilson90. It definitely sounds like a bug in the decimation plugin since you shouldn't need to care about _data.