{"spec_id":"heatmap-annotated","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-annotated: Annotated Heatmap\n// Library: chartjs 4.4.7 | JavaScript 22.23.1\n// Quality: 90/100 | Created: 2026-08-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data --------------------------------------------------------------------\n// Confusion matrix for a 6-class wildlife-camera species classifier. Each row\n// is 100 held-out test images of the true species; columns are the model's\n// predicted species. Counts are single-polarity (0-100) -> imprint_seq.\nconst species = [\"Cat\", \"Dog\", \"Bird\", \"Fish\", \"Rabbit\", \"Horse\"];\nconst N = species.length;\n// Off-diagonal noise concentrates on visually/behaviorally similar pairs\n// (Cat<->Dog, Bird<->Rabbit, Dog<->Horse) rather than dissimilar species.\nconst matrix = [\n  [86, 9, 1, 0, 3, 1],\n  [11, 82, 0, 0, 2, 5],\n  [1, 1, 93, 0, 3, 2],\n  [0, 0, 1, 97, 0, 2],\n  [4, 3, 2, 0, 87, 4],\n  [2, 6, 0, 1, 3, 88],\n];\n\n// --- imprint_seq interpolation (brand green -> blue) --------------------------\nfunction hexToRgb(hex) {\n  const h = hex.replace(\"#\", \"\");\n  return [\n    parseInt(h.slice(0, 2), 16),\n    parseInt(h.slice(2, 4), 16),\n    parseInt(h.slice(4, 6), 16),\n  ];\n}\nconst SEQ_LO = hexToRgb(t.seq[0]);\nconst SEQ_HI = hexToRgb(t.seq[1]);\nfunction seqRgb(ratio) {\n  const u = Math.max(0, Math.min(1, ratio));\n  return [0, 1, 2].map((i) => Math.round(SEQ_LO[i] + (SEQ_HI[i] - SEQ_LO[i]) * u));\n}\n\n// WCAG relative luminance -> pick fixed dark/light annotation text so each\n// cell's number stays readable regardless of how saturated its fill is. Data\n// colors are identical in both themes, so the text color choice is too.\nconst TEXT_ON_DARK_FILL = \"#F0EFE8\";\nconst TEXT_ON_LIGHT_FILL = \"#1A1A17\";\nfunction relativeLuminance([r, g, b]) {\n  const lin = [r, g, b].map((c) => {\n    const s = c / 255;\n    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n  });\n  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];\n}\nfunction textColorFor(rgb) {\n  return relativeLuminance(rgb) > 0.42 ? TEXT_ON_LIGHT_FILL : TEXT_ON_DARK_FILL;\n}\n\nconst FONT_STACK =\n  '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif';\n\n// --- Custom Chart.js plugin ----------------------------------------------------\n// Paints heatmap cells, writes per-cell counts, outlines the diagonal (correct\n// predictions), and renders a vertical colorbar in the right-side padding\n// reserved via options.layout.padding.\nconst CELL_GAP = 3;\nconst CELL_RADIUS = 6;\n\nconst heatmapPainter = {\n  id: \"heatmap-annotated-painter\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const { left, right, top, bottom } = chartArea;\n    const cellW = (right - left) / N;\n    const cellH = (bottom - top) / N;\n\n    // Inset rounded-rect cells (small gap between cells) rather than an\n    // edge-to-edge flat fill, so the fill itself carries visual refinement.\n    for (let r = 0; r < N; r++) {\n      for (let c = 0; c < N; c++) {\n        const [rr, gg, bb] = seqRgb(matrix[r][c] / 100);\n        ctx.fillStyle = `rgb(${rr},${gg},${bb})`;\n        ctx.beginPath();\n        ctx.roundRect(\n          left + c * cellW + CELL_GAP / 2,\n          top + r * cellH + CELL_GAP / 2,\n          cellW - CELL_GAP,\n          cellH - CELL_GAP,\n          CELL_RADIUS,\n        );\n        ctx.fill();\n      }\n    }\n\n    // Diagonal outline — correctly classified samples, the natural focal\n    // structure of a confusion matrix.\n    ctx.save();\n    ctx.strokeStyle = t.ink;\n    ctx.lineWidth = 3;\n    for (let i = 0; i < N; i++) {\n      ctx.beginPath();\n      ctx.roundRect(\n        left + i * cellW + CELL_GAP / 2 + 1.5,\n        top + i * cellH + CELL_GAP / 2 + 1.5,\n        cellW - CELL_GAP - 3,\n        cellH - CELL_GAP - 3,\n        CELL_RADIUS,\n      );\n      ctx.stroke();\n    }\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, width } = chart;\n    const { left, right, top, bottom } = chartArea;\n    const cellW = (right - left) / N;\n    const cellH = (bottom - top) / N;\n\n    // Per-cell annotations, contrast picked from the fill's own luminance.\n    ctx.save();\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.font = `600 26px ${FONT_STACK}`;\n    for (let r = 0; r < N; r++) {\n      for (let c = 0; c < N; c++) {\n        ctx.fillStyle = textColorFor(seqRgb(matrix[r][c] / 100));\n        ctx.fillText(\n          String(matrix[r][c]),\n          left + (c + 0.5) * cellW,\n          top + (r + 0.5) * cellH,\n        );\n      }\n    }\n    ctx.restore();\n\n    // Colorbar — vertical gradient in the right-side padding.\n    const cbGap = 40;\n    const cbW = 28;\n    const cbLeft = right + cbGap;\n    const cbTop = top;\n    const cbBottom = bottom;\n    const cbH = cbBottom - cbTop;\n\n    const [hiR, hiG, hiB] = seqRgb(1);\n    const [loR, loG, loB] = seqRgb(0);\n    const grad = ctx.createLinearGradient(0, cbTop, 0, cbBottom);\n    grad.addColorStop(0, `rgb(${hiR},${hiG},${hiB})`);\n    grad.addColorStop(1, `rgb(${loR},${loG},${loB})`);\n    ctx.fillStyle = grad;\n    ctx.fillRect(cbLeft, cbTop, cbW, cbH);\n\n    ctx.save();\n    ctx.font = `400 16px ${FONT_STACK}`;\n    ctx.fillStyle = t.inkSoft;\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    for (const v of [100, 75, 50, 25, 0]) {\n      const y = cbTop + (1 - v / 100) * cbH;\n      ctx.beginPath();\n      ctx.moveTo(cbLeft + cbW, y);\n      ctx.lineTo(cbLeft + cbW + 6, y);\n      ctx.stroke();\n      ctx.fillText(String(v), cbLeft + cbW + 12, y);\n    }\n    ctx.restore();\n\n    ctx.save();\n    ctx.fillStyle = t.ink;\n    ctx.font = `500 18px ${FONT_STACK}`;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.translate(width - 16, (cbTop + cbBottom) / 2);\n    ctx.rotate(Math.PI / 2);\n    ctx.fillText(\"Test images (of 100)\", 0, 0);\n    ctx.restore();\n  },\n};\n\n// --- Mount ---------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart -----------------------------------------------------------------\n// scatter base purely for axis layout (no points drawn); cells, annotations,\n// and colorbar are rendered by heatmapPainter against the resolved chartArea.\nconst TITLE =\n  \"Wildlife-camera Species Classifier · heatmap-annotated · javascript · chartjs · anyplot.ai\";\nconst TITLE_FONT = TITLE.length > 67 ? Math.max(15, Math.round((22 * 67) / TITLE.length)) : 22;\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [{ data: [], pointRadius: 0 }] },\n  plugins: [heatmapPainter],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 4, right: 190, bottom: 4, left: 4 } },\n    plugins: {\n      title: {\n        display: true,\n        text: TITLE,\n        color: t.ink,\n        font: { size: TITLE_FONT, weight: \"500\" },\n        padding: { top: 4, bottom: 22 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: -0.5,\n        max: N - 0.5,\n        position: \"bottom\",\n        offset: false,\n        afterBuildTicks: (axis) => {\n          axis.ticks = Array.from({ length: N }, (_, i) => ({ value: i }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 18 },\n          autoSkip: false,\n          callback: (v) => (Number.isInteger(v) && v >= 0 && v < N ? species[v] : \"\"),\n        },\n        title: { display: true, text: \"Predicted Species\", color: t.ink, font: { size: 20 } },\n        grid: { display: false },\n        border: { display: false },\n      },\n      y: {\n        type: \"linear\",\n        min: -0.5,\n        max: N - 0.5,\n        reverse: true,\n        offset: false,\n        afterBuildTicks: (axis) => {\n          axis.ticks = Array.from({ length: N }, (_, i) => ({ value: i }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 18 },\n          autoSkip: false,\n          callback: (v) => (Number.isInteger(v) && v >= 0 && v < N ? species[v] : \"\"),\n        },\n        title: { display: true, text: \"True Species\", color: t.ink, font: { size: 20 } },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n});\n"}