Pharmacological Dose-Response Curve — Highcharts

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 Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// curve-dose-response: Pharmacological Dose-Response Curve
// Library: highcharts 12.6.0 | JavaScript 22.22.3
// Quality: 87/100 | Created: 2026-06-24
//# anyplot-orientation: landscape

const t = window.ANYPLOT_TOKENS;

// --- Data --------------------------------------------------------------------

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

// Generate smooth fitted curve across 200 log-spaced points (1e-9 to 1e-4 M)
function smoothCurve(p) {
    const pts = [];
    for (let i = 0; i <= 200; i++) {
        const logC = -9 + 5 * i / 200;
        const c = Math.pow(10, logC);
        pts.push([c, fourPL(c, p.bottom, p.top, p.ec50, p.hill)]);
    }
    return pts;
}

// Deterministic LCG pseudo-random generator (reproducible, no seed API in browser)
function makeLCG(seed) {
    let s = seed >>> 0;
    return function () {
        s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
        return s / 4294967296;
    };
}
function boxMuller(rng) {
    const u1 = rng() + 1e-10, u2 = rng();
    return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Compound parameters — pharmacology drug-potency scenario
const compA = { name: 'Compound A', bottom: 2, top: 98, ec50: 1e-7, hill: 1.5 };
const compB = { name: 'Compound B', bottom: 5, top: 85, ec50: 5e-7, hill: 0.8 };

// Measurement concentrations: 10 log-spaced points from 1 nM to 100 µM
const measConc = [1e-9, 3e-9, 1e-8, 3e-8, 1e-7, 3e-7, 1e-6, 3e-6, 1e-5, 1e-4];

function genMeasured(p, seedVal) {
    const rng = makeLCG(seedVal);
    return measConc.map(c => {
        const ideal = fourPL(c, p.bottom, p.top, p.ec50, p.hill);
        const sem = 2.5 + Math.abs(boxMuller(rng)) * 1.5;
        const noise = boxMuller(rng) * sem;
        return { x: c, y: Math.max(0, Math.min(100, ideal + noise)), sem };
    });
}

const dataA = genMeasured(compA, 42);
const dataB = genMeasured(compB, 137);

const curveA = smoothCurve(compA);
const curveB = smoothCurve(compB);

// 95% CI band for Compound A: ±8 response units around fitted curve
const CI_W = 8;
const ciUpperA = curveA.map(([x, y]) => [x, Math.min(100, y + CI_W)]);

// Half-maximal response thresholds for EC50 reference lines
const halfA = (compA.bottom + compA.top) / 2;  // 50 %
const halfB = (compB.bottom + compB.top) / 2;  // 45 %

// RGBA for CI band: #009E73 = rgb(0,158,115) at 15% opacity
const ciColorA = 'rgba(0,158,115,0.15)';

// --- Chart -------------------------------------------------------------------

Highcharts.chart('container', {
    chart: {
        backgroundColor: 'transparent',
        animation: false,
        style: { fontFamily: 'inherit' },
        marginTop: 90,
        marginRight: 50,
        marginBottom: 90,
        marginLeft: 80,
        events: {
            // Draw error bars via SVG renderer after each render
            render: function () {
                const c = this;
                if (c._errorBars) c._errorBars.forEach(el => el.destroy());
                c._errorBars = [];

                // Series indices 3 (Compound A data) and 4 (Compound B data)
                [[3, t.palette[0]], [4, t.palette[1]]].forEach(([si, color]) => {
                    const s = c.series[si];
                    if (!s || !s.points) return;
                    s.points.forEach(pt => {
                        if (pt.plotX === undefined || pt.plotY === undefined) return;
                        const sem = (pt.options && pt.options.sem) || 3;
                        const px = pt.plotX + c.plotLeft;
                        const topY = c.yAxis[0].toPixels(pt.y + sem, false);
                        const botY = c.yAxis[0].toPixels(pt.y - sem, false);
                        const cap = 5;
                        const path = c.renderer.path([
                            'M', px, topY, 'L', px, botY,
                            'M', px - cap, topY, 'L', px + cap, topY,
                            'M', px - cap, botY, 'L', px + cap, botY
                        ]).attr({
                            stroke: color,
                            'stroke-width': 2,
                            zIndex: 10,
                            'stroke-linecap': 'round'
                        }).add();
                        c._errorBars.push(path);
                    });
                });
            }
        }
    },
    credits: { enabled: false },
    colors: t.palette,

    title: {
        text: 'curve-dose-response · javascript · highcharts · anyplot.ai',
        style: { color: t.ink, fontSize: '22px', fontWeight: '600' }
    },
    subtitle: {
        text: 'Shaded band: 95% CI for Compound A  |  Dashed lines: EC₅₀ and asymptote references',
        style: { color: t.inkSoft, fontSize: '13px' }
    },

    xAxis: {
        type: 'logarithmic',
        title: {
            text: 'Concentration (M)',
            style: { color: t.inkSoft, fontSize: '16px' }
        },
        lineColor: t.inkSoft,
        tickColor: t.inkSoft,
        gridLineColor: t.grid,
        gridLineWidth: 0,
        labels: {
            style: { color: t.inkSoft, fontSize: '14px' },
            formatter: function () {
                const exp = Math.round(Math.log10(this.value));
                return '10<sup>' + exp + '</sup>';
            },
            useHTML: true
        },
        plotLines: [
            {
                value: compA.ec50,
                color: t.palette[0],
                dashStyle: 'ShortDash',
                width: 1.5,
                zIndex: 5,
                label: {
                    text: 'EC₅₀ A',
                    style: { color: t.palette[0], fontSize: '12px' },
                    align: 'right',
                    y: -6
                }
            },
            {
                value: compB.ec50,
                color: t.palette[1],
                dashStyle: 'ShortDash',
                width: 1.5,
                zIndex: 5,
                label: {
                    text: 'EC₅₀ B',
                    style: { color: t.palette[1], fontSize: '12px' },
                    align: 'right',
                    y: -6
                }
            }
        ]
    },

    yAxis: {
        title: {
            text: 'Response (%)',
            style: { color: t.inkSoft, fontSize: '16px' }
        },
        lineColor: t.inkSoft,
        tickColor: t.inkSoft,
        gridLineColor: t.grid,
        gridLineWidth: 1,
        labels: {
            style: { color: t.inkSoft, fontSize: '14px' },
            format: '{value}%'
        },
        min: -5,
        max: 110,
        plotLines: [
            // EC50 half-max horizontal reference lines
            { value: halfA, color: t.palette[0], dashStyle: 'ShortDash', width: 1.5, zIndex: 5 },
            { value: halfB, color: t.palette[1], dashStyle: 'ShortDash', width: 1.5, zIndex: 5 },
            // Top asymptotes
            { value: compA.top, color: t.palette[0], dashStyle: 'LongDash', width: 1, zIndex: 4 },
            { value: compB.top, color: t.palette[1], dashStyle: 'LongDash', width: 1, zIndex: 4 },
            // Bottom asymptote (shared)
            { value: 3, color: t.inkSoft, dashStyle: 'LongDash', width: 1.5, zIndex: 4 }
        ]
    },

    legend: {
        enabled: true,
        align: 'right',
        verticalAlign: 'middle',
        layout: 'vertical',
        itemStyle: { color: t.inkSoft, fontSize: '14px' },
        itemHoverStyle: { color: t.ink },
        backgroundColor: t.elevatedBg,
        borderColor: t.grid,
        borderWidth: 1,
        borderRadius: 4,
        padding: 12,
        symbolWidth: 24
    },

    plotOptions: {
        series: { animation: false },
        spline: {
            lineWidth: 3,
            marker: { enabled: false }
        },
        scatter: {
            marker: { radius: 6 }
        }
    },

    series: [
        // Compound A fitted curve (show first so it appears first in legend)
        {
            type: 'spline',
            id: 'curveA',
            name: 'Compound A (fit)',
            data: curveA,
            color: t.palette[0],
            lineWidth: 3,
            zIndex: 5,
            showInLegend: true
        },
        // Compound A 95% CI band — upper envelope fill
        {
            type: 'area',
            name: 'Compound A 95% CI',
            data: ciUpperA,
            color: t.palette[0],
            fillColor: ciColorA,
            lineWidth: 0,
            threshold: null,
            showInLegend: true,
            enableMouseTracking: false,
            marker: { enabled: false },
            zIndex: 1
        },
        // Compound B fitted curve
        {
            type: 'spline',
            id: 'curveB',
            name: 'Compound B (fit)',
            data: curveB,
            color: t.palette[1],
            lineWidth: 3,
            zIndex: 5,
            showInLegend: true
        },
        // Compound A measured data points (linked to curveA for legend toggling)
        {
            type: 'scatter',
            name: 'Compound A (data)',
            data: dataA.map(d => ({ x: d.x, y: d.y, sem: d.sem })),
            color: t.palette[0],
            marker: {
                radius: 6,
                symbol: 'circle',
                lineWidth: 1.5,
                lineColor: t.pageBg
            },
            zIndex: 8,
            linkedTo: 'curveA',
            showInLegend: false,
            enableMouseTracking: false
        },
        // Compound B measured data points (linked to curveB for legend toggling)
        {
            type: 'scatter',
            name: 'Compound B (data)',
            data: dataB.map(d => ({ x: d.x, y: d.y, sem: d.sem })),
            color: t.palette[1],
            marker: {
                radius: 6,
                symbol: 'square',
                lineWidth: 1.5,
                lineColor: t.pageBg
            },
            zIndex: 8,
            linkedTo: 'curveB',
            showInLegend: false,
            enableMouseTracking: false
        }
    ]
});

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

Other implementations