{"spec_id":"dot-matrix-proportional","library":"muix","language":"javascript","code":"// anyplot.ai\n// dot-matrix-proportional: Dot Matrix Chart for Proportional Counts\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 86/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// dot-matrix-proportional: Dot Matrix Chart for Proportional Counts\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-05\n\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsTooltip } from \"@mui/x-charts/ChartsTooltip\";\nimport { ChartsVoronoiHandler } from \"@mui/x-charts/ChartsVoronoiHandler\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst FONT = \"system-ui, -apple-system, sans-serif\";\nconst TITLE = \"dot-matrix-proportional · javascript · muix · anyplot.ai\";\n\n// Muted anchor (other/rest/neutral) — not part of the harness's ANYPLOT_TOKENS,\n// so it's keyed off ANYPLOT_THEME directly using the documented Imprint hexes.\nconst MUTED = window.ANYPLOT_THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// Survey of 100 respondents on a new workplace policy — one dot per\n// respondent, grid sized to match the total exactly (10x10 = 100).\nconst TOTAL = 100;\nconst GRID_COLS = 10;\nconst GRID_ROWS = 10;\n\n// Sentiment semantic exception: positive -> brand green, negative -> matte\n// red, neutral -> the muted anchor (all from the Imprint palette / anchors).\nconst CATEGORIES = [\n  { label: \"Agreed\", count: 47, color: t.palette[0] },\n  { label: \"Disagreed\", count: 35, color: t.palette[4] },\n  { label: \"No opinion\", count: 18, color: MUTED },\n];\n\n// Fill the grid sequentially by category in reading order (left-to-right,\n// top-to-bottom); row 0 is the top of the chart, so it maps to the highest y.\nlet dotIndex = 0;\nconst series = CATEGORIES.map((category) => {\n  const data = [];\n  for (let n = 0; n < category.count; n += 1) {\n    const row = Math.floor(dotIndex / GRID_COLS);\n    const col = dotIndex % GRID_COLS;\n    data.push({ x: col, y: GRID_ROWS - 1 - row, id: dotIndex });\n    dotIndex += 1;\n  }\n  return {\n    type: \"scatter\",\n    id: category.label,\n    label: category.label,\n    color: category.color,\n    data,\n  };\n});\n\n// CVD-safety: green (\"Agreed\") vs. red (\"Disagreed\") is otherwise a pure-hue\n// distinction, so the \"Disagreed\" dots get a non-color ring cue (a shape/\n// contrast difference, not just a different hex) that a red-green colorblind\n// viewer can still pick up. It's a decorative overlay -- not a chart series --\n// so it never touches MUI X's Voronoi hit-testing or the hover tooltip.\nconst disagreedData = series.find((s) => s.id === \"Disagreed\").data;\n\nconst TITLE_HEIGHT = 76;\nconst LEGEND_HEIGHT = 84;\n\nexport default function Chart() {\n  const width = window.ANYPLOT_SIZE.width;\n  const height = window.ANYPLOT_SIZE.height;\n\n  // The grid is a true square: same pixel span on both axes for the same\n  // 10-unit domain, so every dot renders as a perfect circle in a perfect\n  // cell, never stretched.\n  const gridArea = Math.min(width, height - TITLE_HEIGHT - LEGEND_HEIGHT);\n  const cellSize = gridArea / GRID_COLS;\n  const markerSize = cellSize * 0.38;\n\n  // Ring sized to clear the fill dot with a visible gap, and its outer edge\n  // (ringRadius + ringStrokeWidth / 2 = markerSize * 1.21) stays inside half a\n  // cell (0.5 * cellSize) so rings on adjacent \"Disagreed\" dots never touch.\n  const ringGap = markerSize * 0.05;\n  const ringStrokeWidth = Math.max(1.5, markerSize * 0.16);\n  const ringRadius = markerSize + ringGap + ringStrokeWidth / 2;\n\n  return (\n    <Box\n      sx={{\n        width,\n        height,\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n      }}\n    >\n      <Box\n        sx={{\n          height: TITLE_HEIGHT,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          flexShrink: 0,\n        }}\n      >\n        <Typography sx={{ color: t.ink, fontSize: 22, fontWeight: 500, fontFamily: FONT }}>\n          {TITLE}\n        </Typography>\n      </Box>\n\n      <Box sx={{ position: \"relative\", width: gridArea, height: gridArea, flexShrink: 0 }}>\n        <ChartContainer\n          width={gridArea}\n          height={gridArea}\n          margin={{ top: 0, right: 0, bottom: 0, left: 0 }}\n          skipAnimation\n          series={series.map((s) => ({ ...s, markerSize }))}\n          xAxis={[\n            {\n              scaleType: \"linear\",\n              min: -0.5,\n              max: GRID_COLS - 0.5,\n              domainLimit: \"strict\",\n              disableLine: true,\n              disableTicks: true,\n              tickLabelInterval: () => false,\n            },\n          ]}\n          yAxis={[\n            {\n              scaleType: \"linear\",\n              min: -0.5,\n              max: GRID_ROWS - 0.5,\n              domainLimit: \"strict\",\n              disableLine: true,\n              disableTicks: true,\n              tickLabelInterval: () => false,\n            },\n          ]}\n        >\n          <ScatterPlot />\n          <ChartsVoronoiHandler />\n          <ChartsTooltip trigger=\"item\" />\n        </ChartContainer>\n\n        {/* Decorative CVD-safety ring, matches the grid's own domain-to-pixel\n            mapping ((value + 0.5) * cellSize, y flipped since row 0 is top). */}\n        <svg\n          width={gridArea}\n          height={gridArea}\n          viewBox={`0 0 ${gridArea} ${gridArea}`}\n          style={{ position: \"absolute\", top: 0, left: 0, pointerEvents: \"none\" }}\n        >\n          {disagreedData.map((point) => (\n            <circle\n              key={point.id}\n              cx={(point.x + 0.5) * cellSize}\n              cy={gridArea - (point.y + 0.5) * cellSize}\n              r={ringRadius}\n              fill=\"none\"\n              stroke={t.ink}\n              strokeWidth={ringStrokeWidth}\n            />\n          ))}\n        </svg>\n      </Box>\n\n      <Box\n        sx={{\n          height: LEGEND_HEIGHT,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          gap: \"40px\",\n          flexShrink: 0,\n        }}\n      >\n        {CATEGORIES.map((category) => (\n          <Box key={category.label} sx={{ display: \"flex\", alignItems: \"center\", gap: \"10px\" }}>\n            <Box\n              sx={{\n                width: 20,\n                height: 20,\n                borderRadius: \"50%\",\n                backgroundColor: category.color,\n                flexShrink: 0,\n                // Mirrors the grid's CVD-safety ring so the legend swatch\n                // matches what's actually drawn for \"Disagreed\" dots.\n                boxShadow: category.label === \"Disagreed\" ? `0 0 0 2px ${t.pageBg}, 0 0 0 4px ${t.ink}` : \"none\",\n              }}\n            />\n            <Typography sx={{ color: t.ink, fontSize: 16, fontFamily: FONT }}>\n              {category.label} — {category.count} ({Math.round((category.count / TOTAL) * 100)}%)\n            </Typography>\n          </Box>\n        ))}\n      </Box>\n    </Box>\n  );\n}\n"}