{"spec_id":"curve-dose-response","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// curve-dose-response: Pharmacological Dose-Response Curve\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// 4-parameter logistic (4PL) sigmoid\nfunction fourPL(c, bot, top, ec50, hill) {\n  return bot + (top - bot) / (1 + Math.pow(ec50 / c, hill));\n}\n\n// log-spaced concentration array\nfunction logSpace(lo, hi, n) {\n  const a = Math.log10(lo), b = Math.log10(hi);\n  return Array.from({ length: n }, (_, i) => Math.pow(10, a + (b - a) * i / (n - 1)));\n}\n\n// seeded LCG pseudo-random for reproducible noise\nlet _seed = 42;\nfunction rand() { _seed = (_seed * 1664525 + 1013904223) >>> 0; return _seed / 0x100000000; }\nfunction randN() { return Math.sqrt(-2 * Math.log(rand() + 1e-15)) * Math.cos(2 * Math.PI * rand()); }\n\n// Compound parameters — Imprint palette positions 0 and 1\nconst COMPOUNDS = [\n  { name: \"Compound A\", color: t.palette[0], bot: 3,  top: 96, ec50: 5e-8, hill: 1.5 },\n  { name: \"Compound B\", color: t.palette[1], bot: 5,  top: 85, ec50: 7e-7, hill: 1.1 },\n];\n\n// Concentration grids\nconst DATA_C  = logSpace(1e-9, 1e-4, 10);\nconst CURVE_C = logSpace(1e-9, 1e-4, 200);\n\n// 95% CI half-width: peaks near the inflection point of the sigmoid\nfunction ciHW(c, cpd) {\n  const r   = fourPL(c, cpd.bot, cpd.top, cpd.ec50, cpd.hill);\n  const mid  = (cpd.bot + cpd.top) / 2;\n  const half = (cpd.top - cpd.bot) / 2;\n  return 3 + (1 - Math.abs(r - mid) / half) * 5.5;\n}\n\n// Generate observed data: true response + Gaussian noise + SEM\nconst CPD_DATA = COMPOUNDS.map(cpd => ({\n  ...cpd,\n  pts: DATA_C.map(c => {\n    const tr = fourPL(c, cpd.bot, cpd.top, cpd.ec50, cpd.hill);\n    return { c, r: tr + randN() * 2.8, sem: 1.5 + rand() * 2.5 };\n  }),\n}));\n\n// EC50 formatter: prefer nM below 1000, μM above\nfunction fmtEC50(ec50) {\n  const nM = ec50 * 1e9;\n  return nM < 1000\n    ? `EC₅₀ = ${Math.round(nM)} nM`\n    : `EC₅₀ = ${+(nM / 1000).toFixed(2)} μM`;\n}\n\n// Unicode superscript digits for log-axis tick labels\nfunction toSup(n) {\n  const MAP = { \"0\":\"⁰\",\"1\":\"¹\",\"2\":\"²\",\"3\":\"³\",\"4\":\"⁴\",\"5\":\"⁵\",\"6\":\"⁶\",\"7\":\"⁷\",\"8\":\"⁸\",\"9\":\"⁹\",\"-\":\"⁻\" };\n  return String(n).split(\"\").map(c => MAP[c] || c).join(\"\");\n}\n\n// ---- Datasets: fitted curve (in legend) + observed scatter (hidden) ----\nconst datasets = CPD_DATA.flatMap((cpd, ci) => [\n  {\n    label: cpd.name,\n    data: CURVE_C.map(c => ({ x: c, y: fourPL(c, cpd.bot, cpd.top, cpd.ec50, cpd.hill) })),\n    showLine: true,\n    pointRadius: 0,\n    borderColor: cpd.color,\n    backgroundColor: \"transparent\",\n    borderWidth: 3.5,\n    tension: 0.3,\n  },\n  {\n    label: `_pts${ci}`,\n    data: cpd.pts.map(p => ({ x: p.c, y: p.r })),\n    showLine: false,\n    pointRadius: 7,\n    pointHoverRadius: 9,\n    borderColor: t.pageBg,\n    backgroundColor: cpd.color,\n    borderWidth: 1.5,\n  },\n]);\n\n// ---- CI Band Plugin: drawn before datasets so bands sit behind the curves ----\nconst ciBandPlugin = {\n  id: \"ciBands\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, chartArea: A, scales: { x: xS, y: yS } } = chart;\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(A.left, A.top, A.right - A.left, A.bottom - A.top);\n    ctx.clip();\n\n    CPD_DATA.forEach(cpd => {\n      ctx.fillStyle = cpd.color + \"22\"; // ~13% alpha fill\n      ctx.beginPath();\n      CURVE_C.forEach((c, i) => {\n        const px = xS.getPixelForValue(c);\n        const py = yS.getPixelForValue(fourPL(c, cpd.bot, cpd.top, cpd.ec50, cpd.hill) + ciHW(c, cpd));\n        i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);\n      });\n      [...CURVE_C].reverse().forEach(c => {\n        ctx.lineTo(\n          xS.getPixelForValue(c),\n          yS.getPixelForValue(fourPL(c, cpd.bot, cpd.top, cpd.ec50, cpd.hill) - ciHW(c, cpd))\n        );\n      });\n      ctx.closePath();\n      ctx.fill();\n    });\n\n    ctx.restore();\n  },\n};\n\n// ---- Overlay Plugin: asymptotes, EC50 lines, and SEM error bars ----\nconst overlayPlugin = {\n  id: \"overlay\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea: A, scales: { x: xS, y: yS } } = chart;\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(A.left, A.top, A.right - A.left, A.bottom - A.top);\n    ctx.clip();\n\n    // Top and bottom asymptote dashed lines\n    CPD_DATA.forEach(cpd => {\n      [cpd.bot, cpd.top].forEach(level => {\n        ctx.save();\n        ctx.strokeStyle = cpd.color;\n        ctx.lineWidth = 1.5;\n        ctx.globalAlpha = 0.4;\n        ctx.setLineDash([8, 6]);\n        const yPx = yS.getPixelForValue(level);\n        ctx.beginPath();\n        ctx.moveTo(A.left, yPx);\n        ctx.lineTo(A.right, yPx);\n        ctx.stroke();\n        ctx.restore();\n      });\n    });\n\n    // EC50 dashed reference lines (vertical + horizontal) and text label\n    CPD_DATA.forEach(cpd => {\n      const mid    = (cpd.bot + cpd.top) / 2;\n      const xEC    = xS.getPixelForValue(cpd.ec50);\n      const yMid   = yS.getPixelForValue(mid);\n      const yBotPx = yS.getPixelForValue(cpd.bot);\n\n      ctx.save();\n      ctx.strokeStyle = cpd.color;\n      ctx.lineWidth = 1.5;\n      ctx.globalAlpha = 0.8;\n      ctx.setLineDash([5, 4]);\n\n      // Vertical: bottom asymptote → midpoint\n      ctx.beginPath();\n      ctx.moveTo(xEC, yBotPx);\n      ctx.lineTo(xEC, yMid);\n      ctx.stroke();\n\n      // Horizontal: left axis → EC50\n      ctx.beginPath();\n      ctx.moveTo(A.left, yMid);\n      ctx.lineTo(xEC, yMid);\n      ctx.stroke();\n      ctx.restore();\n\n      // EC50 text label just below the vertical reference line\n      ctx.save();\n      ctx.fillStyle = cpd.color;\n      ctx.globalAlpha = 0.9;\n      ctx.font = \"bold 14px sans-serif\";\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"top\";\n      ctx.fillText(fmtEC50(cpd.ec50), xEC, yBotPx + 5);\n      ctx.restore();\n    });\n\n    // SEM error bars on observed data points\n    CPD_DATA.forEach((cpd, ci) => {\n      const meta = chart.getDatasetMeta(ci * 2 + 1);\n      if (!meta || meta.hidden) return;\n\n      ctx.save();\n      ctx.strokeStyle = cpd.color;\n      ctx.lineWidth = 2;\n      ctx.setLineDash([]);\n\n      meta.data.forEach((el, i) => {\n        const xPx = el.x;\n        const { r, sem } = cpd.pts[i];\n        const yT  = yS.getPixelForValue(r + sem);\n        const yB  = yS.getPixelForValue(r - sem);\n        const cap = 5;\n\n        ctx.beginPath(); ctx.moveTo(xPx, yT); ctx.lineTo(xPx, yB); ctx.stroke();\n        ctx.beginPath(); ctx.moveTo(xPx - cap, yT); ctx.lineTo(xPx + cap, yT); ctx.stroke();\n        ctx.beginPath(); ctx.moveTo(xPx - cap, yB); ctx.lineTo(xPx + cap, yB); ctx.stroke();\n      });\n\n      ctx.restore();\n    });\n\n    ctx.restore();\n  },\n};\n\n// ---- Mount ----\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// ---- Chart ----\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"curve-dose-response · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { top: 10, bottom: 16 },\n      },\n      legend: {\n        labels: {\n          usePointStyle: true,\n          generateLabels: () => CPD_DATA.map((cpd, i) => ({\n            text: cpd.name,\n            strokeStyle: cpd.color,\n            fillStyle: cpd.color,\n            lineWidth: 3.5,\n            fontColor: t.ink,\n            pointStyle: \"line\",\n            hidden: false,\n            datasetIndex: i * 2,\n          })),\n          font: { size: 16 },\n          color: t.ink,\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"logarithmic\",\n        min: 1e-9,\n        max: 1e-3,\n        title: {\n          display: true,\n          text: \"Concentration (M)\",\n          color: t.ink,\n          font: { size: 16 },\n          padding: { top: 8 },\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (v) => {\n            const exp = Math.round(Math.log10(v));\n            return Math.abs(Math.log10(v) - exp) < 0.01 ? `10${toSup(exp)}` : null;\n          },\n        },\n        grid: { color: t.grid },\n      },\n      y: {\n        min: -5,\n        max: 108,\n        title: {\n          display: true,\n          text: \"Response (%)\",\n          color: t.ink,\n          font: { size: 16 },\n          padding: { bottom: 8 },\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (v) => `${v}%`,\n        },\n        grid: { color: t.grid },\n      },\n    },\n  },\n  plugins: [ciBandPlugin, overlayPlugin],\n});\n"}