Pharmacological Dose-Response Curve — MUI X Charts

A sigmoidal dose-response curve that plots biological response against drug concentration on a logarithmic x-axis, fitted using a four-parameter logistic (4PL) model. This visualization is essential for determining drug potency metrics such as EC50 (half-maximal effective concentration) or IC50 (half-maximal inhibitory concentration), Hill slope steepness, and upper/lower response asymptotes. It enables rapid visual comparison of compound efficacy and is a standard tool in pharmacological analysis.

Pharmacological Dose-Response Curve rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// curve-dose-response: Pharmacological Dose-Response Curve
// Library: muix 7.29.1 | JavaScript 22.22.3
// Quality: 89/100 | Created: 2026-06-24
//# anyplot-orientation: landscape
// anyplot.ai
// curve-dose-response: Pharmacological Dose-Response Curve
// Library: MUI X Charts | React | Node 22
// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.
// Quality: pending | Created: 2026-06-24

import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { LinePlot } from "@mui/x-charts/LineChart";
import { ScatterPlot } from "@mui/x-charts/ScatterChart";
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import { ChartsLegend } from "@mui/x-charts/ChartsLegend";
import { ChartsReferenceLine } from "@mui/x-charts/ChartsReferenceLine";
import { useXScale, useYScale } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;

// Four-parameter logistic model (4PL)
function fourPL(c, bottom, top, ec50, hill) {
  return bottom + (top - bottom) / (1 + Math.pow(ec50 / c, hill));
}

// Reproducible LCG (seed 42) — no Math.random() in browser harness context
let seed = 42;
function rng() {
  seed = (1664525 * seed + 1013904223) >>> 0;
  return seed / 4294967296;
}

