{"spec_id":"confusion-matrix","library":"muix","language":"javascript","code":"// anyplot.ai\n// confusion-matrix: Confusion Matrix Heatmap\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-04\n//# anyplot-orientation: square\n// anyplot.ai\n// confusion-matrix: Confusion Matrix Heatmap\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-04\n\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\nimport { ContinuousColorLegend } from \"@mui/x-charts/ChartsLegend\";\n\nconst tokens = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) — recycling-sorter classifier example -\n// Rows = true material, columns = predicted material. Off-diagonal counts\n// model plausible confusions (glass/plastic/metal look alike on a conveyor).\nconst classes = [\"Paper\", \"Glass\", \"Metal\", \"Plastic\", \"Organic\"];\nconst matrix = [\n  [182, 4, 1, 6, 3],\n  [5, 151, 3, 9, 2],\n  [1, 4, 142, 11, 0],\n  [7, 9, 12, 158, 5],\n  [2, 1, 0, 6, 176],\n];\nconst maxCount = Math.max(...matrix.flat());\n\nconst points = [];\nclasses.forEach((trueClass, row) => {\n  const rowTotal = matrix[row].reduce((sum, v) => sum + v, 0);\n  classes.forEach((predictedClass, col) => {\n    const count = matrix[row][col];\n    points.push({\n      id: `${row}-${col}`,\n      x: predictedClass,\n      y: trueClass,\n      z: count,\n      count,\n      rowPct: Math.round((count / rowTotal) * 100),\n    });\n  });\n});\n\n// --- Sequential Imprint color scale (imprint_seq: brand green -> blue) -----\nfunction mixHex(hexA, hexB, ratio) {\n  const a = parseInt(hexA.slice(1), 16);\n  const b = parseInt(hexB.slice(1), 16);\n  const channel = (shift) => {\n    const av = (a >> shift) & 255;\n    const bv = (b >> shift) & 255;\n    return Math.round(av + (bv - av) * ratio);\n  };\n  return `#${[16, 8, 0].map((shift) => channel(shift).toString(16).padStart(2, \"0\")).join(\"\")}`;\n}\n\n// `t` arrives pre-normalized to [0, 1] by the zAxis colorMap's scaleSequential\n// (from the 0..maxCount domain below). The diagonal's high counts dominate\n// that domain, crushing every off-diagonal misclassification count into a\n// near-identical sliver near t=0 — gamma-compress so low counts spread across\n// more of the range while t=1 (the diagonal) still lands on the same `high`\n// endpoint, keeping it visually dominant.\nfunction sequentialColor(t) {\n  const [low, high] = tokens.seq;\n  return mixHex(low, high, Math.pow(t, 0.45));\n}\n\nfunction relativeLuminance(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  const r = ((n >> 16) & 255) / 255;\n  const g = ((n >> 8) & 255) / 255;\n  const b = (n & 255) / 255;\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n// Custom marker: filled square cells with count + row-normalized percentage,\n// and an ink outline on the diagonal to call out correct predictions.\nfunction ConfusionCell(props) {\n  const { series, xScale, yScale, colorGetter, color } = props;\n  const cellWidth = xScale.bandwidth();\n  const cellHeight = yScale.bandwidth();\n  const countFontSize = Math.round(Math.min(cellWidth, cellHeight) * 0.2);\n  const pctFontSize = Math.round(countFontSize * 0.55);\n\n  return (\n    <g>\n      {series.data.map((point, i) => {\n        const x0 = xScale(point.x) ?? 0;\n        const y0 = yScale(point.y) ?? 0;\n        const fill = colorGetter ? colorGetter(i) : color;\n        const textFill = relativeLuminance(fill) > 0.55 ? \"#1A1A17\" : \"#F0EFE8\";\n        const isCorrect = point.x === point.y;\n        return (\n          <g key={point.id}>\n            <rect\n              x={x0}\n              y={y0}\n              width={cellWidth}\n              height={cellHeight}\n              fill={fill}\n              stroke={isCorrect ? tokens.ink : \"none\"}\n              strokeWidth={isCorrect ? 4 : 0}\n            />\n            <text\n              x={x0 + cellWidth / 2}\n              y={y0 + cellHeight / 2 - pctFontSize * 0.7}\n              textAnchor=\"middle\"\n              dominantBaseline=\"central\"\n              fontSize={countFontSize}\n              fontWeight={isCorrect ? 700 : 400}\n              fontFamily=\"inherit\"\n              fill={textFill}\n            >\n              {point.count}\n            </text>\n            <text\n              x={x0 + cellWidth / 2}\n              y={y0 + cellHeight / 2 + countFontSize * 0.6}\n              textAnchor=\"middle\"\n              dominantBaseline=\"central\"\n              fontSize={pctFontSize}\n              fontFamily=\"inherit\"\n              fill={textFill}\n              opacity={0.85}\n            >\n              {point.rowPct}%\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const TITLE_HEIGHT = 80;\n  const RIGHT_BUFFER = 40; // room for the legend's max-value label at the true edge\n  const TOP_MARGIN = 100; // top-axis tick labels + \"Predicted Label\" axis title\n  const LEFT_MARGIN = 220; // left-axis tick labels + \"True Label\" axis title\n  const RIGHT_MARGIN = 20;\n  const LEGEND_SPACE = 80;\n\n  const chartWidth = width - RIGHT_BUFFER;\n  const chartHeight = height - TITLE_HEIGHT;\n\n  return (\n    <Box sx={{ width, height, bgcolor: tokens.pageBg, display: \"flex\", flexDirection: \"column\" }}>\n      <Typography\n        sx={{\n          color: tokens.ink,\n          fontSize: 22,\n          fontWeight: 500,\n          textAlign: \"center\",\n          lineHeight: 1.2,\n          pt: \"16px\",\n          height: TITLE_HEIGHT,\n          fontFamily: \"inherit\",\n        }}\n      >\n        confusion-matrix · javascript · muix · anyplot.ai\n      </Typography>\n      <Box sx={{ flex: 1, display: \"flex\", alignItems: \"flex-start\", justifyContent: \"flex-start\" }}>\n        <ScatterChart\n          width={chartWidth}\n          height={chartHeight}\n          skipAnimation\n          disableVoronoi\n          series={[\n            {\n              id: \"confusion\",\n              type: \"scatter\",\n              data: points,\n              label: \"Predictions\",\n              xAxisId: \"predicted\",\n              yAxisId: \"actual\",\n              zAxisId: \"count\",\n            },\n          ]}\n          xAxis={[\n            {\n              id: \"predicted\",\n              scaleType: \"band\",\n              data: classes,\n              categoryGapRatio: 0.08,\n              label: \"Predicted Label\",\n              labelStyle: { fontSize: 18, fill: tokens.ink, fontFamily: \"inherit\" },\n              tickLabelStyle: { fontSize: 16, fill: tokens.inkSoft, fontFamily: \"inherit\" },\n              disableTicks: true,\n              disableLine: true,\n            },\n          ]}\n          yAxis={[\n            {\n              id: \"actual\",\n              scaleType: \"band\",\n              data: classes,\n              categoryGapRatio: 0.08,\n              label: \"True Label\",\n              labelStyle: { fontSize: 18, fill: tokens.ink, fontFamily: \"inherit\" },\n              tickLabelStyle: { fontSize: 16, fill: tokens.inkSoft, fontFamily: \"inherit\" },\n              // Pushes the rotated axis title clear of the widest tick label\n              // (\"Plastic\"/\"Organic\") — the title's own offset is computed\n              // from this value, not the actual rendered tickLabelStyle size.\n              tickFontSize: 70,\n              disableTicks: true,\n              disableLine: true,\n            },\n          ]}\n          zAxis={[\n            {\n              id: \"count\",\n              min: 0,\n              max: maxCount,\n              colorMap: { type: \"continuous\", min: 0, max: maxCount, color: sequentialColor },\n            },\n          ]}\n          topAxis=\"predicted\"\n          bottomAxis={null}\n          leftAxis=\"actual\"\n          rightAxis={null}\n          margin={{ top: TOP_MARGIN, right: RIGHT_MARGIN, bottom: LEGEND_SPACE, left: LEFT_MARGIN }}\n          slots={{ scatter: ConfusionCell }}\n          slotProps={{ legend: { hidden: true } }}\n        >\n          <ContinuousColorLegend\n            axisId=\"count\"\n            axisDirection=\"z\"\n            position={{ horizontal: \"right\", vertical: \"bottom\" }}\n            direction=\"row\"\n            length=\"50%\"\n            thickness={14}\n            labelStyle={{ fontSize: 14, fill: tokens.inkSoft, fontFamily: \"inherit\" }}\n          />\n        </ScatterChart>\n      </Box>\n    </Box>\n  );\n}\n"}