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: pygal 3.1.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-06-03
"""
import os
import sys
import numpy as np
# Path fix: this file is named 'pygal.py', which shadows the pygal package
_cwd = sys.path[0] if sys.path[0] else "."
if _cwd in sys.path:
sys.path.remove(_cwd)
from pygal.graph.graph import Graph
from pygal.style import Style
sys.path.insert(0, _cwd)
# Theme tokens — Imprint palette
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314")
class LossTriangleHeatmap(Graph):
_series_margin = 0
def __init__(self, *args, **kwargs):
self.matrix_data = kwargs.pop("matrix_data", [])
self.projected_mask = kwargs.pop("projected_mask", [])
self.row_labels = kwargs.pop("row_labels", [])
self.col_labels = kwargs.pop("col_labels", [])
self.dev_factors = kwargs.pop("dev_factors", [])
self.imprint_seq = kwargs.pop("imprint_seq", [])
super().__init__(*args, **kwargs)
def _lerp_color(self, c0, c1, t):
r = int(round(int(c0[1:3], 16) + (int(c1[1:3], 16) - int(c0[1:3], 16)) * t))
g = int(round(int(c0[3:5], 16) + (int(c1[3:5], 16) - int(c0[3:5], 16)) * t))
b = int(round(int(c0[5:7], 16) + (int(c1[5:7], 16) - int(c0[5:7], 16)) * t))
return f"#{r:02X}{g:02X}{b:02X}"
def _value_to_color(self, value, min_val, max_val):
cmap = self.imprint_seq
if max_val == min_val:
return cmap[len(cmap) // 2]
normalized = max(0.0, min(1.0, (value - min_val) / (max_val - min_val)))
pos = normalized * (len(cmap) - 1)
i = int(pos)
return self._lerp_color(cmap[i], cmap[min(i + 1, len(cmap) - 1)], pos - i)
def _ink_for_bg(self, bg_hex, opacity=1.0):
r = int(bg_hex[1:3], 16)
g = int(bg_hex[3:5], 16)
b = int(bg_hex[5:7], 16)
if opacity < 1.0:
pr = int(PAGE_BG[1:3], 16)
pg = int(PAGE_BG[3:5], 16)
pb = int(PAGE_BG[5:7], 16)
r = int(r * opacity + pr * (1 - opacity))
g = int(g * opacity + pg * (1 - opacity))
b = int(b * opacity + pb * (1 - opacity))
luminance = (r * 299 + g * 587 + b * 114) / 1000
# In dark mode the blended cell is dark: low luminance needs light text
if THEME == "dark":
return INK if luminance < 140 else ELEVATED_BG
return INK if luminance > 140 else ELEVATED_BG
def _plot(self):
if not self.matrix_data:
return
n_rows = len(self.matrix_data)
n_cols = len(self.matrix_data[0])
all_vals = [v for row in self.matrix_data for v in row if v is not None]
min_val, max_val = min(all_vals), max(all_vals)
plot_w = self.view.width
plot_h = self.view.height
# Margins within the view area for axis labels and colorbar
lml = 200 # left: accident year labels + axis title
lmr = 195 # right: colorbar
lmt = 90 # top: column headers
lmb = 10 # bottom
aw = plot_w - lml - lmr
ah = plot_h - lmt - lmb
cw = aw / n_cols
ch = ah / (n_rows + 1.25) # extra row for dev factors
gap = 3
gw = n_cols * (cw + gap) - gap
gh = n_rows * (ch + gap) - gap
xo = self.view.x(0) + lml + (aw - gw) / 2
yo = self.view.y(n_rows) + lmt + (ah - gh - ch * 1.1) / 2
pn = self.nodes["plot"]
# SVG defs: hatch pattern for projected cells + colorbar gradient
defs = self.svg.node(pn, "defs")
pat = self.svg.node(
defs,
"pattern",
id="hatch-proj",
patternUnits="userSpaceOnUse",
width="10",
height="10",
patternTransform="rotate(45)",
)
hl = self.svg.node(pat, "line", x1="0", y1="0", x2="0", y2="10")
hl.set("stroke", INK_MUTED)
hl.set("stroke-width", "2.5")
hl.set("opacity", "0.40")
cf = max(22, min(30, int(cw * 0.42)))
# Column axis header
ht = self.svg.node(pn, "text", x=xo + gw / 2, y=yo - 62)
ht.set("text-anchor", "middle")
ht.set("fill", INK_SOFT)
ht.set("style", f"font-size:{cf + 4}px;font-weight:600;font-family:sans-serif")
ht.text = "Development Period (Years)"
# Column headers (period numbers)
for j, lbl in enumerate(self.col_labels):
t = self.svg.node(pn, "text", x=xo + j * (cw + gap) + cw / 2, y=yo - 16)
t.set("text-anchor", "middle")
t.set("fill", INK)
t.set("style", f"font-size:{cf}px;font-weight:700;font-family:sans-serif")
t.text = str(lbl)
rf = max(22, min(30, int(ch * 0.50)))
# Row labels (accident years)
for i, lbl in enumerate(self.row_labels):
t = self.svg.node(pn, "text", x=xo - 16, y=yo + i * (ch + gap) + ch / 2 + rf * 0.35)
t.set("text-anchor", "end")
t.set("fill", INK)
t.set("style", f"font-size:{rf}px;font-weight:600;font-family:sans-serif")
t.text = str(lbl)
# Y-axis title (rotated)
rty = yo + gh / 2
rtx = xo - 148
rt = self.svg.node(pn, "text", x=rtx, y=rty)
rt.set("text-anchor", "middle")
rt.set("fill", INK_SOFT)
rt.set("style", f"font-size:{cf + 4}px;font-weight:600;font-family:sans-serif")
rt.set("transform", f"rotate(-90,{rtx},{rty})")
rt.text = "Accident Year"
vf = max(20, min(32, int(min(cw, ch) * 0.48)))
# Draw cells: single Imprint sequential colormap; projected cells use
# reduced opacity + hatching + dashed border (vs. dual-colormap approach)
for i in range(n_rows):
for j in range(n_cols):
val = self.matrix_data[i][j]
if val is None:
continue
proj = self.projected_mask[i][j]
opac = 0.52 if proj else 1.0
col = self._value_to_color(val, min_val, max_val)
tcol = self._ink_for_bg(col, opac)
cx = xo + j * (cw + gap)
cy = yo + i * (ch + gap)
cg = self.svg.node(pn, "g", class_="cell")
rect = self.svg.node(cg, "rect", x=cx, y=cy, width=cw, height=ch, rx=3, ry=3)
rect.set("fill", col)
rect.set("opacity", str(opac))
rect.set("stroke", PAGE_BG)
rect.set("stroke-width", "2")
if proj:
hr = self.svg.node(cg, "rect", x=cx, y=cy, width=cw, height=ch, rx=3, ry=3)
hr.set("fill", "url(#hatch-proj)")
hr.set("opacity", "0.65")
br = self.svg.node(cg, "rect", x=cx + 2, y=cy + 2, width=cw - 4, height=ch - 4, rx=2, ry=2)
br.set("fill", "none")
br.set("stroke", INK_SOFT)
br.set("stroke-width", "2")
br.set("stroke-dasharray", "6,4")
br.set("opacity", "0.55")
yr_lbl = self.row_labels[i] if i < len(self.row_labels) else ""
dp_lbl = self.col_labels[j] if j < len(self.col_labels) else ""
self._tooltip_data(
cg,
self.value_formatter(val),
cx + cw / 2,
cy + ch / 2,
xlabel=f"AY {yr_lbl} / Dev {dp_lbl} ({'Projected' if proj else 'Actual'})",
)
tx = self.svg.node(cg, "text", x=cx + cw / 2, y=cy + ch / 2 + vf * 0.35)
tx.set("text-anchor", "middle")
tx.set("fill", tcol)
tx.set(
"style",
f"font-size:{vf}px;font-weight:{'400' if proj else '500'};"
f"font-style:{'italic' if proj else 'normal'};font-family:sans-serif",
)
tx.text = self.value_formatter(val)
# Evaluation date diagonal
diag = []
for k in range(n_rows + 1):
ci = n_rows - k
if 0 <= ci <= n_cols and 0 <= k <= n_rows:
diag.append((xo + ci * (cw + gap) - gap / 2, yo + k * (ch + gap) - gap / 2))
if len(diag) > 1:
dl = self.svg.line(pn, diag, close=False, class_="eval-date-line")
dl.set("fill", "none")
dl.set("stroke", INK)
dl.set("stroke-width", "4")
dl.set("stroke-dasharray", "12,6")
dl.set("opacity", "0.75")
# Development factors row
dff = max(18, min(24, int(cw * 0.32)))
dfy = yo + gh + 34
if self.dev_factors:
sl = self.svg.node(pn, "line", x1=xo, y1=yo + gh + 12, x2=xo + gw, y2=yo + gh + 12)
sl.set("stroke", INK_MUTED)
sl.set("stroke-width", "1.5")
t = self.svg.node(pn, "text", x=xo - 16, y=dfy + dff * 0.35)
t.set("text-anchor", "end")
t.set("fill", INK_SOFT)
t.set("style", f"font-size:{dff}px;font-weight:700;font-family:sans-serif")
t.text = "Dev Factor"
for j, fac in enumerate(self.dev_factors):
if fac is None:
continue
t = self.svg.node(pn, "text", x=xo + j * (cw + gap) + cw / 2, y=dfy + dff * 0.35)
t.set("text-anchor", "middle")
t.set("fill", INK_SOFT)
t.set("style", f"font-size:{dff}px;font-weight:500;font-family:sans-serif")
t.text = f"{fac:.3f}"
# Colorbar with SVG gradient
cbw = 32
cbh = gh * 0.74
cbx = xo + gw + 44
cby = yo + (gh - cbh) / 2
grad = self.svg.node(defs, "linearGradient", id="cb-grad", x1="0", y1="0", x2="0", y2="1")
for fi in range(21):
f = fi / 20.0
c = self._value_to_color(max_val - (max_val - min_val) * f, min_val, max_val)
stop = self.svg.node(grad, "stop", offset=f"{f * 100}%")
stop.set("stop-color", c)
cbr = self.svg.node(pn, "rect", x=cbx, y=cby, width=cbw, height=cbh, rx=4, ry=4)
cbr.set("fill", "url(#cb-grad)")
cbr.set("stroke", INK_MUTED)
cbr.set("stroke-width", "1.5")
cls = 22
for fp, fval in [
(0.0, max_val),
(0.25, max_val * 0.75 + min_val * 0.25),
(0.5, (max_val + min_val) / 2),
(0.75, max_val * 0.25 + min_val * 0.75),
(1.0, min_val),
]:
ty = cby + cbh * fp
tk = self.svg.node(pn, "line", x1=cbx + cbw, y1=ty, x2=cbx + cbw + 6, y2=ty)
tk.set("stroke", INK_MUTED)
tk.set("stroke-width", "1.5")
tl = self.svg.node(pn, "text", x=cbx + cbw + 10, y=ty + cls * 0.35)
tl.set("fill", INK_SOFT)
tl.set("style", f"font-size:{cls}px;font-family:sans-serif")
tl.text = self.value_formatter(fval)
cbt = self.svg.node(pn, "text", x=cbx + cbw / 2, y=cby - 18)
cbt.set("text-anchor", "middle")
cbt.set("fill", INK_SOFT)
cbt.set("style", f"font-size:{cls + 2}px;font-weight:600;font-family:sans-serif")
cbt.text = "Cumulative ($k)"
# Legend
lgf = 22
lgy = dfy + dff + 16 if self.dev_factors else yo + gh + 34
lgw_total = 610
lgx = xo + (gw - lgw_total) / 2
mid_col = self._value_to_color((min_val + max_val) / 2, min_val, max_val)
# Actual swatch
self.svg.node(pn, "rect", x=lgx, y=lgy, width=26, height=18, fill=mid_col, stroke=INK_MUTED, rx=3, ry=3)
tl_a = self.svg.node(pn, "text", x=lgx + 34, y=lgy + 13)
tl_a.set("fill", INK)
tl_a.set("style", f"font-size:{lgf}px;font-weight:600;font-family:sans-serif")
tl_a.text = "Actual (Observed)"
# Projected swatch (same color, reduced opacity + hatch overlay + dashed border)
px2 = lgx + 250
ps = self.svg.node(pn, "rect", x=px2, y=lgy, width=26, height=18, fill=mid_col, rx=3, ry=3)
ps.set("opacity", "0.52")
ps.set("stroke", INK_MUTED)
ph2 = self.svg.node(pn, "rect", x=px2, y=lgy, width=26, height=18, fill="url(#hatch-proj)", rx=3, ry=3)
ph2.set("opacity", "0.65")
pb2 = self.svg.node(pn, "rect", x=px2 + 2, y=lgy + 2, width=22, height=14, fill="none", rx=2, ry=2)
pb2.set("stroke", INK_SOFT)
pb2.set("stroke-width", "1.5")
pb2.set("stroke-dasharray", "4,3")
pb2.set("opacity", "0.55")
tl_p = self.svg.node(pn, "text", x=px2 + 34, y=lgy + 13)
tl_p.set("fill", INK)
tl_p.set("style", f"font-size:{lgf}px;font-weight:600;font-family:sans-serif")
tl_p.text = "Projected (IBNR)"
# Evaluation date line swatch
ex = px2 + 250
ey = lgy + 9
el = self.svg.node(pn, "line", x1=ex, y1=ey, x2=ex + 36, y2=ey)
el.set("stroke", INK)
el.set("stroke-width", "3")
el.set("stroke-dasharray", "8,4")
el.set("opacity", "0.75")
tl_e = self.svg.node(pn, "text", x=ex + 46, y=lgy + 13)
tl_e.set("fill", INK)
tl_e.set("style", f"font-size:{lgf}px;font-weight:600;font-family:sans-serif")
tl_e.text = "Evaluation Date"
def _compute(self):
n = len(self.matrix_data) if self.matrix_data else 1
m = len(self.matrix_data[0]) if self.matrix_data and self.matrix_data[0] else 1
self._box.xmin, self._box.xmax = 0, m
self._box.ymin, self._box.ymax = 0, n
# Data: Cumulative paid claims triangle (in thousands)
np.random.seed(42)
accident_years = list(range(2015, 2025))
development_periods = list(range(1, 11))
n_years = len(accident_years)
n_periods = len(development_periods)
base_ultimate = np.array([4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000], dtype=float)
dev_pattern = np.array([0.15, 0.35, 0.52, 0.66, 0.78, 0.87, 0.93, 0.97, 0.99, 1.00])
triangle = np.zeros((n_years, n_periods))
is_projected = [[False] * n_periods for _ in range(n_years)]
for i in range(n_years):
for j in range(n_periods):
base_val = base_ultimate[i] * dev_pattern[j]
noise = np.random.normal(0, base_val * 0.03)
triangle[i][j] = round(base_val + noise, 0)
if i + j >= n_years:
is_projected[i][j] = True
matrix_data = triangle.tolist()
# Age-to-age development factors (weighted average over actual data only)
dev_factors = []
for j in range(n_periods - 1):
num, den = 0.0, 0.0
for i in range(n_years):
if not is_projected[i][j] and not is_projected[i][j + 1]:
num += triangle[i][j + 1]
den += triangle[i][j]
dev_factors.append(num / den if den > 0 else None)
dev_factors.append(None) # no factor for last period
# Imprint sequential colormap: brand green (#009E73) → blue (#4467A3), 12 stops
_ns = 12
imprint_seq = []
for _i in range(_ns):
_t = _i / (_ns - 1)
_r = int(round(0x00 + (0x44 - 0x00) * _t))
_g = int(round(0x9E + (0x67 - 0x9E) * _t))
_b = int(round(0x73 + (0xA3 - 0x73) * _t))
imprint_seq.append(f"#{_r:02X}{_g:02X}{_b:02X}")
chart_title = "heatmap-loss-triangle · python · pygal · anyplot.ai"
title_n = len(chart_title)
title_fs = round(66 * 67 / title_n) if title_n > 67 else 66
# Pygal style with theme-adaptive Imprint tokens
custom_style = Style(
background=PAGE_BG,
plot_background=PAGE_BG,
foreground=INK,
foreground_strong=INK,
foreground_subtle=INK_MUTED,
colors=IMPRINT_PALETTE,
title_font_size=title_fs,
legend_font_size=44,
label_font_size=56,
major_label_font_size=44,
value_font_size=36,
tooltip_font_size=28,
font_family="sans-serif",
)
chart = LossTriangleHeatmap(
width=2400,
height=2400,
style=custom_style,
title=chart_title,
matrix_data=matrix_data,
projected_mask=is_projected,
row_labels=[str(y) for y in accident_years],
col_labels=[str(p) for p in development_periods],
dev_factors=dev_factors,
imprint_seq=imprint_seq,
value_formatter=lambda x: f"{x:,.0f}",
show_legend=False,
margin=80,
margin_top=160,
margin_bottom=80,
margin_left=80,
margin_right=80,
show_x_labels=False,
show_y_labels=False,
)
chart.add("", [0])
# Save
chart.render_to_png(f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
f.write(chart.render())
Part of Actuarial Loss Development Triangle on anyplot.ai.