Actuarial Loss Development Triangle — Chart.js

A triangular matrix visualization showing cumulative insurance claim payments developing over time. Rows represent accident/origin years, columns represent development periods (e.g., 1-10 years), with the upper-left triangle displaying actual observed cumulative claims and the lower-right triangle showing projected/estimated values (IBNR). This plot is essential for actuarial reserving, enabling analysts to visualize the chain-ladder method and identify development patterns in loss data.

Actuarial Loss Development Triangle rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// heatmap-loss-triangle: Actuarial Loss Development Triangle
// Library: chartjs 4.4.7 | JavaScript 22.22.3
// Quality: 91/100 | Created: 2026-06-03
//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;
const THEME = window.ANYPLOT_THEME;

// --- Data -------------------------------------------------------------------
const accidentYears = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024];
const devPeriods    = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const ROWS = 10, COLS = 10;

// Base ultimate losses ($thousands) per accident year
const ultimates = [18500, 19200, 17800, 20600, 21100, 19800, 22000, 20500, 19700, 21800];

// Cumulative development pattern (fraction of ultimate reported by each period)
const devPct = [0.30, 0.52, 0.68, 0.79, 0.87, 0.92, 0.96, 0.98, 0.99, 1.00];

// Build loss triangle — upper-left actual, lower-right projected
const cumulative = [], isProjected = [];
for (let r = 0; r < ROWS; r++) {
    cumulative[r] = [];
    isProjected[r] = [];
    for (let c = 0; c < COLS; c++) {
        cumulative[r][c] = Math.round(ultimates[r] * devPct[c]);
        isProjected[r][c] = (r + c) >= ROWS;
    }
}

// Volume-weighted age-to-age factors (only actual observations)
const ataFactors = [];
for (let c = 0; c < COLS - 1; c++) {
    let num = 0, den = 0;
    for (let r = 0; r < ROWS; r++) {
        if (r + c < ROWS - 1) {
            den += cumulative[r][c];
            num += cumulative[r][c + 1];
        }
    }
    ataFactors[c] = den > 0 ? num / den : null;
}

const maxVal = Math.max(...cumulative.flat());

// Interpolate between two hex colors
function lerpColor(hex1, hex2, f) {
    const parse = h => [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];
    const [r1,g1,b1] = parse(hex1);
    const [r2,g2,b2] = parse(hex2);
    return `rgb(${Math.round(r1+(r2-r1)*f)},${Math.round(g1+(g2-g1)*f)},${Math.round(b1+(b2-b1)*f)})`;
}

// imprint_seq fill — projected cells blended toward page bg for visual distinction
function cellFill(value, proj) {
    const ratio = Math.pow(value / maxVal, 0.5);
    const base  = lerpColor(t.seq[0], t.seq[1], ratio * 0.85 + 0.05);
    return proj ? lerpColor(t.pageBg, base, 0.68) : base;
}

function fmtK(v) { return v >= 1000 ? (v / 1000).toFixed(0) + 'K' : String(v); }

