ministe
ministe

Reputation: 583

ChartJS: Position labels at end of doughnut segment

I'm using chartJS with extension chartjs-gauge, which is basically a customised doughnut used to create a gauge style chart. I've added the chartjs-plugin-datalabels plugin so I can customise the labels.

Labels in pie and doughnut charts are usually used to display the value of that segment, but since this has been customised to appear as a gauge I want to position my data labels at the end of each segment so it displays the value at that point like so:

Label position I'm aiming for

Whilst it is possible to move the labels around using anchor, align and offset, these allow limited movement around a point and I can't figure out a way to achieve what I need. I can anchor to 'end' to get the label outside of the gauge and then I thought maybe I could combine align and offset to push the label to the end of the segment but unfortunately those commands seem to work based on the axis of the whole gauge and not the arced segment.

Here is the code I'm using to set the options and data. This is within a Salesforce Aura component so chartJsGaugeInstance gets the values from a controller:

let chartJsGaugeInstance = component.get("v.dataInstance");
let type = chartJsGaugeInstance.type;
let target = chartJsGaugeInstance.targetValue;
let firstDivide = target * 0.33;
let secondDivide = target * 0.66;
let data = {
    datasets : [{
        backgroundColor: ["#0fdc63", "#fd9704", "#ff7143"],
        data: [firstDivide, secondDivide, target],
        value: chartJsGaugeInstance.dataValues,
        datalabels: {
            anchor: 'center',
            align: 'center',
            offset: 0
        }
    }]
};

let options = {
    responsive: true,
    title: {
        display: true,
        text: chartJsGaugeInstance.title
    },
    layout: {
        padding: {
            bottom: 30
        }
    },
    needle: {
        // Needle circle radius as the percentage of the chart area width
        radiusPercentage: 1.5,
        // Needle width as the percentage of the chart area width
        widthPercentage: 1,
        // Needle length as the percentage of the interval between inner radius (0%) and outer radius (100%) of the arc
        lengthPercentage: 80,
        // The color of the needle
        color: 'rgba(0, 0, 0, 1)'
    },
    valueLabel: {
        formatter: Math.round
    },
    cutoutPercentage : 80,
    plugins: {
        datalabels: {
            display: true,
            formatter:  function (value, context) {
                return context.chart.data.labels[context.dataIndex];
            },
            color: 'rgba(0, 0, 0, 1.0)',
            backgroundColor: null,
            font: {
                size: 16
            }
        }
    }
}

And this is the resulting chart: Labels with center positioning I can move the labels around by changing the datalabels parameters within the datasets object (in this example I've left them in the centre) but as they all move relative to the chart area rather than the direction of the relative segment, I can't get them to all align to the end of their segments. Here is an example which moves the 6k label to about the right place, but the 3k and 10k labels end up way out:

datalabels: {
    anchor: 'end',
    align: 'right',
    offset: 50
}

Labels right aligned This is the documentation I'm using for data label positioning. One line under the Align section says:

'end': the label is positioned after the anchor point, following the same direction

I thought this is what I wanted, "following the same direction" to me suggests it should follow the arc of the segment but it doesn't:

datalabels: {
    anchor: 'end',
    align: 'end',
    offset: 20
}

Labels with end positioning

Has anyone else managed to achieve this? There is a similar issue raised in github marked as "enhancement" since 2018 but wondered if there is a way to do this either programatically or by editing the source files since it doesn't look like that'll be delivered any time soon.

Thanks!

Update

I've managed to find a partial solution to this using Scriptable Options. The following updates to the code gives me nicely positioned labels at the end of each sector. I've moved the datalabels code out of datasets and into options

let numSectors = data.datasets[0].data.length;
let sectorDegree = 180 / numSectors;

datalabels: {
    anchor: 'end',
    align: function(context){
        return (sectorDegree * context.dataIndex) - sectorDegree;
    },
    offset: function(context){
        return 70;
    },
    ...
}

This results in: latest looking gauge

I'm happy with this for my current project because I'll always have three evenly split sectors, HOWEVER it isn't a full solution. The angles I've calculated are massively dependent on the offset. Changing the offset means the align calculation no longer gives nice results. The big problem here is that because of offset being so critical, changing the number of sectors means this solution no longer works.

Upvotes: 7

Views: 18135

Answers (2)

naomi
naomi

Reputation: 2643

My idea was to draw an additional transparent chart on top of the original chart that will be in charge of drawing the labels. The labels chart data will contain segments wrapping the end of each segment in the original chart, in a way that label displayed in the middle of the labels chart segment will actually be displayed at the end of the original chart segments.

Here is the code for drawing the transparent labels chart:


const getLabelsChartData = (min, data) => {
  if (!data) return [];
  const max = data[data.length - 1];
  const step = (max - min) / 1000;
  const values = data
    .slice(0, -1) // remove "max" value
    .map((value, index) => {
      // remove too close values (because labels collapse)
      const prevValue = data[index - 1];
      if (!prevValue || value - prevValue > step * 50) {
        return value;
      }
    })
    .reduce((arr, value) => {
      // create small ranges between each value
      if (value) {
        return [...arr, value - step, value + step];
      }
      return arr;
    }, []);
  return [...values, max];
};

const getLabel = (originalData, labelIndex) => {
  if (labelIndex % 2) {
    const originalDataIndex = Math.floor(labelIndex / 2);
    return originalData[originalDataIndex];
  }
  return '';
};

const labelsChartConfig = {
    type: 'gauge',
    plugins: [ChartDataLabels],
    data: {
      data: getLabelsChartData(minValue, originalData),
      minValue: minValue,
      fill: false,
      backgroundColor: ['transparent'],
      borderColor: 'transparent',
    }
    options: {
      plugins: {
        datalabels: {
          display: true,
          formatter: function (value, context) {
            return getLabel(originalData, context.dataIndex);
          }
          anchor: 'end',
          align: 'end',
        },
      },
    }
}

new Chart(labelsCanvas, labelsChartConfig);

Upvotes: 1

Gretchen
Gretchen

Reputation: 31

Your code in the align function really helped me get started but I wanted the positioning to be a bit different and I wrote this code to calculate the position based on the segment amounts, the desired font size (for proper offset), and the width of the canvas' parent.

let numSectors = insert length of data array here;
let sectorDegree = 180 / numSectors;
let width = (canvas.parent().width() - parseInt(canvas.css('padding').replace("px", "") * 2));
let fontSize = 16;
let b = (width / 2) - 3;
let c = b - (fontSize * 1.8);
let a = Math.sqrt((Math.pow(b, 2) + Math.pow(c, 2)) - (2 * b * c * Math.cos((sectorDegree / 2) * Math.PI / 180)));
let offset = a - (fontSize * 1.2);
let alignAngle = (Math.asin(Math.sin((sectorDegree / 2) * Math.PI / 180) * c / a) * 180 / Math.PI) - (sectorDegree / 2);

datalabels: {
  display: true,
  anchor: 'end',
  font: {
    size: fontSize,
  },
  offset: offset,
  align: function(context) {
    return (sectorDegree * context.dataIndex) - alignAngle;
  },
}

Upvotes: 3

Related Questions