{"spec_id":"heatmap-loss-triangle","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-loss-triangle: Actuarial Loss Development Triangle\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-03\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\n\n// --- Data -------------------------------------------------------------------\nconst accidentYears = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024];\nconst devPeriods    = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];\nconst ROWS = 10, COLS = 10;\n\n// Base ultimate losses ($thousands) per accident year\nconst ultimates = [18500, 19200, 17800, 20600, 21100, 19800, 22000, 20500, 19700, 21800];\n\n// Cumulative development pattern (fraction of ultimate reported by each period)\nconst devPct = [0.30, 0.52, 0.68, 0.79, 0.87, 0.92, 0.96, 0.98, 0.99, 1.00];\n\n// Build loss triangle — upper-left actual, lower-right projected\nconst cumulative = [], isProjected = [];\nfor (let r = 0; r < ROWS; r++) {\n    cumulative[r] = [];\n    isProjected[r] = [];\n    for (let c = 0; c < COLS; c++) {\n        cumulative[r][c] = Math.round(ultimates[r] * devPct[c]);\n        isProjected[r][c] = (r + c) >= ROWS;\n    }\n}\n\n// Volume-weighted age-to-age factors (only actual observations)\nconst ataFactors = [];\nfor (let c = 0; c < COLS - 1; c++) {\n    let num = 0, den = 0;\n    for (let r = 0; r < ROWS; r++) {\n        if (r + c < ROWS - 1) {\n            den += cumulative[r][c];\n            num += cumulative[r][c + 1];\n        }\n    }\n    ataFactors[c] = den > 0 ? num / den : null;\n}\n\nconst maxVal = Math.max(...cumulative.flat());\n\n// Interpolate between two hex colors\nfunction lerpColor(hex1, hex2, f) {\n    const parse = h => [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];\n    const [r1,g1,b1] = parse(hex1);\n    const [r2,g2,b2] = parse(hex2);\n    return `rgb(${Math.round(r1+(r2-r1)*f)},${Math.round(g1+(g2-g1)*f)},${Math.round(b1+(b2-b1)*f)})`;\n}\n\n// imprint_seq fill — projected cells blended toward page bg for visual distinction\nfunction cellFill(value, proj) {\n    const ratio = Math.pow(value / maxVal, 0.5);\n    const base  = lerpColor(t.seq[0], t.seq[1], ratio * 0.85 + 0.05);\n    return proj ? lerpColor(t.pageBg, base, 0.68) : base;\n}\n\nfunction fmtK(v) { return v >= 1000 ? (v / 1000).toFixed(0) + 'K' : String(v); }\n\n// --- Custom drawing plugin --------------------------------------------------\nconst trianglePlugin = {\n    id: 'lossTriangle',\n    beforeDraw(chart) {\n        const { ctx, width, height } = chart;\n        ctx.fillStyle = t.pageBg;\n        ctx.fillRect(0, 0, width, height);\n    },\n    afterDraw(chart) {\n        const { ctx, width, height } = chart;\n\n        // Layout proportions\n        const mTop   = height * 0.10;\n        const mBot   = height * 0.13;\n        const mLeft  = width  * 0.085;\n        const mRight = width  * 0.02;\n        const gX = mLeft, gY = mTop;\n        const gW = width  - mLeft - mRight;\n        const gH = height - mTop  - mBot;\n        const cW = gW / COLS, cH = gH / ROWS;\n\n        // Title\n        const title     = 'heatmap-loss-triangle · javascript · chartjs · anyplot.ai';\n        const titleSize = Math.round(Math.min(width, height) * 0.024);\n        ctx.save();\n        ctx.font         = `bold ${titleSize}px sans-serif`;\n        ctx.fillStyle    = t.ink;\n        ctx.textAlign    = 'center';\n        ctx.textBaseline = 'top';\n        ctx.fillText(title, width / 2, height * 0.013);\n        ctx.restore();\n\n        // Column axis label\n        const axisLblSize = Math.round(cW * 0.17);\n        ctx.save();\n        ctx.font         = `${axisLblSize}px sans-serif`;\n        ctx.fillStyle    = t.inkSoft;\n        ctx.textAlign    = 'center';\n        ctx.textBaseline = 'middle';\n        ctx.fillText('Development Period (Years)', gX + gW / 2, gY - cH * 0.55);\n        ctx.restore();\n\n        // Column headers\n        const hdrSize = Math.round(cW * 0.22);\n        ctx.save();\n        ctx.font         = `bold ${hdrSize}px sans-serif`;\n        ctx.fillStyle    = t.ink;\n        ctx.textAlign    = 'center';\n        ctx.textBaseline = 'middle';\n        for (let c = 0; c < COLS; c++) {\n            ctx.fillText(devPeriods[c], gX + c * cW + cW / 2, gY - cH * 0.22);\n        }\n        ctx.restore();\n\n        // Row axis label (rotated)\n        ctx.save();\n        ctx.translate(gX - mLeft * 0.58, gY + gH / 2);\n        ctx.rotate(-Math.PI / 2);\n        ctx.font         = `${axisLblSize}px sans-serif`;\n        ctx.fillStyle    = t.inkSoft;\n        ctx.textAlign    = 'center';\n        ctx.textBaseline = 'middle';\n        ctx.fillText('Accident Year', 0, 0);\n        ctx.restore();\n\n        // Row headers\n        const rowHdrSize = Math.round(cH * 0.24);\n        ctx.save();\n        ctx.font         = `bold ${rowHdrSize}px sans-serif`;\n        ctx.fillStyle    = t.ink;\n        ctx.textAlign    = 'right';\n        ctx.textBaseline = 'middle';\n        for (let r = 0; r < ROWS; r++) {\n            ctx.fillText(accidentYears[r], gX - cW * 0.07, gY + r * cH + cH / 2);\n        }\n        ctx.restore();\n\n        // Draw all cells\n        const valSize = Math.round(Math.min(cW, cH) * 0.20);\n        for (let r = 0; r < ROWS; r++) {\n            for (let c = 0; c < COLS; c++) {\n                const cx   = gX + c * cW;\n                const cy   = gY + r * cH;\n                const val  = cumulative[r][c];\n                const proj = isProjected[r][c];\n\n                ctx.fillStyle = cellFill(val, proj);\n                ctx.fillRect(cx + 1, cy + 1, cW - 2, cH - 2);\n\n                // Diagonal hatching on projected cells\n                if (proj) {\n                    ctx.save();\n                    ctx.beginPath();\n                    ctx.rect(cx + 1, cy + 1, cW - 2, cH - 2);\n                    ctx.clip();\n                    ctx.globalAlpha    = 0.13;\n                    ctx.strokeStyle    = THEME === 'light' ? '#1A1A17' : '#F0EFE8';\n                    ctx.lineWidth      = 1;\n                    const sp = cW * 0.22;\n                    for (let d = -cH * 2; d < cW * 2; d += sp) {\n                        ctx.beginPath();\n                        ctx.moveTo(cx + d, cy);\n                        ctx.lineTo(cx + d + cH * 1.5, cy + cH);\n                        ctx.stroke();\n                    }\n                    ctx.restore();\n                }\n\n                // Cell border\n                ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.18)' : 'rgba(240,239,232,0.18)';\n                ctx.lineWidth   = 0.5;\n                ctx.strokeRect(cx + 0.5, cy + 0.5, cW - 1, cH - 1);\n\n                // Cell value text\n                const ratio   = Math.pow(val / maxVal, 0.5);\n                const darkBg  = (ratio > 0.55 && !proj) || (ratio > 0.72 && proj);\n                ctx.save();\n                ctx.font         = `${valSize}px sans-serif`;\n                ctx.fillStyle    = darkBg ? t.pageBg : t.ink;\n                ctx.textAlign    = 'center';\n                ctx.textBaseline = 'middle';\n                ctx.fillText(fmtK(val), cx + cW / 2, cy + cH / 2);\n                ctx.restore();\n            }\n        }\n\n        // Step diagonal: boundary between actual (upper-left) and projected (lower-right)\n        ctx.save();\n        ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.82)' : 'rgba(240,239,232,0.82)';\n        ctx.lineWidth   = 2.5;\n        ctx.lineJoin    = 'miter';\n        ctx.beginPath();\n        ctx.moveTo(gX + COLS * cW, gY);\n        for (let r = 0; r < ROWS; r++) {\n            const bx = gX + (ROWS - r) * cW;\n            ctx.lineTo(bx, gY + (r + 1) * cH);\n            if (r < ROWS - 1) ctx.lineTo(gX + (ROWS - r - 1) * cW, gY + (r + 1) * cH);\n        }\n        ctx.lineTo(gX, gY + ROWS * cH);\n        ctx.stroke();\n        ctx.restore();\n\n        // ATA factors row\n        const ataSize = Math.round(cH * 0.17);\n        const ataY    = gY + gH + cH * 0.38;\n        ctx.save();\n        ctx.font         = `bold ${ataSize}px sans-serif`;\n        ctx.fillStyle    = t.inkSoft;\n        ctx.textAlign    = 'right';\n        ctx.textBaseline = 'middle';\n        ctx.fillText('ATA:', gX - cW * 0.07, ataY);\n        ctx.font      = `${ataSize}px sans-serif`;\n        ctx.textAlign = 'center';\n        for (let c = 0; c < COLS - 1; c++) {\n            const f = ataFactors[c];\n            if (f != null) ctx.fillText(f.toFixed(3), gX + (c + 1) * cW, ataY);\n        }\n        ctx.restore();\n\n        // Legend\n        const legY   = gY + gH + cH * 0.82;\n        const boxW   = cW * 1.1, boxH = cH * 0.30;\n        const legSize = Math.round(cH * 0.19);\n\n        // Actual swatch\n        const legX1 = width * 0.28;\n        ctx.fillStyle = cellFill(maxVal * 0.65, false);\n        ctx.fillRect(legX1, legY - boxH / 2, boxW, boxH);\n        ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.3)' : 'rgba(240,239,232,0.3)';\n        ctx.lineWidth   = 0.5;\n        ctx.strokeRect(legX1, legY - boxH / 2, boxW, boxH);\n        ctx.font         = `${legSize}px sans-serif`;\n        ctx.fillStyle    = t.ink;\n        ctx.textAlign    = 'left';\n        ctx.textBaseline = 'middle';\n        ctx.fillText('Actual (Observed)', legX1 + boxW + cW * 0.14, legY);\n\n        // Projected swatch\n        const legX2 = width * 0.60;\n        ctx.fillStyle = cellFill(maxVal * 0.5, true);\n        ctx.fillRect(legX2, legY - boxH / 2, boxW, boxH);\n        ctx.save();\n        ctx.beginPath();\n        ctx.rect(legX2, legY - boxH / 2, boxW, boxH);\n        ctx.clip();\n        ctx.globalAlpha  = 0.15;\n        ctx.strokeStyle  = THEME === 'light' ? '#1A1A17' : '#F0EFE8';\n        ctx.lineWidth    = 1;\n        const sp2 = boxW * 0.28;\n        for (let d = -boxH * 2; d < boxW * 2; d += sp2) {\n            ctx.beginPath();\n            ctx.moveTo(legX2 + d, legY - boxH / 2);\n            ctx.lineTo(legX2 + d + boxH * 1.5, legY + boxH / 2);\n            ctx.stroke();\n        }\n        ctx.restore();\n        ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.3)' : 'rgba(240,239,232,0.3)';\n        ctx.lineWidth   = 0.5;\n        ctx.strokeRect(legX2, legY - boxH / 2, boxW, boxH);\n        ctx.font         = `${legSize}px sans-serif`;\n        ctx.fillStyle    = t.ink;\n        ctx.textAlign    = 'left';\n        ctx.textBaseline = 'middle';\n        ctx.fillText('Projected / IBNR', legX2 + boxW + cW * 0.14, legY);\n    }\n};\n\n// --- Mount ------------------------------------------------------------------\nconst canvas = document.createElement('canvas');\ndocument.getElementById('container').appendChild(canvas);\n\n// --- Chart ------------------------------------------------------------------\nnew Chart(canvas, {\n    type: 'scatter',\n    data: { datasets: [] },\n    plugins: [trianglePlugin],\n    options: {\n        responsive: true,\n        maintainAspectRatio: false,\n        animation: false,\n        plugins: {\n            legend:  { display: false },\n            title:   { display: false },\n            tooltip: { enabled: false },\n        },\n        scales: {\n            x: { display: false },\n            y: { display: false },\n        },\n    },\n});\n"}