// --- Custom drawing plugin --------------------------------------------------
const trianglePlugin = {
    id: 'lossTriangle',
    beforeDraw(chart) {
        const { ctx, width, height } = chart;
        ctx.fillStyle = t.pageBg;
        ctx.fillRect(0, 0, width, height);
    },
    afterDraw(chart) {
        const { ctx, width, height } = chart;

        // Layout proportions
        const mTop   = height * 0.10;
        const mBot   = height * 0.13;
        const mLeft  = width  * 0.085;
        const mRight = width  * 0.02;
        const gX = mLeft, gY = mTop;
        const gW = width  - mLeft - mRight;
        const gH = height - mTop  - mBot;
        const cW = gW / COLS, cH = gH / ROWS;

        // Title
        const title     = 'heatmap-loss-triangle · javascript · chartjs · anyplot.ai';
        const titleSize = Math.round(Math.min(width, height) * 0.024);
        ctx.save();
        ctx.font         = `bold ${titleSize}px sans-serif`;
        ctx.fillStyle    = t.ink;
        ctx.textAlign    = 'center';
        ctx.textBaseline = 'top';
        ctx.fillText(title, width / 2, height * 0.013);
        ctx.restore();

        // Column axis label
        const axisLblSize = Math.round(cW * 0.17);
        ctx.save();
        ctx.font         = `${axisLblSize}px sans-serif`;
        ctx.fillStyle    = t.inkSoft;
        ctx.textAlign    = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('Development Period (Years)', gX + gW / 2, gY - cH * 0.55);
        ctx.restore();

        // Column headers
        const hdrSize = Math.round(cW * 0.22);
        ctx.save();
        ctx.font         = `bold ${hdrSize}px sans-serif`;
        ctx.fillStyle    = t.ink;
        ctx.textAlign    = 'center';
        ctx.textBaseline = 'middle';
        for (let c = 0; c < COLS; c++) {
            ctx.fillText(devPeriods[c], gX + c * cW + cW / 2, gY - cH * 0.22);
        }
        ctx.restore();

        // Row axis label (rotated)
        ctx.save();
        ctx.translate(gX - mLeft * 0.58, gY + gH / 2);
        ctx.rotate(-Math.PI / 2);
        ctx.font         = `${axisLblSize}px sans-serif`;
        ctx.fillStyle    = t.inkSoft;
        ctx.textAlign    = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('Accident Year', 0, 0);
        ctx.restore();

        // Row headers
        const rowHdrSize = Math.round(cH * 0.24);
        ctx.save();
        ctx.font         = `bold ${rowHdrSize}px sans-serif`;
        ctx.fillStyle    = t.ink;
        ctx.textAlign    = 'right';
        ctx.textBaseline = 'middle';
        for (let r = 0; r < ROWS; r++) {
            ctx.fillText(accidentYears[r], gX - cW * 0.07, gY + r * cH + cH / 2);
        }
        ctx.restore();

        // Draw all cells
        const valSize = Math.round(Math.min(cW, cH) * 0.20);
        for (let r = 0; r < ROWS; r++) {
            for (let c = 0; c < COLS; c++) {
                const cx   = gX + c * cW;
                const cy   = gY + r * cH;
                const val  = cumulative[r][c];
                const proj = isProjected[r][c];

                ctx.fillStyle = cellFill(val, proj);
                ctx.fillRect(cx + 1, cy + 1, cW - 2, cH - 2);

                // Diagonal hatching on projected cells
                if (proj) {
                    ctx.save();
                    ctx.beginPath();
                    ctx.rect(cx + 1, cy + 1, cW - 2, cH - 2);
                    ctx.clip();
                    ctx.globalAlpha    = 0.13;
                    ctx.strokeStyle    = THEME === 'light' ? '#1A1A17' : '#F0EFE8';
                    ctx.lineWidth      = 1;
                    const sp = cW * 0.22;
                    for (let d = -cH * 2; d < cW * 2; d += sp) {
                        ctx.beginPath();
                        ctx.moveTo(cx + d, cy);
                        ctx.lineTo(cx + d + cH * 1.5, cy + cH);
                        ctx.stroke();
                    }
                    ctx.restore();
                }

                // Cell border
                ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.18)' : 'rgba(240,239,232,0.18)';
                ctx.lineWidth   = 0.5;
                ctx.strokeRect(cx + 0.5, cy + 0.5, cW - 1, cH - 1);

                // Cell value text
                const ratio   = Math.pow(val / maxVal, 0.5);
                const darkBg  = (ratio > 0.55 && !proj) || (ratio > 0.72 && proj);
                ctx.save();
                ctx.font         = `${valSize}px sans-serif`;
                ctx.fillStyle    = darkBg ? t.pageBg : t.ink;
                ctx.textAlign    = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText(fmtK(val), cx + cW / 2, cy + cH / 2);
                ctx.restore();
            }
        }

        // Step diagonal: boundary between actual (upper-left) and projected (lower-right)
        ctx.save();
        ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.82)' : 'rgba(240,239,232,0.82)';
        ctx.lineWidth   = 2.5;
        ctx.lineJoin    = 'miter';
        ctx.beginPath();
        ctx.moveTo(gX + COLS * cW, gY);
        for (let r = 0; r < ROWS; r++) {
            const bx = gX + (ROWS - r) * cW;
            ctx.lineTo(bx, gY + (r + 1) * cH);
            if (r < ROWS - 1) ctx.lineTo(gX + (ROWS - r - 1) * cW, gY + (r + 1) * cH);
        }
        ctx.lineTo(gX, gY + ROWS * cH);
        ctx.stroke();
        ctx.restore();

        // ATA factors row
        const ataSize = Math.round(cH * 0.17);
        const ataY    = gY + gH + cH * 0.38;
        ctx.save();
        ctx.font         = `bold ${ataSize}px sans-serif`;
        ctx.fillStyle    = t.inkSoft;
        ctx.textAlign    = 'right';
        ctx.textBaseline = 'middle';
        ctx.fillText('ATA:', gX - cW * 0.07, ataY);
        ctx.font      = `${ataSize}px sans-serif`;
        ctx.textAlign = 'center';
        for (let c = 0; c < COLS - 1; c++) {
            const f = ataFactors[c];
            if (f != null) ctx.fillText(f.toFixed(3), gX + (c + 1) * cW, ataY);
        }
        ctx.restore();

        // Legend
        const legY   = gY + gH + cH * 0.82;
        const boxW   = cW * 1.1, boxH = cH * 0.30;
        const legSize = Math.round(cH * 0.19);

        // Actual swatch
        const legX1 = width * 0.28;
        ctx.fillStyle = cellFill(maxVal * 0.65, false);
        ctx.fillRect(legX1, legY - boxH / 2, boxW, boxH);
        ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.3)' : 'rgba(240,239,232,0.3)';
        ctx.lineWidth   = 0.5;
        ctx.strokeRect(legX1, legY - boxH / 2, boxW, boxH);
        ctx.font         = `${legSize}px sans-serif`;
        ctx.fillStyle    = t.ink;
        ctx.textAlign    = 'left';
        ctx.textBaseline = 'middle';
        ctx.fillText('Actual (Observed)', legX1 + boxW + cW * 0.14, legY);

        // Projected swatch
        const legX2 = width * 0.60;
        ctx.fillStyle = cellFill(maxVal * 0.5, true);
        ctx.fillRect(legX2, legY - boxH / 2, boxW, boxH);
        ctx.save();
        ctx.beginPath();
        ctx.rect(legX2, legY - boxH / 2, boxW, boxH);
        ctx.clip();
        ctx.globalAlpha  = 0.15;
        ctx.strokeStyle  = THEME === 'light' ? '#1A1A17' : '#F0EFE8';
        ctx.lineWidth    = 1;
        const sp2 = boxW * 0.28;
        for (let d = -boxH * 2; d < boxW * 2; d += sp2) {
            ctx.beginPath();
            ctx.moveTo(legX2 + d, legY - boxH / 2);
            ctx.lineTo(legX2 + d + boxH * 1.5, legY + boxH / 2);
            ctx.stroke();
        }
        ctx.restore();
        ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.3)' : 'rgba(240,239,232,0.3)';
        ctx.lineWidth   = 0.5;
        ctx.strokeRect(legX2, legY - boxH / 2, boxW, boxH);
        ctx.font         = `${legSize}px sans-serif`;
        ctx.fillStyle    = t.ink;
        ctx.textAlign    = 'left';
        ctx.textBaseline = 'middle';
        ctx.fillText('Projected / IBNR', legX2 + boxW + cW * 0.14, legY);
    }
};

