{"spec_id":"heatmap-loss-triangle","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-loss-triangle: Actuarial Loss Development Triangle\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-08-20\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Color helpers -----------------------------------------------------\n// Data-mark fills (Imprint sequential hues) stay dark on both themes, so\n// on-mark labels use a fixed light tone — the light-theme page background\n// hex, reused here as a foreground — instead of the theme-adaptive ink\n// token, which would render dark-on-dark for the projected cells in dark\n// mode once blended toward a near-black page background.\nconst LIGHT_LABEL = \"#FAF8F1\";\n\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.replace(\"#\", \"\"), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction mixRgb(hexA, hexB, frac) {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  return a.map((ch, i) => Math.round(ch + (b[i] - ch) * frac));\n}\n\nfunction blendOver(rgb, alpha, bgHex) {\n  const bg = hexToRgb(bgHex);\n  return rgb.map((ch, i) => Math.round(ch * alpha + bg[i] * (1 - alpha)));\n}\n\nfunction relLuminance(rgb) {\n  const srgb = rgb\n    .map((ch) => ch / 255)\n    .map((v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));\n  return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];\n}\n\nfunction rgbStr(rgb) {\n  return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;\n}\n\nfunction rgbaStr(rgb, alpha) {\n  return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;\n}\n\n// --- Data (in-memory, deterministic) ----------------------------------\n// Chain-ladder style claim development: a fixed reporting pattern (the\n// fraction of ultimate loss reported by each development period) applied\n// to a per-accident-year ultimate loss estimate. A tiny seeded LCG adds\n// realistic accident-year variation without relying on Math.random().\nlet lcgSeed = 42;\nfunction nextRandom() {\n  lcgSeed = (lcgSeed * 1103515245 + 12345) & 0x7fffffff;\n  return lcgSeed / 0x7fffffff;\n}\n\nconst LATEST_CALENDAR_YEAR = 2024;\nconst accidentYears = Array.from({ length: 10 }, (_, i) => 2015 + i);\nconst devPeriods = Array.from({ length: 10 }, (_, i) => i + 1);\n\n// Cumulative fraction of ultimate loss reported by development period\nconst reportPattern = [0.294, 0.512, 0.678, 0.792, 0.868, 0.918, 0.951, 0.972, 0.986, 1.0];\n// Age-to-age development factors between consecutive periods\nconst devFactors = reportPattern.slice(1).map((p, i) => p / reportPattern[i]);\n\nconst ultimateByYear = accidentYears.map((_, i) => {\n  const trend = 4_000_000 * Math.pow(1.045, i);\n  const jitter = 0.94 + nextRandom() * 0.12;\n  return trend * jitter;\n});\n\nconst cells = [];\naccidentYears.forEach((year, rowIdx) => {\n  devPeriods.forEach((dev, colIdx) => {\n    const calendarYear = year + dev - 1;\n    const amount = Math.round((ultimateByYear[rowIdx] * reportPattern[colIdx]) / 100) * 100;\n    cells.push({\n      x: colIdx,\n      y: rowIdx,\n      amount,\n      isProjected: calendarYear > LATEST_CALENDAR_YEAR,\n    });\n  });\n});\n\nconst amounts = cells.map((c) => c.amount);\nconst minAmount = Math.min(...amounts);\nconst maxAmount = Math.max(...amounts);\n\nfunction pointFor(cell) {\n  const frac = (cell.amount - minAmount) / (maxAmount - minAmount);\n  const solidRgb = mixRgb(t.seq[0], t.seq[1], frac);\n  const alpha = cell.isProjected ? 0.4 : 1;\n  const displayedRgb = cell.isProjected ? blendOver(solidRgb, alpha, t.pageBg) : solidRgb;\n  const labelColor = relLuminance(displayedRgb) < 0.5 ? LIGHT_LABEL : t.ink;\n\n  return {\n    x: cell.x,\n    y: cell.y,\n    amount: cell.amount,\n    marker: {\n      fillColor: cell.isProjected ? rgbaStr(solidRgb, alpha) : rgbStr(solidRgb),\n      lineColor: cell.isProjected ? t.amber : t.pageBg,\n      lineWidth: 2,\n    },\n    dataLabels: { style: { color: labelColor } },\n  };\n}\n\nconst actualPoints = cells.filter((c) => !c.isProjected).map(pointFor);\nconst projectedPoints = cells.filter((c) => c.isProjected).map(pointFor);\n\n// --- Chart ---------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    spacing: [20, 30, 20, 20],\n    events: {\n      load: function () {\n        // Size the square markers to the actual rendered grid so cells\n        // tile edge-to-edge regardless of the auto-computed axis margins.\n        const cellW = this.plotWidth / devPeriods.length;\n        const cellH = this.plotHeight / accidentYears.length;\n        const radius = Math.floor(Math.min(cellW, cellH) * 0.42);\n        this.series.forEach((s) => {\n          s.points.forEach((p) => {\n            // point.update() replaces the whole marker object rather than\n            // merging it, so re-spread the point's own fillColor/lineColor\n            // or the radius-only update would wipe out its per-cell color.\n            const marker = Object.assign({}, p.options.marker, { radius });\n            p.update({ marker }, false);\n          });\n        });\n        this.redraw(false);\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"heatmap-loss-triangle · javascript · highcharts · anyplot.ai\",\n    align: \"left\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Cumulative paid claims by accident year & development period — chain-ladder (IBNR) projection\",\n    align: \"left\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    min: -0.5,\n    max: devPeriods.length - 0.5,\n    tickInterval: 1,\n    startOnTick: false,\n    endOnTick: false,\n    gridLineWidth: 0,\n    lineWidth: 0,\n    tickLength: 0,\n    title: { text: \"Development Period\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    labels: {\n      style: { color: t.inkSoft, fontSize: \"12px\" },\n      formatter: function () {\n        const idx = this.pos;\n        if (idx < 0 || idx >= devPeriods.length) return \"\";\n        return idx === 0\n          ? `Dev ${devPeriods[idx]}`\n          : `Dev ${devPeriods[idx]} ×${devFactors[idx - 1].toFixed(3)}`;\n      },\n    },\n  },\n  yAxis: {\n    min: -0.5,\n    max: accidentYears.length - 0.5,\n    reversed: true,\n    tickInterval: 1,\n    startOnTick: false,\n    endOnTick: false,\n    gridLineWidth: 0,\n    lineWidth: 0,\n    tickLength: 0,\n    title: { text: \"Accident Year\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    labels: {\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n      formatter: function () {\n        const idx = this.pos;\n        if (idx < 0 || idx >= accidentYears.length) return \"\";\n        return String(accidentYears[idx]);\n      },\n    },\n  },\n  legend: {\n    align: \"left\",\n    verticalAlign: \"top\",\n    layout: \"horizontal\",\n    floating: false,\n    margin: 24,\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n    symbolWidth: 14,\n    symbolHeight: 14,\n    symbolRadius: 2,\n  },\n  tooltip: { enabled: false },\n  plotOptions: {\n    series: {\n      marker: { symbol: \"square\", radius: 40, states: { hover: { enabled: false } } },\n      dataLabels: {\n        enabled: true,\n        formatter: function () {\n          return \"$\" + (this.point.amount / 1e6).toFixed(2) + \"M\";\n        },\n        style: { fontSize: \"11px\", fontWeight: \"600\", textOutline: \"none\" },\n      },\n      animation: false,\n      states: { hover: { enabled: false } },\n    },\n  },\n  series: [\n    { name: \"Actual (observed)\", color: t.palette[0], data: actualPoints },\n    { name: \"Projected (IBNR estimate)\", color: t.amber, data: projectedPoints },\n  ],\n});\n"}