Exploring within-person biomarker variability interactively

Author
Published

August 13, 2025

Doi

This interactive tool shows how biomarker concentrations change in the body over time. Based on figures 9.1 and 9.2 in Molecular Epidemiology: Principles and Practices, the tool lets you adjust key parameters like biological half-life and exposure patterns. These adjustments show how biomarkers accumulate and are eliminated from the body. Can you reach a steady state where the diffusion rate into the body equals the diffusion rate out?

Code
d3 = require("d3")

{
  // --- Main Layout Creation ---
  const container = d3.create("div")
    .style("font-family", "sans-serif")
    .style("width", "100%")
    .style("background-color", "#f9f9f9")
    .style("border", "1px solid #dee2e6")
    .style("border-radius", "15px")
    .style("padding", "20px");
    
  // --- CSS Styles for Tooltip ---
  const style = container.append("style");
  style.html(`
    .info-tooltip-container {
      position: relative;
      display: inline-block;
      margin-left: 8px;
    }
    .info-icon {
      font-size: 12px;
      color: #007bff;
      cursor: pointer;
      border: 1px solid #007bff;
      border-radius: 50%;
      width: 16px;
      height: 16px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      font-style: normal;
      font-weight: bold;
    }
    .tooltip-text {
      visibility: hidden;
      width: 260px;
      background-color: #343a40;
      color: #fff;
      text-align: left;
      border-radius: 6px;
      padding: 10px;
      position: absolute;
      z-index: 10;
      top: 125%; 
      left: 50%;
      margin-left: -130px; 
      opacity: 0;
      transition: opacity 0.3s;
      font-size: 12px;
      line-height: 1.5;
    }
    .tooltip-text::after {
      content: "";
      position: absolute;
      bottom: 100%; 
      left: 50%;
      margin-left: -5px;
      border-width: 5px;
      border-style: solid;
      border-color: transparent transparent #343a40 transparent; 
    }
    .info-tooltip-container:hover .tooltip-text {
      visibility: visible;
      opacity: 1;
    }
  `);

  const controlsAndPlot = container.append("div")
    .style("display", "flex")
    .style("flex-wrap", "wrap")
    .style("gap", "20px");

  const controlsSection = controlsAndPlot.append("div")
    .style("min-width", "250px")
    .style("flex", "1");

  const plotSection = controlsAndPlot.append("div")
    .style("flex", "3")
    .style("min-width", "450px")
    .style("display", "flex")
    .style("flex-direction", "column");

  const concentrationPlotContainer = plotSection.append("div");
  const exposurePlotContainer = plotSection.append("div");

  // --- Helper Functions for Controls ---
  function formatTime(hours) {
    if (hours < 48) return `${Math.round(hours)} hours`;
    if (hours < 24 * 30) return `${(hours / 24).toFixed(1)} days`;
    if (hours < 24 * 365) return `${(hours / (24 * 30)).toFixed(1)} months`;
    return `${(hours / (24 * 365)).toFixed(1)} years`;
  }

  function createSlider(config) {
    const sliderContainer = d3.create("div").style("margin-bottom", "15px");
    
    const labelRow = sliderContainer.append("div").style("display", "flex").style("justify-content", "space-between").style("align-items", "center");
    const labelContainer = labelRow.append("div").style("display", "flex").style("align-items", "center");
    labelContainer.append("label").style("font-weight", "600").style("font-size", "14px").style("color", "#495057").text(config.label);

    if (config.tooltipHTML) {
        const tooltip = labelContainer.append("div").attr("class", "info-tooltip-container");
        tooltip.append("i").attr("class", "info-icon").text("i");
        tooltip.append("span").attr("class", "tooltip-text").html(config.tooltipHTML);
    }
    
    const valueDisplay = labelRow.append("div").style("font-size", "12px").style("color", "#6c757d").style("font-weight", "500");
    
    const slider = sliderContainer.append("input").attr("type", "range").attr("min", 0).attr("max", 1000).attr("step", 1).style("width", "100%");
    
    const rangeDisplay = sliderContainer.append("div").style("display", "flex").style("justify-content", "space-between").style("font-size", "11px").style("color", "#6c757d");
    
    let scale;
    if (config.scale === 'log') {
        scale = d3.scaleLog().domain([config.min, config.max]).range([0, 1000]);
        slider.property("value", scale(config.value));
        rangeDisplay.append("span").text(formatTime(config.min));
        rangeDisplay.append("span").text(formatTime(config.max));
    } else {
        scale = d3.scaleLinear().domain([config.min, config.max]).range([0, 1000]);
        slider.property("value", scale(config.value));
        rangeDisplay.append("span").text(config.min);
        rangeDisplay.append("span").text(config.max);
    }
    
    return { ...config, container: sliderContainer, slider, valueDisplay, scale };
  }
  
  function createDropdown(config) {
      const dropdownContainer = d3.create("div").style("margin-bottom", "15px");
      dropdownContainer.append("label").style("display", "block").style("font-weight", "600").style("font-size", "14px").style("color", "#495057").text(config.label);
      const select = dropdownContainer.append("select").style("width", "100%").style("padding", "5px").style("font-size", "12px");
      config.options.forEach(opt => { select.append("option").attr("value", opt.id).text(opt.label); });
      return { container: dropdownContainer, select };
  }

  // --- Create Controls ---
const halfLifeTooltip = `<b>Examples:</b> <br>Toluene: ≈4.5hrs (blood)<br>PCB 153: ≈100,000hrs (blood)<br>Vitamin C: ≈10hrs (blood)<br>Vitamin E: ≈100hrs (blood)<br>Bisphenol A: ≈9hrs (urine)<br>Inorganic mercury: ≈100hrs (blood) <br> Inorganic mercury: ≈1000hrs (urine)<br>`;

  const sliders = {
    halfLife: createSlider({ label: "Biological Half-Life", scale: 'log', min: 2, max: 10 * 365 * 24, value: 24, tooltipHTML: halfLifeTooltip }),
    duration: createSlider({ label: "Exposure Duration", scale: 'linear', min: 24, max: 168, step: 8, value: 120, unit: "hrs" }),
    dose: createSlider({ label: "Avg. Exposure Dose", scale: 'linear', min: 1, max: 20, step: 1, value: 10, unit: "/hr" }),
    variability: createSlider({ label: "Exposure Variability", scale: 'linear', min: 0, max: 1, step: 0.05, value: 0.5, unit: "" })
  };
  
  const patternDropdown = createDropdown({
      label: "Exposure Pattern",
      options: [
          { id: "constant", label: "Constant Environmental" },
          { id: "occupational", label: "Constant Occupational" },
          { id: "variable_env", label: "Variable Environmental" },
          { id: "variable_occ", label: "Variable Occupational" }
      ]
  });
  
  Object.values(sliders).forEach(s => controlsSection.append(() => s.container.node()));
  controlsSection.append(() => patternDropdown.container.node());

  // --- Core Simulation & Visualization Logic ---
  function generateBiomarkerData(params) {
      const totalHours = 168 * 2; 
      const k_elim = Math.log(2) / params.halfLife;
      let concentration = 0;
      const data = [];

      for (let t = 0; t <= totalHours; t++) {
          let currentDose = 0;
          const dayOfWeek = Math.floor(t / 24) % 7;
          const hourOfDay = t % 24;

          if (t < params.duration) {
            switch (params.pattern) {
                case "constant": currentDose = params.dose; break;
                case "occupational": if (dayOfWeek < 5 && hourOfDay >= 9 && hourOfDay < 17) { currentDose = params.dose; } break;
                case "variable_env":
                    let baseDose = params.dose * 0.4;
                    if ((hourOfDay >= 8 && hourOfDay < 10) || (hourOfDay >= 12 && hourOfDay < 14) || (hourOfDay >= 17 && hourOfDay < 19)) { baseDose += params.dose * 0.6; }
                    currentDose = baseDose * (1 + (Math.random() - 0.5) * params.variability); break;
                case "variable_occ":
                    if (dayOfWeek < 5 && hourOfDay >= 9 && hourOfDay < 17) { currentDose = params.dose * (1 + (Math.random() - 0.5) * params.variability); } break;
            }
          }
          currentDose = Math.max(0, currentDose);
          data.push({ hour: t, concentration: concentration, exposure: currentDose });
          const elimination = concentration * k_elim;
          concentration = concentration + currentDose - elimination;
      }
      return data;
  }

  function drawPlots(data) {
      concentrationPlotContainer.selectAll("*").remove();
      exposurePlotContainer.selectAll("*").remove();
      const totalWidth = 600, concHeight = 280, expHeight = 120;
      const margin = { top: 20, right: 30, bottom: 35, left: 60 };
      const x = d3.scaleLinear().domain([0, data.length]).range([margin.left, totalWidth - margin.right]);

      const yConc = d3.scaleLinear().domain([0, d3.max(data, d => d.concentration) * 1.1 || 10]).range([concHeight - margin.bottom, margin.top]);
      const svgConc = concentrationPlotContainer.append("svg").attr("viewBox", [0, 0, totalWidth, concHeight]);
      svgConc.append("rect").attr("width", totalWidth).attr("height", concHeight).attr("fill", "#f9f9f9");
      const yAxisConc = d3.axisLeft(yConc).ticks(5).tickSize(-totalWidth + margin.left + margin.right);
      svgConc.append("g").attr("transform", `translate(${margin.left},0)`).call(yAxisConc).call(g => g.selectAll(".tick line").attr("stroke", "#e9ecef")).call(g => g.select(".domain").remove());
      svgConc.append("text").attr("transform", "rotate(-90)").attr("x", -(concHeight / 2)).attr("y", 15).attr("text-anchor", "middle").style("font-size", "12px").text("Biomarker Concentration");
      const areaConc = d3.area().x(d => x(d.hour)).y0(yConc(0)).y1(d => yConc(d.concentration));
      svgConc.append("path").datum(data).attr("fill", "#78b7c5").attr("d", areaConc);
      const line = d3.line().x(d => x(d.hour)).y(d => yConc(d.concentration));
      svgConc.append("path").datum(data).attr("fill", "none").attr("stroke", "#3b9ab2").attr("stroke-width", 2.5).attr("d", line);

      const yExp = d3.scaleLinear().domain([0, d3.max(data, d => d.exposure) * 1.2 || 10]).range([expHeight - margin.bottom, 0]);
      const svgExp = exposurePlotContainer.append("svg").attr("viewBox", [0, 0, totalWidth, expHeight]).style("margin-top", "-1px");
      svgExp.append("rect").attr("width", totalWidth).attr("height", expHeight).attr("fill", "#f9f9f9");
      const xAxisExp = d3.axisBottom(x);
      svgExp.append("g").attr("transform", `translate(0,${expHeight - margin.bottom})`).call(xAxisExp).call(g => g.select(".domain").remove());
      
      svgExp.append("text").attr("x", totalWidth / 2).attr("y", expHeight - 5).attr("text-anchor", "middle").style("font-size", "12px").text("Time (hours)");

      const yAxisExp = d3.axisLeft(yExp).ticks(3).tickSize(-totalWidth + margin.left + margin.right);
      svgExp.append("g").attr("transform", `translate(${margin.left},0)`).call(yAxisExp).call(g => g.selectAll(".tick line").attr("stroke", "#e9ecef")).call(g => g.select(".domain").remove());
      svgExp.append("text").attr("transform", "rotate(-90)").attr("x", -(expHeight / 2)).attr("y", 15).attr("text-anchor", "middle").style("font-size", "12px").text("Exposure Rate");
      const areaExp = d3.area().x(d => x(d.hour)).y0(expHeight - margin.bottom).y1(d => yExp(d.exposure)).curve(d3.curveStepAfter);
      svgExp.append("path").datum(data).attr("fill", "#6c757d").attr("fill-opacity", 0.7).attr("d", areaExp);
  }

  // --- Main Update Function ---
  function updateVisualization() {
      const params = {};
      for (const key in sliders) {
          const s = sliders[key];
          const currentValue = s.scale.invert(+s.slider.property("value"));
          params[key] = currentValue;
          s.valueDisplay.text(s.scale === 'log' ? formatTime(currentValue) : `${Math.round(currentValue)} ${s.unit || ''}`);
      }
      params.pattern = patternDropdown.select.property("value");
      const biomarkerData = generateBiomarkerData(params);
      drawPlots(biomarkerData);
  }

  // --- Event Listeners ---
  Object.values(sliders).forEach(s => s.slider.on("input", updateVisualization));
  patternDropdown.select.on("change", updateVisualization);

  updateVisualization();

  return container.node();
}
Educational insights
  • Accumulation phase: biomarker builds up during exposure
  • Elimination phase: concentration decreases after exposure stops
  • Half-life determines both accumulation and elimination rates
  • Half-life can differ per matrix
  • Half-life determines what period of exposure can possibly be reflected in a measurement
  • Exposure variability creates fluctuations in biomarker levels
  • Sampling timing: half-life determines optimal collection windows. Biomarkers with a relatively short half-life are more sensitive to fluctuations of exposure
  • Summary measures: recent vs. usual vs. peak vs. cumulative exposure assessment will summarize levels in distinct ways