// --- Mount ------------------------------------------------------------------
const canvas = document.createElement('canvas');
document.getElementById('container').appendChild(canvas);

// --- Chart ------------------------------------------------------------------
new Chart(canvas, {
    type: 'scatter',
    data: { datasets: [] },
    plugins: [trianglePlugin],
    options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: false,
        plugins: {
            legend:  { display: false },
            title:   { display: false },
            tooltip: { enabled: false },
        },
        scales: {
            x: { display: false },
            y: { display: false },
        },
    },
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/heatmap-loss-triangle/chartjs/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "heatmap-loss-triangle",
  "language": "javascript",
  "library": "chartjs",
  "page": "https://anyplot.ai/heatmap-loss-triangle/javascript/chartjs",
  "hub": "https://anyplot.ai/heatmap-loss-triangle",
  "code_json": "https://api.anyplot.ai/specs/heatmap-loss-triangle/chartjs/code",
  "spec_json": "https://api.anyplot.ai/specs/heatmap-loss-triangle",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/heatmap-loss-triangle/javascript/chartjs/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/heatmap-loss-triangle/javascript/chartjs/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/heatmap-loss-triangle/javascript/chartjs/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/heatmap-loss-triangle/javascript/chartjs/plot-dark.html",
  "quality_score": 91.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations