{"spec_id":"upset-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// upset-basic: UpSet Plot for Multi-Set Intersection Analysis\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n// \"muted\" is a style-guide semantic anchor (other/rest, background layer) that the\n// harness doesn't expose as its own token — only its two theme-adaptive hexes are\n// documented, so they're hard-coded here rather than derived from `t`.\nconst MUTED = t.theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\nconst NEUTRAL = t.ink; // totals / baseline anchor — same hex as structural chrome\n\n// --- Deterministic data (in-memory, no network) -----------------------------\n// Small fixed-seed LCG; Node/browser has no seeded RNG built in.\nfunction makeLCG(seed) {\n  let s = seed >>> 0;\n  return () => {\n    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n}\nconst rand = makeLCG(20260909);\n\n// Six genomic assays profiling the same gene pool (spec's own example scenario).\nconst SET_NAMES_RAW = [\"RNA-seq\", \"ChIP-seq\", \"ATAC-seq\", \"CUT&Tag\", \"Hi-C\", \"WGBS\"];\nconst N_SETS = SET_NAMES_RAW.length;\nconst N_CANDIDATES = 900;\nconst BASE_P = [0.34, 0.3, 0.28, 0.2, 0.16, 0.14];\n\nconst comboCounts = new Map();\nconst setSizeRaw = new Array(N_SETS).fill(0);\nlet totalGenes = 0;\n\nfor (let i = 0; i < N_CANDIDATES; i++) {\n  // Two correlated biological signatures create realistic, sizeable overlaps\n  // instead of a flat independent-draw distribution.\n  const activePromoter = rand() < 0.22; // RNA-seq + ChIP-seq + ATAC-seq co-signal\n  const chromatinLoop = rand() < 0.16; // CUT&Tag + Hi-C co-signal, weakly extends to WGBS\n  const members = [];\n  for (let s = 0; s < N_SETS; s++) {\n    let p = BASE_P[s];\n    if (activePromoter && s <= 2) p += 0.42;\n    if (chromatinLoop && (s === 3 || s === 4)) p += 0.4;\n    if (chromatinLoop && s === 5) p += 0.22;\n    if (rand() < Math.min(p, 0.93)) members.push(s);\n  }\n  if (members.length === 0) continue;\n  totalGenes++;\n  members.forEach((s) => setSizeRaw[s]++);\n  const key = members.join(\",\");\n  comboCounts.set(key, (comboCounts.get(key) || 0) + 1);\n}\n\n// Sets ordered largest-first (top-to-bottom in the matrix), the UpSet convention.\nconst order = SET_NAMES_RAW.map((_, i) => i).sort((a, b) => setSizeRaw[b] - setSizeRaw[a]);\nconst setNames = order.map((i) => SET_NAMES_RAW[i]);\nconst setSizes = order.map((i) => setSizeRaw[i]);\nconst rankOf = new Array(N_SETS);\norder.forEach((rawIdx, newIdx) => (rankOf[rawIdx] = newIdx));\n\nconst combos = Array.from(comboCounts.entries()).map(([key, count]) => {\n  const members = key\n    .split(\",\")\n    .map(Number)\n    .map((r) => rankOf[r])\n    .sort((a, b) => a - b);\n  return { members, degree: members.length, count };\n});\ncombos.sort((a, b) => b.count - a.count || a.degree - b.degree);\n\n// Cap columns to the top 12 by size (default sort per spec) — beyond that the\n// matrix gets unreadably narrow at this canvas width.\nconst intersections = combos.slice(0, Math.min(12, combos.length));\nconst nCols = intersections.length;\nconst colLabels = intersections.map((iv) => String(iv.degree));\n\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction lerpColor(hexA, hexB, frac) {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  const c = a.map((v, i) => Math.round(v + (b[i] - v) * frac));\n  return `rgb(${c[0]},${c[1]},${c[2]})`;\n}\nconst minDeg = Math.min(...intersections.map((iv) => iv.degree));\nconst maxDeg = Math.max(...intersections.map((iv) => iv.degree));\nconst degreeSpan = maxDeg - minDeg || 1;\nfunction degreeColor(degree) {\n  return lerpColor(t.seq[0], t.seq[1], (degree - minDeg) / degreeSpan);\n}\n\n// --- Layout: three synced Highcharts panes inside #container -----------------\n// Highcharts core has no built-in \"UpSet\" type, so the plot is composed from a\n// column chart (top), a bar chart (left) and a scatter+line matrix (main), each\n// its own Highcharts.chart instance. Row/column alignment across instances is\n// guaranteed by giving the paired charts *identical* left/right (columns) or\n// top/bottom (rows) pixel margins and matching div sizes — never derived from\n// axis-label auto-sizing, which could drift between instances.\nconst root = document.getElementById(\"container\");\n\nconst PAD = 12;\nconst TITLE_TOP = 8;\nconst TITLE_H = 34;\nconst SUBTITLE_TOP = 44;\nconst SUBTITLE_H = 20;\nconst CHART_TOP = 72;\nconst CHART_BOTTOM = 888;\nconst LEFT_COL_W = 300;\nconst TOP_ROW_H = 260;\n\nconst chartLeft = PAD;\nconst chartRight = 1600 - PAD;\nconst chartAreaW = chartRight - chartLeft;\nconst chartAreaH = CHART_BOTTOM - CHART_TOP;\n\nconst rightW = chartAreaW - LEFT_COL_W;\nconst bottomH = chartAreaH - TOP_ROW_H;\n\nfunction makeDiv(x, y, w, h) {\n  const el = document.createElement(\"div\");\n  el.style.position = \"absolute\";\n  el.style.left = `${x}px`;\n  el.style.top = `${y}px`;\n  el.style.width = `${w}px`;\n  el.style.height = `${h}px`;\n  root.appendChild(el);\n  return el;\n}\n\n// Compact swatch legend decoding the top-bar degree gradient — sits in the\n// column chart's own top margin (20px), so it never overlaps the bars.\nconst degreesShown = Array.from(new Set(intersections.map((iv) => iv.degree))).sort((a, b) => a - b);\nconst legendW = 60 + degreesShown.length * 64;\nconst legendDiv = makeDiv(chartRight - legendW, CHART_TOP + 2, legendW, 18);\nlegendDiv.style.display = \"flex\";\nlegendDiv.style.alignItems = \"center\";\nlegendDiv.style.justifyContent = \"flex-end\";\nlegendDiv.style.gap = \"10px\";\nlegendDiv.style.fontSize = \"12px\";\nlegendDiv.style.color = t.inkSoft;\nlegendDiv.style.whiteSpace = \"nowrap\";\nconst legendPrefix = document.createElement(\"span\");\nlegendPrefix.textContent = \"Degree:\";\nlegendDiv.appendChild(legendPrefix);\ndegreesShown.forEach((d) => {\n  const item = document.createElement(\"span\");\n  item.style.display = \"inline-flex\";\n  item.style.alignItems = \"center\";\n  item.style.gap = \"4px\";\n  const swatch = document.createElement(\"span\");\n  swatch.style.width = \"10px\";\n  swatch.style.height = \"10px\";\n  swatch.style.borderRadius = \"50%\";\n  swatch.style.backgroundColor = degreeColor(d);\n  swatch.style.display = \"inline-block\";\n  const label = document.createElement(\"span\");\n  label.textContent = `${d} set${d > 1 ? \"s\" : \"\"}`;\n  item.appendChild(swatch);\n  item.appendChild(label);\n  legendDiv.appendChild(item);\n});\n\nconst titleDiv = makeDiv(chartLeft, TITLE_TOP, chartAreaW, TITLE_H);\ntitleDiv.style.color = t.ink;\ntitleDiv.style.fontSize = \"22px\";\ntitleDiv.style.fontWeight = \"600\";\ntitleDiv.textContent = \"upset-basic · javascript · highcharts · anyplot.ai\";\n\nconst subtitleDiv = makeDiv(chartLeft, SUBTITLE_TOP, chartAreaW, SUBTITLE_H);\nsubtitleDiv.style.color = t.inkSoft;\nsubtitleDiv.style.fontSize = \"14px\";\nsubtitleDiv.textContent = `Top ${nCols} of ${combos.length} observed intersections among ${totalGenes} genes across ${N_SETS} assays, sorted by size`;\n\nconst topDiv = makeDiv(chartLeft + LEFT_COL_W, CHART_TOP, rightW, TOP_ROW_H);\nconst leftDiv = makeDiv(chartLeft, CHART_TOP + TOP_ROW_H, LEFT_COL_W, bottomH);\nconst matrixDiv = makeDiv(chartLeft + LEFT_COL_W, CHART_TOP + TOP_ROW_H, rightW, bottomH);\n\n// --- Left pane: horizontal bars for individual set sizes ---------------------\nHighcharts.chart(leftDiv, {\n  chart: {\n    type: \"bar\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    margin: [10, 10, 60, 150],\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  title: { text: null },\n  xAxis: {\n    categories: setNames,\n    reversed: true,\n    lineColor: t.inkSoft,\n    tickWidth: 0,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  yAxis: {\n    title: { text: \"Set size\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: { style: { color: t.inkSoft, fontSize: \"12px\" } },\n  },\n  legend: { enabled: false },\n  tooltip: {\n    formatter() {\n      return `<b>${this.point.category}</b><br/>${this.y} genes`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    bar: {\n      color: NEUTRAL,\n      borderWidth: 0,\n      dataLabels: {\n        enabled: true,\n        color: t.ink,\n        style: { fontSize: \"12px\", textOutline: \"none\" },\n      },\n    },\n  },\n  series: [{ name: \"Set size\", data: setSizes }],\n});\n\n// --- Top pane: vertical bars for intersection cardinality --------------------\nHighcharts.chart(topDiv, {\n  chart: {\n    type: \"column\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    margin: [20, 20, 40, 70],\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  title: { text: null },\n  xAxis: {\n    categories: colLabels,\n    lineWidth: 0,\n    tickWidth: 0,\n    gridLineWidth: 0,\n    labels: { enabled: false },\n  },\n  yAxis: {\n    title: { text: \"Intersection size\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: { style: { color: t.inkSoft, fontSize: \"12px\" } },\n  },\n  legend: { enabled: false },\n  tooltip: {\n    formatter() {\n      const iv = intersections[this.point.index];\n      const combo = iv.members.map((r) => setNames[r]).join(\" ∩ \");\n      return `<b>${combo}</b><br/>${this.y} genes`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    column: {\n      pointPadding: 0.15,\n      groupPadding: 0.08,\n      borderWidth: 0,\n      dataLabels: {\n        enabled: true,\n        color: t.ink,\n        style: { fontSize: \"12px\", textOutline: \"none\" },\n      },\n    },\n  },\n  series: [\n    {\n      name: \"Intersection size\",\n      data: intersections.map((iv) => ({ y: iv.count, color: degreeColor(iv.degree) })),\n    },\n  ],\n});\n\n// --- Main pane: dot matrix + connecting lines --------------------------------\nconst backgroundDots = [];\nfor (let r = 0; r < setNames.length; r++) {\n  for (let c = 0; c < nCols; c++) backgroundDots.push({ x: c, y: r });\n}\nconst connectorSeries = intersections.map((iv, c) => {\n  const label = iv.members.map((r) => setNames[r]).join(\" ∩ \");\n  return {\n    type: \"line\",\n    name: `${label} (n=${iv.count})`,\n    color: NEUTRAL,\n    lineWidth: 2,\n    marker: { enabled: true, symbol: \"circle\", radius: 9, fillColor: NEUTRAL, lineWidth: 0 },\n    data: iv.members.map((r) => ({ x: c, y: r, setName: setNames[r] })),\n  };\n});\n\nHighcharts.chart(matrixDiv, {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    margin: [10, 20, 60, 70],\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  title: { text: null },\n  xAxis: {\n    categories: colLabels,\n    title: { text: \"Sets per intersection\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    lineColor: t.inkSoft,\n    tickWidth: 0,\n    gridLineWidth: 1,\n    gridLineColor: t.grid,\n    // Degree numbers are non-monotonic per column (\"1,1,1,3,2,…\") and read as a\n    // broken axis if shown as tick labels; the swatch legend above already\n    // decodes degree via color, so no numeral row is needed here.\n    labels: { enabled: false },\n  },\n  yAxis: {\n    categories: setNames,\n    reversed: true,\n    title: { text: null },\n    lineWidth: 0,\n    tickWidth: 0,\n    gridLineWidth: 1,\n    gridLineColor: t.grid,\n    labels: { enabled: false },\n  },\n  legend: { enabled: false },\n  tooltip: {\n    formatter() {\n      if (this.series.index === 0) {\n        const iv = intersections[this.point.x];\n        const combo = iv.members.map((r) => setNames[r]).join(\" ∩ \");\n        return `<b>${setNames[this.point.y]}</b><br/>not in \"${combo}\"`;\n      }\n      return `<b>${this.point.setName}</b><br/>member of \"${this.series.name}\"`;\n    },\n  },\n  plotOptions: { series: { animation: false, stickyTracking: false } },\n  series: [\n    {\n      type: \"scatter\",\n      name: \"All combinations\",\n      data: backgroundDots,\n      marker: { symbol: \"circle\", radius: 7, fillColor: MUTED, lineWidth: 0 },\n      showInLegend: false,\n    },\n    ...connectorSeries,\n  ],\n});\n\nwindow.__anyplotReady = true;\n"}