{"spec_id":"confusion-matrix","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// confusion-matrix: Confusion Matrix Heatmap\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-04\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Wildlife camera-trap classifier: true vs. predicted species over 400 test images.\nconst classNames = [\"Deer\", \"Fox\", \"Rabbit\", \"Owl\"];\nconst matrix = [\n  [96, 4, 2, 1], // true: Deer\n  [5, 91, 6, 3], // true: Fox\n  [3, 7, 90, 5], // true: Rabbit\n  [1, 4, 9, 93], // true: Owl\n];\nconst maxCount = Math.max(...matrix.flat());\n\n// Row-normalized percentages (recall per true class) shown as a secondary\n// annotation per cell, covering the spec's normalization-options note.\nconst cells = [];\nmatrix.forEach((rowCounts, row) => {\n  const rowTotal = rowCounts.reduce((sum, c) => sum + c, 0);\n  rowCounts.forEach((count, col) => {\n    const pct = (count / rowTotal) * 100;\n    cells.push({ x: classNames[col], y: classNames[row], row, col, count, pct });\n  });\n});\n\n// --- Color scale: imprint_seq (brand green -> blue), low -> high count -----\nfunction hexToRgb(hex) {\n  const num = parseInt(hex.slice(1), 16);\n  return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };\n}\nconst seqLow = hexToRgb(t.seq[0]);\nconst seqHigh = hexToRgb(t.seq[1]);\nfunction cellFill(count) {\n  const ratio = count / maxCount;\n  const r = Math.round(seqLow.r + (seqHigh.r - seqLow.r) * ratio);\n  const g = Math.round(seqLow.g + (seqHigh.g - seqLow.g) * ratio);\n  const b = Math.round(seqLow.b + (seqHigh.b - seqLow.b) * ratio);\n  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n  return { rgb: `rgb(${r}, ${g}, ${b})`, luminance };\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Custom plugin: draws the heatmap cells + a color-scale legend ----------\n// Chart.js core has no matrix/heatmap chart type (that lives in the unpinned\n// chartjs-chart-matrix plugin, out of scope here). A category-scale scatter\n// with invisible points supplies the axes, tick labels, and tooltips; this\n// inline plugin — plain Chart.js plugin-hook API, no external package — owns\n// the cell fills, the diagonal highlight, and the count labels.\nconst heatmapPlugin = {\n  id: \"confusionCells\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const cellW = scales.x.getPixelForTick(1) - scales.x.getPixelForTick(0);\n    const cellH = scales.y.getPixelForTick(1) - scales.y.getPixelForTick(0);\n    const gap = 3;\n\n    cells.forEach(({ row, col, count, pct }) => {\n      const cx = scales.x.getPixelForTick(col);\n      const cy = scales.y.getPixelForTick(row);\n      const { rgb, luminance } = cellFill(count);\n\n      ctx.fillStyle = rgb;\n      ctx.fillRect(cx - cellW / 2 + gap, cy - cellH / 2 + gap, cellW - gap * 2, cellH - gap * 2);\n\n      if (row === col) {\n        ctx.strokeStyle = t.ink;\n        ctx.lineWidth = 4;\n        ctx.strokeRect(cx - cellW / 2 + gap, cy - cellH / 2 + gap, cellW - gap * 2, cellH - gap * 2);\n      }\n\n      const textColor = luminance > 0.55 ? \"#1A1A17\" : \"#F0EFE8\";\n      ctx.fillStyle = textColor;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.font = \"600 30px sans-serif\";\n      ctx.fillText(String(count), cx, cy - 16);\n      ctx.font = \"500 18px sans-serif\";\n      ctx.fillText(`${pct.toFixed(1)}%`, cx, cy + 22);\n    });\n  },\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const barX = chartArea.right + 50;\n    const barWidth = 26;\n    const { top, bottom } = chartArea;\n\n    const gradient = ctx.createLinearGradient(0, bottom, 0, top);\n    gradient.addColorStop(0, t.seq[0]);\n    gradient.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, top, barWidth, bottom - top);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, top, barWidth, bottom - top);\n\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"14px sans-serif\";\n    ctx.textBaseline = \"middle\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"0\", barX + barWidth + 8, bottom);\n    ctx.fillText(String(maxCount), barX + barWidth + 8, top);\n\n    ctx.save();\n    ctx.translate(barX + barWidth + 34, (top + bottom) / 2);\n    ctx.rotate(Math.PI / 2);\n    ctx.textAlign = \"center\";\n    ctx.fillStyle = t.ink;\n    ctx.fillText(\"Samples\", 0, 0);\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Samples\",\n        data: cells,\n        pointRadius: 0,\n        pointHitRadius: 140,\n      },\n    ],\n  },\n  plugins: [heatmapPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 10, right: 150, bottom: 10, left: 10 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"confusion-matrix · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n      },\n      subtitle: {\n        display: true,\n        text: \"Counts with row-normalized (recall) percentage below\",\n        color: t.inkSoft,\n        font: { size: 14 },\n        padding: { bottom: 10 },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label: (context) => {\n            const { x, y, count } = context.raw;\n            return `True: ${y}  Predicted: ${x}  Count: ${count}`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"category\",\n        labels: classNames,\n        offset: true,\n        title: { display: true, text: \"Predicted Label\", color: t.ink, font: { size: 16, weight: \"500\" } },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        border: { display: false },\n      },\n      y: {\n        type: \"category\",\n        labels: classNames,\n        offset: true,\n        title: { display: true, text: \"True Label\", color: t.ink, font: { size: 16, weight: \"500\" } },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n});\n"}