{"spec_id":"upset-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// upset-basic: UpSet Plot for Multi-Set Intersection Analysis\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\n// Five differential-expression experiments run on the same 400-gene panel.\n// A latent per-gene \"regulatory activity\" score drives correlated membership\n// across experiments (RNA-seq/ChIP-seq/ATAC-seq/Proteomics track activity,\n// Methylation is enriched in low-activity regions), producing realistic,\n// unevenly sized overlaps to visualize.\nlet seed = 42;\nfunction lcgRandom() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\n\nconst SET_NAMES = [\"RNA-seq\", \"ChIP-seq\", \"ATAC-seq\", \"Proteomics\", \"Methylation\"];\nconst N_GENES = 400;\nconst membership = [];\nfor (let i = 0; i < N_GENES; i++) {\n  const activity = lcgRandom();\n  const noise = [lcgRandom(), lcgRandom(), lcgRandom(), lcgRandom(), lcgRandom()];\n  membership.push([\n    activity * 0.75 + noise[0] * 0.25 > 0.55,\n    activity * 0.7 + noise[1] * 0.3 > 0.58,\n    activity * 0.55 + noise[2] * 0.45 > 0.55,\n    activity * 0.65 + noise[3] * 0.35 > 0.6,\n    (1 - activity) * 0.7 + noise[4] * 0.3 > 0.62,\n  ]);\n}\n\n// Sets ordered by total membership, descending — this fixes the row order\n// shared by the left bar chart and the dot matrix.\nconst setOrder = SET_NAMES.map((_, si) => si).sort(\n  (a, b) => membership.filter((m) => m[b]).length - membership.filter((m) => m[a]).length\n);\nconst orderedSetNames = setOrder.map((si) => SET_NAMES[si]);\nconst setTotals = setOrder.map((si) => membership.filter((m) => m[si]).length);\n\n// Exclusive intersections (an element counts only for the exact combination of\n// sets it belongs to), sorted by size descending — the spec's default order.\nconst comboCounts = new Map();\nfor (const m of membership) {\n  const key = setOrder.map((si) => (m[si] ? 1 : 0)).join(\"\");\n  if (key === \"0\".repeat(SET_NAMES.length)) continue;\n  comboCounts.set(key, (comboCounts.get(key) || 0) + 1);\n}\nconst MAX_INTERSECTIONS = 12;\nconst topCombos = [...comboCounts.entries()]\n  .sort((a, b) => b[1] - a[1])\n  .slice(0, MAX_INTERSECTIONS);\nconst colLabels = topCombos.map((_, i) => `c${i}`);\nconst colCounts = topCombos.map(([, count]) => count);\nconst colDegrees = topCombos.map(([key]) => key.split(\"\").filter((c) => c === \"1\").length);\nconst maxDegree = Math.max(...colDegrees);\nconst minDegree = Math.min(...colDegrees);\n\nfunction lerpColor(hexA, hexB, frac) {\n  const a = [1, 3, 5].map((p) => parseInt(hexA.slice(p, p + 2), 16));\n  const b = [1, 3, 5].map((p) => parseInt(hexB.slice(p, p + 2), 16));\n  const c = a.map((v, i) => Math.round(v + (b[i] - v) * frac));\n  return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;\n}\n\n// Bar color per intersection encodes degree (sets involved) along the Imprint\n// sequential ramp — a continuous, ordinal signal, not a categorical one.\nconst colColors = colDegrees.map((d) => {\n  const frac = maxDegree === minDegree ? 0 : (d - minDegree) / (maxDegree - minDegree);\n  return lerpColor(t.seq[0], t.seq[1], frac);\n});\n\n// Matrix dot data: member dots (dark), non-member dots (faint), and one\n// connector segment per multi-set intersection spanning its member rows.\nconst memberDots = [];\nconst otherDots = [];\nconst connectors = [];\ntopCombos.forEach(([key], ci) => {\n  const bits = key.split(\"\").map((c) => c === \"1\");\n  const memberRows = [];\n  bits.forEach((isMember, ri) => {\n    const point = { x: colLabels[ci], y: orderedSetNames[ri] };\n    if (isMember) {\n      memberDots.push(point);\n      memberRows.push(ri);\n    } else {\n      otherDots.push(point);\n    }\n  });\n  if (memberRows.length >= 2) {\n    const top = Math.min(...memberRows);\n    const bottom = Math.max(...memberRows);\n    connectors.push({\n      x: colLabels[ci],\n      top: orderedSetNames[top],\n      bottom: orderedSetNames[bottom],\n    });\n  }\n});\nconst connectorDatasets = connectors.map((c) => ({\n  type: \"line\",\n  data: [\n    { x: c.x, y: c.top },\n    { x: c.x, y: c.bottom },\n  ],\n  showLine: true,\n  borderColor: t.ink,\n  borderWidth: 4,\n  pointRadius: 0,\n  order: 1,\n}));\n\n// --- Layout ------------------------------------------------------------------\n// Fixed reserves (CSS px) shared between the visible bar-chart axes and the\n// hidden matching axes of the matrix, so all three panels line up pixel-for-\n// pixel (same technique as the scatter-marginal chartjs implementation).\nconst TITLE_SIZE = 60;\nconst Y_AXIS_RESERVE = 110; // top bar chart's count axis (+ title) width\nconst X_AXIS_RESERVE = 90; // left bar chart's count axis (+ title) height\nconst LEFT_LABEL_WIDTH = 260; // set-name column width\nconst TOP_BAR_HEIGHT = 340;\n\nconst container = document.getElementById(\"container\");\ncontainer.style.display = \"grid\";\ncontainer.style.gridTemplateColumns = `${LEFT_LABEL_WIDTH}px 1fr`;\ncontainer.style.gridTemplateRows = `${TITLE_SIZE}px ${TOP_BAR_HEIGHT}px 1fr`;\ncontainer.style.fontFamily = \"inherit\";\n\nconst titleCell = document.createElement(\"div\");\ntitleCell.style.gridColumn = \"1 / span 2\";\ntitleCell.style.display = \"flex\";\ntitleCell.style.alignItems = \"center\";\ntitleCell.style.justifyContent = \"center\";\ntitleCell.style.color = t.ink;\ntitleCell.style.fontSize = \"26px\";\ntitleCell.style.fontWeight = \"600\";\ntitleCell.textContent = \"upset-basic · javascript · chartjs · anyplot.ai\";\ncontainer.appendChild(titleCell);\n\nconst cornerCell = document.createElement(\"div\");\nconst topCell = document.createElement(\"div\");\nconst leftCell = document.createElement(\"div\");\nconst matrixCell = document.createElement(\"div\");\n[cornerCell, topCell, leftCell, matrixCell].forEach((cell) => {\n  cell.style.position = \"relative\";\n  cell.style.width = \"100%\";\n  cell.style.height = \"100%\";\n});\ncontainer.appendChild(cornerCell);\ncontainer.appendChild(topCell);\ncontainer.appendChild(leftCell);\ncontainer.appendChild(matrixCell);\n\n// Mini legend explaining the intersection-bar degree gradient, placed in the\n// otherwise-empty corner cell above the set-name column.\ncornerCell.style.display = \"flex\";\ncornerCell.style.flexDirection = \"column\";\ncornerCell.style.justifyContent = \"center\";\ncornerCell.style.alignItems = \"stretch\";\ncornerCell.style.boxSizing = \"border-box\";\ncornerCell.style.padding = \"0 20px\";\n\nconst legendCaption = document.createElement(\"div\");\nlegendCaption.style.color = t.inkSoft;\nlegendCaption.style.fontSize = \"12px\";\nlegendCaption.style.textAlign = \"center\";\nlegendCaption.style.marginBottom = \"8px\";\nlegendCaption.textContent = \"Bar color = intersection degree\";\ncornerCell.appendChild(legendCaption);\n\nconst legendRow = document.createElement(\"div\");\nlegendRow.style.display = \"flex\";\nlegendRow.style.alignItems = \"center\";\nlegendRow.style.gap = \"6px\";\n\nconst minDegreeLabel = document.createElement(\"span\");\nminDegreeLabel.style.color = t.inkSoft;\nminDegreeLabel.style.fontSize = \"12px\";\nminDegreeLabel.textContent = String(minDegree);\n\nconst gradientSwatch = document.createElement(\"div\");\ngradientSwatch.style.flex = \"1\";\ngradientSwatch.style.height = \"10px\";\ngradientSwatch.style.borderRadius = \"5px\";\ngradientSwatch.style.background = `linear-gradient(to right, ${t.seq[0]}, ${t.seq[1]})`;\n\nconst maxDegreeLabel = document.createElement(\"span\");\nmaxDegreeLabel.style.color = t.inkSoft;\nmaxDegreeLabel.style.fontSize = \"12px\";\nmaxDegreeLabel.textContent = String(maxDegree);\n\nlegendRow.appendChild(minDegreeLabel);\nlegendRow.appendChild(gradientSwatch);\nlegendRow.appendChild(maxDegreeLabel);\ncornerCell.appendChild(legendRow);\n\nconst legendSub = document.createElement(\"div\");\nlegendSub.style.color = t.inkSoft;\nlegendSub.style.fontSize = \"11px\";\nlegendSub.style.textAlign = \"center\";\nlegendSub.style.marginTop = \"4px\";\nlegendSub.textContent = \"sets combined\";\ncornerCell.appendChild(legendSub);\n\nfunction makeCanvas(cell) {\n  const canvas = document.createElement(\"canvas\");\n  cell.appendChild(canvas);\n  return canvas;\n}\n\n// --- Top: intersection cardinality -----------------------------------------\n// The largest intersection (index 0, since topCombos is sorted descending)\n// gets a bold outline plus its exact count drawn above the bar — a small\n// storytelling touch that anchors the size hierarchy beyond color/sort alone.\nconst colBorderColors = colCounts.map((_, i) => (i === 0 ? t.ink : \"transparent\"));\nconst colBorderWidths = colCounts.map((_, i) => (i === 0 ? 2 : 0));\n\nnew Chart(makeCanvas(topCell), {\n  type: \"bar\",\n  data: {\n    labels: colLabels,\n    datasets: [\n      {\n        data: colCounts,\n        backgroundColor: colColors,\n        borderColor: colBorderColors,\n        borderWidth: colBorderWidths,\n        barPercentage: 0.7,\n        categoryPercentage: 0.9,\n      },\n    ],\n  },\n  plugins: [\n    {\n      id: \"largestIntersectionLabel\",\n      afterDatasetsDraw(chart) {\n        const bar = chart.getDatasetMeta(0).data[0];\n        if (!bar) return;\n        const { ctx } = chart;\n        ctx.save();\n        ctx.fillStyle = t.ink;\n        ctx.font = \"bold 13px sans-serif\";\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"bottom\";\n        ctx.fillText(String(colCounts[0]), bar.x, bar.y - 6);\n        ctx.restore();\n      },\n    },\n  ],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: { legend: { display: false } },\n    scales: {\n      x: {\n        type: \"category\",\n        offset: true,\n        display: true,\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n        afterFit: (scale) => {\n          scale.height = 0;\n        },\n      },\n      y: {\n        type: \"linear\",\n        beginAtZero: true,\n        title: { display: true, text: \"Intersection Size\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 13 } },\n        grid: { color: t.grid },\n        afterFit: (scale) => {\n          scale.width = Y_AXIS_RESERVE;\n        },\n      },\n    },\n  },\n});\n\n// --- Left: individual set size ----------------------------------------------\nnew Chart(makeCanvas(leftCell), {\n  type: \"bar\",\n  data: {\n    labels: orderedSetNames,\n    datasets: [\n      {\n        data: setTotals,\n        backgroundColor: t.palette[0],\n        borderWidth: 0,\n        barPercentage: 0.7,\n        categoryPercentage: 0.9,\n      },\n    ],\n  },\n  options: {\n    indexAxis: \"y\",\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: { legend: { display: false } },\n    scales: {\n      x: {\n        type: \"linear\",\n        beginAtZero: true,\n        reverse: true,\n        title: { display: true, text: \"Set Size\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 13 } },\n        grid: { color: t.grid },\n        afterFit: (scale) => {\n          scale.height = X_AXIS_RESERVE;\n        },\n      },\n      y: {\n        type: \"category\",\n        offset: true,\n        position: \"right\",\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n});\n\n// Set-name labels drawn as plain DOM text next to the (reversed) horizontal\n// bars — keeps the label column width independent of Chart.js's own category\n// axis so it never competes for the shared LEFT_LABEL_WIDTH reserve.\nconst labelLayer = document.createElement(\"div\");\nlabelLayer.style.position = \"absolute\";\nlabelLayer.style.inset = \"0\";\nlabelLayer.style.display = \"flex\";\nlabelLayer.style.flexDirection = \"column\";\nlabelLayer.style.pointerEvents = \"none\";\nlabelLayer.style.paddingBottom = `${X_AXIS_RESERVE}px`;\norderedSetNames.forEach((name) => {\n  const row = document.createElement(\"div\");\n  row.style.flex = \"1\";\n  row.style.display = \"flex\";\n  row.style.alignItems = \"center\";\n  row.style.justifyContent = \"flex-end\";\n  row.style.paddingRight = \"12px\";\n  row.style.color = t.ink;\n  row.style.fontSize = \"15px\";\n  row.textContent = name;\n  labelLayer.appendChild(row);\n});\nleftCell.appendChild(labelLayer);\n\n// --- Matrix: set-membership dots + connectors -------------------------------\nnew Chart(makeCanvas(matrixCell), {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      ...connectorDatasets,\n      {\n        type: \"scatter\",\n        label: \"Not in intersection\",\n        data: otherDots,\n        backgroundColor: t.inkSoft,\n        pointRadius: 7,\n        pointStyle: \"circle\",\n        order: 2,\n      },\n      {\n        type: \"scatter\",\n        label: \"In intersection\",\n        data: memberDots,\n        backgroundColor: t.ink,\n        pointRadius: 10,\n        pointStyle: \"circle\",\n        order: 3,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: { legend: { display: false } },\n    scales: {\n      x: {\n        type: \"category\",\n        labels: colLabels,\n        offset: true,\n        display: true,\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n        afterFit: (scale) => {\n          scale.height = X_AXIS_RESERVE;\n        },\n      },\n      y: {\n        type: \"category\",\n        labels: orderedSetNames,\n        offset: true,\n        display: true,\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n        afterFit: (scale) => {\n          scale.width = Y_AXIS_RESERVE;\n        },\n      },\n    },\n  },\n});\n"}