Search code examples
typescriptchartschart.jsdata-visualizationaurelia

Grouping y-axis labels on multiple lines on a ChartJS with an extra dimension


I have with this answer on SO (Using ChartJS to create a multiple grouped bar chart - see picture below) been able to successfully get the secondary row of labels on the y-axis working correctly and dynamically.

I was wondering if there is a way to make them go on multiple lines? See the labels in the image below:

enter image description here

Since it is a dynamically built chart and behaves pretty responsively, I sometimes have data that is longer than the allowed space.

So the Lower than 2.50 sometimes might be so long it overruns into the next (Total) box. For instance if Lower than 2.50 was something like "The quick brown fox jumps over the lazy dog a bunch of times and ran into the Total column", then it would overlap.

I need to find a way to fix this.


Solution

  • TL;DR

    Look at the very bottom of this answer (there are two ways, choose the second): the easiest way is to use a string array (string []) for individual categories at data side and print each array item with a separate ctx.fillText() call on the printing side.

    Code part #1:

    CanvasRenderingContext2D.prototype.wrapText = function(text, x, y, maxWidth, lineHeight) {
    
      var lines = text.split("\n");
      var linesCount = 0;
      for (var i = 0; i < lines.length; i++) {
    
        var words = lines[i].split(' ');
        var line = '';
    
        for (var n = 0; n < words.length; n++) {
          var testLine = line + words[n] + ' ';
          var metrics = this.measureText(testLine);
          var testWidth = metrics.width;
          if (testWidth > maxWidth && n > 0) {
            this.fillText(line, x, y);
            linesCount += 1;
            line = words[n] + ' ';
            y += lineHeight;
          } else {
            line = testLine;
          }
        }
    
        this.fillText(line, x, y);
        linesCount += 1;
        y += lineHeight;
      }
      return linesCount;
    }
    new Chart('myChart', {
      type: 'bar',
      plugins: [{
        afterDraw: chart => {
          let ctx = chart.chart.ctx;
          ctx.save();
          let xAxis = chart.scales['x-axis-0'];
          let xCenter = (xAxis.left + xAxis.right) / 2;
          let yBottom = chart.scales['y-axis-0'].bottom;
          ctx.textAlign = 'center';
          ctx.font = '12px Arial';
          var size1 = ctx.wrapText(chart.data.categories[0], (xAxis.left + xCenter) / 2, yBottom + 40, 160, 16);
          var size2 = ctx.wrapText(chart.data.categories[1], (xCenter + xAxis.right) / 2, yBottom + 40, 160, 16);
          var size = size1 > size2 ? size1 : size2;
          chart.options.legend.labels.padding = size * 16 + 5;
          ctx.strokeStyle = 'lightgray';
          [xAxis.left, xCenter, xAxis.right].forEach(x => {
            ctx.beginPath();
            ctx.moveTo(x, yBottom);
            ctx.lineTo(x, yBottom + 40);
            ctx.stroke();
          });
          ctx.restore();
        }
      }],
      data: {
        labels: ['2004', '2008', '2012', '2016', '2004', '2008', '2012', '2016'],
        categories: ['The quick brown fox jumps over the lazy dog', 'Lower than 2.50'],
        datasets: [{
            label: 'Male',
            data: [42.4, 43.0, 43.0, 50.3, 49.4, 48.4, 51.2, 51.8],
            backgroundColor: 'rgba(124, 181, 236, 0.9)',
            borderColor: 'rgb(124, 181, 236)',
            borderWidth: 1
          },
          {
            label: 'Female',
            data: [57.6, 57.0, 57.0, 49.7, 50.6, 51.6, 53.7, 54.6],
            backgroundColor: 'rgba(67, 67, 72, 0.9)',
            borderColor: 'rgb(67, 67, 72)',
            borderWidth: 1
          }
        ]
      },
      options: {
        legend: {
          position: 'bottom',
          labels: {
            padding: 30,
            usePointStyle: true
          }
        },
        scales: {
          yAxes: [{
            ticks: {
              min: 0,
              max: 80,
              stepSize: 20
            },
            scaleLabel: {
              display: true,
              labelString: 'Percent (%)'
            }
          }],
          xAxes: [{
            gridLines: {
              drawOnChartArea: false
            }
          }]
        }
      }
    });
    canvas {
      max-width: 400px;
    }
    <!DOCTYPE html>
    <html>
    
    <body>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.js"></script>
      <canvas id="myChart" height="200"></canvas>
    </body>
    
    </html>

    Explanations #1:

    First thing I fixed was to solve, why the chart title on lower x-axis did not write to two lines. There in the linked solution, fillText was used. It is a method of surrounding canvas element, but it has a limitation, that you cannot make it without a work to go on several lines. It can, though, be schrinked to fit the space [1] but soon the text looks awful. So, StackOveflow, find a workaround [2]. There were several solutions, none easy, but I found that wrapText approach most convenient. It makes the text as long as I want and all comes nicely lined.

    Second issue was that I had to adjust legend downwards to avoid multiple content overlapping in same space. Therewhore I added a calculation of lines to wrapText function and used that to generate the correct amount of space.

    The above works fine with a two liner of that font used. For example a sentence "The quick brown fox jumps over the lazy dog" works just fine. Then becomes the limitations of space in chart to take place: if content goes to third line the legend is cut from bottom as it does not fit the canvas anymore. For that I tried the following change:

      ctx.font = '8px Arial';
      var size1 = ctx.wrapText(chart.data.categories[0], (xAxis.left + xCenter) / 2, yBottom + 40, 160, 8);
      var size2 = ctx.wrapText(chart.data.categories[1], (xCenter + xAxis.right) / 2, yBottom + 40, 160, 8);
      var size = size1 > size2 ? size1 : size2;
      chart.options.legend.labels.padding = size * 8 + 8;
    

    where I just played the font size to 8 pixels. It shows all data (that your text also) but looks not so pretty.

    Final notes on part #1:

    This solution is a work around to a work around (the plugin solution). The ultimate solution with the longest possible text looks so awkward that I believe there is a nicer way around somewhere, but as I could make it work this far I decided to share my trial in case no other fix will not be possible so you have at least this one.

    EDIT: More 'Chart.js' way, and convenient:

    Code part, #2

    new Chart('myChart', {
      type: 'bar',
      plugins: [{
        beforeDraw: chart => {
          let ctx = chart.chart.ctx;
          ctx.save();    
          let xAxis = chart.scales['x-axis-0'];
          let xCenter = (xAxis.left + xAxis.right) / 2;
          let yBottom = chart.scales['y-axis-0'].bottom;
          ctx.textAlign = 'center';
          ctx.font = '12px Arial';
          ctx.fillText(chart.data.categories[0][0], (xAxis.left + xCenter) / 2, yBottom + 30);
          ctx.fillText(chart.data.categories[0][1], (xAxis.left + xCenter) / 2, yBottom + 40);
          ctx.fillText(chart.data.categories[0][2], (xAxis.left + xCenter) / 2, yBottom + 50);
          ctx.fillText(chart.data.categories[1], (xCenter + xAxis.right) / 2, yBottom + 40);
          ctx.strokeStyle  = 'lightgray';
          [xAxis.left, xCenter, xAxis.right].forEach(x => {
            ctx.beginPath();
            ctx.moveTo(x, yBottom);
            ctx.lineTo(x, yBottom + 40);
            ctx.stroke();
          });
          ctx.restore();
        }
      }],
      data: {
        labels: ['2004', '2008', '2012', '2016', '2004', '2008', '2012', '2016'],
        categories: [['The quick brown fox jumps over', 'the lazy dog a bunch of times','and ran into the Total column'], ['Lower than 2.50']],
        datasets: [{
            label: 'Male',
            data: [42.4, 43.0, 43.0, 50.3, 49.4, 48.4, 51.2, 51.8],
            backgroundColor: 'rgba(124, 181, 236, 0.9)',
            borderColor: 'rgb(124, 181, 236)',
            borderWidth: 1
          },
          {
            label: 'Female',
            data: [57.6, 57.0, 57.0, 49.7, 50.6, 51.6, 53.7, 54.6],
            backgroundColor: 'rgba(67, 67, 72, 0.9)',
            borderColor: 'rgb(67, 67, 72)',
            borderWidth: 1
          }
        ]
      },
      options: {
        legend: {
          position: 'bottom',
          labels: {
            padding: 30,
        align: 'end',
            usePointStyle: true
          }
        },
        scales: {
          yAxes: [{
            ticks: {
              min: 0,
              max: 80,
              stepSize: 20
            },
            scaleLabel: {
              display: true,
              labelString: 'Percent (%)'
            }
          }],
          xAxes: [{
            gridLines: {
              drawOnChartArea: false
            }
          }]
        }
      }
    });
    canvas {
      max-width: 400px;
    }
    <!DOCTYPE html>
    <html>
    <head>
        <script src="/scripts/snippet-javascript-console.min.js?v=1"></script>
    </head>
    <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.js"></script>
    <canvas id="myChart" height="200"></canvas>
    </body>

    Explanation, part #2

    So, all labels, titles, etc has two options (for example that on [3]):

    1. string

    2. string[]

    From these the option 1 is all in one row, and guess what: in array you can define it row wise. One item is one row in inner array!

    In your example you print this time by yourself, so you just refer the string array and define where the referred content is written!

    I took instead of smaller font an approach to take the content little bit up on page and the example text fits just perfectly!!

    Sources:

    [1] https://www.w3schools.com/tags/playcanvas.asp?filename=playcanvas_filltextmaxwidth

    [2] HTML5 canvas ctx.fillText won't do line breaks?

    [3] https://www.chartjs.org/docs/latest/axes/labelling.html