d3 = require("d3")
viewof simulation = {
// --- Constants for Styling ---
const colorOkabeBlue = "#0072B2";
const colorOkabeVermillion = "#D55E00";
const colorOkabeGreen = "#009E73";
const colorLightBlue = "#78b7c5";
const colorDotStroke = "white";
const colorGridLine = "#f1f3f5";
const colorAxisText = "#6c757d";
const colorConnectLine = "#ced4da";
const colorMeanLine = "#495057";
// --- 1. Setup Container & Layout ---
const container = d3.create("div")
.style("font-family", "Inter, sans-serif")
// Removed explicit width: 100% to prevent border-box overflow in some notebook environments
.style("background-color", "#f8f9fa")
.style("border", "1px solid #dee2e6")
.style("border-radius", "8px")
.style("padding", "15px")
.style("box-sizing", "border-box");
// --- 2. CSS Styles ---
const style = container.append("style");
style.html(`
.control-panel {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #e9ecef;
}
.sliders-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 15px 25px;
flex: 1;
}
@media (max-width: 600px) {
.control-panel { flex-direction: column; align-items: stretch; }
.sliders-grid { grid-template-columns: 1fr; }
}
.metrics-col {
display: flex;
flex-direction: column;
gap: 10px;
flex-shrink: 0;
}
.metric-box {
background: white;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #dee2e6;
text-align: center;
min-width: 120px;
}
.metric-label-container {
display: flex;
align-items: center;
justify-content: center;
}
.metric-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #6c757d; letter-spacing: 0.5px; }
.metric-number { font-size: 20px; font-weight: 700; color: #343a40; line-height: 1.2; }
.plots-container {
display: grid;
/* Use minmax(0, 1fr) to prevent SVG from forcing grid to overflow horizontally */
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 15px;
}
@media (max-width: 768px) {
.plots-container { grid-template-columns: minmax(0, 1fr); }
}
.plot-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.axis-label {
font-weight: bold;
fill: ${colorAxisText};
font-size: 12px;
}
/* Override D3's native injected axis font */
.tick text {
font-family: "Inter", sans-serif;
font-size: 11px;
}
/* --- Tooltip Styles --- */
.info-tooltip-container {
position: relative;
display: inline-block;
margin-left: 6px;
}
.info-icon {
font-size: 10px;
color: #6c757d;
cursor: help;
border: 1.5px solid #6c757d;
border-radius: 50%;
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-style: normal;
font-weight: bold;
opacity: 0.8;
}
.info-icon:hover {
opacity: 1;
color: #343a40;
border-color: #343a40;
}
.tooltip-text {
visibility: hidden;
width: 280px; /* Slightly wider to fit math comfortably */
background-color: #343a40;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 12px;
position: absolute;
z-index: 100; /* Ensure it stays above the plots */
top: 135%;
/* Anchor to the right edge so it expands leftward into the screen */
right: -10px;
left: auto;
margin-left: 0;
opacity: 0;
transition: opacity 0.2s;
font-size: 12px;
font-weight: normal;
line-height: 1.5;
text-transform: none;
letter-spacing: normal;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); /* Drop shadow to separate from charts */
}
.tooltip-text::after {
content: "";
position: absolute;
bottom: 100%;
/* Align arrow with the new right-anchored box */
right: 14px;
left: auto;
margin-left: 0;
border-width: 5px;
border-style: solid;
border-color: transparent transparent #343a40 transparent;
}
.info-tooltip-container:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
`);
// --- 3. DOM Elements Creation ---
const controlsDiv = container.append("div").attr("class", "control-panel");
const slidersGrid = controlsDiv.append("div").attr("class", "sliders-grid");
function createSlider(config) {
const sliderContainer = d3.create("div").style("display", "flex").style("flex-direction", "column").style("gap", "4px");
const labelRow = sliderContainer.append("div").style("display", "flex").style("justify-content", "space-between").style("align-items", "center");
labelRow.append("label").style("font-weight", "600").style("font-size", "13px").style("color", "#495057").text(config.label);
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", config.min)
.attr("max", config.max)
.attr("step", config.step)
.property("value", config.value)
.style("width", "100%");
const rangeDisplay = sliderContainer.append("div").style("display", "flex").style("justify-content", "space-between").style("font-size", "11px").style("color", "#6c757d");
rangeDisplay.append("span").text(config.min.toFixed(1));
rangeDisplay.append("span").text(config.max.toFixed(1));
return { container: sliderContainer, slider, valueDisplay };
}
// Create Sliders
const sliders = {
b: createSlider({ label: "Between-subject variance (σ²_b)", min: 0.1, max: 5.0, step: 0.1, value: 3.0 }),
w: createSlider({ label: "Within-subject variance (σ²_w)", min: 0.1, max: 5.0, step: 0.1, value: 1.0 }),
m1: createSlider({ label: "Mean measurement 1 (μ₁)", min: -5.0, max: 5.0, step: 0.5, value: 0.0 }),
m2: createSlider({ label: "Mean measurement 2 (μ₂)", min: -5.0, max: 5.0, step: 0.5, value: 0.0 })
};
slidersGrid.append(() => sliders.b.container.node());
slidersGrid.append(() => sliders.w.container.node());
slidersGrid.append(() => sliders.m1.container.node());
slidersGrid.append(() => sliders.m2.container.node());
// Metrics Display Column
const metricsCol = controlsDiv.append("div").attr("class", "metrics-col");
// ICC Display with Tooltip
const iccDiv = metricsCol.append("div").attr("class", "metric-box");
const iccHeader = iccDiv.append("div").attr("class", "metric-label-container");
iccHeader.append("div").attr("class", "metric-label").text("ICC (Absolute)");
const tooltipContainer = iccHeader.append("div").attr("class", "info-tooltip-container");
tooltipContainer.append("i").attr("class", "info-icon").text("i");
const tooltipText = tooltipContainer.append("div").attr("class", "tooltip-text");
const iccNum = iccDiv.append("div").attr("class", "metric-number").text("0.90");
const pearsonDiv = metricsCol.append("div").attr("class", "metric-box");
pearsonDiv.append("div").attr("class", "metric-label").text("Pearson r");
const pearsonNum = pearsonDiv.append("div").attr("class", "metric-number").text("0.90");
// Plots Section
const plotsDiv = container.append("div").attr("class", "plots-container");
const cardA = plotsDiv.append("div").attr("class", "plot-card");
const svgA = cardA.append("svg").attr("width", "100%").attr("height", 320);
const cardB = plotsDiv.append("div").attr("class", "plot-card");
const svgB = cardB.append("svg").attr("width", "100%").attr("height", 320);
const cardC = plotsDiv.append("div").attr("class", "plot-card");
const svgC = cardC.append("svg").attr("width", "100%").attr("height", 320);
const cardD = plotsDiv.append("div").attr("class", "plot-card");
const svgD = cardD.append("svg").attr("width", "100%").attr("height", 320);
// --- 4. Logic & Simulation ---
const nSubjects = 25;
const margin = {top: 15, right: 15, bottom: 35, left: 45};
const rng = d3.randomNormal(0, 1);
const baseData = Array.from({length: nSubjects}, (_, i) => ({
id: i,
z_true: rng(),
z_err1: rng(),
z_err2: rng(),
z_err_out: rng()
})).sort((a,b) => a.z_true - b.z_true);
function update() {
const sb = +sliders.b.slider.property("value");
const sw = +sliders.w.slider.property("value");
const m1 = +sliders.m1.slider.property("value");
const m2 = +sliders.m2.slider.property("value");
sliders.b.valueDisplay.text(sb.toFixed(1));
sliders.w.valueDisplay.text(sw.toFixed(1));
sliders.m1.valueDisplay.text(m1.toFixed(1));
sliders.m2.valueDisplay.text(m2.toFixed(1));
const theoreticalICC = sb / (sb + sw);
tooltipText.html(`
<b>Sampling Variability</b><br>
The displayed ICC is the <i>empirical</i> value calculated from our simulated sample of N=${nSubjects}.<br><br>
Because of random sampling error, it fluctuates around the <i>theoretical</i> population ICC of (assuming equal means) <b>${theoreticalICC.toFixed(2)}</b> [${sb.toFixed(1)} / (${sb.toFixed(1)} + ${sw.toFixed(1)}) — for N = 25, this can be quite a discrepancy].
`);
const trueBeta = 0.5;
const data = baseData.map(d => {
const sd_b = Math.sqrt(sb);
const sd_w = Math.sqrt(sw);
const trueVal = d.z_true * sd_b;
const y1_val = trueVal + (d.z_err1 * sd_w) + m1;
const y2_val = trueVal + (d.z_err2 * sd_w) + m2;
const outcome_val = (trueVal * trueBeta) + m1 + (d.z_err_out * 1.0);
return {
id: d.id,
trueVal: trueVal,
y1: y1_val,
y2: y2_val,
mean: (y1_val + y2_val) / 2,
outcome: outcome_val
};
});
// --- Calculate ICC ---
const n = data.length;
const k = 2;
const meanY1 = d3.mean(data, d => d.y1);
const meanY2 = d3.mean(data, d => d.y2);
const grandMean = (meanY1 + meanY2) / 2;
const ssR = k * d3.sum(data, d => Math.pow(d.mean - grandMean, 2));
const ssE = d3.sum(data, d => Math.pow(d.y1 - d.mean, 2) + Math.pow(d.y2 - d.mean, 2));
const msR = ssR / (n - 1);
const msE = ssE / (n * (k - 1));
const varWithin = msE;
const varBetween = (msR - msE) / k;
let icc = varBetween / (varBetween + varWithin);
icc = Math.max(0, icc);
iccNum.text(icc.toFixed(2));
// --- Calculate Pearson r and Linear Trend ---
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
data.forEach(d => {
sumX += d.y1;
sumY += d.y2;
sumXY += d.y1 * d.y2;
sumX2 += d.y1 * d.y1;
sumY2 += d.y2 * d.y2;
});
const denX = (n * sumX2) - (sumX * sumX);
const denY = (n * sumY2) - (sumY * sumY);
const num = (n * sumXY) - (sumX * sumY);
const pearson = (denX <= 0 || denY <= 0) ? 0 : num / Math.sqrt(denX * denY);
pearsonNum.text(pearson.toFixed(2));
const slope = denX <= 0 ? 0 : num / denX;
const intercept = (sumY - slope * sumX) / n;
// --- Dynamic Axis Scaling ---
const maxVal = d3.max(data, d => Math.max(Math.abs(d.y1), Math.abs(d.y2), Math.abs(d.outcome))) * 1.1;
// --- Draw Plot A (Dumbbell) ---
const widthA = cardA.node().getBoundingClientRect().width || 400;
const heightA = 320;
svgA.attr("viewBox", `0 0 ${widthA} ${heightA}`);
svgA.selectAll("*").remove();
const xA = d3.scaleLinear().domain([-1, nSubjects]).range([margin.left, widthA - margin.right]);
const yA = d3.scaleLinear().domain([-maxVal, maxVal]).range([heightA - margin.bottom, margin.top]);
svgA.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(yA).ticks(5).tickSize(-widthA + margin.left + margin.right).tickFormat("")).attr("color", colorGridLine).lower();
svgA.append("g").attr("transform", `translate(0,${heightA - margin.bottom})`).call(d3.axisBottom(xA).ticks(6).tickSize(-heightA + margin.top + margin.bottom).tickFormat("")).attr("color", colorGridLine).lower();
svgA.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(yA).ticks(5)).attr("color", colorAxisText);
svgA.append("g").attr("transform", `translate(0,${heightA - margin.bottom})`).call(d3.axisBottom(xA).ticks(0)).attr("color", colorAxisText);
svgA.append("text").attr("transform", "rotate(-90)").attr("x", -heightA/2).attr("y", 12).attr("text-anchor", "middle").attr("class", "axis-label").text("Measurement value");
svgA.append("text").attr("x", widthA/2).attr("y", heightA - 5).attr("text-anchor", "middle").attr("class", "axis-label").text("Subjects (ranked by mean)");
svgA.append("text").attr("x", 10).attr("y", 20).attr("font-weight", "bold").attr("font-size", "14px").text("A");
svgA.selectAll("line.connect").data(data).join("line").attr("x1", (d, i) => xA(i)).attr("x2", (d, i) => xA(i)).attr("y1", d => yA(d.y1)).attr("y2", d => yA(d.y2)).attr("stroke", colorConnectLine).attr("stroke-width", 0.8);
svgA.selectAll("line.mean").data(data).join("line").attr("x1", (d, i) => xA(i) - 4).attr("x2", (d, i) => xA(i) + 4).attr("y1", d => yA(d.mean)).attr("y2", d => yA(d.mean)).attr("stroke", colorMeanLine).attr("stroke-width", 1.5);
svgA.selectAll("circle.p1").data(data).join("circle").attr("cx", (d, i) => xA(i)).attr("cy", d => yA(d.y1)).attr("r", 4).attr("fill", colorLightBlue).attr("stroke", colorDotStroke).attr("stroke-width", 1).attr("opacity", 1);
svgA.selectAll("circle.p2").data(data).join("circle").attr("cx", (d, i) => xA(i)).attr("cy", d => yA(d.y2)).attr("r", 4).attr("fill", colorLightBlue).attr("stroke", colorDotStroke).attr("stroke-width", 1).attr("opacity", 1);
// --- Draw Plot B (Scatter) ---
const widthB = cardB.node().getBoundingClientRect().width || 400;
const heightB = 320;
svgB.attr("viewBox", `0 0 ${widthB} ${heightB}`);
svgB.selectAll("*").remove();
const scaleB = d3.scaleLinear().domain([-maxVal, maxVal]).range([margin.left, widthB - margin.right]);
const scaleBy = d3.scaleLinear().domain([-maxVal, maxVal]).range([heightB - margin.bottom, margin.top]);
svgB.append("g").attr("transform", `translate(0,${heightB - margin.bottom})`).call(d3.axisBottom(scaleB).ticks(5).tickSize(-heightB + margin.top + margin.bottom).tickFormat("")).attr("color", colorGridLine).lower();
svgB.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(scaleBy).ticks(5).tickSize(-widthB + margin.left + margin.right).tickFormat("")).attr("color", colorGridLine).lower();
svgB.append("g").attr("transform", `translate(0,${heightB - margin.bottom})`).call(d3.axisBottom(scaleB).ticks(5)).attr("color", colorAxisText);
svgB.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(scaleBy).ticks(5)).attr("color", colorAxisText);
// Identity Line (y=x)
svgB.append("line")
.attr("x1", scaleB(-maxVal)).attr("y1", scaleBy(-maxVal))
.attr("x2", scaleB(maxVal)).attr("y2", scaleBy(maxVal))
.attr("stroke", "#dc3545").attr("stroke-dasharray", "4").attr("stroke-opacity", 0.5);
// Trend Line (Linear Regression based on Pearson)
svgB.append("line")
.attr("x1", scaleB(-maxVal))
.attr("y1", scaleBy(slope * -maxVal + intercept))
.attr("x2", scaleB(maxVal))
.attr("y2", scaleBy(slope * maxVal + intercept))
.attr("stroke", colorMeanLine)
.attr("stroke-width", 2);
svgB.append("text").attr("transform", "rotate(-90)").attr("x", -heightB/2).attr("y", 12).attr("text-anchor", "middle").attr("class", "axis-label").text("Measurement 2");
svgB.append("text").attr("x", widthB/2).attr("y", heightB - 5).attr("text-anchor", "middle").attr("class", "axis-label").text("Measurement 1");
svgB.append("text").attr("x", 10).attr("y", 20).attr("font-weight", "bold").attr("font-size", "14px").text("B");
svgB.selectAll("circle.scatter").data(data).join("circle").attr("cx", d => scaleB(d.y1)).attr("cy", d => scaleBy(d.y2)).attr("r", 4).attr("fill", colorLightBlue).attr("stroke", colorDotStroke).attr("stroke-width", 1).attr("opacity", 1);
// Dynamic Legend for Plot B
const legendB = svgB.append("g").attr("transform", `translate(${margin.left + 15}, ${margin.top + 10})`);
legendB.append("rect")
.attr("x", -5).attr("y", -5).attr("width", 115).attr("height", 44)
.attr("fill", "rgba(255, 255, 255, 0.9)").attr("stroke", "#dee2e6").attr("rx", 4);
// Identity Line Legend Group
legendB.append("line").attr("x1", 5).attr("y1", 8).attr("x2", 25).attr("y2", 8).attr("stroke", "#dc3545").attr("stroke-width", 1).attr("stroke-dasharray", "4");
legendB.append("text").attr("x", 35).attr("y", 12).attr("font-size", "11px").attr("fill", colorAxisText).attr("font-weight", "600").text("Identity (y=x)");
// Linear Fit Legend Group
legendB.append("line").attr("x1", 5).attr("y1", 26).attr("x2", 25).attr("y2", 26).attr("stroke", colorMeanLine).attr("stroke-width", 2);
legendB.append("text").attr("x", 35).attr("y", 30).attr("font-size", "11px").attr("fill", colorAxisText).attr("font-weight", "600").text("Linear Fit");
// --- Draw Plot C (Flattened Slope) ---
const widthC = cardC.node().getBoundingClientRect().width || 400;
const heightC = 320;
svgC.attr("viewBox", `0 0 ${widthC} ${heightC}`);
svgC.selectAll("*").remove();
const scaleCx = d3.scaleLinear().domain([-maxVal, maxVal]).range([margin.left, widthC - margin.right]);
const scaleCy = d3.scaleLinear().domain([-maxVal, maxVal]).range([heightC - margin.bottom, margin.top]);
svgC.append("g").attr("transform", `translate(0,${heightC - margin.bottom})`).call(d3.axisBottom(scaleCx).ticks(5).tickSize(-heightC + margin.top + margin.bottom).tickFormat("")).attr("color", colorGridLine).lower();
svgC.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(scaleCy).ticks(5).tickSize(-widthC + margin.left + margin.right).tickFormat("")).attr("color", colorGridLine).lower();
svgC.append("g").attr("transform", `translate(0,${heightC - margin.bottom})`).call(d3.axisBottom(scaleCx).ticks(5)).attr("color", colorAxisText);
// Unitless Y-axis: Keep ticks but hide text
svgC.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(scaleCy).ticks(5).tickFormat("")).attr("color", colorAxisText);
const meanOutcome = d3.mean(data, d => d.outcome);
const obsBeta = trueBeta * icc;
// Plot TRUE underlying exposure data (Grey dots -> Green dots)
svgC.selectAll("circle.scatter-true").data(data).join("circle")
.attr("cx", d => scaleCx(d.trueVal + m1))
.attr("cy", d => scaleCy(d.outcome))
.attr("r", 4)
.attr("fill", colorOkabeGreen)
.attr("stroke", colorDotStroke)
.attr("stroke-width", 1)
.attr("opacity", 0.5);
// Plot generated outcome data for k=1 (Light Blue -> Vermillion dots)
svgC.selectAll("circle.scatter-c").data(data).join("circle")
.attr("cx", d => scaleCx(d.y1))
.attr("cy", d => scaleCy(d.outcome))
.attr("r", 4)
.attr("fill", colorOkabeVermillion)
.attr("stroke", colorDotStroke)
.attr("stroke-width", 1)
.attr("opacity", 0.7);
// Theoretical True Relationship (Beta = 0.5) passing through the mean
svgC.append("line")
.attr("x1", scaleCx(-maxVal)).attr("y1", scaleCy(meanOutcome + trueBeta * (-maxVal - meanY1)))
.attr("x2", scaleCx(maxVal)).attr("y2", scaleCy(meanOutcome + trueBeta * (maxVal - meanY1)))
.attr("stroke", colorOkabeGreen).attr("stroke-width", 2.5);
// Flattened Observed Relationship (Beta = True Beta * ICC, for k=1) passing through the mean
svgC.append("line")
.attr("x1", scaleCx(-maxVal)).attr("y1", scaleCy(meanOutcome + obsBeta * (-maxVal - meanY1)))
.attr("x2", scaleCx(maxVal)).attr("y2", scaleCy(meanOutcome + obsBeta * (maxVal - meanY1)))
.attr("stroke", colorOkabeVermillion).attr("stroke-dasharray", "6,4").attr("stroke-width", 2.5);
svgC.append("text").attr("transform", "rotate(-90)").attr("x", -heightC/2).attr("y", 12).attr("text-anchor", "middle").attr("class", "axis-label").text("Outcome (unitless)");
svgC.append("text").attr("x", widthC/2).attr("y", heightC - 5).attr("text-anchor", "middle").attr("class", "axis-label").text("Observed exposure 1 (k = 1)");
svgC.append("text").attr("x", 10).attr("y", 20).attr("font-weight", "bold").attr("font-size", "14px").text("C");
// Dynamic Legend for Plot C
const legendC = svgC.append("g").attr("transform", `translate(${margin.left + 15}, ${margin.top + 10})`);
legendC.append("rect")
.attr("x", -5).attr("y", -5).attr("width", 190).attr("height", 44)
.attr("fill", "rgba(255, 255, 255, 0.9)").attr("stroke", "#dee2e6").attr("rx", 4);
// True Beta Legend Group
legendC.append("circle").attr("cx", 15).attr("cy", 8).attr("r", 4).attr("fill", colorOkabeGreen).attr("opacity", 0.5);
legendC.append("line").attr("x1", 5).attr("y1", 8).attr("x2", 25).attr("y2", 8).attr("stroke", colorOkabeGreen).attr("stroke-width", 2.5);
legendC.append("text").attr("x", 35).attr("y", 12).attr("font-size", "11px").attr("fill", colorAxisText).attr("font-weight", "600").text(`True data & fit (\u03B2 = ${trueBeta.toFixed(2)})`);
// Observed Beta Legend Group
legendC.append("circle").attr("cx", 15).attr("cy", 26).attr("r", 4).attr("fill", colorOkabeVermillion).attr("opacity", 0.7);
legendC.append("line").attr("x1", 5).attr("y1", 26).attr("x2", 25).attr("y2", 26).attr("stroke", colorOkabeVermillion).attr("stroke-width", 2.5).attr("stroke-dasharray", "6,4");
legendC.append("text").attr("x", 35).attr("y", 30).attr("font-size", "11px").attr("fill", colorAxisText).attr("font-weight", "600").text(`Obs. data & fit (\u03B2 = ${obsBeta.toFixed(2)})`);
// --- Draw Plot D (Spearman-Brown) ---
const widthD = cardD.node().getBoundingClientRect().width || 400;
const heightD = 320;
svgD.attr("viewBox", `0 0 ${widthD} ${heightD}`);
svgD.selectAll("*").remove();
const scaleDx = d3.scaleLinear().domain([1, 10]).range([margin.left, widthD - margin.right]);
const scaleDy = d3.scaleLinear().domain([0, 1.1]).range([heightD - margin.bottom, margin.top]);
svgD.append("g").attr("transform", `translate(0,${heightD - margin.bottom})`).call(d3.axisBottom(scaleDx).ticks(10).tickSize(-heightD + margin.top + margin.bottom).tickFormat("")).attr("color", colorGridLine).lower();
svgD.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(scaleDy).ticks(5).tickSize(-widthD + margin.left + margin.right).tickFormat("")).attr("color", colorGridLine).lower();
svgD.append("g").attr("transform", `translate(0,${heightD - margin.bottom})`).call(d3.axisBottom(scaleDx).ticks(10).tickFormat(d3.format("d"))).attr("color", colorAxisText);
svgD.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(scaleDy).ticks(5)).attr("color", colorAxisText);
const lambda = varWithin / Math.max(0.000001, varBetween);
const kData = d3.range(1, 11).map(k => ({ k: k, att: 1 / (1 + lambda / k) }));
const lineGen = d3.line()
.x(d => scaleDx(d.k))
.y(d => scaleDy(d.att));
// Draw Attenuation Curve
svgD.append("path")
.datum(kData)
.attr("fill", "none")
.attr("stroke", colorOkabeBlue)
.attr("stroke-width", 2)
.attr("d", lineGen);
// Draw dots for each K measure, highlighting K=1
svgD.selectAll("circle.k-pt").data(kData).join("circle")
.attr("cx", d => scaleDx(d.k)).attr("cy", d => scaleDy(d.att))
.attr("r", d => d.k === 1 ? 6 : 4) // Slightly larger radius for k=1
.attr("fill", d => d.k === 1 ? colorOkabeVermillion : colorLightBlue) // Match Vermillion color of Observed Beta
.attr("stroke", colorDotStroke).attr("stroke-width", 1).attr("opacity", 1);
// Perfect recovery line reference at 1.0
svgD.append("line")
.attr("x1", scaleDx(1)).attr("y1", scaleDy(1))
.attr("x2", scaleDx(10)).attr("y2", scaleDy(1))
.attr("stroke", "#dc3545").attr("stroke-dasharray", "4").attr("stroke-opacity", 0.5);
svgD.append("text").attr("transform", "rotate(-90)").attr("x", -heightD/2).attr("y", 12).attr("text-anchor", "middle").attr("class", "axis-label").text("Attenuation Factor");
svgD.append("text").attr("x", widthD/2).attr("y", heightD - 5).attr("text-anchor", "middle").attr("class", "axis-label").text("Measurements averaged (k)");
svgD.append("text").attr("x", 10).attr("y", 20).attr("font-weight", "bold").attr("font-size", "14px").text("D");
// Label for the highlighted k=1 dot
svgD.append("text")
.attr("x", scaleDx(1) + 12)
.attr("y", scaleDy(kData[0].att) + 4)
.attr("font-size", "11px")
.attr("font-weight", "bold")
.attr("fill", colorOkabeVermillion)
.text("k = 1");
}
// --- 5. Event Listeners ---
Object.values(sliders).forEach(s => s.slider.on("input", update));
const observer = new ResizeObserver(() => { update(); });
observer.observe(plotsDiv.node());
update();
return container.node();
}