javascriptreactjschart.js

Line fill borderradius in Chart.js


Is there a way to add a borderradius to the fill background on a line chart in Chart.js?

An example of what I want to achieve: Line fill with borderradius

I've used fill: 'start' on the dataset and the background gets set. The only thing missing is the borderradius.

Edit: Added current progress

const ctx = document.getElementById('LineFill').getContext('2d');
const data = {
  labels: ['2020', '2021', '2022', '2023'],
  datasets: [
        {
            label: 'Dataset 1',
            data: [1,3,5,7],
            backgroundColor: 'transparent',
            borderDash: [10, 5],
            borderColor: '#1189D0',
        },
        {
            label: 'Dataset 2',
            data: [1,2,3],
            borderColor: '#001946',
            backgroundColor: '#001946',
            fill: 'start',
            tension: 0.9,
            borderRadius: 5,
        },
    ],
};

const config = {
  type: 'line',
  data: data,
  options: {
    responsive: true,
    plugins: {
      legend: {
        display: false
      },
    },
    scales: {
      x: {
        stacked: false,
        ticks: {
          display: true
        },
      },
      y: {
        stacked: false,
        beginAtZero: true,
      }
    }
  }
};

new Chart(ctx, config);
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<canvas id="LineFill">


Solution

  • There is no option to set a borderRadius-like property for chart.js's filler. And, in general, unlike with CSS, it is non-trivial to set a border radius for a figure (that is not a rectangle) drawn using the Canvas API.

    Since a plugin in chart.js can draw on the canvas and also interrogate the positions of the data points on the plot, one can set up such a plugin that fills a region corresponding to the standard fill of a line dataset with rounded corners.

    However, such a figure is not compatible with the rest of the line drawing, since the points should remain at their (corner) positions, and the line will start and end at those points; so the points and line for the dataset that is filled-rounded should be made invisible.

    Here's a simplified version of such a plugin:

    // from chart.js/chunks/helpers.dataset.js
    function _bezierInterpolation(p1, p2, t) {
        const _pointInLine = (p1, p2, t) => ({
            x: p1.x + t * (p2.x - p1.x),
            y: p1.y + t * (p2.y - p1.y)
        });
    
        const cp1 = {
            x: p1.cp2x,
            y: p1.cp2y
        };
        const cp2 = {
            x: p2.cp1x,
            y: p2.cp1y
        };
        const a = _pointInLine(p1, cp1, t);
        const b = _pointInLine(cp1, cp2, t);
        const c = _pointInLine(cp2, p2, t);
        const d = _pointInLine(a, b, t);
        const e = _pointInLine(b, c, t);
        return _pointInLine(d, e, t);
    }
    
    const pluginRoundedCornersFill = {
        id: 'pluginRoundedCornersFill',
        afterDatasetDraw(chart, {meta}, options) {
            let indexFound = false, R, fillColor;
            for(const datasetOptions of options?.datasets ?? []){
                if(datasetOptions.index === meta.index){
                    indexFound = true;
                    R = datasetOptions.radius ?? 5;
                    fillColor = datasetOptions.fillColor ?? 'rgba(0, 0, 0, 0.1)';
                    break;
                }
            }
            if(indexFound){
                const points = meta.data;
                const ctx = meta.controller._ctx;
                ctx.save();
                ctx.fillStyle = fillColor;
                ctx.beginPath();
                const nPoints = points.length;
                const factFirst = R / Math.hypot(points[0].x-points[1].x, points[0].y-points[1].y);
                const factLast = R / Math.hypot(points[nPoints-1].x-points[nPoints-2].x, points[nPoints-1].y-points[nPoints-2].y);
                const pFirst = _bezierInterpolation(points[0], points[1], factFirst);
                const pLast = _bezierInterpolation(points[nPoints-2], points[nPoints-1], 1-factLast);
                const pointsMod = [...points];
                pointsMod[0] = {...points[0]};
                pointsMod[0].x  = pFirst.x;
                pointsMod[0].y  = pFirst.y;
                pointsMod[nPoints-1] = {...points[nPoints-1]};
                pointsMod[nPoints-1].x  = pLast.x;
                pointsMod[nPoints-1].y  = pLast.y;
                ctx.moveTo(pointsMod[0].x, pointsMod[0].y);
                for(let i = 0; i < nPoints - 1; i++){
                    const previous = pointsMod[i], target = pointsMod[i + 1];
                    ctx.bezierCurveTo(previous.cp2x, previous.cp2y,
                        target.cp1x, target.cp1y, target.x, target.y);
                }
                ctx.arcTo(points[nPoints-1].x, points[nPoints-1].y, points[nPoints-1].x, points[nPoints-1].y + R, R);
                const y0 = meta.iScale.top;
                ctx.lineTo(points[nPoints-1].x, y0 - R);
                ctx.arcTo(points[nPoints-1].x, y0, points[nPoints-1].x - R, y0, R);
                ctx.lineTo(points[0].x + R, y0);
                ctx.arcTo(points[0].x, y0, points[0].x, y0 - R, R);
                ctx.lineTo(points[0].x, points[0].y + R);
                ctx.arcTo(points[0].x, points[0].y, pointsMod[0].x, pointsMod[0].y, R);
                ctx.lineTo(pointsMod[0].x, pointsMod[0].y);
                ctx.fill();
                ctx.restore();
            }
        }
    }
    
    const data = {
        labels: ['2020', '2021', '2022', '2023'],
        datasets: [
            {
                label: 'Dataset 1',
                data: [2, 3, 5, 7],
                backgroundColor: 'transparent',
                borderDash: [10, 5],
                borderColor: '#1189D0',
            },
            {
                label: 'Dataset 2',
                data: [1, 2, 3],
                borderWidth: 0,
                pointRadius: 0,
                pointHitRadius: 0,
                //backgroundColor: '#00d0f6',
                //fill: 'start',
                tension: 0.4, // for line-interior angles
            },
        ],
    };
    
    const config = {
        type: 'line',
        data: data,
        options: {
            responsive: true,
            tension: 0.3,
            animation: false,
            plugins: {
                legend: {
                    display: false
                },
                pluginRoundedCornersFill: {
                    datasets: [
                        {
                            index: 1,
                            fillColor: '#1189D077',
                            radius: 10
                        }
                    ]
                }
            },
            scales: {
                x: {
                    stacked: false,
                    ticks: {
                        display: true
                    },
                },
                y: {
                    stacked: false,
                    beginAtZero: true,
                }
            }
        },
        plugins: [pluginRoundedCornersFill]
    };
    
    new Chart('LineFill', config);
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <canvas id="LineFill"></canvas>