Radial Dendrogram — Highcharts

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 Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// dendrogram-radial: Radial Dendrogram
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 83/100 | Created: 2026-09-05
//# anyplot-orientation: square
// anyplot.ai
// dendrogram-radial: Radial Dendrogram
// Library: Highcharts 12.6.0 | Node 22
// License: Highcharts — commercial license, free for non-commercial use (highcharts.com/license)
// Quality: pending | Created: 2026-09-05

const t = window.ANYPLOT_TOKENS;

// --- Data: bird species with wingspan / beak-length traits, grouped by family ----------
// [name, wingspanCm, beakLengthMm, familyIndex]
const SPECIES = [
  ["Golden Eagle", 204, 48, 0],
  ["Red-tailed Hawk", 127, 32, 0],
  ["Peregrine Falcon", 104, 22, 0],
  ["Barn Owl", 107, 22, 0],
  ["Osprey", 168, 40, 0],
  ["Kestrel", 71, 14, 0],
  ["Mallard Duck", 89, 55, 1],
  ["Canada Goose", 155, 50, 1],
  ["Trumpeter Swan", 200, 70, 1],
  ["Wood Duck", 74, 33, 1],
  ["Northern Pintail", 88, 45, 1],
  ["Green-winged Teal", 61, 28, 1],
  ["House Sparrow", 24, 11, 2],
  ["American Robin", 36, 17, 2],
  ["Blue Jay", 41, 20, 2],
  ["Northern Cardinal", 31, 14, 2],
  ["Black-capped Chickadee", 19, 8, 2],
  ["American Goldfinch", 22, 9, 2],
  ["Spotted Sandpiper", 37, 22, 3],
  ["Semipalmated Plover", 43, 13, 3],
  ["American Avocet", 71, 40, 3],
  ["Marbled Godwit", 76, 80, 3],
  ["Long-billed Curlew", 91, 130, 3],
  ["Dunlin", 38, 32, 3],
];
const FAMILY_NAMES = ["Raptors", "Waterfowl", "Songbirds", "Shorebirds"];
const FAMILY_COLORS = [t.palette[0], t.palette[1], t.palette[2], t.palette[3]];
const n = SPECIES.length;

// --- Standardize traits, then average-linkage (UPGMA) agglomerative clustering --------
const mean = (arr) => arr.reduce((sum, v) => sum + v, 0) / arr.length;
const std = (arr) => {
  const m = mean(arr);
  return Math.sqrt(mean(arr.map((v) => (v - m) ** 2)));
};
const wingMean = mean(SPECIES.map((s) => s[1]));
const wingStd = std(SPECIES.map((s) => s[1]));
const beakMean = mean(SPECIES.map((s) => s[2]));
const beakStd = std(SPECIES.map((s) => s[2]));
const points = SPECIES.map((s) => [(s[1] - wingMean) / wingStd, (s[2] - beakMean) / beakStd]);
const euclidean = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);

// linkage rows in scipy format: [idA, idB, distance, mergedSize]
const linkage = [];
let clusters = points.map((_, i) => ({ id: i, members: [i] }));
let nextId = n;
while (clusters.length > 1) {
  let best = { i: 0, j: 1, d: Infinity };
  for (let i = 0; i < clusters.length; i += 1) {
    for (let j = i + 1; j < clusters.length; j += 1) {
      let total = 0;
      let count = 0;
      for (const a of clusters[i].members) {
        for (const b of clusters[j].members) {
          total += euclidean(points[a], points[b]);
          count += 1;
        }
      }
      const d = total / count;
      if (d < best.d) best = { i, j, d };
    }
  }
  const a = clusters[best.i];
  const b = clusters[best.j];
  const merged = { id: nextId, members: a.members.concat(b.members) };
  linkage.push([a.id, b.id, best.d, merged.members.length]);
  nextId += 1;
  clusters = clusters.filter((_, idx) => idx !== best.i && idx !== best.j).concat(merged);
}

// --- Tree structure: parent links, merge distance, family purity per node -------------
const parent = new Map();
const mergeDistance = new Map();
const children = new Map();
const family = new Map();
SPECIES.forEach((s, leafId) => family.set(leafId, s[3]));
linkage.forEach((row, i) => {
  const nodeId = n + i;
  const [a, b, d] = row;
  parent.set(a, nodeId);
  parent.set(b, nodeId);
  mergeDistance.set(nodeId, d);
  children.set(nodeId, [a, b]);
  const fa = family.get(a);
  const fb = family.get(b);
  family.set(nodeId, fa === fb ? fa : null);
});
const rootId = n + linkage.length - 1;
const maxDistance = mergeDistance.get(rootId);

// --- Leaf ordering (in-order traversal) drives angular position -----------------------
const leafOrder = [];
const collectLeaves = (nodeId) => {
  if (nodeId < n) {
    leafOrder.push(nodeId);
    return;
  }
  const [a, b] = children.get(nodeId);
  collectLeaves(a);
  collectLeaves(b);
};
collectLeaves(rootId);

// --- Radial layout: leaves on the circumference, root at the center -------------------
const angle = new Map();
const radius = new Map();
leafOrder.forEach((leafId, i) => {
  angle.set(leafId, (2 * Math.PI * i) / n);
  radius.set(leafId, 1);
});
linkage.forEach((row, i) => {
  const nodeId = n + i;
  const [a, b, d] = row;
  angle.set(nodeId, (angle.get(a) + angle.get(b)) / 2);
  radius.set(nodeId, (maxDistance - d) / maxDistance);
});

