{"spec_id":"manhattan-gwas","library":"d3","language":"javascript","code":"// anyplot.ai\n// manhattan-gwas: Manhattan Plot for GWAS\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 50, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Deterministic PRNG (LCG) ------------------------------------------------\nfunction makeLcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rng = makeLcg(42);\n\n// --- Data: simulated GWAS scan across 22 autosomes + X (approx. GRCh38 Mb) --\nconst chromLengthsMb = {\n  \"1\": 248.9, \"2\": 242.2, \"3\": 198.3, \"4\": 190.2, \"5\": 181.5, \"6\": 170.8,\n  \"7\": 159.3, \"8\": 145.1, \"9\": 138.4, \"10\": 133.8, \"11\": 135.1, \"12\": 133.3,\n  \"13\": 114.4, \"14\": 107.0, \"15\": 101.9, \"16\": 90.3, \"17\": 83.3, \"18\": 80.4,\n  \"19\": 58.6, \"20\": 64.4, \"21\": 46.7, \"22\": 50.8, \"X\": 156.0,\n};\nconst chromosomes = Object.keys(chromLengthsMb);\nconst pointsPerMb = 10;\n\n// Localized association peaks (linkage-disequilibrium-like bumps above the null background)\nconst peaks = [\n  { chrom: \"2\", posMb: 120, height: 11.5, width: 2.5 },\n  { chrom: \"6\", posMb: 32, height: 13.8, width: 1.8 },\n  { chrom: \"9\", posMb: 100, height: 6.2, width: 2.0 },\n  { chrom: \"11\", posMb: 65, height: 9.4, width: 1.5 },\n  { chrom: \"17\", posMb: 44, height: 8.0, width: 2.2 },\n];\n\nconst chromOffsetMb = {};\nlet cumulativeMb = 0;\nconst data = [];\nfor (const chrom of chromosomes) {\n  chromOffsetMb[chrom] = cumulativeMb;\n  const lengthMb = chromLengthsMb[chrom];\n  const n = Math.round(lengthMb * pointsPerMb);\n  for (let i = 0; i < n; i++) {\n    const posMb = ((i + rng()) / n) * lengthMb;\n\n    // Null-model background: -log10(p) for p ~ Uniform(0, 1)\n    let negLogP = -Math.log10(rng());\n\n    // Overlay any peak centered on this chromosome\n    for (const peak of peaks) {\n      if (peak.chrom !== chrom) continue;\n      const d = posMb - peak.posMb;\n      const bump = peak.height * Math.exp(-(d * d) / (2 * peak.width * peak.width));\n      if (bump > negLogP) negLogP = bump + (rng() - 0.5) * 0.6;\n    }\n\n    data.push({ chrom, cumPos: cumulativeMb + posMb, negLogP: Math.max(0, negLogP) });\n  }\n  cumulativeMb += lengthMb;\n}\nconst genomeLengthMb = cumulativeMb;\nconst genomeWideThreshold = -Math.log10(5e-8); // ~7.3\nconst suggestiveThreshold = -Math.log10(1e-5); // 5\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, genomeLengthMb]).range([0, iw]);\nconst y = d3\n  .scaleLinear()\n  .domain([0, d3.max(data, (d) => d.negLogP) + 1])\n  .nice()\n  .range([ih, 0]);\nconst color = d3.scaleOrdinal().domain(chromosomes).range([t.palette[0], t.palette[2]]);\n\n// --- Y grid + axis ----------------------------------------------------------\ng.append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(y).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nyAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nyAxis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nyAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -80)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .text(\"−log₁₀(p-value)\");\n\n// --- X axis: chromosome labels centered on each chromosome region -----------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(\n    d3\n      .axisBottom(x)\n      .tickValues(chromosomes.map((c) => chromOffsetMb[c] + chromLengthsMb[c] / 2))\n      .tickFormat((_, i) => chromosomes[i])\n      .tickSize(0)\n  );\nxAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .text(\"Chromosome\");\n\n// --- Significance threshold lines --------------------------------------------\nfunction thresholdLine(value, label, stroke) {\n  g.append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", iw)\n    .attr(\"y1\", y(value))\n    .attr(\"y2\", y(value))\n    .attr(\"stroke\", stroke)\n    .attr(\"stroke-width\", 2)\n    .attr(\"stroke-dasharray\", \"8,5\");\n  g.append(\"text\")\n    .attr(\"x\", iw - 6)\n    .attr(\"y\", y(value) - 8)\n    .attr(\"text-anchor\", \"end\")\n    .attr(\"fill\", stroke)\n    .style(\"font-size\", \"13px\")\n    .text(label);\n}\nthresholdLine(suggestiveThreshold, \"Suggestive (p = 1×10⁻⁵)\", t.inkSoft);\nthresholdLine(genomeWideThreshold, \"Genome-wide significant (p = 5×10⁻⁸)\", t.palette[4]);\n\n// --- Points: alternating chromosome color, sized down for overplotting ------\ng.selectAll(\"circle.point\")\n  .data(data)\n  .join(\"circle\")\n  .attr(\"class\", \"point\")\n  .attr(\"cx\", (d) => x(d.cumPos))\n  .attr(\"cy\", (d) => y(d.negLogP))\n  .attr(\"r\", 1.7)\n  .attr(\"fill\", (d) => color(d.chrom))\n  .attr(\"opacity\", 0.55);\n\n// Genome-wide-significant SNPs get a bigger, outlined, fully opaque marker so\n// they stand out from the null-distribution band instead of sharing its color only.\nconst significant = data.filter((d) => d.negLogP >= genomeWideThreshold);\ng.selectAll(\"circle.significant\")\n  .data(significant)\n  .join(\"circle\")\n  .attr(\"class\", \"significant\")\n  .attr(\"cx\", (d) => x(d.cumPos))\n  .attr(\"cy\", (d) => y(d.negLogP))\n  .attr(\"r\", 3.2)\n  .attr(\"fill\", (d) => color(d.chrom))\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 0.8)\n  .attr(\"opacity\", 1);\n\n// --- Lead-signal callout: d3-quadtree nearest-neighbor lookup pinpoints the\n// actual rendered point closest to the tallest peak's theoretical apex, so the\n// annotation anchors to a real datum rather than an idealized coordinate -----\nconst pointIndex = d3.quadtree()\n  .x((d) => x(d.cumPos))\n  .y((d) => y(d.negLogP))\n  .addAll(data);\nconst strongestPeak = peaks.reduce((a, b) => (b.height > a.height ? b : a));\nconst targetX = x(chromOffsetMb[strongestPeak.chrom] + strongestPeak.posMb);\nconst targetY = y(strongestPeak.height);\nconst leadSnp = pointIndex.find(targetX, targetY, 40);\nif (leadSnp) {\n  const ax = x(leadSnp.cumPos);\n  const ay = y(leadSnp.negLogP);\n  const labelX = ax + 16;\n  const labelY = ay - 24;\n  g.append(\"line\")\n    .attr(\"x1\", ax)\n    .attr(\"y1\", ay - 5)\n    .attr(\"x2\", labelX - 2)\n    .attr(\"y2\", labelY + 5)\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 1);\n  g.append(\"text\")\n    .attr(\"x\", labelX)\n    .attr(\"y\", labelY)\n    .attr(\"text-anchor\", \"start\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"14px\")\n    .style(\"font-weight\", \"600\")\n    .text(`chr${leadSnp.chrom} lead signal`);\n}\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 54)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"manhattan-gwas · javascript · d3 · anyplot.ai\");\n"}