{"spec_id":"heatmap-loss-triangle","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-loss-triangle: Actuarial Loss Development Triangle\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-03\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: actuarial loss triangle (chain-ladder method) -------------------\nconst accidentYears = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024];\nconst nYears = accidentYears.length;\nconst nPeriods = 10;\n\n// Age-to-age cumulative development factors (period 1→2, 2→3, ..., 9→10)\nconst devFactors = [2.85, 1.72, 1.38, 1.21, 1.12, 1.07, 1.04, 1.02, 1.01];\n\n// Initial paid claims at development period 1 (dollars, gradual upward trend by year)\nconst initialPaid = [845000, 912000, 978000, 1048000, 1115000, 1074000, 1196000, 1348000, 1420000, 1494000];\n\n// Build cumulative triangle: apply chain-ladder factors sequentially\nconst cells = [];\nfor (let i = 0; i < nYears; i++) {\n  let cumVal = initialPaid[i];\n  for (let j = 0; j < nPeriods; j++) {\n    // Upper-left triangle (i + j < nYears) is observed; lower-right is projected\n    const actual = i + j < nYears;\n    cells.push({ i, j, year: accidentYears[i], period: j + 1, value: cumVal, actual });\n    if (j < nPeriods - 1) cumVal *= devFactors[j];\n  }\n}\n\nconst vMin = d3.min(cells, d => d.value);\nconst vMax = d3.max(cells, d => d.value);\n\n// --- Layout ---------------------------------------------------------------\nconst cellSize = 90;\nconst gridW = cellSize * nPeriods;   // 900\nconst gridH = cellSize * nYears;     // 900\nconst marginLeft = 118;\nconst marginTop = 108;\n\nconst svg = d3.select(\"#container\").append(\"svg\")\n  .attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${marginLeft},${marginTop})`);\n\n// --- Defs -----------------------------------------------------------------\nconst defs = svg.append(\"defs\");\n\n// Diagonal hatch pattern for projected cells\nconst hatchPat = defs.append(\"pattern\")\n  .attr(\"id\", \"hatch\")\n  .attr(\"patternUnits\", \"userSpaceOnUse\")\n  .attr(\"width\", 10).attr(\"height\", 10)\n  .attr(\"patternTransform\", \"rotate(45 0 0)\");\nhatchPat.append(\"line\")\n  .attr(\"x1\", 0).attr(\"y1\", 0).attr(\"x2\", 0).attr(\"y2\", 10)\n  .attr(\"stroke\", t.ink).attr(\"stroke-width\", 1.4).attr(\"stroke-opacity\", 0.20);\n\n// Sequential gradient for legend bar\nconst seqGrad = defs.append(\"linearGradient\")\n  .attr(\"id\", \"seq-grad\").attr(\"x1\", \"0%\").attr(\"x2\", \"100%\");\nseqGrad.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", t.seq[0]);\nseqGrad.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", t.seq[1]);\n\n// --- Color scale (Imprint sequential: green → blue) ----------------------\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain([vMin, vMax]);\n\n// Cell text: light text on darker (high-value blue) cells, dark on lighter (low-value green)\nconst textColor = d => ((d.value - vMin) / (vMax - vMin)) > 0.45 ? \"#F0EFE8\" : \"#1A1A17\";\n\n// Value formatter: $1.2M for millions, $845K for thousands\nconst fmt = v => v >= 1e6 ? `$${(v / 1e6).toFixed(1)}M` : `$${Math.round(v / 1000)}K`;\n\n// --- Axis labels ----------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", marginLeft + gridW / 2).attr(\"y\", 76)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\").style(\"font-weight\", \"500\")\n  .text(\"Development Period (Years)\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", `translate(26,${marginTop + gridH / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\").style(\"font-weight\", \"500\")\n  .text(\"Accident Year\");\n\n// Column headers (1–10)\ng.selectAll(\".col-hdr\").data(d3.range(nPeriods)).join(\"text\")\n  .attr(\"class\", \"col-hdr\")\n  .attr(\"x\", j => j * cellSize + cellSize / 2)\n  .attr(\"y\", -16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(j => j + 1);\n\n// Row labels (2015–2024)\ng.selectAll(\".row-lbl\").data(accidentYears).join(\"text\")\n  .attr(\"class\", \"row-lbl\")\n  .attr(\"x\", -10)\n  .attr(\"y\", (_, i) => i * cellSize + cellSize / 2)\n  .attr(\"text-anchor\", \"end\").attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(d => d);\n\n// --- Heatmap cells --------------------------------------------------------\nconst cellG = g.selectAll(\".cell\").data(cells).join(\"g\")\n  .attr(\"class\", \"cell\")\n  .attr(\"transform\", d => `translate(${d.j * cellSize},${d.i * cellSize})`);\n\n// Color fill (projected cells at 50% opacity so background shows through)\ncellG.append(\"rect\")\n  .attr(\"width\", cellSize - 1).attr(\"height\", cellSize - 1)\n  .attr(\"fill\", d => colorScale(d.value))\n  .attr(\"opacity\", d => d.actual ? 1.0 : 0.50);\n\n// Diagonal hatch overlay distinguishes projected from actual\ncellG.filter(d => !d.actual).append(\"rect\")\n  .attr(\"width\", cellSize - 1).attr(\"height\", cellSize - 1)\n  .attr(\"fill\", \"url(#hatch)\");\n\n// Cell value annotations (formatted with thousands separator)\ncellG.append(\"text\")\n  .attr(\"x\", (cellSize - 1) / 2).attr(\"y\", (cellSize - 1) / 2)\n  .attr(\"text-anchor\", \"middle\").attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", textColor)\n  .style(\"font-size\", \"12px\").style(\"font-weight\", \"500\")\n  .text(d => fmt(d.value));\n\n// --- Age-to-age development factors row -----------------------------------\nconst cdfBaseY = gridH + 12;\n\ng.append(\"text\")\n  .attr(\"x\", -10).attr(\"y\", cdfBaseY + 14)\n  .attr(\"text-anchor\", \"end\").attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\")\n  .text(\"CDF:\");\n\nd3.range(1, nPeriods).forEach(j => {\n  g.append(\"text\")\n    .attr(\"x\", j * cellSize + cellSize / 2)\n    .attr(\"y\", cdfBaseY + 14)\n    .attr(\"text-anchor\", \"middle\").attr(\"dominant-baseline\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"13px\").style(\"font-weight\", \"600\")\n    .text(devFactors[j - 1].toFixed(2) + \"×\");\n});\n\n// --- Color legend ---------------------------------------------------------\nconst lgY = gridH + 56;\nconst lgW = Math.round(gridW * 0.66);\nconst lgX = Math.round((gridW - lgW) / 2);\nconst lgBarH = 16;\n\n// Gradient bar\ng.append(\"rect\")\n  .attr(\"x\", lgX).attr(\"y\", lgY).attr(\"width\", lgW).attr(\"height\", lgBarH)\n  .attr(\"fill\", \"url(#seq-grad)\");\n\n// Axis below bar\nconst lgScale = d3.scaleLinear().domain([vMin, vMax]).range([lgX, lgX + lgW]);\ng.append(\"g\").attr(\"transform\", `translate(0,${lgY + lgBarH})`)\n  .call(d3.axisBottom(lgScale).ticks(5).tickFormat(fmt))\n  .call(ax => {\n    ax.select(\".domain\").remove();\n    ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\");\n    ax.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  });\n\n// Legend title\ng.append(\"text\")\n  .attr(\"x\", lgX + lgW / 2).attr(\"y\", lgY - 8)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\")\n  .text(\"Cumulative Paid Claims\");\n\n// Actual vs projected swatches\nconst lgItemY = lgY + lgBarH + 38;\n\ng.append(\"rect\").attr(\"x\", lgX).attr(\"y\", lgItemY - 10)\n  .attr(\"width\", 18).attr(\"height\", 14).attr(\"fill\", t.seq[1]).attr(\"opacity\", 1.0);\ng.append(\"text\").attr(\"x\", lgX + 24).attr(\"y\", lgItemY - 3)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\").text(\"Actual (Observed)\");\n\nconst projSwatchX = lgX + lgW / 2 + 10;\ng.append(\"rect\").attr(\"x\", projSwatchX).attr(\"y\", lgItemY - 10)\n  .attr(\"width\", 18).attr(\"height\", 14).attr(\"fill\", t.seq[1]).attr(\"opacity\", 0.50);\ng.append(\"rect\").attr(\"x\", projSwatchX).attr(\"y\", lgItemY - 10)\n  .attr(\"width\", 18).attr(\"height\", 14).attr(\"fill\", \"url(#hatch)\");\ng.append(\"text\").attr(\"x\", projSwatchX + 24).attr(\"y\", lgItemY - 3)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\").text(\"Projected (IBNR)\");\n\n// --- Title ----------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"heatmap-loss-triangle · javascript · d3 · anyplot.ai\");\n"}