{"spec_id":"curve-dose-response","library":"d3","language":"javascript","code":"// anyplot.ai\n// curve-dose-response: Pharmacological Dose-Response Curve\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-24\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst margin = { top: 80, right: 60, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// 4-parameter logistic model\nfunction fourPL(conc, bottom, top, ec50, hill) {\n  return bottom + (top - bottom) / (1 + Math.pow(ec50 / conc, hill));\n}\n\n// Compound parameters\nconst paramA = { name: \"Compound A\", bottom: 2, top: 97, ec50: 1e-7, hill: 1.5 };\nconst paramB = { name: \"Compound B\", bottom: 5, top: 92, ec50: 5e-7, hill: 0.9 };\n\n// Hard-coded observed data points (deterministic)\nconst concPoints = [1e-9, 3e-9, 1e-8, 3e-8, 1e-7, 3e-7, 1e-6, 3e-6, 1e-5];\nconst noiseA   = [0.8, -1.2, 1.5, -2.1, 2.3, -1.8, 0.9, -0.7, 1.1];\nconst noiseB   = [0.5, -0.9, 1.2, -1.5, 1.8, -2.0, 1.4, -0.8, 0.6];\nconst semA     = [1.2,  1.5, 1.8,  2.5, 3.1,  2.8, 1.9,  1.3, 1.0];\nconst semB     = [1.0,  1.3, 1.6,  2.2, 2.8,  3.2, 2.4,  1.7, 1.1];\n\nconst dataA = concPoints.map((c, i) => ({\n  conc: c,\n  response: Math.min(100, Math.max(0,\n    fourPL(c, paramA.bottom, paramA.top, paramA.ec50, paramA.hill) + noiseA[i])),\n  sem: semA[i],\n}));\nconst dataB = concPoints.map((c, i) => ({\n  conc: c,\n  response: Math.min(100, Math.max(0,\n    fourPL(c, paramB.bottom, paramB.top, paramB.ec50, paramB.hill) + noiseB[i])),\n  sem: semB[i],\n}));\n\n// Log-spaced smooth curve points\nconst nCurve = 200;\nconst logMin = Math.log10(3e-10);\nconst logMax = Math.log10(2e-5);\nconst curveConcList = d3.range(nCurve).map(i =>\n  Math.pow(10, logMin + (logMax - logMin) * i / (nCurve - 1))\n);\n\n// CI half-width: bell-shaped, widest in the transition zone\nfunction ciHalfWidth(response, bottom, top) {\n  const p = (response - bottom) / (top - bottom);\n  return 2.0 + 8.0 * 4 * p * (1 - p);\n}\n\nconst curveA = curveConcList.map(c => {\n  const resp = fourPL(c, paramA.bottom, paramA.top, paramA.ec50, paramA.hill);\n  const w = ciHalfWidth(resp, paramA.bottom, paramA.top);\n  return { conc: c, response: resp, lower: Math.max(-3, resp - w), upper: Math.min(103, resp + w) };\n});\nconst curveB = curveConcList.map(c => ({\n  conc: c,\n  response: fourPL(c, paramB.bottom, paramB.top, paramB.ec50, paramB.hill),\n}));\n\n// Scales\nconst x = d3.scaleLog().domain([3e-10, 2e-5]).range([0, iw]);\nconst y = d3.scaleLinear().domain([-8, 112]).range([ih, 0]);\n\n// SVG\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nsvg.append(\"defs\").append(\"clipPath\").attr(\"id\", \"plot-clip\")\n  .append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\n\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\nconst clip = g.append(\"g\").attr(\"clip-path\", \"url(#plot-clip)\");\n\n// Gridlines\n[0, 20, 40, 60, 80, 100].forEach(yVal => {\n  g.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", y(yVal)).attr(\"y2\", y(yVal))\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n});\n\n// Asymptote dashed lines — Compound A\n[paramA.top, paramA.bottom].forEach(aVal => {\n  clip.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", y(aVal)).attr(\"y2\", y(aVal))\n    .attr(\"stroke\", t.palette[0]).attr(\"stroke-dasharray\", \"6,9\")\n    .attr(\"stroke-width\", 1.5).attr(\"opacity\", 0.4);\n});\n\n// Asymptote dashed lines — Compound B\n[paramB.top, paramB.bottom].forEach(aVal => {\n  clip.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", y(aVal)).attr(\"y2\", y(aVal))\n    .attr(\"stroke\", t.palette[1]).attr(\"stroke-dasharray\", \"6,9\")\n    .attr(\"stroke-width\", 1.5).attr(\"opacity\", 0.3);\n});\n\n// 95% CI band — Compound A\nconst areaGen = d3.area()\n  .x(d => x(d.conc)).y0(d => y(d.lower)).y1(d => y(d.upper))\n  .curve(d3.curveCatmullRom.alpha(0.5));\n\nclip.append(\"path\").datum(curveA)\n  .attr(\"fill\", t.palette[0]).attr(\"opacity\", 0.20)\n  .attr(\"d\", areaGen);\n\n// Fitted curves\nconst lineGen = d3.line().x(d => x(d.conc)).y(d => y(d.response))\n  .curve(d3.curveCatmullRom.alpha(0.5));\n\nclip.append(\"path\").datum(curveA)\n  .attr(\"fill\", \"none\").attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 3).attr(\"d\", lineGen);\n\nclip.append(\"path\").datum(curveB)\n  .attr(\"fill\", \"none\").attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 3).attr(\"d\", lineGen);\n\n// EC50 reference lines — Compound A\nconst ec50AResp = (paramA.bottom + paramA.top) / 2;\ng.append(\"line\")\n  .attr(\"x1\", x(paramA.ec50)).attr(\"x2\", x(paramA.ec50))\n  .attr(\"y1\", y(paramA.bottom - 2)).attr(\"y2\", y(ec50AResp))\n  .attr(\"stroke\", t.palette[0]).attr(\"stroke-dasharray\", \"8,5\")\n  .attr(\"stroke-width\", 1.5).attr(\"opacity\", 0.7);\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", x(paramA.ec50))\n  .attr(\"y1\", y(ec50AResp)).attr(\"y2\", y(ec50AResp))\n  .attr(\"stroke\", t.palette[0]).attr(\"stroke-dasharray\", \"8,5\")\n  .attr(\"stroke-width\", 1.5).attr(\"opacity\", 0.7);\n\n// EC50 reference lines — Compound B\nconst ec50BResp = (paramB.bottom + paramB.top) / 2;\ng.append(\"line\")\n  .attr(\"x1\", x(paramB.ec50)).attr(\"x2\", x(paramB.ec50))\n  .attr(\"y1\", y(paramB.bottom - 2)).attr(\"y2\", y(ec50BResp))\n  .attr(\"stroke\", t.palette[1]).attr(\"stroke-dasharray\", \"8,5\")\n  .attr(\"stroke-width\", 1.5).attr(\"opacity\", 0.6);\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", x(paramB.ec50))\n  .attr(\"y1\", y(ec50BResp)).attr(\"y2\", y(ec50BResp))\n  .attr(\"stroke\", t.palette[1]).attr(\"stroke-dasharray\", \"8,5\")\n  .attr(\"stroke-width\", 1.5).attr(\"opacity\", 0.6);\n\n// EC50 annotations\ng.append(\"text\")\n  .attr(\"x\", x(paramA.ec50) + 7).attr(\"y\", y(ec50AResp) - 9)\n  .attr(\"fill\", t.palette[0]).style(\"font-size\", \"14px\").style(\"font-weight\", \"600\")\n  .text(\"IC₅₀ = 100 nM\");\n\ng.append(\"text\")\n  .attr(\"x\", x(paramB.ec50) + 7).attr(\"y\", y(ec50BResp) - 9)\n  .attr(\"fill\", t.palette[1]).style(\"font-size\", \"14px\").style(\"font-weight\", \"600\")\n  .text(\"IC₅₀ = 500 nM\");\n\n// Error bars — D3 data-join, inside clip group to respect plot boundary\nconst capW = 5;\n[{ data: dataA, color: t.palette[0] }, { data: dataB, color: t.palette[1] }].forEach(({ data, color }) => {\n  const ebG = clip.append(\"g\");\n  ebG.selectAll(\"line.stem\").data(data).join(\"line\")\n    .attr(\"x1\", d => x(d.conc)).attr(\"x2\", d => x(d.conc))\n    .attr(\"y1\", d => y(d.response - d.sem)).attr(\"y2\", d => y(d.response + d.sem))\n    .attr(\"stroke\", color).attr(\"stroke-width\", 1.5);\n  ebG.selectAll(\"line.cap-top\").data(data).join(\"line\")\n    .attr(\"x1\", d => x(d.conc) - capW).attr(\"x2\", d => x(d.conc) + capW)\n    .attr(\"y1\", d => y(d.response + d.sem)).attr(\"y2\", d => y(d.response + d.sem))\n    .attr(\"stroke\", color).attr(\"stroke-width\", 1.5);\n  ebG.selectAll(\"line.cap-bot\").data(data).join(\"line\")\n    .attr(\"x1\", d => x(d.conc) - capW).attr(\"x2\", d => x(d.conc) + capW)\n    .attr(\"y1\", d => y(d.response - d.sem)).attr(\"y2\", d => y(d.response - d.sem))\n    .attr(\"stroke\", color).attr(\"stroke-width\", 1.5);\n});\n\n// Data points\nclip.selectAll(\".dot-a\").data(dataA).join(\"circle\")\n  .attr(\"cx\", d => x(d.conc)).attr(\"cy\", d => y(d.response)).attr(\"r\", 6)\n  .attr(\"fill\", t.palette[0]).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 2);\n\nclip.selectAll(\".dot-b\").data(dataB).join(\"circle\")\n  .attr(\"cx\", d => x(d.conc)).attr(\"cy\", d => y(d.response)).attr(\"r\", 6)\n  .attr(\"fill\", t.palette[1]).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 2);\n\n// Axes\nfunction xTickFormat(d) {\n  const e = Math.round(Math.log10(d));\n  const labels = { \"-9\": \"1 nM\", \"-8\": \"10 nM\", \"-7\": \"100 nM\", \"-6\": \"1 µM\", \"-5\": \"10 µM\" };\n  return labels[String(e)] || \"\";\n}\n\nconst xAxisG = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).tickValues([1e-9, 1e-8, 1e-7, 1e-6, 1e-5]).tickFormat(xTickFormat).tickSize(6));\nxAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nxAxisG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nxAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nconst yAxisG = g.append(\"g\")\n  .call(d3.axisLeft(y).tickValues([0, 20, 40, 60, 80, 100]).tickFormat(d => `${d}%`).tickSize(6));\nyAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nyAxisG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nyAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Axis labels\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw / 2).attr(\"y\", height - 18)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"16px\")\n  .text(\"Concentration\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", `translate(22,${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"16px\")\n  .text(\"% Inhibition\");\n\n// Legend — D3 data join\nconst legendEntries = [\n  { name: \"Compound A  (95% CI)\", color: t.palette[0], hasCI: true },\n  { name: \"Compound B\", color: t.palette[1], hasCI: false },\n];\n\nconst lx = iw - 290;\nconst ly = 18;\nconst lPad = 12;\nconst lW = 300;\nconst lH = 90;\nconst rowH = 38;\n\ng.append(\"rect\")\n  .attr(\"x\", lx - lPad).attr(\"y\", ly - lPad)\n  .attr(\"width\", lW).attr(\"height\", lH)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 5).attr(\"opacity\", 0.92)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\nconst legendRows = g.selectAll(\".legend-row\").data(legendEntries).join(\"g\")\n  .attr(\"class\", \"legend-row\")\n  .attr(\"transform\", (d, i) => `translate(${lx},${ly + i * rowH + 10})`);\n\nlegendRows.filter(d => d.hasCI).append(\"rect\")\n  .attr(\"x\", 0).attr(\"y\", -4).attr(\"width\", 22).attr(\"height\", 8)\n  .attr(\"fill\", d => d.color).attr(\"opacity\", 0.20);\n\nlegendRows.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", 28)\n  .attr(\"y1\", 0).attr(\"y2\", 0)\n  .attr(\"stroke\", d => d.color).attr(\"stroke-width\", 2.5);\n\nlegendRows.append(\"circle\")\n  .attr(\"cx\", 14).attr(\"cy\", 0).attr(\"r\", 5)\n  .attr(\"fill\", d => d.color).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1.5);\n\nlegendRows.append(\"text\")\n  .attr(\"x\", 36).attr(\"y\", 5)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"14px\")\n  .text(d => d.name);\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"curve-dose-response · javascript · d3 · anyplot.ai\");\n"}