{"spec_id":"circos-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// circos-basic: Circos Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-04\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: structural-variant links across 8 chromosome segments -----------\n// Segment arc length is proportional to chromosome size (Mb, approximate\n// human karyotype lengths for chr1-chr8). An inner track carries a second\n// data layer (differential expression, log2 fold-change) rendered as a\n// diverging-color band, and ribbons bow through the centre to show\n// inter-chromosomal structural-variant links (supporting read count).\nconst chromNames = [\"Chr1\", \"Chr2\", \"Chr3\", \"Chr4\", \"Chr5\", \"Chr6\", \"Chr7\", \"Chr8\"];\nconst segmentSizes = [248, 242, 198, 190, 181, 170, 159, 145]; // Mb\nconst expression = [1.8, -1.4, 0.6, -2.2, 1.1, -0.5, 2.4, -1.7]; // log2FC\n\n// [from, to, supportingReads] — undirected structural-variant links.\nconst links = [\n  [0, 1, 45], [0, 2, 12], [0, 4, 30], [0, 6, 8],\n  [1, 3, 55], [1, 5, 22], [1, 7, 14],\n  [2, 3, 18], [2, 5, 40], [2, 6, 10],\n  [3, 4, 25], [3, 7, 33],\n  [4, 5, 15], [4, 6, 48],\n  [5, 7, 20],\n  [6, 7, 28],\n];\n\nconst n = chromNames.length;\nconst linkMatrix = Array.from({ length: n }, () => new Array(n).fill(0));\nfor (const [i, j, value] of links) {\n  linkMatrix[i][j] = value;\n  linkMatrix[j][i] = value;\n}\nconst nodeTotal = linkMatrix.map((row) => row.reduce((a, b) => a + b, 0));\n\n// Each chromosome keeps a distinct Imprint hue (brand green leads at slot 0).\nconst segmentColors = chromNames.map((_, i) => t.palette[i % t.palette.length]);\n\n// --- Diverging colormap (imprint_div) for the expression track --------------\nconst hexToRgb = (hex) => {\n  const h = hex.replace(\"#\", \"\");\n  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];\n};\nconst rgbToHex = ([r, g, b]) =>\n  `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, \"0\")).join(\"\")}`;\nconst lerpRgb = (a, b, f) => a.map((v, i) => v + (b[i] - v) * f);\n\nconst [divNeg, divMid, divPos] = t.div.map(hexToRgb);\nconst maxAbsExpression = Math.max(...expression.map(Math.abs));\nconst divergingColor = (value) => {\n  const u = 0.5 + 0.5 * (value / maxAbsExpression); // 0..1, 0.5 = midpoint\n  return u < 0.5 ? rgbToHex(lerpRgb(divNeg, divMid, u / 0.5)) : rgbToHex(lerpRgb(divMid, divPos, (u - 0.5) / 0.5));\n};\nconst trackColors = expression.map(divergingColor);\n\nconst hexToRgba = (hex, alpha) => {\n  const [r, g, b] = hexToRgb(hex);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n};\n\n// --- Structural-variant ribbon plugin ---------------------------------------\n// Chart.js has no native circos/chord type, but its plugin API exposes the\n// live canvas plus the doughnut's computed arc geometry. Ribbons anchor to\n// the inner edge of the expression track (dataset 1) and bow through the\n// centre; ribbon end-width is proportional to link strength. Weak links get\n// an opacity/stroke floor so they stay legible under denser overlaps, and\n// the top 2 links by supporting-read count are redrawn last (on top of\n// everything) with a heavier stroke so the strongest relationships read as\n// a clear focal point. No external library, no community plugin: pure\n// Chart.js extensibility (same technique used for chord-basic's ribbons,\n// generalised here to a sparse link list plus a second concentric data\n// track).\nconst linkValues = links.map(([, , value]) => value);\nconst lowValueThreshold = Math.min(...linkValues) + (Math.max(...linkValues) - Math.min(...linkValues)) * 0.25;\nconst focalLinkCount = 2;\nconst circosRibbons = {\n  id: \"circosRibbons\",\n  afterDatasetsDraw(chart) {\n    const segmentArcs = chart.getDatasetMeta(0).data;\n    const trackArcs = chart.getDatasetMeta(1).data;\n    if (!segmentArcs.length || !trackArcs.length) return;\n\n    const { x: cx, y: cy } = segmentArcs[0].getProps([\"x\", \"y\"], true);\n    const innerR = trackArcs[0].getProps([\"innerRadius\"], true).innerRadius;\n\n    // Angular interval [a0, a1] of every ordered slot (i -> j) inside arc i,\n    // sized proportional to that link's share of node i's total link value.\n    const slot = segmentArcs.map((arc, i) => {\n      const { startAngle, endAngle } = arc.getProps([\"startAngle\", \"endAngle\"], true);\n      const span = endAngle - startAngle;\n      let acc = 0;\n      return linkMatrix[i].map((value) => {\n        const a0 = startAngle + (acc / nodeTotal[i]) * span;\n        acc += value;\n        const a1 = startAngle + (acc / nodeTotal[i]) * span;\n        return [a0, a1];\n      });\n    });\n\n    const pointAt = (angle, r) => [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];\n\n    const pairsByValue = links.map(([i, j, value]) => [i, j, value]).sort((a, b) => b[2] - a[2]);\n    const focalPairs = pairsByValue.slice(0, focalLinkCount);\n    const normalPairs = pairsByValue.slice(focalLinkCount);\n\n    const ctx = chart.ctx;\n    ctx.save();\n    ctx.lineJoin = \"round\";\n\n    const drawRibbon = (i, j, { fillAlpha, strokeAlpha, lineWidth }) => {\n      const [si0, si1] = slot[i][j];\n      const [sj0, sj1] = slot[j][i];\n      const [xi, yi] = pointAt(si0, innerR);\n      const [xj, yj] = pointAt(sj0, innerR);\n\n      ctx.beginPath();\n      ctx.moveTo(xi, yi);\n      ctx.arc(cx, cy, innerR, si0, si1);    // ride segment i's inner edge\n      ctx.lineTo(xj, yj);\n      ctx.arc(cx, cy, innerR, sj0, sj1);    // ride segment j's inner edge\n      ctx.quadraticCurveTo(cx, cy, xi, yi); // bow back through the centre\n      ctx.closePath();\n\n      const dominant = linkMatrix[i][j] >= linkMatrix[j][i] ? i : j;\n      ctx.fillStyle = hexToRgba(segmentColors[dominant], fillAlpha);\n      ctx.lineWidth = lineWidth;\n      ctx.strokeStyle = hexToRgba(segmentColors[dominant], strokeAlpha);\n      ctx.fill();\n      ctx.stroke();\n    };\n\n    // Strongest-of-the-rest first, weakest last (so weak links sit on top of\n    // the normal group); each weak link gets an opacity/stroke floor so it\n    // does not disappear under denser overlaps.\n    for (const [i, j, value] of normalPairs) {\n      const isFaint = value <= lowValueThreshold;\n      drawRibbon(i, j, {\n        fillAlpha: isFaint ? 0.65 : 0.5,\n        strokeAlpha: isFaint ? 0.95 : 0.85,\n        lineWidth: isFaint ? 2 : 1.5,\n      });\n    }\n    // Focal links redrawn last, on top of every other ribbon, with a\n    // heavier stroke so the strongest relationships read as a clear story.\n    for (const [i, j] of focalPairs) {\n      drawRibbon(i, j, { fillAlpha: 0.8, strokeAlpha: 1, lineWidth: 2.5 });\n    }\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart: two concentric doughnut rings (segments + expression track) ----\n// plus the structural-variant ribbon plugin bowing through the centre.\nnew Chart(canvas, {\n  type: \"doughnut\",\n  data: {\n    labels: chromNames,\n    datasets: [\n      {\n        label: \"Chromosome\",\n        data: segmentSizes,\n        backgroundColor: segmentColors,\n        borderColor: t.pageBg,\n        borderWidth: 3,\n        radius: \"100%\",\n        cutout: \"80%\",\n      },\n      {\n        label: \"Differential expression\",\n        data: segmentSizes,\n        backgroundColor: trackColors,\n        borderColor: t.pageBg,\n        borderWidth: 2,\n        radius: \"78%\",\n        cutout: \"64%\",\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 28 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"circos-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"600\" },\n        padding: { top: 4, bottom: 18 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          padding: 18,\n          boxWidth: 16,\n          boxHeight: 16,\n        },\n      },\n      tooltip: {\n        callbacks: {\n          label: (item) =>\n            item.datasetIndex === 0\n              ? `${item.label}: ${item.parsed} Mb`\n              : `${item.label} expression: ${expression[item.dataIndex].toFixed(1)} log2FC`,\n        },\n      },\n    },\n  },\n  plugins: [circosRibbons],\n});\n"}