Search code examples
chart.jschart.js-datalabels

Hide overlapping labels based on smallest value (or other custom logic) in chartjs-plugin-datalabels


display: "auto" only hides overlapping labels based on the relative index; instead I need to hide, if 2 or more labels overlap, the label that has the smallest value.

I got this far, and it partially works, but only if I manually hover the mouse pointer over each slice of the doughnut in turn:

var ctx = document.querySelector('#myChart');

var chart = new Chart(ctx, {
type: 'doughnut',
data: {
    labels: Array.from(Array(50).keys()),
    datasets: [{
        label: "Value",
        data: Array.from(Array(50).keys()).map((e) => Math.round(Math.random() * 800 + 200)),
        datalabels: {
            anchor: "end",
            align: "end",

            display: function (context) {
                const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
                const cl = context.chart.$datalabels._labels.find((l) =>
                    context.dataIndex == l._index
                );
                const clv = context.dataset.data[context.dataIndex];
                return !context.chart.$datalabels._labels.some((l) =>
                    context.dataIndex != l._index &&
                    overlap(cl.$layout._box._rect, l.$layout._box._rect) &&
                    clv <= context.dataset.data[l._index]
                );
            },
        },
    }],
},
options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    parsing: false,
    plugins: {
        legend: {
            display: false,
        },
    },
    layout: {
        padding: 20,
    },

},
plugins: [ChartDataLabels],
});
<canvas id="myChart"></canvas>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-datalabels.min.js"></script>

I think the problem is that datalabels only computes the layout of the labels if the label is visible -- but I need the layout of the label (to test for overlap between labels) to decide whether the label should be visible. Also my code above feels a bit gross as it's reaching into undocumented data structures of datalabels, and also it's technically O(n^2) - although n tends to be small.

Does anyone have any suggestion about how to ask datalabel to reliably give me the layout of the label even if it has not been computed yet (or, otherwise, any way to reliably achieve the goal stated above)?


