{"spec_id":"dendrogram-radial","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// dendrogram-radial: Radial Dendrogram\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 83/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// dendrogram-radial: Radial Dendrogram\n// Library: Highcharts 12.6.0 | Node 22\n// License: Highcharts — commercial license, free for non-commercial use (highcharts.com/license)\n// Quality: pending | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: bird species with wingspan / beak-length traits, grouped by family ----------\n// [name, wingspanCm, beakLengthMm, familyIndex]\nconst SPECIES = [\n  [\"Golden Eagle\", 204, 48, 0],\n  [\"Red-tailed Hawk\", 127, 32, 0],\n  [\"Peregrine Falcon\", 104, 22, 0],\n  [\"Barn Owl\", 107, 22, 0],\n  [\"Osprey\", 168, 40, 0],\n  [\"Kestrel\", 71, 14, 0],\n  [\"Mallard Duck\", 89, 55, 1],\n  [\"Canada Goose\", 155, 50, 1],\n  [\"Trumpeter Swan\", 200, 70, 1],\n  [\"Wood Duck\", 74, 33, 1],\n  [\"Northern Pintail\", 88, 45, 1],\n  [\"Green-winged Teal\", 61, 28, 1],\n  [\"House Sparrow\", 24, 11, 2],\n  [\"American Robin\", 36, 17, 2],\n  [\"Blue Jay\", 41, 20, 2],\n  [\"Northern Cardinal\", 31, 14, 2],\n  [\"Black-capped Chickadee\", 19, 8, 2],\n  [\"American Goldfinch\", 22, 9, 2],\n  [\"Spotted Sandpiper\", 37, 22, 3],\n  [\"Semipalmated Plover\", 43, 13, 3],\n  [\"American Avocet\", 71, 40, 3],\n  [\"Marbled Godwit\", 76, 80, 3],\n  [\"Long-billed Curlew\", 91, 130, 3],\n  [\"Dunlin\", 38, 32, 3],\n];\nconst FAMILY_NAMES = [\"Raptors\", \"Waterfowl\", \"Songbirds\", \"Shorebirds\"];\nconst FAMILY_COLORS = [t.palette[0], t.palette[1], t.palette[2], t.palette[3]];\nconst n = SPECIES.length;\n\n// --- Standardize traits, then average-linkage (UPGMA) agglomerative clustering --------\nconst mean = (arr) => arr.reduce((sum, v) => sum + v, 0) / arr.length;\nconst std = (arr) => {\n  const m = mean(arr);\n  return Math.sqrt(mean(arr.map((v) => (v - m) ** 2)));\n};\nconst wingMean = mean(SPECIES.map((s) => s[1]));\nconst wingStd = std(SPECIES.map((s) => s[1]));\nconst beakMean = mean(SPECIES.map((s) => s[2]));\nconst beakStd = std(SPECIES.map((s) => s[2]));\nconst points = SPECIES.map((s) => [(s[1] - wingMean) / wingStd, (s[2] - beakMean) / beakStd]);\nconst euclidean = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);\n\n// linkage rows in scipy format: [idA, idB, distance, mergedSize]\nconst linkage = [];\nlet clusters = points.map((_, i) => ({ id: i, members: [i] }));\nlet nextId = n;\nwhile (clusters.length > 1) {\n  let best = { i: 0, j: 1, d: Infinity };\n  for (let i = 0; i < clusters.length; i += 1) {\n    for (let j = i + 1; j < clusters.length; j += 1) {\n      let total = 0;\n      let count = 0;\n      for (const a of clusters[i].members) {\n        for (const b of clusters[j].members) {\n          total += euclidean(points[a], points[b]);\n          count += 1;\n        }\n      }\n      const d = total / count;\n      if (d < best.d) best = { i, j, d };\n    }\n  }\n  const a = clusters[best.i];\n  const b = clusters[best.j];\n  const merged = { id: nextId, members: a.members.concat(b.members) };\n  linkage.push([a.id, b.id, best.d, merged.members.length]);\n  nextId += 1;\n  clusters = clusters.filter((_, idx) => idx !== best.i && idx !== best.j).concat(merged);\n}\n\n// --- Tree structure: parent links, merge distance, family purity per node -------------\nconst parent = new Map();\nconst mergeDistance = new Map();\nconst children = new Map();\nconst family = new Map();\nSPECIES.forEach((s, leafId) => family.set(leafId, s[3]));\nlinkage.forEach((row, i) => {\n  const nodeId = n + i;\n  const [a, b, d] = row;\n  parent.set(a, nodeId);\n  parent.set(b, nodeId);\n  mergeDistance.set(nodeId, d);\n  children.set(nodeId, [a, b]);\n  const fa = family.get(a);\n  const fb = family.get(b);\n  family.set(nodeId, fa === fb ? fa : null);\n});\nconst rootId = n + linkage.length - 1;\nconst maxDistance = mergeDistance.get(rootId);\n\n// --- Leaf ordering (in-order traversal) drives angular position -----------------------\nconst leafOrder = [];\nconst collectLeaves = (nodeId) => {\n  if (nodeId < n) {\n    leafOrder.push(nodeId);\n    return;\n  }\n  const [a, b] = children.get(nodeId);\n  collectLeaves(a);\n  collectLeaves(b);\n};\ncollectLeaves(rootId);\n\n// --- Radial layout: leaves on the circumference, root at the center -------------------\nconst angle = new Map();\nconst radius = new Map();\nleafOrder.forEach((leafId, i) => {\n  angle.set(leafId, (2 * Math.PI * i) / n);\n  radius.set(leafId, 1);\n});\nlinkage.forEach((row, i) => {\n  const nodeId = n + i;\n  const [a, b, d] = row;\n  angle.set(nodeId, (angle.get(a) + angle.get(b)) / 2);\n  radius.set(nodeId, (maxDistance - d) / maxDistance);\n});\n\nconst nodeAngleRad = (nodeId) => angle.get(nodeId) - Math.PI / 2;\nconst toXY = (nodeId) => {\n  const r = radius.get(nodeId);\n  const a = nodeAngleRad(nodeId);\n  return { x: r * Math.cos(a), y: r * Math.sin(a) };\n};\n\n// --- Branches: an arc at the parent's radius plus a radial segment out to the child.\n// Straight point-to-point connectors can visually cross unrelated branches once a\n// subtree spans a wide angle; the arc+radial \"elbow\" (the classic circular-dendrogram\n// convention) never does, because each segment stays either at a fixed radius or a\n// fixed angle. Colored while a subtree stays within one family, gray once families merge.\nconst arcPoints = (r, angleFrom, angleTo) => {\n  const steps = Math.max(6, Math.ceil((Math.abs(angleTo - angleFrom) * 180) / Math.PI / 2));\n  const pts = [];\n  for (let s = 0; s <= steps; s += 1) {\n    const a = angleFrom + ((angleTo - angleFrom) * s) / steps;\n    pts.push({ x: r * Math.cos(a), y: r * Math.sin(a) });\n  }\n  return pts;\n};\nconst edgeColor = (childId) => {\n  const f = family.get(childId);\n  return f === null || f === undefined ? t.inkSoft : FAMILY_COLORS[f];\n};\nconst branchSeries = [];\nconst totalNodes = 2 * n - 1;\nfor (let nodeId = 0; nodeId < totalNodes; nodeId += 1) {\n  if (nodeId === rootId) continue;\n  const parentId = parent.get(nodeId);\n  const parentR = radius.get(parentId);\n  const arc = arcPoints(parentR, nodeAngleRad(parentId), nodeAngleRad(nodeId));\n  const childXY = toXY(nodeId);\n  const pure = family.get(nodeId) !== null && family.get(nodeId) !== undefined;\n  branchSeries.push({\n    type: \"line\",\n    data: [...arc, childXY],\n    color: edgeColor(nodeId),\n    lineWidth: pure ? 2.5 : 1.5,\n    marker: { enabled: false },\n    enableMouseTracking: false,\n    showInLegend: false,\n  });\n}\n\n// --- Leaves: one scatter series per family so the legend reads as cluster identity ----\nconst leafSeriesByFamily = FAMILY_NAMES.map((fname, fi) => ({\n  type: \"scatter\",\n  name: fname,\n  color: FAMILY_COLORS[fi],\n  marker: { radius: 5, symbol: \"circle\", lineWidth: 1, lineColor: t.pageBg },\n  tooltip: { pointFormat: \"<b>{point.name}</b>\" },\n  data: [],\n}));\nSPECIES.forEach((s, leafId) => {\n  const [name, , , fi] = s;\n  const { x, y } = toXY(leafId);\n  const a = nodeAngleRad(leafId);\n  const leftHalf = Math.cos(a) < 0;\n  const deg = (a * 180) / Math.PI;\n  leafSeriesByFamily[fi].data.push({\n    x,\n    y,\n    name,\n    dataLabels: {\n      enabled: true,\n      format: \"{point.name}\",\n      rotation: leftHalf ? deg + 180 : deg,\n      align: leftHalf ? \"right\" : \"left\",\n      x: leftHalf ? -10 : 10,\n      y: 0,\n      style: { color: t.ink, fontSize: \"13px\", fontWeight: \"400\", textOutline: \"none\" },\n    },\n  });\n});\n\n// --- Root marker --------------------------------------------------------------------\nconst rootPoint = toXY(rootId);\nconst rootSeries = {\n  type: \"scatter\",\n  name: \"Root\",\n  data: [rootPoint],\n  color: t.ink,\n  marker: { radius: 6, symbol: \"circle\" },\n  enableMouseTracking: false,\n  showInLegend: false,\n};\n\n// --- Chart ----------------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    margin: [150, 100, 50, 100],\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"dendrogram-radial · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Bird species clustered by wingspan and beak length (UPGMA linkage)\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { min: -1.4, max: 1.4, startOnTick: false, endOnTick: false, visible: false },\n  yAxis: { min: -1.4, max: 1.4, startOnTick: false, endOnTick: false, visible: false, title: null },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: { backgroundColor: t.elevatedBg, style: { color: t.ink } },\n  // allowOverlap: with 24 radiating labels, Highcharts' default overlap suppression\n  // silently drops labels whose axis-aligned bounding box touches a neighbor's — even\n  // though the rotated glyphs themselves diverge outward and rarely actually collide.\n  // Every leaf must keep its label visible.\n  plotOptions: { series: { animation: false, dataLabels: { allowOverlap: true } } },\n  series: [...branchSeries, rootSeries, ...leafSeriesByFamily],\n});\n"}