{"spec_id":"heatmap-loss-triangle","library":"echarts","language":"javascript","code":"// anyplot.ai\n// heatmap-loss-triangle: Actuarial Loss Development Triangle\n// Library: echarts 5.5.1 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-03\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data -------------------------------------------------------------------\n// General liability insurance portfolio\n// 10 accident years (2015–2024) × 10 development periods\n\nconst accidentYears = [\"2015\",\"2016\",\"2017\",\"2018\",\"2019\",\"2020\",\"2021\",\"2022\",\"2023\",\"2024\"];\nconst devPeriods = [\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\",\"10\"];\n\n// Cumulative paid claims as % of ultimate (typical general liability development pattern)\nconst pctPaid = [0.28, 0.45, 0.62, 0.74, 0.83, 0.90, 0.95, 0.98, 0.99, 1.00];\n\n// Ultimate loss estimates by accident year ($M) — deterministic\nconst ultimates = [45.2, 52.8, 38.6, 61.3, 48.9, 55.1, 42.7, 67.4, 51.3, 59.8];\n\n// Age-to-age link ratio factors between consecutive development periods\nconst ataFactors = pctPaid.slice(1).map((v, i) => (v / pctPaid[i]).toFixed(3));\n\n// Triangle boundary: accident year i (0=2015) has actual data for periods j <= (9-i)\nconst actualData = [];\nconst projectedData = [];\nlet actualMin = Infinity, actualMax = 0;\nlet projectedMin = Infinity, projectedMax = 0;\n\nfor (let i = 0; i < 10; i++) {\n  for (let j = 0; j < 10; j++) {\n    const value = +(ultimates[i] * pctPaid[j]).toFixed(1);\n    if (j <= 9 - i) {\n      actualData.push([j, i, value]);\n      if (value < actualMin) actualMin = value;\n      if (value > actualMax) actualMax = value;\n    } else {\n      projectedData.push([j, i, value]);\n      if (value < projectedMin) projectedMin = value;\n      if (value > projectedMax) projectedMax = value;\n    }\n  }\n}\n\n// --- Grid geometry for evaluation-date boundary line (CSS pixels, 1200×1200 canvas) ---\nconst gridLeft = 90, gridRight = 132, gridTop = 148, gridBottom = 145;\nconst canvasW = 1200;\nconst gridW = canvasW - gridLeft - gridRight; // 978\nconst gridH = canvasW - gridTop - gridBottom; // 907\nconst cellW = gridW / 10;\nconst cellH = gridH / 10;\n\n// Staircase polyline tracing the diagonal boundary between actual and projected regions\nconst boundaryPoints = [[gridLeft + 10 * cellW, gridTop]];\nfor (let i = 0; i < 10; i++) {\n  const x = gridLeft + (10 - i) * cellW;\n  const yBot = gridTop + (i + 1) * cellH;\n  boundaryPoints.push([x, yBot]);\n  if (i < 9) {\n    boundaryPoints.push([gridLeft + (10 - i - 1) * cellW, yBot]);\n  }\n}\n\n// Legend centering: two items ~370px wide total\nconst legendLeft = Math.round((canvasW - 370) / 2);\n\n// --- Init -------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option -----------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n\n  title: {\n    text: \"heatmap-loss-triangle · javascript · echarts · anyplot.ai\",\n    subtext: \"General Liability · Cumulative Paid Claims ($M) · Accident Years 2015–2024\",\n    left: \"center\",\n    top: 22,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: \"normal\" },\n    subtextStyle: { color: t.inkSoft, fontSize: 15 }\n  },\n\n  grid: {\n    left: gridLeft,\n    right: gridRight,\n    top: gridTop,\n    bottom: gridBottom\n  },\n\n  xAxis: {\n    type: \"category\",\n    data: devPeriods,\n    name: \"Development Period (Years)\",\n    nameLocation: \"middle\",\n    nameGap: 68,\n    nameTextStyle: { color: t.inkSoft, fontSize: 16 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 13,\n      interval: 0,\n      lineHeight: 22,\n      formatter: (value, index) =>\n        index > 0 ? `${value}\\n×${ataFactors[index - 1]}` : `${value}\\n`\n    },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false }\n  },\n\n  yAxis: {\n    type: \"category\",\n    data: accidentYears,\n    name: \"Accident Year\",\n    nameLocation: \"middle\",\n    nameGap: 58,\n    nameTextStyle: { color: t.inkSoft, fontSize: 16 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false },\n    inverse: true\n  },\n\n  visualMap: [\n    {\n      // Actual cells: full Imprint sequential colormap with colorbar\n      min: actualMin,\n      max: actualMax,\n      seriesIndex: [0],\n      show: true,\n      orient: \"vertical\",\n      right: 12,\n      top: \"middle\",\n      itemHeight: 200,\n      itemWidth: 14,\n      inRange: { color: t.seq || [\"#009E73\", \"#4467A3\"] },\n      text: [\n        \"$\" + Math.round(actualMax) + \"M\",\n        \"$\" + Math.round(actualMin) + \"M\"\n      ],\n      textStyle: { color: t.inkSoft, fontSize: 12 },\n      calculable: false\n    },\n    {\n      // Projected cells: muted (38% opacity) variant of imprint_seq — encodes magnitude\n      min: projectedMin,\n      max: projectedMax,\n      seriesIndex: [1],\n      show: false,\n      inRange: { color: [\"rgba(0,158,115,0.38)\", \"rgba(68,103,163,0.38)\"] }\n    }\n  ],\n\n  series: [\n    {\n      name: \"Actual\",\n      type: \"heatmap\",\n      data: actualData,\n      label: {\n        show: true,\n        fontSize: 13,\n        color: \"#FFFFFF\",\n        formatter: (p) => \"$\" + p.value[2].toFixed(1) + \"M\"\n      },\n      itemStyle: {\n        borderColor: t.pageBg,\n        borderWidth: 1.5\n      },\n      emphasis: { disabled: true }\n    },\n    {\n      name: \"Projected (IBNR)\",\n      type: \"heatmap\",\n      data: projectedData,\n      label: {\n        show: true,\n        fontSize: 13,\n        color: t.inkSoft,\n        fontStyle: \"italic\",\n        formatter: (p) => \"$\" + p.value[2].toFixed(1) + \"M\"\n      },\n      itemStyle: {\n        // color controlled by second visualMap; border marks actual/projected boundary\n        borderColor: t.inkSoft,\n        borderWidth: 1.5\n      },\n      emphasis: { disabled: true }\n    }\n  ],\n\n  graphic: [\n    // Evaluation-date diagonal boundary — bold staircase stroke as structural landmark\n    {\n      type: \"polyline\",\n      shape: { points: boundaryPoints },\n      style: { stroke: t.ink, lineWidth: 2.5, fill: \"none\" },\n      z: 10\n    },\n    // Bottom legend centered on canvas\n    {\n      type: \"group\",\n      left: legendLeft,\n      bottom: 22,\n      children: [\n        {\n          type: \"rect\",\n          shape: { x: 0, y: 2, width: 18, height: 14 },\n          style: { fill: t.seq ? t.seq[0] : \"#009E73\" }\n        },\n        {\n          type: \"text\",\n          x: 22,\n          y: 0,\n          style: {\n            text: \"Actual (Observed)\",\n            fill: t.inkSoft,\n            fontSize: 14,\n            fontFamily: \"sans-serif\"\n          }\n        },\n        {\n          type: \"rect\",\n          shape: { x: 200, y: 2, width: 18, height: 14 },\n          style: {\n            fill: \"rgba(68,103,163,0.38)\",\n            stroke: t.inkSoft,\n            lineWidth: 1.5\n          }\n        },\n        {\n          type: \"text\",\n          x: 222,\n          y: 0,\n          style: {\n            text: \"Projected / IBNR\",\n            fill: t.inkSoft,\n            fontSize: 14,\n            fontFamily: \"sans-serif\"\n          }\n        }\n      ]\n    }\n  ]\n});\n"}