{"spec_id":"dendrogram-radial","library":"muix","language":"javascript","code":"// anyplot.ai\n// dendrogram-radial: Radial Dendrogram\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// dendrogram-radial: Radial Dendrogram\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 { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst FONT = \"Inter, system-ui, -apple-system, sans-serif\";\n\n// --- Deterministic LCG so the \"random\" flavor noise reproduces without a browser RNG.\nfunction createRng(seed) {\n  let state = seed >>> 0;\n  return function next() {\n    state = (Math.imul(1664525, state) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nfunction randomGaussian(rng) {\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\n// --- Data: coffee-origin flavor profiles across five growing regions --------\n// Vector dims: [acidity, body, sweetness, floralAroma, earthiness], 0-10 scale.\n// Ethiopian/Kenyan highlands share a high-acidity East-African profile, so the\n// clustering merges them into a shared clade before joining the rest.\nconst REGIONS = [\n  { code: \"ETH\", name: \"Ethiopian Highlands\", center: [8.5, 3.0, 7.0, 8.5, 1.0] },\n  { code: \"KEN\", name: \"Kenyan Highlands\", center: [8.8, 4.0, 6.0, 5.0, 1.5] },\n  { code: \"COL\", name: \"Colombian Andes\", center: [5.5, 6.0, 7.5, 2.5, 2.0] },\n  { code: \"GUA\", name: \"Guatemalan Volcanic\", center: [6.0, 5.0, 5.5, 4.0, 2.5] },\n  { code: \"SUM\", name: \"Sumatran Lowlands\", center: [2.0, 8.5, 3.0, 1.0, 8.5] },\n];\nconst SAMPLES_PER_REGION = 6;\nconst NOISE_SD = 0.7;\n\nconst rng = createRng(42);\nconst samples = [];\nREGIONS.forEach((region, regionId) => {\n  for (let s = 0; s < SAMPLES_PER_REGION; s++) {\n    samples.push({\n      id: samples.length,\n      name: `${region.code}-${s + 1}`,\n      region: regionId,\n      vec: region.center.map((v) => v + randomGaussian(rng) * NOISE_SD),\n    });\n  }\n});\nconst n = samples.length;\nconst nodeCount = 2 * n - 1;\n\n// --- Hierarchical clustering (complete linkage) ------------------------------\nfunction euclid(a, b) {\n  return Math.sqrt(a.reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0));\n}\nconst D = Array.from({ length: nodeCount }, () => new Array(nodeCount).fill(Infinity));\nfor (let i = 0; i < n; i++) {\n  for (let j = i + 1; j < n; j++) {\n    const d = euclid(samples[i].vec, samples[j].vec);\n    D[i][j] = d;\n    D[j][i] = d;\n  }\n}\n\nconst active = new Set(Array.from({ length: n }, (_, i) => i));\nconst linkage = [];\nlet nextId = n;\nwhile (active.size > 1) {\n  let a = -1, b = -1, best = Infinity;\n  const ids = Array.from(active);\n  for (let i = 0; i < ids.length; i++) {\n    for (let j = i + 1; j < ids.length; j++) {\n      if (D[ids[i]][ids[j]] < best) {\n        best = D[ids[i]][ids[j]];\n        a = ids[i];\n        b = ids[j];\n      }\n    }\n  }\n  const newId = nextId++;\n  active.forEach((c) => {\n    if (c === a || c === b) return;\n    const d = Math.max(D[a][c], D[b][c]); // complete linkage: farthest-pair distance\n    D[newId][c] = d;\n    D[c][newId] = d;\n  });\n  linkage.push([a, b, best]);\n  active.delete(a);\n  active.delete(b);\n  active.add(newId);\n}\nconst root = nextId - 1;\nconst maxHeight = Math.max(...linkage.map((row) => row[2]));\n\n// --- Radial layout: leaves on the rim, contiguous angular span per subtree --\nfunction leafOrder(nodeId) {\n  if (nodeId < n) return [nodeId];\n  const [a, b] = linkage[nodeId - n];\n  return leafOrder(a).concat(leafOrder(b));\n}\nconst order = leafOrder(root);\n\nconst angleOf = new Array(nodeCount).fill(0);\nconst radiusOf = new Array(nodeCount).fill(0);\nconst regionOf = new Array(nodeCount).fill(null);\norder.forEach((leafId, idx) => {\n  angleOf[leafId] = Math.PI / 2 - idx * ((2 * Math.PI) / n);\n  radiusOf[leafId] = 1;\n  regionOf[leafId] = samples[leafId].region;\n});\nfor (let id = n; id < nodeCount; id++) {\n  const [a, b, height] = linkage[id - n];\n  angleOf[id] = (angleOf[a] + angleOf[b]) / 2;\n  radiusOf[id] = 1 - height / maxHeight;\n  regionOf[id] = regionOf[a] === regionOf[b] ? regionOf[a] : null;\n}\n\nconst REGION_COLORS = t.palette.slice(0, REGIONS.length);\n\n// --- MUI X scatter series: one per region, positioned on the unit circle ----\nconst scatterSeries = REGIONS.map((region, regionId) => ({\n  type: \"scatter\",\n  data: samples\n    .filter((s) => s.region === regionId)\n    .map((s) => ({ id: s.name, x: Math.cos(angleOf[s.id]), y: Math.sin(angleOf[s.id]) })),\n  label: region.name,\n  color: REGION_COLORS[regionId],\n  markerSize: 11,\n}));\n\n// --- Custom SVG overlay: reference rings, branches, metadata ring, labels ---\n// Drawn on the ChartContainer's own coordinate system via useXScale/useYScale,\n// the standard MUI X composition pattern for chart types the library has no\n// built-in component for.\nfunction RadialDendrogram() {\n  const xs = useXScale();\n  const ys = useYScale();\n  if (!xs || !ys) return null;\n\n  const cx = xs(0);\n  const cy = ys(0);\n  const px = (r) => Math.abs(xs(r) - cx);\n  const point = (r, theta) => ({ x: xs(r * Math.cos(theta)), y: ys(r * Math.sin(theta)) });\n\n  // Sample an arc between two angles (shortest way round) into an SVG path —\n  // avoids reasoning about SVG's sweep-flag convention entirely.\n  function arcPath(r, angleA, angleB, steps) {\n    let diff = angleB - angleA;\n    while (diff > Math.PI) diff -= 2 * Math.PI;\n    while (diff < -Math.PI) diff += 2 * Math.PI;\n    let d = \"\";\n    for (let i = 0; i <= steps; i++) {\n      const p = point(r, angleA + (diff * i) / steps);\n      d += (i === 0 ? \"M\" : \"L\") + `${p.x},${p.y} `;\n    }\n    return d;\n  }\n\n  const branches = [];\n  for (let id = n; id < nodeCount; id++) {\n    const [a, b] = linkage[id - n];\n    const rParent = radiusOf[id];\n    const bridgeColor = regionOf[id] !== null ? REGION_COLORS[regionOf[id]] : t.inkSoft;\n    branches.push(\n      <path\n        key={`arc-${id}`}\n        d={arcPath(rParent, angleOf[a], angleOf[b], 14)}\n        fill=\"none\"\n        stroke={bridgeColor}\n        strokeWidth={2.5}\n        strokeLinecap=\"round\"\n      />,\n    );\n    [a, b].forEach((child) => {\n      const childColor = regionOf[child] !== null ? REGION_COLORS[regionOf[child]] : t.inkSoft;\n      const p1 = point(rParent, angleOf[child]);\n      const p2 = point(radiusOf[child], angleOf[child]);\n      branches.push(\n        <line\n          key={`seg-${id}-${child}`}\n          x1={p1.x}\n          y1={p1.y}\n          x2={p2.x}\n          y2={p2.y}\n          stroke={childColor}\n          strokeWidth={2.5}\n          strokeLinecap=\"round\"\n        />,\n      );\n    });\n  }\n\n  // Outer metadata ring — one colored arc segment per leaf, encoding region.\n  const ringInner = 1.06;\n  const ringOuter = 1.13;\n  const ringMid = (ringInner + ringOuter) / 2;\n  const ringThickness = px(ringOuter) - px(ringInner);\n  const metadataRing = order.map((leafId) => {\n    const half = (Math.PI / n) * 0.72;\n    return (\n      <path\n        key={`ring-${leafId}`}\n        d={arcPath(ringMid, angleOf[leafId] - half, angleOf[leafId] + half, 4)}\n        fill=\"none\"\n        stroke={REGION_COLORS[samples[leafId].region]}\n        strokeWidth={ringThickness}\n        strokeLinecap=\"butt\"\n      />\n    );\n  });\n\n  const labelRadius = 1.24;\n  const labels = order.map((leafId) => {\n    const theta = angleOf[leafId];\n    const p = point(labelRadius, theta);\n    const rotDeg = (Math.atan2(p.y - cy, p.x - cx) * 180) / Math.PI;\n    const flip = Math.cos(theta) < 0;\n    return (\n      <text\n        key={`label-${leafId}`}\n        x={p.x}\n        y={p.y}\n        transform={`rotate(${flip ? rotDeg + 180 : rotDeg}, ${p.x}, ${p.y})`}\n        textAnchor={flip ? \"end\" : \"start\"}\n        dominantBaseline=\"middle\"\n        fontSize={13}\n        fontFamily={FONT}\n        fill={t.inkSoft}\n      >\n        {samples[leafId].name}\n      </text>\n    );\n  });\n\n  return (\n    <g>\n      {[0.25, 0.5, 0.75, 1.0].map((frac) => (\n        <circle key={`ref-${frac}`} cx={cx} cy={cy} r={px(frac)} fill=\"none\" stroke={t.grid} strokeWidth={1} />\n      ))}\n      {metadataRing}\n      {branches}\n      <circle cx={cx} cy={cy} r={5} fill={t.inkSoft} />\n      {labels}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  const W = window.ANYPLOT_SIZE.width;\n  const H = window.ANYPLOT_SIZE.height;\n  const TITLE_H = 72;\n  const LEGEND_H = 96;\n  const chartSize = Math.min(W, H - TITLE_H - LEGEND_H);\n\n  return (\n    <div\n      style={{\n        width: W,\n        height: H,\n        background: t.pageBg,\n        fontFamily: FONT,\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n      }}\n    >\n      <div style={{ height: TITLE_H, display: \"flex\", alignItems: \"center\" }}>\n        <span style={{ fontSize: 22, fontWeight: 600, color: t.ink }}>\n          dendrogram-radial · javascript · muix · anyplot.ai\n        </span>\n      </div>\n      <ChartContainer\n        width={chartSize}\n        height={chartSize}\n        skipAnimation\n        series={scatterSeries}\n        margin={{ top: 20, right: 20, bottom: 20, left: 20 }}\n        xAxis={[{ min: -1.3, max: 1.3 }]}\n        yAxis={[{ min: -1.3, max: 1.3 }]}\n      >\n        <RadialDendrogram />\n        <ScatterPlot />\n      </ChartContainer>\n      <div\n        style={{\n          height: LEGEND_H,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          gap: 28,\n          flexWrap: \"wrap\",\n        }}\n      >\n        {REGIONS.map((region, i) => (\n          <div key={region.code} style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n            <span\n              style={{\n                width: 14,\n                height: 14,\n                borderRadius: \"50%\",\n                background: REGION_COLORS[i],\n                display: \"inline-block\",\n              }}\n            />\n            <span style={{ fontSize: 14, color: t.ink }}>{region.name}</span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"}