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
  • Exposure variability creates fluctuations in biomarker levels
  • Half-life determines what period of exposure can possibly be reflected in a measurement
  • 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

The model is a useful starting point to get a sense of the basic relationship between exposure and internal concentration. However, by treating the human body as a single, well-mixed container it of course simplifies many biological realities. In reality, 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, but I have tried to learn the (very) basics. So I used this as an opportunity to learn and write on where the reality is more messy as I think it’s always useful to know what an ideal model should capture.

The model assumes chemicals enter and exit the body through a single pathway, but actual ADME processes (Absorption, Distribution, Metabolism, and Excretion) following exposure involve multiple, complex routes. Inhaled chemicals bypass the liver and enter arterial blood directly, giving them immediate access to sensitive organs like the brain and heart. Ingested substances first pass through the liver, where enzymes can partially metabolize them before they reach general circulation. Skin absorption follows yet another pathway, delivering chemicals into venous blood. Each route creates different kinetic patterns that a single-compartment model cannot capture.

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, lipid-soluble compounds concentrate heavily in fatty tissues, while water-soluble substances remain largely in blood and other body water. This partitioning creates multiple body reservoirs with vastly different uptake and release rates.

The model treats elimination (excretion) as a simple, constant process, but the body actually transforms (metabolizes) most foreign chemicals through complex metabolic pathways. Cytochrome P-450 enzymes and other biotransformation systems can either detoxify substances or activate them into more harmful metabolites. High exposure levels can saturate these enzyme systems, causing elimination (excretion) rates to slow dramatically and violating the model’s assumption of first-order kinetics. When saturation occurs, chemicals persist in the body much longer than the constant half-life would predict.

Lastly, the model cannot capture the complex multi-compartment dynamics of persistent chemicals. While the model can accommodate long half-lives by adjusting the half-life parameter, it presents elimination as a smooth exponential decay from a single compartment. Fat tissue actually creates a separate reservoir for lipophilic compounds like PCBs and DDT. This 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 complex interplay between storage and release.

And in reality, all of these ‘parameters’ can differ from person to person..

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();
}