Solution

  • Computing datalabels visibility could be implemented in a custom plugin that runs an afterUpdate callback after that of the standard ChartDataLabels plugin and "post-processes" the results of that plugin's layout.

    It is important to note that if the datalabels.display is set to "auto", chart datalabels plugin will compute the layout of the labels in the update cycle, see layout.js#L139 and #L145 in the source code, called from layout.prepare which in turn is called by plugin.afterUpdate.

    If display is not "auto", the layout is computed in the draw cycle, at afterDatasetsDraw, which makes changing the visibility impossible without an explicit call to chart.update from the plugin, which should obviously be avoided if possible.

    Here's a possible implementation of the callbacks afterUpdate and beforeDraw of such a plugin (I called it ChartDataLabels_pp0 in the full snippet below):

    afterUpdate(chart){
       const display = chart.$datalabels._labels.map(()=>true);
       chart.$datalabels._labels.forEach((cl) => {
          const cIdx = cl.$context.dataIndex;
          const clv = cl.$context.dataset.data[cIdx];
          display[cIdx] = !chart.$datalabels._labels.some(l =>
             cl !== l &&
             display[l.$context.dataIndex] && // ignore if already hidden
             cl.$layout._box.intersects(l.$layout._box) &&
             clv <= l.$context.dataset.data[l.$context.dataIndex]
          );
       });
       chart.$datalabels._labels.forEach((cl) => {
          // one way to store the values of `display` for each label
          cl.$computedV = display[cl.$context.dataIndex];
       });
    },
    beforeDraw(chart){
       chart.$datalabels._labels.forEach((cl) => {
          cl.$layout._visible = cl.$computedV;
       });
    }
    

    Obviously, these methods could be appended to the ChartDataLabels plugin itself to avoid the need of a supplemental plugin, but that would be probably too hacky.

    Here's the original snippet with this plugin added:

    const ChartDataLabels_pp0 = {
        beforeInit(chart){
            if(!chart.$datalabels){
                throw new Error(`ChartDataLabels_pp0 plugin should be loaded after ChartDataLabels`);
            }
        },
        beforeUpdate(chart){
            chart.data.datasets[0].datalabels.display = "auto"; // needed to compute the labels at update time
        },
        afterUpdate(chart){
            const display = chart.$datalabels._labels.map(()=>true);
            chart.$datalabels._labels.forEach((cl) => {
                const cIdx = cl.$context.dataIndex;
                const clv = cl.$context.dataset.data[cIdx];
                display[cIdx] = !chart.$datalabels._labels.some(l =>
                    cl !== l &&
                    display[l.$context.dataIndex] && // ignore if already hidden
                    cl.$layout._box.intersects(l.$layout._box) &&
                    clv <= l.$context.dataset.data[l.$context.dataIndex]
                );
            });
            chart.$datalabels._labels.forEach((cl) => {
                // one way to store the values of `display` for each label
                cl.$computedV = display[cl.$context.dataIndex];
            });
            console.log('pp0:', display.reduce((s, v)=>s+(v?1:0), 0) + ' visible labels')
        },
        beforeDraw(chart){
            chart.$datalabels._labels.forEach((cl) => {
                cl.$layout._visible = cl.$computedV;
            });
        }
    }
    
    const N = 50;
    const data = Array.from(Array(N).keys()).map((e) => Math.round(Math.random() * 800 + 200));
    console.log(`const data = `+JSON.stringify(data))
    
    const config = {
        type: 'doughnut',
        data: {
            labels: Array.from(Array(N).keys()),
            datasets: [{
                label: "Value",
                data,
                datalabels: {
                    anchor: "end",
                    align: "end",
                    backgroundColor: 'rgba(255, 0, 0, 0.2)',
                    display: "auto"
                },
            }],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            animation: false,
            parsing: false,
            plugins: {
                legend: {
                    display: false,
                },
            },
            layout: {
                padding: 20,
            },
    
        },
        
        plugins: [ChartDataLabels, ChartDataLabels_pp0]
    
    };
    
    new Chart('myChart', config);
    <canvas id="myChart"></canvas>
    
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-datalabels.min.js"></script>

    There's one more issue that might be relevant: if a label that was just hidden, was (before being hidden) overlapping another label, the status of that other label should be reconsidered after the first one is no longer visible. Experiments with the provided MRE and the solution above show that this is a significant effect: there are often large gaps between the visible labels, where one or more hidden labels could be set visible without overlapping with the other visible labels.

    The code below implements an improved version of the visibility computation, dealing with this issue. Describing the code that is posted below can't be very helpful; it is sufficient to mention that in a first stage it is computing for each label all the labels that are overlapping it according to the custom logic, then in a while loop updates the display array until stationary.

    Here's the code snippet with this:

    const ChartDataLabels_pp1 = {
        beforeInit(chart){
            if(!chart.$datalabels){
                throw new Error(`ChartDataLabels_pp1 plugin should be loaded after ChartDataLabels`)
            }
        },
        beforeUpdate(chart){
            chart.data.datasets[0].datalabels.display = "auto";
        },
        afterUpdate(chart){
            // first, compute all overlaps, according to custom logic
            const overlappedBy = chart.$datalabels._labels.map(()=>[]);
            chart.$datalabels._labels.forEach((cl) => {
                const cIdx =  cl.$context.dataIndex,
                    clv = cl.$context.dataset.data[cIdx];
                overlappedBy[cIdx] = chart.$datalabels._labels.filter(l =>
                    l !== cl &&
                    cl.$layout._box.intersects(l.$layout._box) &&
                    clv <= l.$context.dataset.data[l.$context.dataIndex]
                ).map(l => l.$context.dataIndex);
            });
    
            let changed = true,
                memo_visible = [chart.$datalabels._labels.length], // to avoid infinite looping
                display = chart.$datalabels._labels.map(()=>true);
            while(changed){
                changed = false;
                const display1 = [...display];
                overlappedBy.forEach((overlapping, idx) => {
                    display1[idx] = overlapping.filter(idxO=>display1[idxO]).length === 0;
                    if(display1[idx] !== display[idx]){
                        changed = true;
                    }
                });
                const nVisible_new = display1.reduce((s, v)=>s+(v?1:0), 0);
                if(!changed){
                    display = display1;
                    break;
                }
                if(memo_visible.includes(nVisible_new)){
                    break;
                }
                memo_visible.push(nVisible_new);
                display = display1;
            }
            chart.$datalabels._labels.forEach((cl) => {
                cl.$computedV = display[cl.$context.dataIndex];
            });
            console.log('pp1:', display.reduce((s, v)=>s+(v?1:0), 0) + ' visible labels')
        },
        beforeDraw(chart){
            chart.$datalabels._labels.forEach((cl) => {
                cl.$layout._visible = cl.$computedV;
            });
        }
    }
    
    const N = 50;
    const data = Array.from(Array(N).keys()).map((e) => Math.round(Math.random() * 800 + 200));
    console.log(`const data = `+JSON.stringify(data))
    
    const config = {
        type: 'doughnut',
        data: {
            labels: Array.from(Array(N).keys()),
            datasets: [{
                label: "Value",
                data,
                datalabels: {
                    anchor: "end",
                    align: "end",
                    backgroundColor: 'rgba(255, 0, 0, 0.2)',
                    display: "auto"
                },
            }],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            animation: false,
            parsing: false,
            plugins: {
                legend: {
                    display: false,
                },
            },
            layout: {
                padding: 20,
            },
    
        },
        
        plugins: [ChartDataLabels, ChartDataLabels_pp1]
    
    };
    
    new Chart('myChart', config);
    <canvas id="myChart"></canvas>
    
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-datalabels.min.js"></script>

    This codepen is showing side by side the results of the first version of the plugin, the improved version and the default "auto" algorithm.

    For both versions, the results will always depend on the first label that is considered: there is one result if we loop through the labels by their data indices [0, 1, ..., 49] and another if we loop through the labels as [1, 2, ..., 49, 0].

    Note that this is, as requested, just an idea, not a generic solution: allowing for multiple datasets, reading options also from options.plugins.datalabels rather than just data.dataset[0].datalabels, or enabling animation would make the code much more complicated.