Model logic

Details

The tool uses a one-compartment pharmacokinetic model with first-order kinetics. In this model, the elimination rate stays proportional to the substance’s concentration, and concentration changes reflect the balance between intake (exposure) and elimination rates. We model this process using two equations:

\[ \begin{aligned} & k=\frac{\ln (2)}{t_{1 / 2}} \\ & C_{\text {new }}=C_{\text {old }}+\left(\operatorname{Exposure}(t)-k \times C_{\text {old }}\right) \times \Delta t \end{aligned} \] Here, \(k\) represents the elimination constant, \(t_{1/2}\) the half-life, \(C\) the concentration, and \(\Delta t\) the time step (1 hour).

Exposure senarios

The model includes four different exposure patterns, each based on (somewhat) realistic environmental or occupational conditions. The dose for any hour, \(D(t)\), follows these patterns:

  • Constant Environmental: steady exposure at the average dose level. The exposure is the same 24/7: \[ D(t)=D_{\text {avg }} \]

  • Constant Occupational: restricts exposure to weekdays during standard work hours (9 to 17 o’clock): \[D(t) = \begin{cases} D_{\text{avg}} & \text{if weekday and } 9 \leq h < 17 \\ 0 & \text{otherwise} \end{cases}\]

  • Variable Environmental: this represents fluctuating environmental exposures with daily peaks. There’s a baseline exposure that gets boosted during certain hours, plus random day-to-day variation: \[D(t) = \begin{cases} D_{\text{avg}} \cdot (1+\epsilon_V) & \text{during peak hours} \\ 0.4 \cdot D_{\text{avg}} \cdot (1+\epsilon_V) & \text{during off-peak hours} \end{cases}\]

  • Variable Occupational: combines occupational timing with random fluctuation: \[D(t) = \begin{cases} D_{\text{avg}} \cdot (1+\epsilon_V) & \text{if weekday and } 9 \leq h < 17 \\ 0 & \text{otherwise} \end{cases}\]

