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/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/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)?
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/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/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/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/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.