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