event handler on center of doughnut chart using chart.js
Asked Answered
S

1

6

I have created 4 doughnut chart on a page which has some text in the center,I know this can be done by placing a DIV over the center but I cant use that as the text doesn't get exported when the chart is downloaded as PNG :

Demo:https://jsfiddle.net/cmyker/ooxdL2vj/

I need to track the click of the center text for this I tried using the pageX,pageY to determine if the click is made on the center section.

The coordinates are of the corners of the rectangular section which is inside the central hole of the doughnut chart & is likely to have the text within.

jQuery('#canvas').on('click',function(e){
  var pageX = e.pageX;                                  
  var pageY = e.pageY;
      if((pageY >= 379 && pageY <= 571) && (pageX >= 440 && pageX <= 629)){   //coordinates are of rectangular area which has text inside the center of doughnut chart.
             //do something                                          
      }
});

but this wont work if the resolution of the screen is different as the coordinates will vary.

Any Ideas please?

I tried to use raphael.js to make the center clickable but not very sure of this attempt. I am trying to use the container approach to create a circle in the center hole of donuts on which a click handler could be attached.

Code info using Raphael JS

Chart.pluginService.register({
                  beforeDraw: function(chart) {
                  if(chart['data']['midNum']){
                      var width = chart.chart.width,
                          height = chart.chart.height,
                          ctx = chart.chart.ctx;

                      ctx.restore();
                      var fontSize = (height / 114).toFixed(2);
                      ctx.font = fontSize + "em sans-serif";
                      ctx.textBaseline = "middle";

                      var text = chart['data']['midNum'],
                          textX = Math.round((width - ctx.measureText(text).width) / 2),
                          textY = height / 2.5;
                        var chartID = chart['chart']['canvas']['id']; //the ID of element on which this donut was created

                        var paper  = Raphael(chartID,textX,textY); //trying to use the container approach
                        var circle = paper.circle(textX, textY, 10);
                            circle.attr("fill", "#f00");
                      //ctx.clearRect(0,0,width,height);
                      //ctx.fillText(text, textX, textY);
                      //ctx.save();
                    }
                  }
                })
Stiff answered 7/7, 2016 at 10:20 Comment(0)
B
5

This is an answer to the original question about this code. Since it was posted the question has been changed several times - the requirement to save as PNG was added, the number of charts was changed from 1 in the original code to 4 and the framework used was changed from Chart.js rendering on HTML Canvas to Raphaël rendering on SVG. I am leaving the solutions that I posted in hope that it will be useful to someone in the future.

I have few ideas here:

Finding pixels

A slower but a sure way: knowing that you are interested in black pixels, you can iterate over all pixels of the canvas and remember 4 numbers: the smallest and biggest x and y coordinates of any black pixel that you find. You can add some margin to that and you'll have a rectangle that is always spot on, even when the library starts to write the text in a different place in future versions.

You'll have to recalculate it every time a window is resized, after the canvas is redrawn.

For that to work your text will have to be in a color that is not present anywhere else on the canvas (which is currently the case).

Guess coordinates

