{"spec_id":"dendrogram-radial","library":"echarts","language":"javascript","code":"// anyplot.ai\n// dendrogram-radial: Radial Dendrogram\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: synthetic gene-expression samples across 5 tissue types ---------\n// A small Park-Miller LCG keeps the browser-side generation deterministic\n// (there is no seeded Math.random in the DOM).\nlet seed = 20260905 % 2147483647;\nif (seed <= 0) seed += 2147483646;\nfunction rand() {\n  seed = (seed * 16807) % 2147483647;\n  return (seed - 1) / 2147483646;\n}\nfunction noise(scale) {\n  return ((rand() + rand() + rand() - 1.5) / 1.5) * scale;\n}\n\nconst TISSUES = [\"Liver\", \"Heart\", \"Kidney\", \"Lung\", \"Brain\"];\nconst SAMPLES_PER_TISSUE = 9;\nconst FEATURE_DIMS = 5;\n\nconst tissueCenters = TISSUES.map(() =>\n  Array.from({ length: FEATURE_DIMS }, () => rand() * 20 - 10),\n);\n\nconst leaves = [];\nTISSUES.forEach((tissue, tissueIndex) => {\n  for (let s = 1; s <= SAMPLES_PER_TISSUE; s++) {\n    const features = tissueCenters[tissueIndex].map((c) => c + noise(3));\n    leaves.push({ label: `${tissue}-${s}`, cluster: tissueIndex, features });\n  }\n});\n\nfunction euclidean(a, b) {\n  let sum = 0;\n  for (let i = 0; i < a.length; i++) {\n    const d = a[i] - b[i];\n    sum += d * d;\n  }\n  return Math.sqrt(sum);\n}\n\nconst n = leaves.length;\nconst dist = Array.from({ length: n }, () => new Array(n).fill(0));\nfor (let i = 0; i < n; i++) {\n  for (let j = i + 1; j < n; j++) {\n    const d = euclidean(leaves[i].features, leaves[j].features);\n    dist[i][j] = d;\n    dist[j][i] = d;\n  }\n}\n\n// --- Average-linkage agglomerative clustering (scipy linkage equivalent) ---\nfunction clusterDistance(a, b) {\n  let sum = 0;\n  for (const i of a) for (const j of b) sum += dist[i][j];\n  return sum / (a.length * b.length);\n}\n\nlet active = leaves.map((_, i) => ({\n  members: [i],\n  node: { leafIndex: i, height: 0 },\n}));\n\nwhile (active.length > 1) {\n  let bestI = 0;\n  let bestJ = 1;\n  let bestD = Infinity;\n  for (let i = 0; i < active.length; i++) {\n    for (let j = i + 1; j < active.length; j++) {\n      const d = clusterDistance(active[i].members, active[j].members);\n      if (d < bestD) {\n        bestD = d;\n        bestI = i;\n        bestJ = j;\n      }\n    }\n  }\n  const a = active[bestI];\n  const b = active[bestJ];\n  const merged = {\n    members: a.members.concat(b.members),\n    node: { left: a.node, right: b.node, height: bestD },\n  };\n  active = active.filter((_, idx) => idx !== bestI && idx !== bestJ);\n  active.push(merged);\n}\nconst root = active[0].node;\n\n// --- Radial layout: leaf order preserves the merge tree (no crossing branches) --\nfunction collectLeafOrder(node, order) {\n  if (node.leafIndex !== undefined) {\n    order.push(node.leafIndex);\n    return;\n  }\n  collectLeafOrder(node.left, order);\n  collectLeafOrder(node.right, order);\n}\nconst leafOrder = [];\ncollectLeafOrder(root, leafOrder);\n\nconst angleStep = (2 * Math.PI) / leafOrder.length;\nconst leafAngle = {};\nleafOrder.forEach((leafIndex, position) => {\n  leafAngle[leafIndex] = position * angleStep - Math.PI / 2;\n});\n\nconst cx = size.width / 2;\nconst cy = size.height / 2 + 15;\nconst maxRadius = Math.min(size.width, size.height) / 2 - 250;\nconst maxHeight = root.height;\n\n// Root sits at radius 0 (center); leaves sit at maxRadius (circumference).\n// Radial distance between a node and its parent is proportional to the\n// merge-distance delta, matching a linear dendrogram's y-axis.\nfunction layoutNode(node) {\n  if (node.leafIndex !== undefined) {\n    node.angle = leafAngle[node.leafIndex];\n    node.radius = maxRadius;\n    node.cluster = leaves[node.leafIndex].cluster;\n    return;\n  }\n  layoutNode(node.left);\n  layoutNode(node.right);\n  node.angle = (node.left.angle + node.right.angle) / 2;\n  node.radius = maxRadius * (1 - node.height / maxHeight);\n  node.cluster = node.left.cluster === node.right.cluster ? node.left.cluster : -1;\n}\nlayoutNode(root);\n\nconst internalNodes = [];\nfunction collectInternal(node) {\n  if (node.leafIndex !== undefined) return;\n  internalNodes.push(node);\n  collectInternal(node.left);\n  collectInternal(node.right);\n}\ncollectInternal(root);\n\nconst leafNodes = leaves.map((leaf, i) => ({\n  angle: leafAngle[i],\n  radius: maxRadius,\n  cluster: leaf.cluster,\n  label: leaf.label,\n}));\n\nfunction toXY(radius, angle) {\n  return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];\n}\nfunction branchColor(clusterId) {\n  return clusterId === -1 ? t.inkSoft : t.palette[clusterId];\n}\n\n// Each internal node draws an arc (the merge bar, at the parent's radius,\n// spanning its two children's angles) plus two radial segments reaching out\n// to each child — the polar equivalent of a linear dendrogram's elbow links.\nfunction renderBranch(params) {\n  const node = internalNodes[params.dataIndex];\n  const leftAngle = node.left.angle;\n  const rightAngle = node.right.angle;\n  const [lx0, ly0] = toXY(node.radius, leftAngle);\n  const [lx1, ly1] = toXY(node.left.radius, leftAngle);\n  const [rx0, ry0] = toXY(node.radius, rightAngle);\n  const [rx1, ry1] = toXY(node.right.radius, rightAngle);\n\n  return {\n    type: \"group\",\n    children: [\n      {\n        type: \"arc\",\n        shape: {\n          cx,\n          cy,\n          r: node.radius,\n          startAngle: Math.min(leftAngle, rightAngle),\n          endAngle: Math.max(leftAngle, rightAngle),\n          clockwise: true,\n        },\n        style: { stroke: branchColor(node.cluster), lineWidth: 2.5, fill: \"none\" },\n      },\n      {\n        type: \"line\",\n        shape: { x1: lx0, y1: ly0, x2: lx1, y2: ly1 },\n        style: { stroke: branchColor(node.left.cluster), lineWidth: 2.5 },\n      },\n      {\n        type: \"line\",\n        shape: { x1: rx0, y1: ry0, x2: rx1, y2: ry1 },\n        style: { stroke: branchColor(node.right.cluster), lineWidth: 2.5 },\n      },\n    ],\n  };\n}\n\n// Leaf marker + a color-coded metadata-ring arc (one short arc band per leaf,\n// at a fixed outer radius, split by a small angular gap from its neighbors)\n// + a radially-aligned label, flipped upright on the left hemisphere.\nconst ringRadius = maxRadius + 20;\nconst ringHalfWidth = angleStep * 0.35;\nfunction renderLeaf(params) {\n  const leaf = leafNodes[params.dataIndex];\n  const color = t.palette[leaf.cluster];\n  const [dx, dy] = toXY(leaf.radius, leaf.angle);\n  const [lx, ly] = toXY(ringRadius + 20, leaf.angle);\n\n  const isLeftHalf = Math.cos(leaf.angle) < 0;\n  const rotation = isLeftHalf ? -(leaf.angle + Math.PI) : -leaf.angle;\n\n  return {\n    type: \"group\",\n    children: [\n      { type: \"circle\", shape: { cx: dx, cy: dy, r: 5 }, style: { fill: color } },\n      {\n        type: \"arc\",\n        shape: {\n          cx,\n          cy,\n          r: ringRadius,\n          startAngle: leaf.angle - ringHalfWidth,\n          endAngle: leaf.angle + ringHalfWidth,\n          clockwise: true,\n        },\n        style: { stroke: color, lineWidth: 6, fill: \"none\", lineCap: \"round\" },\n      },\n      {\n        type: \"text\",\n        x: lx,\n        y: ly,\n        rotation,\n        style: {\n          text: leaf.label,\n          fill: t.inkSoft,\n          fontSize: 15,\n          fontWeight: 600,\n          align: isLeftHalf ? \"right\" : \"left\",\n          verticalAlign: \"middle\",\n        },\n      },\n    ],\n  };\n}\n\nconst legendItemWidth = 190;\nconst legendY = size.height - 44;\nconst legendStartX = cx - (TISSUES.length * legendItemWidth) / 2;\nconst legend = TISSUES.map((tissue, i) => ({\n  type: \"group\",\n  left: legendStartX + i * legendItemWidth,\n  top: legendY,\n  children: [\n    { type: \"circle\", shape: { cx: 8, cy: 8, r: 8 }, style: { fill: t.palette[i] } },\n    {\n      type: \"text\",\n      x: 24,\n      y: 8,\n      style: { text: tissue, fill: t.inkSoft, fontSize: 16, verticalAlign: \"middle\" },\n    },\n  ],\n}));\n\n// --- Init + option ------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"dendrogram-radial · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 20,\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  graphic: legend,\n  series: [\n    {\n      type: \"custom\",\n      coordinateSystem: \"none\",\n      renderItem: renderBranch,\n      data: internalNodes.map((_, i) => i),\n      silent: true,\n    },\n    {\n      type: \"custom\",\n      coordinateSystem: \"none\",\n      renderItem: renderLeaf,\n      data: leafNodes.map((_, i) => i),\n      silent: true,\n    },\n  ],\n});\n"}