How do I selecting a date range (like onClick but drag/select)

12,826

Solution 1

Building on @jordanwillis's and your answers, you can easily achieve anything you want, by placing another canvas on top on your chart.
Just add pointer-events:none to it's style to make sure it doesn't intefere with the chart's events.
No need to use the annotations plugin.
For example (in this example canvas is the original chart canvas and overlay is your new canvas placed on top):

var options = {
  type: 'line',
  data: {
    labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
    datasets: [{
        label: '# of Votes',
        data: [12, 19, 3, 5, 2, 3],
        borderWidth: 1
      },
      {
        label: '# of Points',
        data: [7, 11, 5, 8, 3, 7],
        borderWidth: 1
      }
    ]
  },
  options: {
    scales: {
      yAxes: [{
        ticks: {
          reverse: false
        }
      }]
    }
  }
}

var canvas = document.getElementById('chartJSContainer');
var ctx = canvas.getContext('2d');
var chart = new Chart(ctx, options);
var overlay = document.getElementById('overlay');
var startIndex = 0;
overlay.width = canvas.width;
overlay.height = canvas.height;
var selectionContext = overlay.getContext('2d');
var selectionRect = {
  w: 0,
  startX: 0,
  startY: 0
};
var drag = false;
canvas.addEventListener('pointerdown', evt => {
  const points = chart.getElementsAtEventForMode(evt, 'index', {
    intersect: false
  });
  startIndex = points[0]._index;
  const rect = canvas.getBoundingClientRect();
  selectionRect.startX = evt.clientX - rect.left;
  selectionRect.startY = chart.chartArea.top;
  drag = true;
  // save points[0]._index for filtering
});
canvas.addEventListener('pointermove', evt => {

  const rect = canvas.getBoundingClientRect();
  if (drag) {
    const rect = canvas.getBoundingClientRect();
    selectionRect.w = (evt.clientX - rect.left) - selectionRect.startX;
    selectionContext.globalAlpha = 0.5;
    selectionContext.clearRect(0, 0, canvas.width, canvas.height);
    selectionContext.fillRect(selectionRect.startX,
      selectionRect.startY,
      selectionRect.w,
      chart.chartArea.bottom - chart.chartArea.top);
  } else {
    selectionContext.clearRect(0, 0, canvas.width, canvas.height);
    var x = evt.clientX - rect.left;
    if (x > chart.chartArea.left) {
      selectionContext.fillRect(x,
        chart.chartArea.top,
        1,
        chart.chartArea.bottom - chart.chartArea.top);
    }
  }
});
canvas.addEventListener('pointerup', evt => {

  const points = chart.getElementsAtEventForMode(evt, 'index', {
    intersect: false
  });
  drag = false;
  console.log('implement filter between ' + options.data.labels[startIndex] + ' and ' + options.data.labels[points[0]._index]);  
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.0/Chart.js"></script>

<body>
  <canvas id="overlay" width="600" height="400" style="position:absolute;pointer-events:none;"></canvas>
  <canvas id="chartJSContainer" width="600" height="400"></canvas>
</body>

Notice we're basing our events and coordinates on the original canvas, but we draw on the overlay. This way we don't mess the chart's functionality.

Solution 2

Unfortunately, nothing like this is built into chart.js. You would have to implement your own event hooks and handlers that would render a highlighted section on a chart and then use the .getElementsAtEvent(e) prototype method to figure out what data has been highlighted. Even these hooks that are built in may not be enough to implement what you are wanting.

Event hook options are:

  • Add event handlers on the canvas element itself (see example below)

    canvas.onclick = function(evt){
        var activePoints = myLineChart.getElementsAtEvent(evt);
        // => activePoints is an array of points on the canvas that are at the same position as the click event.
    };
    
  • Add event handler on the chart.js chart object using the onClick config option (explained here).

  • Extend some of the core charts event hooks and add your own. (see here for some guidance).

Assuming this approach works, then you could then filter your original chart data array accordingly (in the underlying chart.js object) and call the .update() prototype method to paint a new chart.

Solution 3

For all of you interested in Jony Adamits solution, I created a ChartJs plugin based on his implementation. Additionaly I fixed some minor issues in regard to resizing the chart and detection of the selected data points.

Feel free to use it or to create a plugin github repo for it.

Installation

import "chart.js";
import {Chart} from 'chart.js';
import {ChartJsPluginRangeSelect} from "./chartjs-plugin-range-select";

Chart.pluginService.register(new ChartJsPluginRangeSelect());

Configuration

let chartOptions = rangeSelect: {
  onSelectionChanged: (result: Array<Array<any>>) => {
    console.log(result);
  }
}

Plugin Code

import {Chart, ChartSize, PluginServiceGlobalRegistration, PluginServiceRegistrationOptions} from "chart.js";

interface ChartJsPluginRangeSelectExtendedOptions {
  rangeSelect?: RangeSelectOptions;
}

interface RangeSelectOptions {
  onSelectionChanged?: (filteredDataSets: Array<Array<any>>) => void;
  fillColor?: string | CanvasGradient | CanvasPattern;
  cursorColor?: string | CanvasGradient | CanvasPattern;
  cursorWidth?: number;
  state?: RangeSelectState;
}

interface RangeSelectState {
  canvas: HTMLCanvasElement;
}

interface ActiveSelection {
  x: number;
  w: number;
}

export class ChartJsPluginRangeSelect implements PluginServiceRegistrationOptions, PluginServiceGlobalRegistration {
  public id = 'rangeSelect';

  beforeInit(chartInstance: Chart, options?: any) {
    const opts = (chartInstance.config.options as ChartJsPluginRangeSelectExtendedOptions);
    if (opts.rangeSelect) {
      const canvas = this.createOverlayCanvas(chartInstance);
      opts.rangeSelect = Object.assign({}, opts.rangeSelect, {state: {canvas: canvas}});
      chartInstance.canvas.parentElement.prepend(canvas);
    }
  }

  resize(chartInstance: Chart, newChartSize: ChartSize, options?: any) {
    const rangeSelectOptions = (chartInstance.config.options as ChartJsPluginRangeSelectExtendedOptions).rangeSelect;
    if (rangeSelectOptions) {
      rangeSelectOptions.state.canvas.width = newChartSize.width;
      rangeSelectOptions.state.canvas.height = newChartSize.height;
    }
  }

  destroy(chartInstance: Chart) {
    const rangeSelectOptions = (chartInstance.config.options as ChartJsPluginRangeSelectExtendedOptions).rangeSelect;
    if (rangeSelectOptions) {
      rangeSelectOptions.state.canvas.remove();
      delete rangeSelectOptions.state;
    }
  }

  private createOverlayCanvas(chart: Chart): HTMLCanvasElement {
    const rangeSelectOptions = (chart.config.options as ChartJsPluginRangeSelectExtendedOptions).rangeSelect;
    const overlay = this.createOverlayHtmlCanvasElement(chart);
    const ctx = overlay.getContext('2d');

    let selection: ActiveSelection = {x: 0, w: 0};
    let isDragging = false;

    chart.canvas.addEventListener('pointerdown', evt => {
      const rect = chart.canvas.getBoundingClientRect();
      selection.x = this.getXInChartArea(evt.clientX - rect.left, chart);
      isDragging = true;
    });

    chart.canvas.addEventListener('pointerleave', evt => {
      if (!isDragging) {
        ctx.clearRect(0, 0, overlay.width, overlay.height);
      }
    });

    chart.canvas.addEventListener('pointermove', evt => {
      ctx.clearRect(0, 0, chart.canvas.width, chart.canvas.height);

      const chartContentRect = chart.canvas.getBoundingClientRect();
      const currentX = this.getXInChartArea(evt.clientX - chartContentRect.left, chart);
      if (isDragging) {
        selection.w = currentX - selection.x;
        ctx.fillStyle = rangeSelectOptions.fillColor || '#00000044';
        ctx.fillRect(selection.x, chart.chartArea.top, selection.w, chart.chartArea.bottom - chart.chartArea.top);
      } else {
        const cursorWidth = rangeSelectOptions.cursorWidth || 1;
        ctx.fillStyle = rangeSelectOptions.cursorColor || '#00000088';
        ctx.fillRect(currentX, chart.chartArea.top, cursorWidth, chart.chartArea.bottom - chart.chartArea.top);
      }
    });

    chart.canvas.addEventListener('pointerup', evt => {
      const onSelectionChanged = rangeSelectOptions.onSelectionChanged;
      if (onSelectionChanged) {
        onSelectionChanged(this.getDataSetDataInSelection(selection, chart));
      }
      selection = {w: 0, x: 0};
      isDragging = false;
      ctx.clearRect(0, 0, overlay.width, overlay.height);
    });
    return overlay;
  }

  private createOverlayHtmlCanvasElement(chartInstance: Chart): HTMLCanvasElement {
    const overlay = document.createElement('canvas');
    overlay.style.position = 'absolute';
    overlay.style.pointerEvents = 'none';
    overlay.width = chartInstance.canvas.width;
    overlay.height = chartInstance.canvas.height;
    return overlay;
  }

  private getXInChartArea(val: number, chartInstance: Chart) {
    return Math.min(Math.max(val, chartInstance.chartArea.left), chartInstance.chartArea.right);
  }

  private getDataSetDataInSelection(selection: ActiveSelection, chartInstance: Chart): Array<any> {
    const result = [];
    const xMin = Math.min(selection.x, selection.x + selection.w);
    const xMax = Math.max(selection.x, selection.x + selection.w);
    for (let i = 0; i < chartInstance.data.datasets.length; i++) {
      result[i] = chartInstance.getDatasetMeta(i)
        .data
        .filter(data => xMin <= data._model.x && xMax >= data._model.x)
        .map(data => chartInstance.data.datasets[i].data[data._index]);
    }
    return result;
  }
}

Solution 4

Update a few months later based on @jordanwillis' answer: I've got the beginnings of range selection.

canvas.onpointerdown = function (evt) {
  clearAnnotations()
  const points = chart.getElementsAtEventForMode(evt, 'index', { intersect: false })
  const label = chart.data.labels[points[0]._index]
  addAnnotation(label)
}

canvas.onpointerup = function (evt) {
  const points = chart.getElementsAtEventForMode(evt, 'index', { intersect: false })
  const label = chart.data.labels[points[0]._index]
  addAnnotation(label)
}

function clearAnnotations () {
  if (chart.options.annotation) {
    chart.options.annotation.annotations = []
  }
}

function addAnnotation (label) {
  const annotation = {
    scaleID: 'x-axis-0',
    type: 'line',
    mode: 'vertical',
    value: label,
    borderColor: 'red'
  }
  chart.options.annotation = chart.options.annotation || {}
  chart.options.annotation.annotations = chart.options.annotation.annotations || []
  chart.options.annotation.annotations.push(annotation)
  chart.update()
}

Still need to figure out how to show a visual hover indicator as in the demo linked in the question, but it's a start.

Share:
12,826

Related videos on Youtube

Tobias Fünke
Author by

Tobias Fünke

Updated on June 04, 2022

Comments

  • Tobias Fünke
    Tobias Fünke almost 2 years

    I'd like to rewrite vizwit using Chart.js, and I'm having a hard time figuring out how to get the date/time chart interaction to work. If you try selecting a date range on this demo, you'll see that it filters the other charts. How do I get Chart.js to let me select a range like that on its time scale chart? It seems like by default it only lets me click on a specific date point.

    Thanks for your time.

  • Jayanta
    Jayanta over 3 years
    Kali You have not mentioned that this is in TypeScript. The main code too uses TS stuff Array<Array<any>> Please give a working example in jsfiddle eliminating all TS After all we are dealing with chart.js not chart.ts I can sense your code may work hence please do help.