The variability component \(\epsilon_V\) introduces randomness through the formula \((U(0,1)-0.5) \times V\), where \(V\) represents the variability factor you set and \(U(0,1)\) generates a random number between 0 and 1 from a uniform distribution.

Where this model simplifies

Details

This single-compartment pharmacokinetic (PK) model is a useful starting point to get a sense of the basic relationship between exposure and internal concentration. However, it simplifies many biological realities of course. In reality, the body is not a single, well-mixed compartment, an exposure dose is not simply added, and the substance is not eliminated from a single compartment at a constant rate. I’m not a toxicologist myself - so the nuances of these processes are outside of my area of expertise - but I have tried to learn the (very) basics. Below are my thoughts on where the reality is more complicated than implied by this model.

First, the model assumes chemicals enter and exit the body instantly and completely and do so through a single route, but actual ADME processes (Absorption, Distribution, Metabolism, Excretion) involve multiple, complex routes and physical barriers following exposure. Namely, as a result of the gut wall, skin, and lungs absorption takes time, and the amount of a chemical that makes it into the bloodstream (its bioavailability) after exposure is rarely 100%. For example, inhaled chemicals bypass the liver and enter arterial blood directly, giving them immediate access to sensitive organs like the brain and heart. Ingested substances, on the other hand, first pass through the liver, where enzymes can partially destroy (= first-pass effect) them before they ever reach general circulation. Skin absorption follows yet another route by delivering chemicals into venous blood. Each route creates different kinetic patterns that our single-compartment model does not capture.

