{"spec_id":"silhouette-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// silhouette-basic: Silhouette Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-09\n//# anyplot-orientation: square\n// anyplot.ai\n// silhouette-basic: Silhouette Plot\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-09\nimport { BarChart } from \"@mui/x-charts/BarChart\";\nimport { ChartsReferenceLine } from \"@mui/x-charts/ChartsReferenceLine\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ---------------------------------------\n// Tiny fixed-seed LCG — the browser has no seeded RNG.\nfunction makeLcg(seed: number) {\n  let state = seed >>> 0;\n  return function next() {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\n\nfunction randNormal(rng: () => number) {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst rng = makeLcg(42);\nconst MIN_SCORE = -0.6;\nconst MAX_SCORE = 0.95;\n\n// Customer-segmentation clustering (RFM features -> k-means, k=4). Silhouette\n// coefficient per customer, grouped by segment and sorted best-to-worst within\n// each segment — segments ordered by average score, best cohesion first.\nconst segments = [\n  { name: \"Champions\", meanScore: 0.6, spread: 0.16, count: 22 },\n  { name: \"Loyal\", meanScore: 0.35, spread: 0.22, count: 20 },\n  { name: \"At-Risk\", meanScore: 0.12, spread: 0.22, count: 19 },\n  { name: \"Lost\", meanScore: -0.05, spread: 0.24, count: 19 },\n];\n\nconst segmentColors = [t.palette[0], t.palette[1], t.palette[2], t.palette[3]];\n\nlet allScores: number[] = [];\nconst segmentData = segments.map((segment) => {\n  const scores: number[] = [];\n  for (let i = 0; i < segment.count; i += 1) {\n    const raw = segment.meanScore + randNormal(rng) * segment.spread;\n    scores.push(Math.max(MIN_SCORE, Math.min(MAX_SCORE, raw)));\n  }\n  scores.sort((a, b) => b - a);\n  allScores = allScores.concat(scores);\n  const avgScore = scores.reduce((sum, v) => sum + v, 0) / scores.length;\n  return { ...segment, scores, avgScore };\n});\n\nconst overallAvg = allScores.reduce((sum, v) => sum + v, 0) / allScores.length;\nconst totalSamples = segmentData.reduce((sum, s) => sum + s.count, 0);\nconst categories = Array.from(\n  { length: totalSamples },\n  (_, i) => `customer-${i}`,\n);\n\n// One series per segment, stacked on the same axis positions — each series\n// only has a non-null value at the index range that belongs to it, so every\n// bar renders with exactly one (segment-colored) fill.\nlet cursor = 0;\nconst series = segmentData.map((segment, segmentIndex) => {\n  const data: (number | null)[] = new Array(totalSamples).fill(null);\n  for (let i = 0; i < segment.scores.length; i += 1) {\n    data[cursor + i] = segment.scores[i];\n  }\n  cursor += segment.count;\n  return {\n    id: segment.name,\n    label: segment.name,\n    data,\n    color: segmentColors[segmentIndex],\n    stack: \"silhouette\",\n  };\n});\n\n// Midpoint category of each segment's band, used to place its average-score\n// annotation at the segment's vertical center.\nlet offset = 0;\nconst segmentMidpoints = segmentData.map((segment) => {\n  const mid = categories[offset + Math.floor(segment.count / 2)];\n  offset += segment.count;\n  return mid;\n});\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  const W = window.ANYPLOT_SIZE.width;\n  const H = window.ANYPLOT_SIZE.height;\n  const CHART_TOP = 64;\n  const theme = window.ANYPLOT_THEME;\n\n  const title = \"silhouette-basic · javascript · muix · anyplot.ai\";\n  const titleSize =\n    title.length > 67 ? Math.round((22 * 67) / title.length) : 22;\n\n  // Light theme puts lavender/ochre annotation text directly on the cream\n  // background, below WCAG 3:1 contrast for those two hues. A thin ink-color\n  // halo (paintOrder \"stroke\" draws the stroke behind the fill) keeps the\n  // segment-colored fill intact while restoring crisp edges. Dark theme\n  // already reads cleanly (light hues on near-black), so skip it there.\n  const labelHalo =\n    theme === \"light\"\n      ? { stroke: t.ink, strokeWidth: 3, paintOrder: \"stroke\" as const }\n      : {};\n\n  return (\n    <Box sx={{ position: \"relative\", width: W, height: H, bgcolor: t.pageBg }}>\n      <Box sx={{ position: \"absolute\", top: 20, left: 56, right: 56 }}>\n        <Typography sx={{ color: t.ink, fontSize: titleSize, fontWeight: 500 }}>\n          {title}\n        </Typography>\n      </Box>\n      <Box\n        sx={{\n          position: \"absolute\",\n          top: CHART_TOP,\n          left: 0,\n          right: 0,\n          bottom: 0,\n        }}\n      >\n        <BarChart\n          width={W}\n          height={H - CHART_TOP}\n          layout=\"horizontal\"\n          skipAnimation\n          borderRadius={0}\n          series={series}\n          yAxis={[\n            {\n              scaleType: \"band\",\n              data: categories,\n              label: \"Customers (by segment)\",\n              labelStyle: { fontSize: 16, fill: t.ink },\n              categoryGapRatio: 0.04,\n              disableTicks: true,\n              disableLine: true,\n              tickLabelInterval: () => false,\n            },\n          ]}\n          xAxis={[\n            {\n              min: MIN_SCORE,\n              max: 1,\n              label: \"Silhouette coefficient\",\n              labelStyle: { fontSize: 16, fill: t.ink },\n              tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n              valueFormatter: (value: number) => value.toFixed(1),\n            },\n          ]}\n          grid={{ vertical: true }}\n          slotProps={{\n            legend: {\n              labelStyle: { fontSize: 14, fontWeight: 500 },\n              itemGap: 20,\n            },\n          }}\n        >\n          <ChartsReferenceLine\n            x={overallAvg}\n            label={`Overall avg ${overallAvg.toFixed(2)}`}\n            labelAlign=\"end\"\n            labelStyle={{ fontSize: 15, fill: t.ink, fontWeight: 600 }}\n            lineStyle={{\n              stroke: t.ink,\n              strokeDasharray: \"6 4\",\n              strokeWidth: 2,\n            }}\n          />\n          {segmentData.map((segment, i) => (\n            <ChartsReferenceLine\n              key={segment.name}\n              y={segmentMidpoints[i]}\n              label={`${segment.name} · avg ${segment.avgScore.toFixed(2)}`}\n              labelAlign=\"start\"\n              labelStyle={{\n                fontSize: 14,\n                fill: segmentColors[i],\n                fontWeight: 600,\n                ...labelHalo,\n              }}\n              lineStyle={{\n                stroke: t.grid,\n                strokeDasharray: \"3 5\",\n                strokeWidth: 1,\n              }}\n            />\n          ))}\n        </BarChart>\n      </Box>\n    </Box>\n  );\n}\n"}