{"spec_id":"confusion-matrix","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// confusion-matrix: Confusion Matrix Heatmap\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-04\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: camera-trap species classifier, true vs. predicted species ------\n// The core Highcharts bundle has no heatmap/colorAxis module loaded, so the\n// grid is filled cell-by-cell with the SVG renderer on top of a native\n// categorical axis pair (which supplies the tick labels and axis titles).\nconst SPECIES = ['Deer', 'Fox', 'Rabbit', 'Raccoon', 'Squirrel'];\nconst N = SPECIES.length;\n\n// Approximate cell size from the known CSS mount (square canvas, harness-\n// guaranteed 1200x1200) — only used to size the invisible hover marker below;\n// the visible grid itself is drawn from chart.plotWidth/plotHeight, which is\n// exact after layout.\nconst MARKER_RADIUS = Math.min(window.ANYPLOT_SIZE.width, window.ANYPLOT_SIZE.height) / N / 2 - 4;\n\n// COUNTS[row][col] = true label `row` predicted as `col`. Built so the\n// confusions read like real camera-trap mix-ups: Fox/Raccoon (similar size,\n// both nocturnal) and Rabbit/Squirrel (small, fast, easy to blur together).\nconst COUNTS = [\n  [180, 2, 0, 1, 0],\n  [3, 142, 1, 18, 2],\n  [0, 2, 156, 1, 25],\n  [1, 21, 2, 138, 3],\n  [0, 1, 19, 2, 149],\n];\n\nconst ROW_TOTALS = COUNTS.map((row) => row.reduce((sum, v) => sum + v, 0));\n\n// Row-normalized recall drives both the cell color and the headline\n// percentage, so the grid highlights *where* a class is most often confused\n// rather than letting classes with more test samples dominate the palette.\nfunction recall(row, col) {\n  return COUNTS[row][col] / ROW_TOTALS[row];\n}\n\n// --- Color: imprint_seq — low recall (green) .. high recall (blue) ---------\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nconst SEQ_LOW = hexToRgb(t.seq[0]); // #009E73\nconst SEQ_HIGH = hexToRgb(t.seq[1]); // #4467A3\n\nfunction lerp(a, b, f) {\n  return a + (b - a) * f;\n}\nfunction cellRgb(fraction) {\n  return [\n    Math.round(lerp(SEQ_LOW[0], SEQ_HIGH[0], fraction)),\n    Math.round(lerp(SEQ_LOW[1], SEQ_HIGH[1], fraction)),\n    Math.round(lerp(SEQ_LOW[2], SEQ_HIGH[2], fraction)),\n  ];\n}\nfunction cellFill(fraction) {\n  const [red, green, blue] = cellRgb(fraction);\n  return `rgb(${red},${green},${blue})`;\n}\n\n// Relative luminance (WCAG formula) — pick ink-on-fill or paper-on-fill\n// text color, whichever gives the stronger contrast against the cell.\nfunction cellTextColor(fraction) {\n  const [red, green, blue] = cellRgb(fraction);\n  const lin = (c) => {\n    const v = c / 255;\n    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n  };\n  const luminance = 0.2126 * lin(red) + 0.7152 * lin(green) + 0.0722 * lin(blue);\n  return luminance > 0.4 ? '#1A1A17' : '#F0EFE8';\n}\n\n// --- Title (fontsize scaled off the 67-char baseline) ----------------------\nconst TITLE_TEXT = 'Camera-Trap Species ID · confusion-matrix · javascript · highcharts · anyplot.ai';\nconst TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);\n\nconst drawn = [];\nfunction clearDrawn() {\n  drawn.forEach((el) => {\n    try {\n      el.destroy();\n    } catch (_err) {\n      // already removed on redraw\n    }\n  });\n  drawn.length = 0;\n}\n\nfunction drawAll() {\n  const chart = this;\n  clearDrawn();\n  const r = chart.renderer;\n\n  const cellW = chart.plotWidth / N;\n  const cellH = chart.plotHeight / N;\n\n  for (let row = 0; row < N; row++) {\n    for (let col = 0; col < N; col++) {\n      const fraction = recall(row, col);\n      const onDiagonal = row === col;\n      const x = chart.plotLeft + col * cellW;\n      const y = chart.plotTop + row * cellH;\n\n      drawn.push(\n        r\n          .rect(x + 2, y + 2, cellW - 4, cellH - 4, 4)\n          .attr({\n            fill: cellFill(fraction),\n            stroke: onDiagonal ? t.ink : 'none',\n            'stroke-width': onDiagonal ? 3 : 0,\n            zIndex: 2,\n          })\n          .add()\n      );\n\n      const textColor = cellTextColor(fraction);\n      drawn.push(\n        r\n          .text(String(COUNTS[row][col]), x + cellW / 2, y + cellH / 2 - 2)\n          .attr({ align: 'center', zIndex: 3 })\n          .css({ color: textColor, fontSize: '20px', fontWeight: onDiagonal ? '700' : '600' })\n          .add()\n      );\n      drawn.push(\n        r\n          .text(`${(fraction * 100).toFixed(1)}%`, x + cellW / 2, y + cellH / 2 + 20)\n          .attr({ align: 'center', zIndex: 3 })\n          .css({ color: textColor, fontSize: '13px' })\n          .add()\n      );\n    }\n  }\n\n  // Vertical recall colorbar, docked in the right margin.\n  const barWidth = 26;\n  const barLeft = chart.plotLeft + chart.plotWidth + 55;\n  const barTop = chart.plotTop + chart.plotHeight * 0.15;\n  const barHeight = chart.plotHeight * 0.7;\n\n  drawn.push(\n    r\n      .rect(barLeft, barTop, barWidth, barHeight)\n      .attr({\n        fill: {\n          linearGradient: { x1: 0, y1: 1, x2: 0, y2: 0 },\n          stops: [\n            [0, cellFill(0)],\n            [1, cellFill(1)],\n          ],\n        },\n        stroke: t.inkSoft,\n        'stroke-width': 1,\n        zIndex: 2,\n      })\n      .add()\n  );\n  [0, 0.5, 1].forEach((frac) => {\n    drawn.push(\n      r\n        .text(`${Math.round(frac * 100)}%`, barLeft + barWidth + 10, barTop + (1 - frac) * barHeight + 5)\n        .attr({ align: 'left', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n  drawn.push(\n    r\n      .text('Recall', barLeft + barWidth / 2, barTop - 16)\n      .attr({ align: 'center', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n}\n\n// Invisible scatter layer aligned to each grid cell so hovering exposes a\n// real Highcharts tooltip in the interactive HTML output.\nconst cellPoints = [];\nfor (let row = 0; row < N; row++) {\n  for (let col = 0; col < N; col++) {\n    cellPoints.push({\n      x: col,\n      y: row,\n      count: COUNTS[row][col],\n      recallPct: recall(row, col) * 100,\n      trueLabel: SPECIES[row],\n      predictedLabel: SPECIES[col],\n    });\n  }\n}\n\nHighcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: [130, 170, 110, 150],\n    events: { load: drawAll, redraw: drawAll },\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE_TEXT,\n    style: { color: t.ink, fontSize: TITLE_FS + 'px', fontWeight: '600' },\n  },\n  subtitle: {\n    text: 'Wildlife camera-trap classifier — cell color and % show row-normalized recall',\n    style: { color: t.inkSoft, fontSize: '14px' },\n  },\n  xAxis: {\n    categories: SPECIES,\n    lineColor: t.inkSoft,\n    tickLength: 0,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: '14px' } },\n    title: { text: 'Predicted Label', style: { color: t.inkSoft, fontSize: '16px' } },\n  },\n  yAxis: {\n    categories: SPECIES,\n    reversed: true,\n    lineColor: t.inkSoft,\n    tickLength: 0,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: '14px' } },\n    title: { text: 'True Label', style: { color: t.inkSoft, fontSize: '16px' } },\n  },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    borderRadius: 6,\n    style: { color: t.ink, fontSize: '13px' },\n    formatter: function () {\n      const p = this.point;\n      return (\n        `<b>True: ${p.trueLabel}</b> · Predicted: ${p.predictedLabel}<br/>` +\n        `${p.count} samples (${p.recallPct.toFixed(1)}% of true ${p.trueLabel})`\n      );\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      enableMouseTracking: true,\n      stickyTracking: false,\n      marker: {\n        enabled: true,\n        symbol: 'square',\n        radius: MARKER_RADIUS,\n        fillColor: 'rgba(0,0,0,0.001)',\n        lineWidth: 0,\n        states: { hover: { enabled: false } },\n      },\n    },\n  },\n  series: [\n    {\n      type: 'scatter',\n      name: 'Confusion cell',\n      data: cellPoints,\n    },\n  ],\n});\n"}