const nodeAngleRad = (nodeId) => angle.get(nodeId) - Math.PI / 2;
const toXY = (nodeId) => {
  const r = radius.get(nodeId);
  const a = nodeAngleRad(nodeId);
  return { x: r * Math.cos(a), y: r * Math.sin(a) };
};

// --- Branches: an arc at the parent's radius plus a radial segment out to the child.
// Straight point-to-point connectors can visually cross unrelated branches once a
// subtree spans a wide angle; the arc+radial "elbow" (the classic circular-dendrogram
// convention) never does, because each segment stays either at a fixed radius or a
// fixed angle. Colored while a subtree stays within one family, gray once families merge.
const arcPoints = (r, angleFrom, angleTo) => {
  const steps = Math.max(6, Math.ceil((Math.abs(angleTo - angleFrom) * 180) / Math.PI / 2));
  const pts = [];
  for (let s = 0; s <= steps; s += 1) {
    const a = angleFrom + ((angleTo - angleFrom) * s) / steps;
    pts.push({ x: r * Math.cos(a), y: r * Math.sin(a) });
  }
  return pts;
};
const edgeColor = (childId) => {
  const f = family.get(childId);
  return f === null || f === undefined ? t.inkSoft : FAMILY_COLORS[f];
};
const branchSeries = [];
const totalNodes = 2 * n - 1;
for (let nodeId = 0; nodeId < totalNodes; nodeId += 1) {
  if (nodeId === rootId) continue;
  const parentId = parent.get(nodeId);
  const parentR = radius.get(parentId);
  const arc = arcPoints(parentR, nodeAngleRad(parentId), nodeAngleRad(nodeId));
  const childXY = toXY(nodeId);
  const pure = family.get(nodeId) !== null && family.get(nodeId) !== undefined;
  branchSeries.push({
    type: "line",
    data: [...arc, childXY],
    color: edgeColor(nodeId),
    lineWidth: pure ? 2.5 : 1.5,
    marker: { enabled: false },
    enableMouseTracking: false,
    showInLegend: false,
  });
}

// --- Leaves: one scatter series per family so the legend reads as cluster identity ----
const leafSeriesByFamily = FAMILY_NAMES.map((fname, fi) => ({
  type: "scatter",
  name: fname,
  color: FAMILY_COLORS[fi],
  marker: { radius: 5, symbol: "circle", lineWidth: 1, lineColor: t.pageBg },
  tooltip: { pointFormat: "<b>{point.name}</b>" },
  data: [],
}));
SPECIES.forEach((s, leafId) => {
  const [name, , , fi] = s;
  const { x, y } = toXY(leafId);
  const a = nodeAngleRad(leafId);
  const leftHalf = Math.cos(a) < 0;
  const deg = (a * 180) / Math.PI;
  leafSeriesByFamily[fi].data.push({
    x,
    y,
    name,
    dataLabels: {
      enabled: true,
      format: "{point.name}",
      rotation: leftHalf ? deg + 180 : deg,
      align: leftHalf ? "right" : "left",
      x: leftHalf ? -10 : 10,
      y: 0,
      style: { color: t.ink, fontSize: "13px", fontWeight: "400", textOutline: "none" },
    },
  });
});

// --- Root marker --------------------------------------------------------------------
const rootPoint = toXY(rootId);
const rootSeries = {
  type: "scatter",
  name: "Root",
  data: [rootPoint],
  color: t.ink,
  marker: { radius: 6, symbol: "circle" },
  enableMouseTracking: false,
  showInLegend: false,
};

// --- Chart ----------------------------------------------------------------------------
Highcharts.chart("container", {
  chart: {
    type: "scatter",
    backgroundColor: "transparent",
    animation: false,
    margin: [150, 100, 50, 100],
    style: { fontFamily: "inherit" },
  },
  credits: { enabled: false },
  colors: t.palette,
  title: {
    text: "dendrogram-radial · javascript · highcharts · anyplot.ai",
    style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
  },
  subtitle: {
    text: "Bird species clustered by wingspan and beak length (UPGMA linkage)",
    style: { color: t.inkSoft, fontSize: "14px" },
  },
  xAxis: { min: -1.4, max: 1.4, startOnTick: false, endOnTick: false, visible: false },
  yAxis: { min: -1.4, max: 1.4, startOnTick: false, endOnTick: false, visible: false, title: null },
  legend: {
    itemStyle: { color: t.inkSoft, fontSize: "14px" },
    itemHoverStyle: { color: t.ink },
  },
  tooltip: { backgroundColor: t.elevatedBg, style: { color: t.ink } },
  // allowOverlap: with 24 radiating labels, Highcharts' default overlap suppression
  // silently drops labels whose axis-aligned bounding box touches a neighbor's — even
  // though the rotated glyphs themselves diverge outward and rarely actually collide.
  // Every leaf must keep its label visible.
  plotOptions: { series: { animation: false, dataLabels: { allowOverlap: true } } },
  series: [...branchSeries, rootSeries, ...leafSeriesByFamily],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/dendrogram-radial/highcharts/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": "highcharts",
  "page": "https://anyplot.ai/dendrogram-radial/javascript/highcharts",
  "hub": "https://anyplot.ai/dendrogram-radial",
  "code_json": "https://api.anyplot.ai/specs/dendrogram-radial/highcharts/code",
  "spec_json": "https://api.anyplot.ai/specs/dendrogram-radial",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/highcharts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/highcharts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/highcharts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/javascript/highcharts/plot-dark.html",
  "quality_score": 83.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Radial Dendrogram on anyplot.ai.

Other implementations