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