You can guess the coordinates - or calculate them, to be more precise - knowing how it is drawn. It seems that the big circle is taking the entire space on the smaller dimension (the entire height in this case) and is centered in the other axis (in this case it's centered horizontally).

Using that knowledge you can calculate the size and position of the inner (white) circle having only the canvas dimension in a way similar to this:

Find which width or height of the canvas is smaller and use it as a base number. Dividing it by 2 will give you R - the radius of the big circle. Dividing R/2 will roughly give you r - the radius of the small, internal white circle. Calculate x and y - coordinates of the center of the canvas - x = width/2 and y = height /2.

Now you can experiment with the rectangle where the text will be. It may be something like: x - 0.7*r and x + 0.7*r for left and right edges and y - 0.4*r and y + 0.4*r for the bottom and top edges. Those are just examples, you can tweek those numbers to your satisfaction.

Those numbers don't have to be perfect because you should have a few pixels of margin around the text anyway.

Here it may not work when the library starts to draw it completely differently in the future, but it probably won't for a simple chart like this.

The good thing is that you don't have to look for specific colors and that calculation will be faster that examining every pixel.

Again, you have to recalculate those dimensions if the chart ever gets redrawn with a different size.

Change your pluginService

Another idea would be to change you pluginService's beforeDraw function so that it saves the numbers that it already has.

In particular, you already have:

textX = Math.round((width - ctx.measureText(text).width) / 2),
textY = height / 2;

If you change it to:

var measured = ctx.measureText(text);
textX = Math.round((width - measured.width) / 2),
textY = height / 2;

(just to avoid recalculating the text measurement later) then you can store somewhere the following numbers:

Either just textX and textY together with measured.width and measured.height or maybe an object with following properties:

var textPos = {
    x1: textX,
    y1: textY,
    x2: textX + measured.width,
    y2: textY + measured.height
}

Make sure to use rounding if you need to. You can store that object for example in some global object, or as a data-* attribute of some HTML element (like on the canvas itself).

This last solution is nice because you don't have to worry about color, you don't have to guess where the text will be put because you know that exactly, and you don't have to worry about recalculation of this on resize because that code runs every time the text itself is drawn.

The drawback is that you need to modify your pluginService.

A div over canvas

Another way is putting a div over your canvas and putting your text in that div instead of in the canvas. That way you have all the convenience of adding event listeners etc.

You can do something like this:

Put your canvas and and empty div (or more divs) inside a bigger div:

<div id="chart">
  <canvas id="myChart"></canvas>
  <div class="chart-text" id="text1"></div>
</div>

You can add more divs like the text1 for more circles/charts, like this:

<div id="chart">
  <canvas id="myChart"></canvas>
  <div class="chart-text" id="text1"></div>
  <div class="chart-text" id="text2"></div>
</div>

Add this CSS to have them stack properly:

#chart { position: relative; }
.chart-text { position: absolute; }

And now you add your text to that inner div instead of drawing it on the canvas:

var text1 = document.getElementById('text1');
text1.addEventListener("click", function (e) {
  alert("CLICKED!");
});

Chart.pluginService.register({
  beforeDraw: function(chart) {
    var width = chart.chart.width,
        height = chart.chart.height;

    var fontSize = (height / 114).toFixed(2);
    text1.style.font = fontSize + "em sans-serif";

    var text = "75%";
    text1.innerText = text;
    var r = text1.getBoundingClientRect();
    text1.style.left = ((width-r.width)/2)+"px";
    text1.style.top = ((height-r.height)/2)+"px";
  }
});

See DEMO.

It can probably be simplified but it is probably simpler that putting the text inside of the canvas, and you can have event listeners or easy CSS styling. For example adding:

.chart-text:hover { color: red; }

will make it red on hover.

Empty div over canvas

Here is yet another update after posting another requirements in the comments that were not included in the question.

You can have this HTML as in the version above:

<div id="chart">
<canvas id="myChart"></canvas>
<div class="chart-text" id="text1"></div>
</div>

But this time you can add an empty div over your canvas, so that way the text is included in the canvas and saving will it will include the text.

Here is CSS that is needed:

#chart { position: relative; }
.chart-text { position: absolute; }

Here is CSS that will show you the position of the invisible div:

#chart { position: relative; }
.chart-text { position: absolute; border: 1px solid red; }

And now the code to put the div where it should be:

var text1 = document.getElementById('text1');
text1.addEventListener("click", function (e) {
  alert("CLICKED!");
});

Chart.pluginService.register({
  beforeDraw: function(chart) {
    var width = chart.chart.width,
        height = chart.chart.height,
        ctx = chart.chart.ctx;

    ctx.restore();
    var fontSize = (height / 114).toFixed(2);
    ctx.font = fontSize + "em sans-serif";
    ctx.textBaseline = "middle";

    var text = "75%",
        m = ctx.measureText(text),
        textX = Math.round((width - m.width) / 2),
        textY = height / 2;

    var emInPx = 16;
    text1.style.left = textX + "px";
    text1.style.top = (textY - fontSize*emInPx/2) + "px";
    text1.style.width = m.width + "px";
    text1.style.height = fontSize+"em";

    ctx.fillText(text, textX, textY);
    ctx.save();
  }
});

Make sure that the emInPx has the correct numper of px (CSS pixels) per one em unit. You define the fontSize in em units and we need pixels to calculate the correct position.

See DEMO (it has a red border to make the div visible - just remove border: 1px solid red; from CSS to make it disappear)

Big empty div over canvas

Another example - this time the div is bigger than the text:

var text1 = document.getElementById('text1');
text1.addEventListener("click", function (e) {
  alert("CLICKED!");
});

Chart.pluginService.register({
  beforeDraw: function(chart) {
    var width = chart.chart.width,
        height = chart.chart.height,
        ctx = chart.chart.ctx;

    ctx.restore();
    var fontSize = (height / 114).toFixed(2);
    ctx.font = fontSize + "em sans-serif";
    ctx.textBaseline = "middle";

    var text = "75%",
        m = ctx.measureText(text),
        textX = Math.round((width - m.width) / 2),
        textY = height / 2;

    var d = Math.min(width, height);
    var a = d/2.5;

    text1.style.left = ((width - a) / 2) + "px";
    text1.style.top = ((height - a) / 2) + "px";
    text1.style.width = a + "px";
    text1.style.height = a + "px";

    ctx.fillText(text, textX, textY);
    ctx.save();
  }
});

See DEMO. It doesn't depend on the em size in px and on the text size. This line changes the size of the square:

var a = d / 2.5;

You can try changing the 2.5 to 2 or 3 or something else.

Round empty div over canvas

This is a variant that uses border-radius to make a round div instead of rectangular and seems to fill up the inner white circle perfectly.

HTML:

<div id="chart">
<canvas id="myChart"></canvas>
<div class="chart-text" id="text1"></div>
</div>

CSS:

#chart, #myChart, .chart-text {  padding: 0; margin: 0; }
#chart { position: relative; }
.chart-text { position: absolute; border-radius: 100%; }

JS:

var text1 = document.getElementById('text1');
text1.addEventListener("click", function (e) {
  alert("CLICKED!");
});

Chart.pluginService.register({
  beforeDraw: function(chart) {
    var width = chart.chart.width,
        height = chart.chart.height,
        ctx = chart.chart.ctx;

    ctx.restore();
    var fontSize = (height / 114).toFixed(2);
    ctx.font = fontSize + "em sans-serif";
    ctx.textBaseline = "middle";

    var text = "75%",
        m = ctx.measureText(text),
        textX = Math.round((width - m.width) / 2),
        textY = height / 2;

    var d = Math.min(width, height);
    var a = d / 2;

    text1.style.left = (((width - a) / 2 - 1)|0) + "px";
    text1.style.top = (((height - a) / 2 - 1)|0) + "px";
    text1.style.width = a + "px";
    text1.style.height = a + "px";

    ctx.fillText(text, textX, textY);
    ctx.save();
  }
});

See DEMO.

Brook answered 18/7, 2016 at 9:1 Comment(12)
There are 4 donuts on a single page & clickable text is to be there in center for each.Can I not somehow create a circle & then fill text using Raphael in the central hole of each?Stiff
@Stiff If you have more than one circle then I think another solution would be better. In fact it is even simpler even in one circle. See the updated answer. If you have 4 circles then just add text2, text3, text4 in addition to the text1 in my example.Brook
yes I have tried this before but the DOM elements do not get exported when the chart is extracted as a PNG(using base64encoding)Stiff
I have updated my attempt with Raphael in the question.Im trying to use the container approach here where the chartID is the id of canvas element on which donuts are drawn but this doesnt draw anything.Am I doing this wrong?Stiff
@Stiff You didn't specify in the question that you need to save the chart as a PNG. In that case you may use another solution. See another update in my answer.Brook
I can consider the rectangular area up to the edges of the circle it is not necessarily to be limited over the text only so emInPx can be omitted in that case?Stiff
I just need the area to be clickable under different screen resolutions the rectangle can expand beyond the text width upto the inner edges of the circleStiff
@Stiff See another update with an example with large square that fills the inside of the circle. You may want to change its size in the line var a = d / 2.5; by changing the 2.5 to something else like 3 or 2.Brook
@Stiff Another example makes the entire white inner circle clickable - see the lastest update.Brook
Let us continue this discussion in chat.Stiff
This works fine but I cant have padding: 0; margin: 0; on the elements I guess this was causing the circle to not overlap the central hole perfectly(it was overlapping donuts body for some).The page has 4 donut charts in one over another so I just add/subtracted some pixels from left,top,width,height.It should not be a problem anywhere?Stiff
I did this jQuery(divID).css({ left : (((width - a) / 2 - 1)|0) + 5 + "px", top : (((height - a) / 2 - 1)|0) -15 + "px", width : (a - 8) + "px", height : a + "px" }); should be ok?Stiff

© 2022 - 2024 — McMap. All rights reserved.