I have this following Prototype created by our Designer using Figma.
My stack is:
So my questions is can we add this custom border on certain segment on click?
Goals: Add "custom border" with different color and enable only the Top border
I did my research but still not having the exact expectation:
My closes result codepen
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chart.js Doughnut Chart with Clickable Legend</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* Limit the size of the chart */
#chart-container {
width: 500px;
height: 500px;
margin: auto; /* Center the chart horizontally */
}
canvas {
display: block;
}
</style>
</head>
<body>
<div id="chart-container">
<canvas id="myDoughnutChart"></canvas>
</div>
<script>
const ctx = document.getElementById('myDoughnutChart').getContext('2d');
const myDoughnutChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Green', 'Yellow', 'Orange', 'Purple', 'Blue', 'Pink'],
datasets: [{
data: [25, 25, 25, 25, 25, 30],
backgroundColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderWidth: Array(6).fill(5),
borderAlign: 'outer'
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'right',
onClick: function(e, legendItem, legend) {
const dataset = legend.chart.data.datasets[0];
const index = legendItem.index;
// Reset all borders to 5px
dataset.borderWidth = dataset.borderWidth.map(() => 5);
// dataset.borderColor = dataset.borderColor.map(() => '');
// Set the border width of the selected segment to 10px
dataset.borderWidth[index] = 20;
// dataset.borderAlign[index] = 'outer';
// Update the chart to apply the changes
legend.chart.update();
}
}
}
}
});
</script>
</body>
</html>
A custom border can be drawn using a custom plugin. The
hook to be used to draw
on the canvas after the other elements were drawn is afterDraw
. The plugin can have a state, stored in its options. How to retrieve the options in the plugin code and how/where to set them in the chart configuration object or elsewhere is well documented and included in many similar small plugins included in answers here on SO.
I'll assume, based on your code that you want the custom border to be activated by a click to the legend; in the snippet below, a subsequent click will disable the border.
new Chart('myDoughnutChart', {
type: 'doughnut',
data: {
labels: ['Green', 'Yellow', 'Orange', 'Purple', 'Blue', 'Pink'],
datasets: [{
data: [25, 25, 25, 25, 25, 30],
backgroundColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderWidth: 0,
hoverBorderWidth: 0,
borderAlign: 'outer'
}]
},
options: {
responsive: true,
radius: "95%", // use to create space between the doughnut and the legend
plugins: {
// "custom-border": {
// color: '#8fa', // default '#48f', in plugin code, indexable
// size: 20 // default 10, in plugin code, indexable
// },
legend: {
position: 'right',
onClick: function({chart}, {index}) {
chart.options.plugins["custom-border"].on[index] = !chart.options.plugins["custom-border"].on[index];
chart.update('none');
}
}
}
},
plugins:[
{
id: "custom-border",
beforeInit(chart){
if(!chart.options.plugins['custom-border']){
chart.options.plugins['custom-border'] = {};
}
if(!chart.options.plugins['custom-border'].on || chart.options.plugins['custom-border'].on.length === 0){
chart.options.plugins['custom-border'].on = Array(chart.data.datasets[0].data.length).fill(false);
}
},
afterDraw(chart, _, options){
if(!options.on || !options.on.includes(true)){
return;
}
const meta = chart.getDatasetMeta(0);
for(let i = 0; i < options.on.length; i++){
if(options.on[i]){
const {x, y, startAngle, endAngle, outerRadius: r} = meta.data[i];
const {ctx} = chart;
ctx.save();
const colorOption = chart.options.plugins['custom-border'].color;
const color = Array.isArray(colorOption) ? colorOption[i] : colorOption;
ctx.fillStyle = color || '#48f';
const sizeOption = chart.options.plugins['custom-border'].size;
const size = Array.isArray(sizeOption) ? sizeOption[i] : sizeOption;
const dr = size ?? 10;
ctx.beginPath();
ctx.arc(x, y, r - 1, startAngle, endAngle);
ctx.lineTo(x + (r + dr) * Math.cos(endAngle), y + (r + dr) * Math.sin(endAngle));
ctx.arc(x, y, r + dr, endAngle, startAngle, true);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
}
}
]
});
#chart-container {
width: 500px;
height: 500px;
margin: auto; /* Center the chart horizontally */
}
canvas {
display: block;
}
<div id="chart-container">
<canvas id="myDoughnutChart"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
The same, triggered by hovering the doughnut elements (based on the title of the post):
new Chart('myDoughnutChart', {
type: 'doughnut',
data: {
labels: ['Green', 'Yellow', 'Orange', 'Purple', 'Blue', 'Pink'],
datasets: [{
data: [25, 25, 25, 25, 25, 30],
backgroundColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderWidth: 0,
hoverBorderWidth: 0,
borderAlign: 'outer'
}]
},
options: {
responsive: true,
radius: "95%", // use to create space between the doughnut and the legend
onHover: function({chart}, elements){
const elementIndices = elements.map(el=>el.index);
chart.options.plugins['custom-border'].on = Array.from({length: chart.data.datasets[0].data[0]},
(_, i) => elementIndices.includes(i));
chart.update('none');
},
plugins: {
// "custom-border": {
// color: '#8fa', // default '#48f', in plugin code, indexable
// size: 20 // default 10, in plugin code, indexable
// },
legend: {
position: 'right'
}
}
},
plugins:[
{
id: "custom-border",
beforeInit(chart){
if(!chart.options.plugins['custom-border']){
chart.options.plugins['custom-border'] = {};
}
if(!chart.options.plugins['custom-border'].on || chart.options.plugins['custom-border'].on.length === 0){
chart.options.plugins['custom-border'].on = Array(chart.data.datasets[0].data.length).fill(false);
}
},
beforeEvent(chart, {event}){
if (event.type === 'mouseout') {
chart.options.plugins['custom-border'].on = Array(chart.data.datasets[0].data[0]).fill(false);
chart.update('none');
}
},
afterDraw(chart, _, options){
if(!options.on || !options.on.includes(true)){
return;
}
const meta = chart.getDatasetMeta(0);
for(let i = 0; i < options.on.length; i++){
if(options.on[i]){
const {x, y, startAngle, endAngle, outerRadius: r} = meta.data[i];
const {ctx} = chart;
ctx.save();
const colorOption = chart.options.plugins['custom-border'].color;
const color = Array.isArray(colorOption) ? colorOption[i] : colorOption;
ctx.fillStyle = color || '#48f';
const sizeOption = chart.options.plugins['custom-border'].size;
const size = Array.isArray(sizeOption) ? sizeOption[i] : sizeOption;
const dr = size ?? 10;
ctx.beginPath();
ctx.arc(x, y, r - 1, startAngle, endAngle);
ctx.lineTo(x + (r + dr) * Math.cos(endAngle), y + (r + dr) * Math.sin(endAngle));
ctx.arc(x, y, r + dr, endAngle, startAngle, true);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
}
}
]
});
#chart-container {
width: 500px;
height: 500px;
margin: auto; /* Center the chart horizontally */
}
canvas {
display: block;
}
<div id="chart-container">
<canvas id="myDoughnutChart"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
The next snippet, adds some additional features, not covered by the minimal solution above. Still, it might prove interesting to some, because it includes code that shows how to work with plugin defaults
and how to retrieve the default values outside the plugin, (see radius
callback)
and how to change the appearance of the legend color box, in this case to indicate that the custom border is on for that data item:
new Chart('myDoughnutChart', {
type: 'doughnut',
data: {
labels: ['Green', 'Yellow', 'Orange', 'Purple', 'Blue', 'Pink'],
datasets: [{
data: [25, 25, 25, 25, 25, 30],
backgroundColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderColor: [
'rgb(71,159,182)',
'rgb(248,210,103)',
'rgb(238,129,48)',
'rgb(66,44,142)',
'rgb(41,99,246)',
'rgb(221,84,112)'
],
borderWidth: 0,
hoverBorderWidth: 0,
borderAlign: 'outer'
}]
},
options: {
responsive: true,
radius: function({chart}){
// used to create space between the doughnut and the legend
// might also be set manually
const pluginCustomBorder = chart.registry.plugins.get("custom-border") ??
chart.config.plugins.find(({id})=>id==='custom-border');
let customBorderSize = chart.options.plugins["custom-border"].size ??
pluginCustomBorder.defaults.size;
if(Array.isArray(customBorderSize)){
customBorderSize = Math.max(...customBorderSize);
}
customBorderSize += 5;
const radiusFrac = 1-customBorderSize/chart.chartArea.width;
return `${Math.floor(radiusFrac*100)}%`;
},
plugins: {
// "custom-border": {
// color: ['#8fa', '#f8a', '#a8f', '#8fa', '#f8a', '#a8f'], // default '#48f', in plugin code, indexable
// size: 15 // default 10, in plugin code, indexable
// },
legend: {
position: 'right',
labels:{
usePointStyle: true,
pointStyleWidth: 40,
pointStyleHeight: 12,
generateLabels: function(chart){
const labelPlugin = this;
if(!labelPlugin.$cachedImages){
labelPlugin.$cachedImages = Array(chart.data.datasets[0].data.length).fill(null);
}
const ret = Array.from({length: chart.data.datasets[0].data.length}, (_, index) => {
let pointStyle = 'rect';
if(chart.options.plugins["custom-border"].on[index]){
if(labelPlugin.$cachedImages[index]){
pointStyle = labelPlugin.$cachedImages[index];
}
else{
const canvasImg = document.createElement('canvas');
const width = chart.legend.options.labels.pointStyleWidth ?? 40,
height = chart.legend.options.labels.pointStyleHeight ?? 12;
canvasImg.width = width;
canvasImg.height = height;
const ctx = canvasImg.getContext('2d');
ctx.fillStyle = chart.data.datasets[0].backgroundColor[index];
ctx.fillRect(0, Math.round(height/4) + 1, width, height);
const colorOption = chart.options.plugins['custom-border'].color;
const color = Array.isArray(colorOption) ? colorOption[index] : colorOption;
ctx.fillStyle = color || '#48f';
ctx.fillRect(0, 0, width, Math.round(height/4));
const image = new Image(width, height);
image.onload = function(){
chart.update('none');
}
image.src = canvasImg.toDataURL("image/png").replace("image/png", "image/octet-stream");
labelPlugin.$cachedImages[index] = image;
pointStyle = image;
}
}
return {
text: chart.data.labels[index],
fillStyle: chart.data.datasets[0].backgroundColor[index],
strokeStyle: chart.data.datasets[0].backgroundColor[index],
index,
pointStyle
}
});
return ret;
}
},
onClick: function({chart}, {index}) {
chart.options.plugins["custom-border"].on[index] = !chart.options.plugins["custom-border"].on[index];
chart.update('none');
}
}
}
},
plugins:[
{
id: "custom-border",
defaults: {
color: '#48f',
size: 10
},
beforeInit(chart){
if(!chart.options.plugins['custom-border']){
chart.options.plugins['custom-border'] = {};
}
if(!chart.options.plugins['custom-border'].on || chart.options.plugins['custom-border'].on.length === 0){
chart.options.plugins['custom-border'].on = Array(chart.data.datasets[0].data.length).fill(false);
}
},
afterDraw(chart, _, options){
if(!options.on || !options.on.includes(true)){
return;
}
const meta = chart.getDatasetMeta(0);
for(let i = 0; i < options.on.length; i++){
if(options.on[i]){
const {x, y, startAngle, endAngle, outerRadius: r} = meta.data[i];
const {ctx} = chart;
ctx.save();
const colorOption = chart.options.plugins['custom-border'].color;
const color = Array.isArray(colorOption) ? colorOption[i] : colorOption;
ctx.fillStyle = color || this.defaults.color;
const sizeOption = chart.options.plugins['custom-border'].size;
const size = Array.isArray(sizeOption) ? sizeOption[i] : sizeOption;
const dr = size ?? this.defaults.size;
ctx.beginPath();
ctx.arc(x, y, r - 1, startAngle, endAngle);
ctx.lineTo(x + (r + dr) * Math.cos(endAngle), y + (r + dr) * Math.sin(endAngle));
ctx.arc(x, y, r + dr, endAngle, startAngle, true);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
}
}
]
});
#chart-container {
width: 500px;
height: 500px;
margin: auto; /* Center the chart horizontally */
}
canvas {
display: block;
}
<div id="chart-container">
<canvas id="myDoughnutChart"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>