Second, once absorbed, chemicals do not distribute uniformly throughout the body as the model assumes. The circulatory system transports substances unevenly, and chemicals partition between blood and tissues based on blood flow, tissue composition, and the chemical’s own properties. For example, lipophilic (lipid-soluble) compounds like PCBs and DDT (but also some vitamins) concentrate heavily in fatty tissues, while water-soluble substances like carbohydrates and amino acids remain largely in blood and other body water. This partitioning creates multiple body reservoirs/compartments with vastly different uptake and release rates that our model cannot capture. Specifically, partitioning can create a two-phased elimination pattern with rapid initial clearance from blood and organs, followed by very slow release from fat stores. During periods of weight loss or metabolic stress, stored chemicals can remobilize and create concentration spikes in circulation, causing internal re-exposure long after external exposure has ceased. The single-compartment model does not show these dynamic phases caused by such interplay between storage and release.

Lastly, the model assumes elimination (which includes both metabolism and excretion) occurs at a fixed, predictable rate. However, this assumption can break down at high exposure levels when metabolic systems become saturated. Namely, biotransformation systems in the liver such as the Cytochrome P450 enzymes - which can either detoxify substances or convert them into more harmful metabolites - have a finite capacity that when exceeded, results in lower elimination rates. Such a state is better described by so called zero-order kinetics (Michaelis-Menten kinetics), where a constant amount is eliminated per unit time, regardless of how high the concentration gets. In the end, such a violation of our first-order kinetics assumption means that chemicals can persist longer in the body than predicted at very higher exposure/concentration levels by our model.

