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.

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