{"spec_id":"heatmap-loss-triangle","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-loss-triangle: Actuarial Loss Development Triangle\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-08-20\n//# anyplot-orientation: square\n// anyplot.ai\n// heatmap-loss-triangle: Actuarial Loss Development Triangle\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-08-20\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { ChartsText } from \"@mui/x-charts/ChartsText\";\nimport { ContinuousColorLegend } from \"@mui/x-charts/ChartsLegend\";\nimport {\n  useXScale,\n  useYScale,\n  useZColorScale,\n  useDrawingArea,\n} from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst SIZE = window.ANYPLOT_SIZE;\n\n// --- Data: cumulative paid-claims triangle, $000s (in-memory, deterministic) ----------\n// Tiny fixed-seed LCG — no Math.random() in the browser render harness.\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1103515245 + 12345) & 0x7fffffff;\n    return state / 0x7fffffff;\n  };\n}\nconst random = lcg(42);\n\nconst ACCIDENT_YEARS = Array.from({ length: 10 }, (_, i) => 2015 + i);\nconst DEVELOPMENT_PERIODS = Array.from({ length: 10 }, (_, i) => i + 1);\nconst LATEST_EVALUATION_YEAR = 2024;\n\n// Age-to-age development factors, period p -> p+1 (classic chain-ladder decay).\nconst DEV_FACTORS = [1.45, 1.2, 1.12, 1.07, 1.045, 1.028, 1.017, 1.01, 1.005];\n\n// Period-1 base losses grow with accident-year exposure plus mild noise.\nconst basePeriod1 = ACCIDENT_YEARS.map((year, i) =>\n  Math.round(820 + i * 35 + (random() - 0.5) * 60),\n);\n\n// Full cumulative grid: same development factors drive both the observed\n// history and the chain-ladder projection of the still-open accident years.\nconst cumulativeByYear = basePeriod1.map((base) => {\n  const row = [base];\n  for (let p = 1; p < DEVELOPMENT_PERIODS.length; p += 1) {\n    row.push(row[p - 1] * DEV_FACTORS[p - 1]);\n  }\n  return row.map((value) => Math.round(value));\n});\n\n// Accident year `i` rows down from 2015 has (10 - i) periods observed by the\n// latest evaluation date, so a cell is actual when row-index + development\n// period <= 10 — the anti-diagonal running from the top-right corner to the\n// bottom-left corner. Everything past it is the chain-ladder projection.\nconst cells = [];\nACCIDENT_YEARS.forEach((year, i) => {\n  DEVELOPMENT_PERIODS.forEach((period, j) => {\n    cells.push({\n      id: `${year}-${period}`,\n      x: String(period),\n      y: String(year),\n      value: cumulativeByYear[i][j],\n      isProjected: i + period > DEVELOPMENT_PERIODS.length,\n    });\n  });\n});\n\nconst allValues = cells.map((cell) => cell.value);\nconst MIN_VALUE = Math.min(...allValues);\nconst MAX_VALUE = Math.max(...allValues);\n\n// --- Colour: sequential Imprint colormap (imprint_seq) driven by the z-axis colorMap --\nfunction hexToRgb(hex) {\n  const int = parseInt(hex.slice(1), 16);\n  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];\n}\n\nfunction lerp(a, b, ratio) {\n  return Math.round(a + (b - a) * ratio);\n}\n\nfunction imprintSeqInterpolator(stops) {\n  const [low, high] = stops.map(hexToRgb);\n  return (position) => {\n    const ratio = Math.min(1, Math.max(0, position));\n    const [r, g, b] = [0, 1, 2].map((channel) =>\n      lerp(low[channel], high[channel], ratio),\n    );\n    return `rgb(${r}, ${g}, ${b})`;\n  };\n}\n\n// Cell text must stay legible against that cell's own fill luminance regardless\n// of the current site theme, so pick between the two fixed Imprint ink literals\n// (light-mode ink / dark-mode ink) rather than the theme-dependent `t.ink`.\nconst CELL_TEXT_ON_LIGHT_FILL = \"#1A1A17\";\nconst CELL_TEXT_ON_DARK_FILL = \"#F0EFE8\";\n\nfunction textColorFor(fill) {\n  const [r, g, b] = fill.match(/[\\d.]+/g).map(Number);\n  const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;\n  return luminance > 140 ? CELL_TEXT_ON_LIGHT_FILL : CELL_TEXT_ON_DARK_FILL;\n}\n\nconst HATCH_ID = \"loss-triangle-hatch\";\n\n// --- Cells (custom composition: MUI X band scales + z-axis colour scale) --------------\nfunction TriangleCells() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const colorScale = useZColorScale();\n\n  return (\n    <g>\n      {cells.map((cell) => {\n        const x = xScale(cell.x) ?? 0;\n        const y = yScale(cell.y) ?? 0;\n        const width = xScale.bandwidth();\n        const height = yScale.bandwidth();\n        const fill = colorScale(cell.value);\n        const textFill = textColorFor(fill);\n        return (\n          <g key={cell.id}>\n            <rect\n              x={x}\n              y={y}\n              width={width}\n              height={height}\n              fill={fill}\n              rx={4}\n              stroke={cell.isProjected ? t.inkSoft : t.pageBg}\n              strokeWidth={cell.isProjected ? 1.5 : 2}\n              strokeDasharray={cell.isProjected ? \"4 3\" : undefined}\n            />\n            {cell.isProjected && (\n              <rect\n                x={x}\n                y={y}\n                width={width}\n                height={height}\n                fill={`url(#${HATCH_ID})`}\n                rx={4}\n              />\n            )}\n            <ChartsText\n              text={cell.value.toLocaleString(\"en-US\")}\n              x={x + width / 2}\n              y={y + height / 2}\n              style={{\n                fontSize: 13,\n                fill: textFill,\n                textAnchor: \"middle\",\n                dominantBaseline: \"central\",\n              }}\n            />\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Age-to-age development factors, shown as a row below the triangle ----------------\nfunction DevelopmentFactorRow() {\n  const xScale = useXScale();\n  const area = useDrawingArea();\n  const rowY = area.top + area.height + 108;\n\n  return (\n    <g>\n      <ChartsText\n        text=\"Age-to-age factors\"\n        x={area.left - 12}\n        y={rowY}\n        style={{\n          fontSize: 12,\n          fill: t.inkSoft,\n          textAnchor: \"end\",\n          dominantBaseline: \"central\",\n        }}\n      />\n      {DEV_FACTORS.map((factor, k) => {\n        const period = k + 1;\n        const boundaryX = (xScale(String(period)) ?? 0) + xScale.bandwidth();\n        return (\n          <ChartsText\n            key={`factor-${period}`}\n            text={`×${factor.toFixed(2)}`}\n            x={boundaryX}\n            y={rowY}\n            style={{\n              fontSize: 12,\n              fill: t.inkSoft,\n              textAnchor: \"middle\",\n              dominantBaseline: \"central\",\n            }}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Actual vs. projected legend (manual swatches — not a data-series legend) ---------\nconst LEGEND_ITEMS = [\n  { label: \"Actual (observed)\", hatched: false },\n  { label: \"Projected (IBNR estimate)\", hatched: true },\n];\nconst LEGEND_SWATCH = 18;\nconst LEGEND_GAP = 8;\nconst LEGEND_BLOCK_GAP = 40;\nconst LEGEND_CHAR_WIDTH = 7;\n\nfunction LegendSwatches() {\n  const blockWidths = LEGEND_ITEMS.map(\n    (item) =>\n      LEGEND_SWATCH + LEGEND_GAP + item.label.length * LEGEND_CHAR_WIDTH,\n  );\n  const totalWidth =\n    blockWidths.reduce((sum, w) => sum + w, 0) +\n    LEGEND_BLOCK_GAP * (LEGEND_ITEMS.length - 1);\n  let cursorX = SIZE.width / 2 - totalWidth / 2;\n  const y = 118;\n\n  return (\n    <g>\n      {LEGEND_ITEMS.map((item, index) => {\n        const swatchX = cursorX;\n        cursorX += blockWidths[index] + LEGEND_BLOCK_GAP;\n        return (\n          <g key={item.label}>\n            <rect\n              x={swatchX}\n              y={y - LEGEND_SWATCH / 2}\n              width={LEGEND_SWATCH}\n              height={LEGEND_SWATCH}\n              rx={3}\n              fill={t.seq[0]}\n              stroke={item.hatched ? t.inkSoft : \"none\"}\n              strokeDasharray={item.hatched ? \"3 2\" : undefined}\n            />\n            {item.hatched && (\n              <rect\n                x={swatchX}\n                y={y - LEGEND_SWATCH / 2}\n                width={LEGEND_SWATCH}\n                height={LEGEND_SWATCH}\n                rx={3}\n                fill={`url(#${HATCH_ID})`}\n              />\n            )}\n            <ChartsText\n              text={item.label}\n              x={swatchX + LEGEND_SWATCH + LEGEND_GAP}\n              y={y}\n              style={{\n                fontSize: 13,\n                fill: t.inkSoft,\n                textAnchor: \"start\",\n                dominantBaseline: \"central\",\n              }}\n            />\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) ----------------------\nconst TITLE = \"heatmap-loss-triangle · javascript · muix · anyplot.ai\";\nconst MARGIN = { top: 190, right: 300, bottom: 200, left: 150 };\n// The ContinuousColorLegend (translated -28px, ~40px wide) hugs the canvas's\n// right edge, so its column spans roughly [SIZE.width - 132, SIZE.width - 28].\n// Anchor the caption well clear of both that column and the drawing area\n// (which ends at SIZE.width - MARGIN.right) instead of crowding the bar.\nconst LEGEND_CAPTION_X = SIZE.width - MARGIN.right / 2;\nconst LEGEND_CAPTION_Y = SIZE.height / 2;\n\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={SIZE.width}\n      height={SIZE.height}\n      series={[]}\n      margin={MARGIN}\n      skipAnimation\n      xAxis={[\n        {\n          scaleType: \"band\",\n          data: DEVELOPMENT_PERIODS.map(String),\n          categoryGapRatio: 0.06,\n          disableLine: true,\n          disableTicks: true,\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n        },\n      ]}\n      yAxis={[\n        {\n          scaleType: \"band\",\n          data: ACCIDENT_YEARS.map(String),\n          categoryGapRatio: 0.06,\n          disableLine: true,\n          disableTicks: true,\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n        },\n      ]}\n      zAxis={[\n        {\n          colorMap: {\n            type: \"continuous\",\n            min: MIN_VALUE,\n            max: MAX_VALUE,\n            color: imprintSeqInterpolator(t.seq),\n          },\n        },\n      ]}\n    >\n      <defs>\n        <pattern\n          id={HATCH_ID}\n          width={8}\n          height={8}\n          patternUnits=\"userSpaceOnUse\"\n          patternTransform=\"rotate(45)\"\n        >\n          <line\n            x1={0}\n            y1={0}\n            x2={0}\n            y2={8}\n            stroke={t.ink}\n            strokeWidth={2}\n            strokeOpacity={0.22}\n          />\n        </pattern>\n      </defs>\n      <TriangleCells />\n      <DevelopmentFactorRow />\n      <ChartsXAxis />\n      <ChartsYAxis />\n      <g transform=\"translate(-28, 0)\">\n        <ContinuousColorLegend\n          position={{ horizontal: \"right\", vertical: \"middle\" }}\n          direction=\"column\"\n          length=\"50%\"\n          thickness={18}\n          labelStyle={{ fontSize: 12, fill: t.inkSoft }}\n        />\n      </g>\n      <ChartsText\n        text=\"Cumulative paid claims ($000s)\"\n        x={LEGEND_CAPTION_X}\n        y={LEGEND_CAPTION_Y}\n        style={{\n          fontSize: 12,\n          fill: t.inkSoft,\n          textAnchor: \"middle\",\n          angle: -90,\n        }}\n      />\n      <ChartsText\n        text=\"Development Period (years since occurrence)\"\n        x={MARGIN.left + (SIZE.width - MARGIN.left - MARGIN.right) / 2}\n        y={SIZE.height - MARGIN.bottom + 60}\n        style={{\n          fontSize: 14,\n          fill: t.inkSoft,\n          textAnchor: \"middle\",\n        }}\n      />\n      <LegendSwatches />\n      <ChartsText\n        text={TITLE}\n        x={SIZE.width / 2}\n        y={50}\n        style={{\n          fontSize: 26,\n          fontWeight: 600,\n          fill: t.ink,\n          textAnchor: \"middle\",\n        }}\n      />\n      <ChartsText\n        text={`Accident years ${ACCIDENT_YEARS[0]}–${ACCIDENT_YEARS[ACCIDENT_YEARS.length - 1]} · evaluated as of Dec ${LATEST_EVALUATION_YEAR}`}\n        x={SIZE.width / 2}\n        y={84}\n        style={{\n          fontSize: 15,\n          fill: t.inkSoft,\n          textAnchor: \"middle\",\n        }}\n      />\n    </ChartContainer>\n  );\n}\n"}