[Chartjs]-How to add icons next to the text on the scales with chart.js?

1👍

Drawing a svg in chart.js is relatively easy, it can be done thus:

const imgSVG = new Image();
imgSVG.src = '../assets/svg.svg';
// .... after the image is loaded
ctx.drawImage(imgSVG, x, y, w, h);

where access to the 2d context ctx is available throughout various chart.js handlers as a property of the chart or scale objects (chart.ctx or scale.ctx).

Getting more into details of the proposed solution, we define (for one svg image) an object, svgDrawPositions that contains the details of (possibly multiple) required renderings:

svgDrawPositions: {[id: string]: {x: number, y: number, rotation: number, h: number}}

The ids by which the drawing positions are indexed, are, in this particular case, the ids of the axes, since the svg is to be displayed for each of the two axes.

We’ll come later to the way in which the x, y, rotation, h values are computed. For now, we’ll consider that they are already available. Then, the actual drawing of the svg can be done through a simple plugin with just an afterDraw method:

let svgLoaded = false;
const imgSVG = new Image();
imgSVG.onerror = function(){
    console.warn('Error loading image');
};
imgSVG.onload = function(){
    svgLoaded = true;
    drawAllPositions(document.getElementById('chart1').getContext('2d'));
};
imgSVG.src = '../assets/svg.svg';
function drawAllPositions(ctx){
    if(!ctx || !svgLoaded){
        return;
    }
    Object.values(svgDrawPositions).forEach(({x, y, rotation, h})=>
    {
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(rotation);
        const w = imgSVG.width / imgSVG.height * h;
        ctx.drawImage(
            imgSVG, 0, 0, w, h
        );
        ctx.restore();
    })
};

const pluginDrawSVG = {
    id: 'drawSVG',
    afterDraw: (chart) => {
        drawAllPositions(chart.ctx)
    },
};
Chart.register(pluginDrawSVG);

We only required the height of the svg rendering in svgDrawPositions because we use the actual svg’s aspect ratio to compute the width.

Now, the real difficulty is how to set the positions of the renderings. That’s because the title of the axes is one of the very few items whose rendering details are not exposed in the chart object. The axes lines, ticks, tick labels, grid lines, all have their live information in the chart object, but the axis title is not there. The solution for that then is not very nice, and consists in duplicating the source code of chart.js related to positioning of axes titles.

The framework is then that of custom scale classes:

function scaleDrawTitle(){
    const {
        ctx,
        id,
        options: {position, title},
    } = this;
    
    // code taken from chart.js source to compute the position: x, y, rotation, h

    svgDrawPositions[id] = {x, y, rotation, h};
};

class CategoryScaleTE extends CategoryScale{
    static id = 'category_te';

    drawTitle(){
        super.drawTitle();
        scaleDrawTitle.call(this);
    }
}

class LinearScaleTE extends LinearScale{
    static id = 'linear_te';

    drawTitle(){
        super.drawTitle();
        scaleDrawTitle.call(this);
    }
}

Chart.register(CategoryScaleTE, LinearScaleTE);

The code in this stackblitz fork uses the original chart.js 3.3.2, while the snippet demo below (no angular) is based on the latest version 4.3.3

const {toFont, toPadding, isArray} = Chart.helpers;
const tooltipPlugin = Chart.registry.getPlugin('tooltip');
const {CategoryScale, LinearScale} = Chart;

tooltipPlugin.positioners.verticallyCenter = (elements) => {
    if(!elements.length){
        return tooltipPlugin.positioners.average(elements);
    }
    const {x, y, base, width} = elements[0].element;
    const height = (base - y) / 2;
    const offset = x + width / 2;
    return {
        x: offset,
        y: y + height,
    };
};

/* ------------
CategoryScaleTE and LinearScaleTE - custom scale classes, that set up the positions where the
svg image will be drawn. Since the axis title is not exposed in the chart object, the following
functions duplicate the source code of chart.js related to positioning of axis title
*/
const offsetFromEdge = (scale, edge, offset) =>
    edge === 'top' || edge === 'left'
        ? scale[edge] + offset
        : scale[edge] - offset;

const _alignStartEnd = (align, start, end) =>
    align === 'start' ? start : align === 'end' ? end : (start + end) / 2;

function titleArgs(scale, offset, position, align){
    const {top, left, bottom, right} = scale;
    let rotation = 0;
    let maxWidth, titleX, titleY;
    if(scale.isHorizontal()){
        titleX = _alignStartEnd(align, left, right);
        titleY = offsetFromEdge(scale, position, offset);
        maxWidth = right - left;
    }
    else{
        titleX = offsetFromEdge(scale, position, offset);
        titleY = _alignStartEnd(align, bottom, top);
        rotation = position === 'left' ? -Math.PI / 2 : Math.PI / 2;
    }
    return {titleX, titleY, maxWidth, rotation};
}

