Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

event handler on center of doughnut chart using chart.js

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();
                    }
                  }
                })
like image 391
techie_28 Avatar asked Jul 07 '16 10:07

techie_28


1 Answers

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.

like image 196
rsp Avatar answered Sep 30 '22 18:09

rsp