{"spec_id":"dendrogram-radial","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// dendrogram-radial: Radial Dendrogram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Updated: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: synthetic marker-gene expression per immune cell subtype --------\n// Deterministic LCG so the \"random\" noise is reproducible without a browser RNG.\nfunction makeRng(seed) {\n  let state = seed >>> 0;\n  return function rng() {\n    state = (Math.imul(1664525, state) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeRng(42);\nfunction gaussian() {\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// Five marker genes, one diagnostic per cell type (diagonal-dominant profile).\nconst CLUSTERS = [\n  { name: \"T cells\", prefix: \"T\", center: [8.5, 0.5, 0.8, 0.6, 0.4] },\n  { name: \"B cells\", prefix: \"B\", center: [0.6, 8.2, 0.5, 0.7, 0.9] },\n  { name: \"NK cells\", prefix: \"NK\", center: [0.9, 0.4, 8.0, 0.5, 0.6] },\n  { name: \"Monocytes\", prefix: \"Mo\", center: [0.5, 0.6, 0.4, 8.3, 1.2] },\n  { name: \"Dendritic cells\", prefix: \"DC\", center: [0.7, 0.8, 0.6, 1.5, 7.9] },\n];\nconst SAMPLES_PER_CLUSTER = 6;\nconst NOISE_SD = 0.8;\n\nconst samples = [];\nCLUSTERS.forEach((c, clusterId) => {\n  for (let s = 0; s < SAMPLES_PER_CLUSTER; s++) {\n    samples.push({\n      id: samples.length,\n      name: `${c.prefix}-${String(s + 1).padStart(2, \"0\")}`,\n      cluster: clusterId,\n      vec: c.center.map((v) => v + gaussian() * NOISE_SD),\n    });\n  }\n});\nconst n = samples.length;\nconst nodeCount = 2 * n - 1;\n\n// --- Hierarchical clustering (average linkage / UPGMA) ----------------------\n// Produces a scipy-style linkage matrix: rows [childA, childB, distance, size].\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    let sq = 0;\n    for (let k = 0; k < samples[i].vec.length; k++) {\n      const d = samples[i].vec[k] - samples[j].vec[k];\n      sq += d * d;\n    }\n    const dist = Math.sqrt(sq);\n    D[i][j] = dist;\n    D[j][i] = dist;\n  }\n}\n\nconst clusterSize = new Array(nodeCount).fill(1);\nconst active = new Set(Array.from({ length: n }, (_, i) => i));\nconst linkage = [];\nlet nextId = n;\nwhile (active.size > 1) {\n  let best = { a: -1, b: -1, d: 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      const a = ids[i], b = ids[j];\n      if (D[a][b] < best.d) best = { a, b, d: D[a][b] };\n    }\n  }\n  const { a, b, d } = best;\n  const newId = nextId++;\n  const newSize = clusterSize[a] + clusterSize[b];\n  active.forEach((c) => {\n    if (c === a || c === b) return;\n    const avg = (clusterSize[a] * D[a][c] + clusterSize[b] * D[b][c]) / newSize;\n    D[newId][c] = avg;\n    D[c][newId] = avg;\n  });\n  clusterSize[newId] = newSize;\n  active.delete(a);\n  active.delete(b);\n  active.add(newId);\n  linkage.push([a, b, d, newSize]);\n}\nconst root = nextId - 1;\nconst maxHeight = Math.max(...linkage.map((row) => row[2]));\n\n// --- Radial layout: leaves on the rim, root at the center -------------------\n// Leaf order follows a recursive left+right traversal from the root so every\n// subtree occupies a contiguous angular span (standard dendrogram ordering).\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 clusterOf = new Array(nodeCount).fill(null);\n\norder.forEach((leafId, idx) => {\n  angleOf[leafId] = Math.PI / 2 - idx * ((2 * Math.PI) / n);\n  radiusOf[leafId] = 1;\n  clusterOf[leafId] = samples[leafId].cluster;\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  clusterOf[id] = clusterOf[a] === clusterOf[b] ? clusterOf[a] : null;\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Datasets: one per cluster, purely for legend + tooltip + leaf markers --\nconst datasets = CLUSTERS.map((c, clusterId) => ({\n  label: c.name,\n  data: samples\n    .filter((s) => s.cluster === clusterId)\n    .map((s) => ({\n      x: Math.cos(angleOf[s.id]),\n      y: Math.sin(angleOf[s.id]),\n      name: s.name,\n    })),\n  backgroundColor: t.palette[clusterId],\n  borderColor: t.pageBg,\n  borderWidth: 1.5,\n  pointRadius: 7,\n  pointHoverRadius: 9,\n  showLine: false,\n}));\n\n// --- Plugin: force the x/y linear scales into an exact 1:1 pixel ratio ------\n// The title + bottom legend consume unequal vertical space, so the naive\n// scatter chart area is not a perfect square. Chart.js's own layout gives us\n// the true chart area only after the first pass, so we widen the shorter axis\n// once (in afterLayout) and trigger a single corrective update.\nconst squareAxesPlugin = {\n  id: \"squareAxes\",\n  afterLayout(chart) {\n    if (chart.$anyplotSquared) return;\n    const area = chart.chartArea;\n    const w = area.right - area.left;\n    const h = area.bottom - area.top;\n    const xs = chart.scales.x;\n    const xRange = xs.max - xs.min;\n    const newYRange = xRange * (h / w);\n    chart.options.scales.y.min = -newYRange / 2;\n    chart.options.scales.y.max = newYRange / 2;\n    chart.$anyplotSquared = true;\n    chart.update();\n  },\n};\n\n// --- Plugin: draw the radial tree (rings behind, branches, tip labels) -----\nconst dendrogramPlugin = {\n  id: \"radialDendrogram\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const xs = scales.x, ys = scales.y;\n    const cx = xs.getPixelForValue(0);\n    const cy = ys.getPixelForValue(0);\n    const px = (r) => Math.abs(xs.getPixelForValue(r) - cx);\n    const point = (r, theta) => ({\n      x: xs.getPixelForValue(r * Math.cos(theta)),\n      y: ys.getPixelForValue(r * Math.sin(theta)),\n    });\n\n    // Distance reference rings (subtle, matches the style guide's grid opacity).\n    ctx.save();\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    [0.25, 0.5, 0.75, 1.0].forEach((frac) => {\n      ctx.beginPath();\n      ctx.arc(cx, cy, px(frac), 0, Math.PI * 2);\n      ctx.stroke();\n    });\n    ctx.restore();\n\n    // Branches: an arc bridging the two children at the parent's radius, then\n    // a radial segment from that radius out to each child's own position.\n    ctx.save();\n    ctx.lineCap = \"round\";\n    for (let id = n; id < nodeCount; id++) {\n      const [a, b] = linkage[id - n];\n      const rParent = radiusOf[id];\n      const rPxParent = px(rParent);\n      const cAngleA = -angleOf[a];\n      const cAngleB = -angleOf[b];\n      const arcColor = clusterOf[id] !== null ? t.palette[clusterOf[id]] : t.inkSoft;\n\n      ctx.strokeStyle = arcColor;\n      ctx.lineWidth = 2.5;\n      ctx.beginPath();\n      ctx.arc(cx, cy, rPxParent, Math.min(cAngleA, cAngleB), Math.max(cAngleA, cAngleB));\n      ctx.stroke();\n\n      [a, b].forEach((child) => {\n        const childColor = clusterOf[child] !== null ? t.palette[clusterOf[child]] : t.inkSoft;\n        const p1 = point(rParent, angleOf[child]);\n        const p2 = point(radiusOf[child], angleOf[child]);\n        ctx.strokeStyle = childColor;\n        ctx.beginPath();\n        ctx.moveTo(p1.x, p1.y);\n        ctx.lineTo(p2.x, p2.y);\n        ctx.stroke();\n      });\n    }\n    ctx.restore();\n\n    // Root anchor.\n    ctx.save();\n    ctx.fillStyle = t.inkSoft;\n    ctx.beginPath();\n    ctx.arc(cx, cy, 5, 0, Math.PI * 2);\n    ctx.fill();\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const xs = scales.x, ys = scales.y;\n    const cx = xs.getPixelForValue(0);\n    const cy = ys.getPixelForValue(0);\n    const labelRadius = 1.14;\n\n    ctx.save();\n    ctx.font = '13px -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif';\n    ctx.fillStyle = t.inkSoft;\n    ctx.textBaseline = \"middle\";\n    order.forEach((leafId) => {\n      const theta = angleOf[leafId];\n      const lx = xs.getPixelForValue(labelRadius * Math.cos(theta));\n      const ly = ys.getPixelForValue(labelRadius * Math.sin(theta));\n      const rot = Math.atan2(ly - cy, lx - cx);\n      const flip = Math.cos(rot) < 0;\n      ctx.save();\n      ctx.translate(lx, ly);\n      ctx.rotate(flip ? rot + Math.PI : rot);\n      ctx.textAlign = flip ? \"right\" : \"left\";\n      ctx.fillText(samples[leafId].name, flip ? -4 : 4, 0);\n      ctx.restore();\n    });\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  plugins: [squareAxesPlugin, dendrogramPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 24 },\n    scales: {\n      x: { type: \"linear\", min: -1.42, max: 1.42, display: false, grid: { display: false } },\n      y: { type: \"linear\", min: -1.42, max: 1.42, display: false, grid: { display: false } },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"dendrogram-radial · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { bottom: 16 },\n      },\n      legend: {\n        display: true,\n        position: \"bottom\",\n        labels: { color: t.ink, font: { size: 14 }, boxWidth: 14, usePointStyle: true, padding: 18 },\n      },\n      tooltip: {\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.inkSoft,\n        borderColor: t.grid,\n        borderWidth: 1,\n        callbacks: {\n          title: (items) => (items[0] ? items[0].raw.name : \"\"),\n          label: (item) => item.dataset.label,\n        },\n      },\n    },\n  },\n});\n"}