// Unicode superscript formatter for log10 tick labels (e.g. 10⁻⁹)
const SUPERSCRIPT = { '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '-': '⁻' };
function toSuperscript(n) {
  return String(n).split('').map(c => SUPERSCRIPT[c] || c).join('');
}

// Compound pharmacological parameters
const compA = { label: "Compound A (EC₅₀ = 100 nM)", bottom: 2, top: 98, ec50: 1e-7, hill: 1.8 };
const compB = { label: "Compound B (EC₅₀ = 800 nM)", bottom: 5, top: 85, ec50: 8e-7, hill: 1.1 };

// Ten measured concentrations per compound spanning 1 nM – 100 µM
const measConcs = [1e-9, 3.16e-9, 1e-8, 3.16e-8, 1e-7, 3.16e-7, 1e-6, 3.16e-6, 1e-5, 1e-4];

function makeMeasured(comp, idPrefix) {
  return measConcs.map((c, i) => ({
    x: c,
    y: Math.max(0, Math.min(100, fourPL(c, comp.bottom, comp.top, comp.ec50, comp.hill) + (rng() - 0.5) * 9)),
    sem: 2.5 + rng() * 4,
    id: `${idPrefix}-${i}`,
  }));
}

const measA = makeMeasured(compA, "A");
const measB = makeMeasured(compB, "B");

// Dense log-spaced x-axis for smooth fitted curves (80 points)
const N = 80;
const logMin = Math.log10(3e-10);
const logMax = Math.log10(3e-3);
const curveXs = Array.from({ length: N }, (_, i) =>
  Math.pow(10, logMin + (i / (N - 1)) * (logMax - logMin))
);

const curveAY = curveXs.map(x => fourPL(x, compA.bottom, compA.top, compA.ec50, compA.hill));
const curveBY = curveXs.map(x => fourPL(x, compB.bottom, compB.top, compB.ec50, compB.hill));

// 95% CI band for Compound A (±5 pp approximation)
const ciUpper = curveAY.map(y => Math.min(100, y + 5));
const ciLower = curveAY.map(y => Math.max(0, y - 5));

// Y-midpoint for the 50% response reference line
const midA = (compA.bottom + compA.top) / 2;

// Custom 95% CI band polygon rendered via MUI X axis scale hooks
function CIBand() {
  const xScale = useXScale();
  const yScale = useYScale();
  if (!xScale || !yScale) return null;

  const upper = curveXs.map((x, i) => [xScale(x), yScale(ciUpper[i])]);
  const lower = curveXs.map((x, i) => [xScale(x), yScale(ciLower[i])]);
  const pts = [...upper, ...lower.slice().reverse()];
  const d =
    pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ") + " Z";

  return <path d={d} fill={t.palette[0]} fillOpacity={0.20} stroke="none" />;
}

// SEM error bars rendered directly as SVG lines
function ErrorBars({ data, color }) {
  const xScale = useXScale();
  const yScale = useYScale();
  if (!xScale || !yScale) return null;

  const cap = 8;
  return (
    <g>
      {data.map(d => {
        const cx = xScale(d.x);
        const ytop = yScale(d.y + d.sem);
        const ybot = yScale(d.y - d.sem);
        if (isNaN(cx) || isNaN(ytop) || isNaN(ybot)) return null;
        return (
          <g key={d.id}>
            <line x1={cx} y1={ytop} x2={cx} y2={ybot} stroke={color} strokeWidth={2.5} />
            <line x1={cx - cap} y1={ytop} x2={cx + cap} y2={ytop} stroke={color} strokeWidth={2.5} />
            <line x1={cx - cap} y1={ybot} x2={cx + cap} y2={ybot} stroke={color} strokeWidth={2.5} />
          </g>
        );
      })}
    </g>
  );
}

const TITLE = "curve-dose-response · javascript · muix · anyplot.ai";

export default function Chart() {
  return (
    <ChartContainer
      width={width}
      height={height}
      margin={{ top: 62, right: 56, bottom: 88, left: 96 }}
      sx={{ '& .MuiChartsAxis-top, & .MuiChartsAxis-right': { display: 'none' } }}
      series={[
        {
          type: "line",
          id: "curveA",
          data: curveAY,
          label: compA.label,
          color: t.palette[0],
          showMark: false,
          curve: "monotoneX",
          xAxisId: "concentration",
        },
        {
          type: "line",
          id: "curveB",
          data: curveBY,
          label: compB.label,
          color: t.palette[1],
          showMark: false,
          curve: "monotoneX",
          xAxisId: "concentration",
        },
        {
          type: "scatter",
          id: "dataA",
          data: measA,
          color: t.palette[0],
          markerSize: 9,
          xAxisId: "concentration",
        },
        {
          type: "scatter",
          id: "dataB",
          data: measB,
          color: t.palette[1],
          markerSize: 9,
          xAxisId: "concentration",
        },
      ]}
      xAxis={[{
        id: "concentration",
        scaleType: "log",
        data: curveXs,
        min: 3e-10,
        max: 3e-3,
        label: "Concentration (M)",
        tickInterval: [1e-9, 1e-8, 1e-7, 1e-6, 1e-5, 1e-4, 1e-3],
        valueFormatter: v => `10${toSuperscript(Math.round(Math.log10(v)))}`,
        tickLabelStyle: { fontSize: 14, fill: t.inkSoft },
        labelStyle: { fontSize: 16, fill: t.ink },
      }]}
      yAxis={[{
        id: "response",
        min: -5,
        max: 108,
        label: "Response (%)",
        tickLabelStyle: { fontSize: 14, fill: t.inkSoft },
        labelStyle: { fontSize: 16, fill: t.ink },
      }]}
    >
      <ChartsGrid horizontal />
      <CIBand />
      <LinePlot skipAnimation />
      <ScatterPlot skipAnimation />
      <ErrorBars data={measA} color={t.palette[0]} />
      <ErrorBars data={measB} color={t.palette[1]} />
      <ChartsXAxis
        axisId="concentration"
        tickLabelStyle={{ fontSize: 14, fill: t.inkSoft }}
        labelStyle={{ fontSize: 16, fill: t.ink }}
      />
      <ChartsYAxis
        axisId="response"
        tickLabelStyle={{ fontSize: 14, fill: t.inkSoft }}
        labelStyle={{ fontSize: 16, fill: t.ink }}
      />
      <ChartsReferenceLine
        x={compA.ec50}
        axisId="concentration"
        label="EC₅₀-A"
        labelAlign="start"
        labelStyle={{ fill: t.palette[0], fontSize: 13, fontWeight: "bold" }}
        lineStyle={{ stroke: t.palette[0], strokeDasharray: "8,5", strokeWidth: 1.5 }}
      />
      <ChartsReferenceLine
        x={compB.ec50}
        axisId="concentration"
        label="EC₅₀-B"
        labelAlign="start"
        labelStyle={{ fill: t.palette[1], fontSize: 13, fontWeight: "bold" }}
        lineStyle={{ stroke: t.palette[1], strokeDasharray: "8,5", strokeWidth: 1.5 }}
      />
      <ChartsReferenceLine
        y={midA}
        axisId="response"
        label="50%"
        labelAlign="end"
        labelStyle={{ fill: t.inkSoft, fontSize: 12 }}
        lineStyle={{ stroke: t.inkSoft, strokeDasharray: "4,4", strokeWidth: 1, opacity: 0.55 }}
      />
      {/* Top asymptotes — Compound A (98%) and Compound B (85%) */}
      <ChartsReferenceLine
        y={compA.top}
        axisId="response"
        label={`${compA.top}%`}
        labelAlign="start"
        labelStyle={{ fill: t.palette[0], fontSize: 11 }}
        lineStyle={{ stroke: t.palette[0], strokeDasharray: "3,5", strokeWidth: 1, opacity: 0.45 }}
      />
      <ChartsReferenceLine
        y={compB.top}
        axisId="response"
        label={`${compB.top}%`}
        labelAlign="start"
        labelStyle={{ fill: t.palette[1], fontSize: 11 }}
        lineStyle={{ stroke: t.palette[1], strokeDasharray: "3,5", strokeWidth: 1, opacity: 0.45 }}
      />
      {/* Bottom asymptotes — Compound A (2%) and Compound B (5%) */}
      <ChartsReferenceLine
        y={compA.bottom}
        axisId="response"
        label={`${compA.bottom}%`}
        labelAlign="start"
        labelStyle={{ fill: t.palette[0], fontSize: 11 }}
        lineStyle={{ stroke: t.palette[0], strokeDasharray: "3,5", strokeWidth: 1, opacity: 0.45 }}
      />
      <ChartsReferenceLine
        y={compB.bottom}
        axisId="response"
        label={`${compB.bottom}%`}
        labelAlign="start"
        labelStyle={{ fill: t.palette[1], fontSize: 11 }}
        lineStyle={{ stroke: t.palette[1], strokeDasharray: "3,5", strokeWidth: 1, opacity: 0.45 }}
      />
      <ChartsLegend
        position={{ vertical: "top", horizontal: "right" }}
        slotProps={{
          legend: {
            itemMarkWidth: 22,
            itemMarkHeight: 4,
            markGap: 8,
            itemGap: 30,
            labelStyle: { fontSize: 14, fill: t.ink },
          },
        }}
      />
      <text
        x={width / 2}
        y={30}
        textAnchor="middle"
        fontSize={22}
        fontWeight={600}
        fill={t.ink}
      >
        {TITLE}
      </text>
    </ChartContainer>
  );
}

Part of Pharmacological Dose-Response Curve on anyplot.ai.

Other implementations