Radial Dendrogram — Chart.js

A radial dendrogram renders hierarchical clustering in a circular layout where the root node sits at the center and branches extend outward, with leaf nodes arranged around the circumference. This layout is a space-efficient alternative to linear dendrograms for large hierarchies, making it well-suited for datasets with hundreds of leaves. Branch lengths are proportional to distance or dissimilarity, preserving the quantitative interpretation of cluster merges.

Radial Dendrogram rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// dendrogram-radial: Radial Dendrogram
// Library: chartjs 4.4.7 | JavaScript 22.23.2
// Quality: 93/100 | Updated: 2026-09-05
//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;

// --- Data: synthetic marker-gene expression per immune cell subtype --------
// Deterministic LCG so the "random" noise is reproducible without a browser RNG.
function makeRng(seed) {
  let state = seed >>> 0;
  return function rng() {
    state = (Math.imul(1664525, state) + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rng = makeRng(42);
function gaussian() {
  const u1 = Math.max(rng(), 1e-9);
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Five marker genes, one diagnostic per cell type (diagonal-dominant profile).
const CLUSTERS = [
  { name: "T cells", prefix: "T", center: [8.5, 0.5, 0.8, 0.6, 0.4] },
  { name: "B cells", prefix: "B", center: [0.6, 8.2, 0.5, 0.7, 0.9] },
  { name: "NK cells", prefix: "NK", center: [0.9, 0.4, 8.0, 0.5, 0.6] },
  { name: "Monocytes", prefix: "Mo", center: [0.5, 0.6, 0.4, 8.3, 1.2] },
  { name: "Dendritic cells", prefix: "DC", center: [0.7, 0.8, 0.6, 1.5, 7.9] },
];
const SAMPLES_PER_CLUSTER = 6;
const NOISE_SD = 0.8;

const samples = [];
CLUSTERS.forEach((c, clusterId) => {
  for (let s = 0; s < SAMPLES_PER_CLUSTER; s++) {
    samples.push({
      id: samples.length,
      name: `${c.prefix}-${String(s + 1).padStart(2, "0")}`,
      cluster: clusterId,
      vec: c.center.map((v) => v + gaussian() * NOISE_SD),
    });
  }
});
const n = samples.length;
const nodeCount = 2 * n - 1;

// --- Hierarchical clustering (average linkage / UPGMA) ----------------------
// Produces a scipy-style linkage matrix: rows [childA, childB, distance, size].
const D = Array.from({ length: nodeCount }, () => new Array(nodeCount).fill(Infinity));
for (let i = 0; i < n; i++) {
  for (let j = i + 1; j < n; j++) {
    let sq = 0;
    for (let k = 0; k < samples[i].vec.length; k++) {
      const d = samples[i].vec[k] - samples[j].vec[k];
      sq += d * d;
    }
    const dist = Math.sqrt(sq);
    D[i][j] = dist;
    D[j][i] = dist;
  }
}

const clusterSize = new Array(nodeCount).fill(1);
const active = new Set(Array.from({ length: n }, (_, i) => i));
const linkage = [];
let nextId = n;
while (active.size > 1) {
  let best = { a: -1, b: -1, d: Infinity };
  const ids = Array.from(active);
  for (let i = 0; i < ids.length; i++) {
    for (let j = i + 1; j < ids.length; j++) {
      const a = ids[i], b = ids[j];
      if (D[a][b] < best.d) best = { a, b, d: D[a][b] };
    }
  }
  const { a, b, d } = best;
  const newId = nextId++;
  const newSize = clusterSize[a] + clusterSize[b];
  active.forEach((c) => {
    if (c === a || c === b) return;
    const avg = (clusterSize[a] * D[a][c] + clusterSize[b] * D[b][c]) / newSize;
    D[newId][c] = avg;
    D[c][newId] = avg;
  });
  clusterSize[newId] = newSize;
  active.delete(a);
  active.delete(b);
  active.add(newId);
  linkage.push([a, b, d, newSize]);
}
const root = nextId - 1;
const maxHeight = Math.max(...linkage.map((row) => row[2]));

// --- Radial layout: leaves on the rim, root at the center -------------------
// Leaf order follows a recursive left+right traversal from the root so every
// subtree occupies a contiguous angular span (standard dendrogram ordering).
function leafOrder(nodeId) {
  if (nodeId < n) return [nodeId];
  const [a, b] = linkage[nodeId - n];
  return leafOrder(a).concat(leafOrder(b));
}
const order = leafOrder(root);

const angleOf = new Array(nodeCount).fill(0);
const radiusOf = new Array(nodeCount).fill(0);
const clusterOf = new Array(nodeCount).fill(null);

order.forEach((leafId, idx) => {
  angleOf[leafId] = Math.PI / 2 - idx * ((2 * Math.PI) / n);
  radiusOf[leafId] = 1;
  clusterOf[leafId] = samples[leafId].cluster;
});
for (let id = n; id < nodeCount; id++) {
  const [a, b, height] = linkage[id - n];
  angleOf[id] = (angleOf[a] + angleOf[b]) / 2;
  radiusOf[id] = 1 - height / maxHeight;
  clusterOf[id] = clusterOf[a] === clusterOf[b] ? clusterOf[a] : null;
}

// --- Mount -------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// --- Datasets: one per cluster, purely for legend + tooltip + leaf markers --
const datasets = CLUSTERS.map((c, clusterId) => ({
  label: c.name,
  data: samples
    .filter((s) => s.cluster === clusterId)
    .map((s) => ({
      x: Math.cos(angleOf[s.id]),
      y: Math.sin(angleOf[s.id]),
      name: s.name,
    })),
  backgroundColor: t.palette[clusterId],
  borderColor: t.pageBg,
  borderWidth: 1.5,
  pointRadius: 7,
  pointHoverRadius: 9,
  showLine: false,
}));

// --- Plugin: force the x/y linear scales into an exact 1:1 pixel ratio ------
// The title + bottom legend consume unequal vertical space, so the naive
// scatter chart area is not a perfect square. Chart.js's own layout gives us
// the true chart area only after the first pass, so we widen the shorter axis
// once (in afterLayout) and trigger a single corrective update.
const squareAxesPlugin = {
  id: "squareAxes",
  afterLayout(chart) {
    if (chart.$anyplotSquared) return;
    const area = chart.chartArea;
    const w = area.right - area.left;
    const h = area.bottom - area.top;
    const xs = chart.scales.x;
    const xRange = xs.max - xs.min;
    const newYRange = xRange * (h / w);
    chart.options.scales.y.min = -newYRange / 2;
    chart.options.scales.y.max = newYRange / 2;
    chart.$anyplotSquared = true;
    chart.update();
  },
};

// --- Plugin: draw the radial tree (rings behind, branches, tip labels) -----
const dendrogramPlugin = {
  id: "radialDendrogram",
  beforeDatasetsDraw(chart) {
    const { ctx, scales } = chart;
    const xs = scales.x, ys = scales.y;
    const cx = xs.getPixelForValue(0);
    const cy = ys.getPixelForValue(0);
    const px = (r) => Math.abs(xs.getPixelForValue(r) - cx);
    const point = (r, theta) => ({
      x: xs.getPixelForValue(r * Math.cos(theta)),
      y: ys.getPixelForValue(r * Math.sin(theta)),
    });

    // Distance reference rings (subtle, matches the style guide's grid opacity).
    ctx.save();
    ctx.strokeStyle = t.grid;
    ctx.lineWidth = 1;
    [0.25, 0.5, 0.75, 1.0].forEach((frac) => {
      ctx.beginPath();
      ctx.arc(cx, cy, px(frac), 0, Math.PI * 2);
      ctx.stroke();
    });
    ctx.restore();

    // Branches: an arc bridging the two children at the parent's radius, then
    // a radial segment from that radius out to each child's own position.
    ctx.save();
    ctx.lineCap = "round";
    for (let id = n; id < nodeCount; id++) {
      const [a, b] = linkage[id - n];
      const rParent = radiusOf[id];
      const rPxParent = px(rParent);
      const cAngleA = -angleOf[a];
      const cAngleB = -angleOf[b];
      const arcColor = clusterOf[id] !== null ? t.palette[clusterOf[id]] : t.inkSoft;

      ctx.strokeStyle = arcColor;
      ctx.lineWidth = 2.5;
      ctx.beginPath();
      ctx.arc(cx, cy, rPxParent, Math.min(cAngleA, cAngleB), Math.max(cAngleA, cAngleB));
      ctx.stroke();

      [a, b].forEach((child) => {
        const childColor = clusterOf[child] !== null ? t.palette[clusterOf[child]] : t.inkSoft;
        const p1 = point(rParent, angleOf[child]);
        const p2 = point(radiusOf[child], angleOf[child]);
        ctx.strokeStyle = childColor;
        ctx.beginPath();
        ctx.moveTo(p1.x, p1.y);
        ctx.lineTo(p2.x, p2.y);
        ctx.stroke();
      });
    }
    ctx.restore();

    // Root anchor.
    ctx.save();
    ctx.fillStyle = t.inkSoft;
    ctx.beginPath();
    ctx.arc(cx, cy, 5, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  },
  afterDatasetsDraw(chart) {
    const { ctx, scales } = chart;
    const xs = scales.x, ys = scales.y;
    const cx = xs.getPixelForValue(0);
    const cy = ys.getPixelForValue(0);
    const labelRadius = 1.14;

    ctx.save();
    ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
    ctx.fillStyle = t.inkSoft;
    ctx.textBaseline = "middle";
    order.forEach((leafId) => {
      const theta = angleOf[leafId];
      const lx = xs.getPixelForValue(labelRadius * Math.cos(theta));
      const ly = ys.getPixelForValue(labelRadius * Math.sin(theta));
      const rot = Math.atan2(ly - cy, lx - cx);
      const flip = Math.cos(rot) < 0;
      ctx.save();
      ctx.translate(lx, ly);
      ctx.rotate(flip ? rot + Math.PI : rot);
      ctx.textAlign = flip ? "right" : "left";
      ctx.fillText(samples[leafId].name, flip ? -4 : 4, 0);
      ctx.restore();
    });
    ctx.restore();
  },
};

// --- Chart -------------------------------------------------------------------
new Chart(canvas, {
  type: "scatter",
  data: { datasets },
  plugins: [squareAxesPlugin, dendrogramPlugin],
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: 24 },
    scales: {
      x: { type: "linear", min: -1.42, max: 1.42, display: false, grid: { display: false } },
      y: { type: "linear", min: -1.42, max: 1.42, display: false, grid: { display: false } },
    },
    plugins: {
      title: {
        display: true,
        text: "dendrogram-radial · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 22 },
        padding: { bottom: 16 },
      },
      legend: {
        display: true,
        position: "bottom",
        labels: { color: t.ink, font: { size: 14 }, boxWidth: 14, usePointStyle: true, padding: 18 },
      },
      tooltip: {
        backgroundColor: t.elevatedBg,
        titleColor: t.ink,
        bodyColor: t.inkSoft,
        borderColor: t.grid,
        borderWidth: 1,
        callbacks: {
          title: (items) => (items[0] ? items[0].raw.name : ""),
          label: (item) => item.dataset.label,
        },
      },
    },
  },
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/dendrogram-radial/chartjs/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "dendrogram-radial",
  "language": "javascript",
  "library": "chartjs",
  "page": "https://anyplot.ai/dendrogram-radial/javascript/chartjs",
  "hub": "https://anyplot.ai/dendrogram-radial",
  "code_json": "https://api.anyplot.ai/specs/dendrogram-radial/chartjs/code",
  "spec_json": "https://api.anyplot.ai/specs/dendrogram-radial",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/chartjs/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/chartjs/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/chartjs/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/chartjs/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Radial Dendrogram on anyplot.ai.

Other implementations