chartjs / Chart.js

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

Click event doesn't fire on labels but hover does #8773

Closed karlschwaier closed 3 years ago

karlschwaier commented 3 years ago

Expected Behavior

Click event should fire when clicking on labels.

Current Behavior

Click event fires only when clicking on the chart. But hover fires on chart and labels.

Steps to Reproduce

https://codepen.io/karlschwaier/pen/poReONR?editors=1111

Context

I'm trying to show a tooltip with a longer label when clicking on a label.

Environment

kurkle commented 3 years ago

Its working as designed, only clicks in chartArea (+ overflow). You should use the plugin hooks to receive events outside chart area.


new Chart(ctx, {
  type: ...
  data: ..
  options: ..
  plugins:[{
    id: 'click',
    afterEvent(chart, args, pluginOpts) {
      if (args.event.type === 'click') {
        console.log('click');
      }
    }
  }]
});
kurkle commented 3 years ago

The hover firing outside chartArea could be considered a bug though. @etimberg?

karlschwaier commented 3 years ago

Alright, Thanks for the quick answer and the code snippet @kurkle.

Is there a way to find out which label was clicked? I'm trying with https://www.chartjs.org/docs/master/developers/api.html#getelementsateventformode-e-mode-options-usefinalposition, but the return is always empty.

kurkle commented 3 years ago

There are no DOM elements inside the chart, so you'll need to use the chart.js API to find out.

So for example, if you are interested in the x-axis labels you'd first check that the y is greater than the chart.scales.x.top, then you'd use the x to find the label using the following methods on the chart.scales.x:

https://www.chartjs.org/docs/master/api/classes/scale.html#getvalueforpixel https://www.chartjs.org/docs/master/api/classes/scale.html#getlabelforvalue

example values run on console of your pen:

chart.scales.x.top
271.6
chart.scales.x.getValueForPixel(300)
1
chart.scales.x.getLabelForValue(1)
"Label 2"
etimberg commented 3 years ago

It sounds like hover should work the same way

karlschwaier commented 3 years ago

Awesome, that works. Thank you @kurkle!

Actually I'm interested in the label ids from the y-axis of a horizontal bar chart. This is how I'm doing it now:

afterEvent(chart, args) {
  const { x, y } = args.event;
  if (
    x < chart.scales.x.left
    && y > chart.scales.y.top
    && y < chart.scales.y.bottom
  ) {
    const labelId = chart.scales.y.getValueForPixel(y);
  }
}
etimberg commented 3 years ago

@karlschwaier The check for y looks good, but I'm not sure about the x check. I would think you'd want