And in reality, all of these ‘parameters’ can differ from person to person.. that may sound hopeless but in some cases this variation between people can enable better causal inferences on the health effects of chemicals through mendelian randomization!

Future stuff

Details Originally, I wanted to create this little interactive visualization to illustrate variability in biomarkers and demonstrate how sampling design and summary measures matter in epidemiological studies. I use within-subject variation for this demonstration, but effective epidemiological studies also take the between-person variation into account, so I hope to incorporate the latter as well. I’m not sure how yet though.

Lastly, just because we can, an interactive version of figure 12.1 from Molecular Epidemiology: Principles and Practices to demonstrate four types of hormonal biomarker variation:

Code
{
  // --- Main Layout Creation ---
  const container = d3.create("div")
    .style("font-family", "Inter, sans-serif")
    .style("width", "100%")
    .style("background-color", "#f9f9f9")
    .style("border", "1px solid #dee2e6")
    .style("border-radius", "15px")
    .style("padding", "20px");

  const controlsAndPlot = container.append("div")
    .style("display", "flex")
    .style("flex-wrap", "wrap")
    .style("gap", "20px");

  const controlsSection = controlsAndPlot.append("div")
    .style("min-width", "250px")
    .style("flex", "1");

  const plotContainer = controlsAndPlot.append("div")
    .style("flex", "3")
    .style("min-width", "450px")
    .style("display", "flex")
    .style("align-items", "center")
    .style("justify-content", "center");

  // --- Helper Functions for Controls ---
  function createSlider(config) {
    const sliderContainer = d3.create("div").style("margin-bottom", "16px");
    const labelRow = sliderContainer.append("div").style("display", "flex").style("justify-content", "space-between").style("align-items", "center").style("margin-bottom", "4px");
    labelRow.append("label").style("font-weight", "600").style("font-size", "14px").style("color", "#495057").text(config.label);
    const valueDisplay = labelRow.append("span").style("font-size", "12px").style("color", "#6c757d");
    const slider = sliderContainer.append("input").attr("type", "range").attr("min", config.min).attr("max", config.max).attr("step", config.step || 1).attr("value", config.value).style("width", "100%");
    return { ...config, container: sliderContainer, slider, valueDisplay };
  }
  
  function createDropdown(config) {
      const dropdownContainer = d3.create("div").style("margin-bottom", "24px");
      dropdownContainer.append("label").style("display", "block").style("font-weight", "600").style("font-size", "14px").style("color", "#495057").style("margin-bottom", "8px").text(config.label);
      const select = dropdownContainer.append("select")
          .style("width", "100%").style("padding", "8px").style("font-size", "14px").style("border-radius", "6px")
          .style("border", "1px solid #ced4da").style("background-color", "#fff")
          .on("change", updateVisualization);
      config.options.forEach(opt => { select.append("option").attr("value", opt.id).text(opt.label); });
      return { container: dropdownContainer, select };
  }

  // --- Create All Controls ---
  const variationDropdown = createDropdown({
      label: "Variation Type",
      options: [
          { id: "circadian", label: "Circadian (Daily)" },
          { id: "menstrual", label: "Menstrual (Monthly)" },
          { id: "seasonal", label: "Seasonal (Annual)" },
          { id: "age", label: "Age-Related (Lifespan)" }
      ]
  });
  controlsSection.append(() => variationDropdown.container.node());
  
  // Define slider configurations
  const circadianSliders = {
      youngAmp: createSlider({ label: "Young Adult Amplitude", min: 1000, max: 3000, value: 2200 }),
      elderAmp: createSlider({ label: "Older Adult Amplitude", min: 500, max: 2000, value: 1200 })
  };
  const menstrualSliders = {
      youngerPeak: createSlider({ label: "20-34yr Peak (pg/mL)", min: 200, max: 600, value: 375 }),
      olderPeak: createSlider({ label: "35-46yr Peak (pg/mL)", min: 100, max: 400, value: 250 })
  };
  const seasonalSliders = {
      mean: createSlider({ label: "Annual Mean (nmol/L)", min: 20, max: 100, value: 55 }),
      amplitude: createSlider({ label: "Seasonal Swing (+/- nmol/L)", min: 5, max: 40, value: 20 })
  };
  const ageSliders = {
      femalePeak: createSlider({ label: "Female Peak Level", min: 400, max: 1200, value: 850 }),
      malePeak: createSlider({ label: "Male Peak Level", min: 500, max: 1500, value: 950 })
  };
  
  const paramConfigs = {
      circadian: { title: "Circadian Parameters (Melatonin)", sliders: circadianSliders },
      menstrual: { title: "Menstrual Parameters (Estradiol)", sliders: menstrualSliders },
      seasonal: { title: "Seasonal Parameters (Vitamin D)", sliders: seasonalSliders },
      age: { title: "Age-Related Parameters (IGF-1)", sliders: ageSliders }
  };
  
  const defaultType = variationDropdown.select.property("value");

  // Create containers for each set of parameters
  for (const type in paramConfigs) {
      const config = paramConfigs[type];
      // Set the display style correctly on creation
      const section = controlsSection.append("div")
        .attr("id", `params-${type}`)
        .style("display", type === defaultType ? "block" : "none"); 

      section.append("h3").text(config.title)
        .style("font-size", "15px").style("font-weight", "600").style("color", "#495057")
        .style("margin-top", "16px").style("margin-bottom", "12px").style("border-bottom", "1px solid #dee2e6").style("padding-bottom", "8px");
      
      Object.values(config.sliders).forEach(s => {
          section.append(() => s.container.node());
          s.slider.on("input", updateVisualization);
      });
  }

  // --- Data Generation Functions ---
  const generateCircadianData = (p) => {
      const data = d3.range(0, 97).map(h => ({
          x: h,
          young: p.youngAmp / 2 * (1 - Math.cos(2 * Math.PI * (h - 3) / 24)) + 50,
          elder: p.elderAmp / 2 * (1 - Math.cos(2 * Math.PI * (h - 3) / 24)) + 50
      }));
      return { data, datasets: [
          { key: 'young', label: 'Young Adult', color: '#3b9ab2' },
          { key: 'elder', label: 'Older Adult', color: '#78b7c5', dashed: true }
      ], xLabel: 'Time (hours)', yLabel: 'Melatonin Excretion (ng/h)'};
  };

  const generateMenstrualData = (p) => {
      const shape = [0.2, 0.22, 0.25, 0.3, 0.4, 0.5, 0.65, 0.8, 0.95, 1.0, 0.9, 0.7, 0.5, 0.4, 0.35, 0.3, 0.28, 0.26, 0.24, 0.22, 0.2];
      const baselineRatio = 0.25;
      const data = shape.map((s, i) => {
          const youngerBaseline = p.youngerPeak * baselineRatio;
          const olderBaseline = p.olderPeak * baselineRatio;
          return {
              x: i - 10,
              younger: youngerBaseline + (p.youngerPeak - youngerBaseline) * s,
              older: olderBaseline + (p.olderPeak - olderBaseline) * s,
          };
      });
      return { data, datasets: [
          { key: 'younger', label: '20-34 year olds', color: '#db2777' },
          { key: 'older', label: '35-46 year olds', color: '#f472b6', dashed: true }
      ], xLabel: 'Days Centered on Ovulation', yLabel: 'Estradiol (pg/mL)' };
  };

  const generateSeasonalData = (p) => {
      const data = d3.range(0, 366, 15).map(d => ({ x: d, value: p.mean + p.amplitude * Math.cos(2 * Math.PI * (d - 180) / 365) }));
      return { data, datasets: [{ key: 'value', label: '25-OH Vitamin D', color: '#16a34a' }], xLabel: 'Day of the year', yLabel: '25-OH Vitamin D (nmol/L)' };
  };

  const generateAgeData = (p) => {
      const femaleShape = [0.06, 0.12, 0.35, 0.65, 1.0, 0.90, 0.80, 0.65, 0.55, 0.45, 0.38, 0.32, 0.28];
      const maleShape   = [0.05, 0.1,  0.3,  0.6,  0.9,  1.0,  0.95, 0.85, 0.75, 0.65, 0.55, 0.5,  0.45];
      const labels      = [0,    3,    6,    9,    12,   15,   18,   27,   35,   45,   55,   65,   80];
      const data = labels.map((age, i) => ({
          x: age,
          female: femaleShape[i] * p.femalePeak,
          male: maleShape[i] * p.malePeak,
      }));
      return { data, datasets: [
          { key: 'male', label: 'Male', color: '#a16207' },
          { key: 'female', label: 'Female', color: '#ca8a04', dashed: true }
      ], xLabel: 'Age (years)', yLabel: 'IGF-1 (units)'};
  };

  // --- Visualization Function ---
  function drawPlot(plotData) {
      plotContainer.selectAll("*").remove();

      const { data, datasets, xLabel, yLabel } = plotData;
      const width = 600, height = 400;
      const margin = { top: 40, right: 30, bottom: 50, left: 60 };

      const svg = plotContainer.append("svg")
          .attr("viewBox", [0, 0, width, height])
          .style("max-width", "100%");
      
      svg.append("rect").attr("width", width).attr("height", height).attr("fill", "#f9f9f9");

      const x = d3.scaleLinear().domain(d3.extent(data, d => d.x)).range([margin.left, width - margin.right]);
      const yMax = d3.max(data, d => d3.max(datasets, set => d[set.key]));
      const y = d3.scaleLinear().domain([0, yMax * 1.15]).nice().range([height - margin.bottom, margin.top]);
      
      const xAxisGrid = d3.axisBottom(x).tickSize(-height + margin.top + margin.bottom).tickFormat("").ticks(10);
      const yAxisGrid = d3.axisLeft(y).tickSize(-width + margin.left + margin.right).tickFormat("").ticks(5);
      svg.append("g").attr("class", "x grid").attr("transform", `translate(0,${height - margin.bottom})`).call(xAxisGrid).selectAll("line").attr("stroke", "#e9ecef");
      svg.append("g").attr("class", "y grid").attr("transform", `translate(${margin.left},0)`).call(yAxisGrid).selectAll("line").attr("stroke", "#e9ecef");

      const xAxis = g => g.attr("transform", `translate(0,${height - margin.bottom})`).call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0));
      const yAxis = g => g.attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(y));
      svg.append("g").call(xAxis).call(g => g.select(".domain").remove());
      svg.append("g").call(yAxis).call(g => g.select(".domain").remove());

      svg.selectAll(".tick text").style("font-size", "11px").style("color", "#6c757d");

      svg.append("text").attr("x", width / 2).attr("y", height - 10).attr("text-anchor", "middle").style("font-size", "13px").style("font-weight", "500").text(xLabel);
      svg.append("text").attr("transform", "rotate(-90)").attr("y", 15).attr("x", -(height / 2)).attr("text-anchor", "middle").style("font-size", "13px").style("font-weight", "500").text(yLabel);

      datasets.forEach(set => {
          const line = d3.line().x(d => x(d.x)).y(d => y(d[set.key])).curve(d3.curveCatmullRom);
          svg.append("path").datum(data).attr("fill", "none").attr("stroke", set.color).attr("stroke-width", 3).attr("stroke-linejoin", "round").attr("stroke-linecap", "round").attr("d", line).style("stroke-dasharray", set.dashed ? "6, 4" : "none");
      });
      
      const legend = svg.append("g").attr("transform", `translate(${margin.left + 10}, 8)`);
      datasets.forEach((set, i) => {
          const legendItem = legend.append("g").attr("transform", `translate(${i * 120}, 0)`);
          legendItem.append("line").attr("x1", 0).attr("x2", 20).attr("y1", 6).attr("y2", 6).attr("stroke", set.color).attr("stroke-width", 3).style("stroke-dasharray", set.dashed ? "4, 3" : "none");
          legendItem.append("text").attr("x", 25).attr("y", 10).text(set.label).style("font-size", "12px").style("fill", "#343a40");
      });
  }
  
  // --- Main Update Function ---
  function updateVisualization() {
      const type = variationDropdown.select.property("value");
      
      // Show/hide relevant parameter sections
      Object.keys(paramConfigs).forEach(id => {
          d3.select(`#params-${id}`).style("display", id === type ? "block" : "none");
      });

      const params = {};
      const currentSliders = paramConfigs[type].sliders;
      for (const key in currentSliders) {
          const s = currentSliders[key];
          const val = +s.slider.property("value");
          params[key] = val;
          s.valueDisplay.text(`${val.toFixed(s.step < 1 ? 1 : 0)}`);
      }
      
      let plotData;
      switch (type) {
          case 'circadian': plotData = generateCircadianData(params); break;
          case 'menstrual': plotData = generateMenstrualData(params); break;
          case 'seasonal': plotData = generateSeasonalData(params); break;
          case 'age': plotData = generateAgeData(params); break;
      }
      
      drawPlot(plotData);
  }
  
  updateVisualization();

  return container.node();
}