function scaleDrawTitle(){
    const {
        ctx,
        id,
        options: {position, title},
    } = this;
    if(!title.display){
        return;
    }
    const font = toFont(title.font);
    const padding = toPadding(title.padding);
    const align = title.align;
    let offset = font.lineHeight / 2;
    if(position === 'bottom'){
        offset += padding.bottom;
        if(isArray(title.text)){
            offset += font.lineHeight * (title.text.length - 1);
        }
    }
    else{
        offset += padding.top;
    }
    const {titleX, titleY, rotation} = titleArgs(
        this,
        offset,
        position,
        align
    );

    ctx.save();
    ctx.font = title.font;
    const mt = ctx.measureText(title.text);
    ctx.restore();
    const h0 = mt.fontBoundingBoxAscent + mt.fontBoundingBoxDescent,
        h = Math.max(20, h0);

    const dx0 = mt.width / 2 + 4, // 4 for the space between text and svg
        dy0 = h0 / 2 - h - 1, // to have approx the same "underline" as the text
        dx = dx0 * Math.cos(rotation) - dy0 * Math.sin(rotation),
        dy = dx0 * Math.sin(rotation) + dy0 * Math.cos(rotation),
        x = titleX + dx,
        y = titleY + dy;

    svgDrawPositions[id] = {x, y, rotation, h};
};

class CategoryScaleTE extends CategoryScale{
    static id = 'category_te';

    drawTitle(){
        super.drawTitle();
        scaleDrawTitle.call(this);
    }
}

class LinearScaleTE extends LinearScale{
    static id = 'linear_te';

    drawTitle(){
        super.drawTitle();
        scaleDrawTitle.call(this);
    }
}

Chart.register(CategoryScaleTE, LinearScaleTE);

/* ------------
svg image and pluginDrawSVG - actually drawing the swg on canvas
*/
const svgDrawPositions = {};
let svgLoaded = false;

const imgSVG = new Image();
imgSVG.onerror = function(){
    console.warn('Error loading image');
};
imgSVG.onload = function(){
    svgLoaded = true;
    drawAllPositions(document.getElementById('chart1').getContext('2d'));
};
imgSVG.src = 'https://web.archive.org/web/20230602150221if_/https://upload.wikimedia.org/wikipedia/commons/4/4f/SVG_Logo.svg';

function drawAllPositions(ctx){
    if(!ctx || !svgLoaded){
        return;
    }
    Object.values(svgDrawPositions).forEach(({x, y, rotation, h})=>
    {
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(rotation);
        const w = imgSVG.width / imgSVG.height * h;
        ctx.drawImage(
            imgSVG, 0, 0, w, h
        );
        ctx.restore();
    })
};

const pluginDrawSVG = {
    id: 'drawSVG',
    afterDraw: (chart) => {
        drawAllPositions(chart.ctx)
    },
};
Chart.register(pluginDrawSVG);

const labels = [
    'Jan',
    'Feb',
    'Mar',
    'april',
    'may',
    'jun',
    'july',
    'aug',
    'sept',
];

const data = {
    labels: labels,
    datasets: [
        {
            maxBarThickness: 40,
            label: '',
            data: [50, 20, 30, 75, 30, 60, 70, 80, 100],
            backgroundColor: 'red',
        },
    ],
};

chart = new Chart('chart1', {
    type: "bar",
    data,
    options: {
        aspectRatio: 2,
        layout: {
            padding: {
                top: 0,
            },
        },
        responsive: true,
        maintainAspectRatio: true,
        scales: {
            y: {
                type: 'linear_te',
                title: {
                    display: true,
                    text: 'Y Axis',
                },
                axis: 'y',
                grid: {
                    display: false,
                    drawTicks: false,
                    tickLength: 0,
                },
                max: 100,

                ticks: {
                    major: {
                        enabled: false,
                    },
                    padding: 17,
                    stepSize: 25,
                    callback: (value, index, ticks) => {
                        return index === 0 || index === ticks.length - 1 ? '' : `${value}%`;
                    },
                },
            },
            x: {
                type: 'category_te',
                title: {
                    display: true,
                    text: 'X Axis axis axis',
                },
                axis: 'x',
                grid: {drawTicks: false},
                ticks: {
                    padding: 17,
                },
            },
        },
        plugins: {
            tooltip: {
                position: 'verticallyCenter',
                animation: {duration: 0},
                callbacks: {
                    title: (context) => {
                        return context[0].label;
                    },
                },
            },
            legend: {
                display: false,
                position: 'bottom',
            },
            title: {
                display: false,
            },
        },
    }
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.3.3/chart.umd.min.js"
        integrity="sha512-mCXCsj30LV3PLPTIuWjZQX84qiQ56EgBZOsPUA+ya5mWmAb8Djdxa976zWzxquOwkh0TxI12KA4eniKpY3yKhA=="
        crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<div style="max-height:500px">
    <canvas id="chart1"></canvas>
</div>

Leave a comment