x >= chart.scales.y.left && x <= chart.scales.y.right`
karlschwaier commented 3 years ago

Thanks @etimberg. You are right. That makes sense. My check would have caused an issue when used together with left padding.

danieljoeblack commented 2 years ago

Is this functionality outlined in the documentation anywhere? Took me quite a while to track down this issue to find the answer, especially since labels used to fire the click event in chartjs 2.

LeeLenaleee commented 2 years ago

@danieljoeblack it's in the migration guide: options.onClick is now limited to the chart area

https://www.chartjs.org/docs/master/getting-started/v3-migration.html#interactions

losttheplot commented 2 years ago

Using chart.js 3.7.1, I want to be able to open a customer-data-modal for customers whose names are listed as labels next to their spend in the format of a horizontal bar chart. Users will intuitively click the customer's name (the label) rather than the bar (the value), but as they could click either, I had my work cut out to find a solution!

The various posts above were most helpful, thanks, and with some modification they gave me what I needed, which I have included below in case it helps anyone else struggling with this issue...

var chart = new Chart(ctx, {
    type: 'bar',
    data: {
        labels: myLabels,
        datasets: [{ data: myValues }],
    },
    options: {
        indexAxis: 'y',
    },
    plugins: [{
        afterEvent(chart, args, options) {
            if (args.event.type === 'click') {
                const { x, y } = args.event;
                if ( y > chart.scales.y.top && y < chart.scales.y.bottom ) {
                    const index = chart.scales.y.getValueForPixel(y);
                    const label = chart.scales['y'].getLabelForValue(index);
                    const value = myValues[index];
                    console.log('index', index, ', label', label, ', value', value);
                }
            }
        }
    }]
})

Obviously this is just a basic POC but it does the job. It also occurred to me that the label can be extracted from myLabels in the same way as the value is extracted from myValues, and in fact if one is referencing the chart data, one can also include and extract secondary data, all referenced from the data index.

losttheplot commented 2 years ago

Further to my post above, and again using chart.js 3.7.1, I have a stacked vertical bar chart whereby I need to open a modal after clicking on a particular element within a stacked bar. So how to trigger the call and fetch the tooltip title of the clicked element in order to identify which element has been clicked? Well, there follows my solution in case it helps anyone. I've no doubt it could be written more elegantly, but it works for me.

plugins: [{
    afterEvent(chart, args, options) {
        if (args.event.type === 'click' && typeof chart.tooltip.dataPoints !== 'undefined') {
            // note the cursor position upon clicking
            const { x, y } = args.event;
            // note the boundary positions of the nearest chart element
            const left = (chart.tooltip.dataPoints[0].element.x - (chart.tooltip.dataPoints[0].element.width/2));
            const right = (chart.tooltip.dataPoints[0].element.x + (chart.tooltip.dataPoints[0].element.width/2));
            const top = (chart.tooltip.dataPoints[0].element.y);
            const bottom = (chart.tooltip.dataPoints[0].element.y + chart.tooltip.dataPoints[0].element.height);
            // proceed if the cursor is within the element boundaries
            if (x >= left && x <= right && y >= top && y <= bottom) {
                // note the selected element title
                const title = chart.tooltip.title[0];
                console.log('title', title);
            }
        }
    }
}]
stockiNail commented 2 years ago

So how to trigger the call and fetch the tooltip title of the clicked element in order to identify which element has been clicked? Well, there follows my solution in case it helps anyone. I've no doubt it could be written more elegantly, but it works for me.

@losttheplot maybe I didn't catch your use case but I was was wondering why you are not using getElementsAtEventForMode method of the chart to get the "clicked" element.

losttheplot commented 2 years ago

maybe I didn't catch your use case but I was was wondering why you are not using getElementsAtEventForMode method of the chart to get the "clicked" element.

@stockiNail As I understand it, getElementsAtEventForMode() is good for fetching the label and value of the clicked element, but not for fetching the tooltip title, although I might be wrong. Without the title, the return from clicking any element in a stacked bar will give the same label, which is no good if one needs to identify the element more than the bar.

stockiNail commented 2 years ago

@losttheplot the tooltip title is by default the label and then the tick on the scale. Therefore if you haven't changed by a tooltip callback, the title is the label at that index. But getting any element (whatever in the stack), you have the index property of the element which is the index for your label.

Here is a codepen: https://codepen.io/stockinail/pen/abqaooq

Note that I have added the plugin options with events: ['click'] in order to be sure that the plugin will receive ONLY click events. I have used a div element in html page to set the text with the selected label.

Plugin:

const plugin = {
    id: 'customer',
    afterEvent(chart, args, options) {
      const div = document.getElementById("text");
      const elements = chart.getElementsAtEventForMode(args.event, 'nearest', { axis: 'xy', intersect: true }, true);
      if (elements && elements.length) {
        const element = elements[0];
        div.innerText = myLabels[element.index];
      } else {
        div.innerText = '';
      }
    }
};

Plugin Options:

    options: {
      ...
      plugins: {
        customer: {
          events: ['click']
        }
      },
      ...
   }
losttheplot commented 2 years ago

@stockiNail - Thank you for taking the time to discuss this issue, and I am sorry if I am missing something that you are attempting to educate me about. I understand the use of the index - if you look at my first post in this thread (7 March), you will see how I have used it to reference my label dataset. But this is a stacked bar chart.

I have indeed changed the default tooltip titles as my bar labels repeat themselves as groups of three years with additional custom month labels beneath them (see attached pic).

tooltip: {
    callbacks: {
        title: function(tooltipItems) {
            if (typeof tooltipItems[0] !== 'undefined') {
                var dstLabel = tooltipItems[0].dataset.label;
                return parseInt(tooltipItems[0].label, 10)+(2000) + ', ' + dstLabel;
            }
        },
        label: function(context) {
            return ' ' + Number(context.raw).toFixed(0).replace(/./g, function(c, i, a) {
                return i > 0 && c !== '.' && (a.length - i) % 3 === 0 ? ',' + c : c;
            });
        }
    }
}

So the default labels (20, 21, 22 etc.) are not unique to each bar, let alone to each element on those bars.

a

If we look at your codepen (again, thanks for doing this), your output is identical for each stacked element within a given bar, which is not what I need. I need to identify exactly which element has been clicked so that I can then use this title to grab the correct data for display in the resultant modal which opens when clicked via an ajax call (I left this code out of my snippet for simplicity). For example: '2022, Group Booker' and I then identify the month clicked with the following code...

const mthWidth = ((chart.chartArea.width/47)*4);
const month = Math.floor(((x - chart.chartArea.left)/mthWidth)+1);

So you are right in what you first said about not understanding the context of my use-case, but regardless of this, I still can't see how your suggested code can identify (by its output) the precise stacked element that has been clicked. Sorry again if I'm missing something.

stockiNail commented 2 years ago

@losttheplot Thank you very much for the details. Now it's clear. In fact, I didn't catch your use case but now it's clear. I thought the chart would have the same config of your post on March, 7th but you customized the tooltip and probably also the scales in order to have those ticks. I have to admit my curiosity in this use case. Personally, but that's my opinion and could be wrong, I don't like to go thru the tooltip to get my metadata even because the tooltip is usually build with data that you can retrieve from the chart.

If it's not a problem for you (I don't want to annoying you), I'd like to change my codepen going to the picture you put here. And if I understood well, you are looking to have the dataset label ("Group broker") and the month and year ("2022", "Jan").

stockiNail commented 2 years ago

@losttheplot may I ask you if you have time to prepare a codepen with your use case?

stockiNail commented 2 years ago

@losttheplot I have tried to recreate your use case in codepen: https://codepen.io/stockinail/pen/dydqpLj

I used your tooltip callbacks but I use a plugin which is using the getElement to extract data from chart.

Let me know if it's close to your case.

losttheplot commented 2 years ago

@stockiNail - As requested, I have prepared a codepen of my use-case, although it took a while to recreate because the chart is a small cog in a very big wheel and the various items of data are fetched dynamically via ajax calls. The chart also sits within a complex layout framework so it is completely responsive in its proper context. And just for the record, I altered the larger app such that it is impossible for the user to create sales category names that are not unique.

https://codepen.io/losttheplot/pen/NWyLpwV

Thank you for preparing your far-more-elegant solution which I will incorporate into my use-case in a few days time. Your simple construction of the year/month labels is much better than mine.

Prior to my adding my post of a few days ago, I could not find anything at all on the web to help me work out how to do what we have now both achieved by different means, so in terms of contributing to the wider community, I don't think either of us have wasted our time :)

stockiNail commented 2 years ago

As requested, I have prepared a codepen of my use-case, although it took a while to recreate because the chart is a small cog in a very big wheel and the various items of data are fetched dynamically via ajax calls. The chart also sits within a complex layout framework so it is completely responsive in its proper context. And just for the record, I altered the larger app such that it is impossible for the user to create sales category names that are not unique.

THANK YOU VERY MUCH!!!

I don't think either of us have wasted our time :)

Absolutely agree with you! And both methods are correct! I believe that when you are sharing, with different standpoints, it's always a learning for all. Thank you very much!

losttheplot commented 2 years ago

@stockiNail - The labels sub-array idea didn't work for me because the length of the month names tipped the layout balance such that the labels slanted at the most common viewport size (I'm a perfectionist designer). So I combined your more elegant data-grabbing solution with my month-labels workaround to end up with this:

plugins: [{
    afterEvent(chart, args, options) {
        if (args.event.type === 'click' && typeof chart.tooltip.dataPoints !== 'undefined') {
            const elements = chart.getElementsAtEventForMode(args.event, 'nearest', { axis: 'xy', intersect: true }, true);
            if (elements && elements.length) {
                const element = elements[0];
                const dataset = chart.data.datasets[element.datasetIndex];
                const value = dataset.data[element.index];
                const label = chartLabels[element.index];
                const year = parseInt(label, 10) + 2000;
                const mthWidth = ((chart.chartArea.width/47)*4);
                const month = Math.floor(((args.event.x - chart.chartArea.left)/mthWidth)+1);
                alert("Year: " + year + ", Month: " + month + ", Category: '" + dataset.label +"', Value: " + value);
            }
        }
    }
}]

Thanks again for your help with this.

I also managed to achieve a much more responsive-width-stable (and centred) layout for the 'year' labels thus (with deliberately verbose CSS for the sake of this example):

<div class="chart-container">
    <canvas id="stackedBarChart" width="1000" height="600" aria-label="Sales by Category" role="img"></canvas>
</div>
<div class="months-container" style="margin:0 0 6px 45px">
    <table style="width:100%;">
        <tr>
            <td style="width: 8.5%; padding-left: 2.2%">Jan</td>
            <td style="width: 8.5%; padding-left: 2.2%">Feb</td>
            <td style="width: 8.5%; padding-left: 2.2%">Mar</td>
            <td style="width: 8.5%; padding-left: 2.2%">Apr</td>
            <td style="width: 8.5%; padding-left: 2.2%">May</td>
            <td style="width: 8.5%; padding-left: 2.2%">Jun</td>
            <td style="width: 8.5%; padding-left: 2.2%">Jul</td>
            <td style="width: 8.5%; padding-left: 2.2%">Aug</td>
            <td style="width: 8.5%; padding-left: 2.2%">Sep</td>
            <td style="width: 8.5%; padding-left: 2.2%">Oct</td>
            <td style="width: 8.5%; padding-left: 2.2%">Nov</td>
            <td style="width: 6.5%; padding-left: 2.2%">Dec</td>
        </tr>
    </table>
</div>
stockiNail commented 2 years ago

@losttheplot Yeah! when I have prepared the codepen I wasn't sure how you implemented the multiline ticks (for this reason I asked you for a sample ;) ). That's GREAT! thank you!