bayesTutorial = {
/* =========================================================================
* MATH
* Exact analytic posterior for Pearson's rho under BayesFactor's
* "Jeffreys-beta*" model (Ly, Marsman & Wagenmakers, 2018):
*
* posterior(rho | r, n) proportional to
* (1-rho^2)^((n-1)/2 + 1/rscale - 1) * (1-rho*r)^(-(n-3/2))
* * 2F1(1/2, 1/2; n-1/2; (rho*r+1)/2)
*
* Prior on rho is a shifted/scaled Beta(1/rscale, 1/rscale) on [-1,1].
* The "likelihood" curve shown is the standard Fisher-z approximation to
* the sampling distribution of r (matching the reference R/Shiny app),
* transformed back to the rho scale.
*
* All three curves are rendered as closed-form densities (no MCMC/KDE),
* validated against BayesFactor::correlationBF()+posterior() in R to
* within MCMC noise.
* ========================================================================= */
const RSCALE = {
"medium.narrow": 1 / Math.sqrt(27),
medium: 1 / 3,
wide: 1 / Math.sqrt(3),
ultrawide: 1,
};
// Lanczos approximation for log(Gamma(x))
const LANCZOS_G = 7;
const LANCZOS_COEF = [
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
];
function lgamma(x) {
if (x < 0.5) {
return Math.log(Math.PI / Math.sin(Math.PI * x)) - lgamma(1 - x);
}
x -= 1;
let a = LANCZOS_COEF[0];
const t = x + LANCZOS_G + 0.5;
for (let i = 1; i < LANCZOS_COEF.length; i++) a += LANCZOS_COEF[i] / (x + i);
return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a);
}
function logBeta(a, b) {
return lgamma(a) + lgamma(b) - lgamma(a + b);
}
function atanh(x) {
return 0.5 * Math.log((1 + x) / (1 - x));
}
// log 2F1(1/2, 1/2; c; z) via term-ratio series, z in (0,1)
function log2F1Half(c, z, maxTerms = 3000, tol = 1e-13) {
let term = 1.0;
let sum = 1.0;
for (let k = 0; k < maxTerms; k++) {
term *= ((0.5 + k) * (0.5 + k) * z) / ((c + k) * (k + 1));
sum += term;
if (Math.abs(term) < tol * Math.abs(sum)) break;
}
return Math.log(sum);
}
function priorLogDensity(rho, a) {
const constant = (2 * a - 1) * Math.LN2 + logBeta(a, a);
return (a - 1) * Math.log(1 - rho * rho) - constant;
}
function likelihoodLogDensity(rho, r, n) {
const zr = atanh(r);
const se = 1 / Math.sqrt(n - 3);
const z = atanh(rho);
const logNormal = -0.5 * Math.log(2 * Math.PI) - Math.log(se) - 0.5 * Math.pow((z - zr) / se, 2);
return logNormal - Math.log(1 - rho * rho); // + log|dz/drho|
}
function posteriorLogKernel(rho, r, n, a) {
const thetaExp = (n - 1) / 2 + a - 1;
const c = n - 0.5;
const z = (rho * r + 1) / 2;
return (
thetaExp * Math.log(1 - rho * rho) -
(n - 1.5) * Math.log(1 - rho * r) +
log2F1Half(c, z)
);
}
function trapNormalize(xs, unnorm) {
let area = 0;
for (let i = 0; i < xs.length - 1; i++) {
area += ((unnorm[i] + unnorm[i + 1]) / 2) * (xs[i + 1] - xs[i]);
}
return unnorm.map((v) => v / area);
}
function cumulativeTrap(xs, pdf) {
const cdf = new Array(xs.length).fill(0);
for (let i = 1; i < xs.length; i++) {
cdf[i] = cdf[i - 1] + ((pdf[i - 1] + pdf[i]) / 2) * (xs[i] - xs[i - 1]);
}
return cdf;
}
function quantile(xs, cdf, q) {
if (q <= cdf[0]) return xs[0];
for (let i = 1; i < cdf.length; i++) {
if (cdf[i] >= q) {
const t = (q - cdf[i - 1]) / (cdf[i] - cdf[i - 1] || 1e-12);
return xs[i - 1] + t * (xs[i] - xs[i - 1]);
}
}
return xs[xs.length - 1];
}
function interpCdf(xs, cdf, x0) {
if (x0 <= xs[0]) return 0;
if (x0 >= xs[xs.length - 1]) return 1;
for (let i = 1; i < xs.length; i++) {
if (xs[i] >= x0) {
const t = (x0 - xs[i - 1]) / (xs[i] - xs[i - 1]);
return cdf[i - 1] + t * (cdf[i] - cdf[i - 1]);
}
}
return 1;
}
function densityAt(xs, pdf, x0) {
if (x0 <= xs[0]) return pdf[0];
if (x0 >= xs[xs.length - 1]) return pdf[pdf.length - 1];
for (let i = 1; i < xs.length; i++) {
if (xs[i] >= x0) {
const t = (x0 - xs[i - 1]) / (xs[i] - xs[i - 1]);
return pdf[i - 1] + t * (pdf[i] - pdf[i - 1]);
}
}
return pdf[pdf.length - 1];
}
// ---- seeded RNG (mulberry32) + Box-Muller gaussian, mirrors the R app's
// set.seed(); x <- rnorm(n); y <- rho*x + sqrt(1-rho^2)*rnorm(n) pipeline ----
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeGaussian(rng) {
let spare = null;
return function () {
if (spare !== null) {
const v = spare;
spare = null;
return v;
}
let u, v, s;
do {
u = rng() * 2 - 1;
v = rng() * 2 - 1;
s = u * u + v * v;
} while (s >= 1 || s === 0);
const mul = Math.sqrt((-2 * Math.log(s)) / s);
spare = v * mul;
return u * mul;
};
}
function pearson(x, y) {
const n = x.length;
const mx = x.reduce((a, b) => a + b, 0) / n;
const my = y.reduce((a, b) => a + b, 0) / n;
let sxy = 0, sxx = 0, syy = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - mx, dy = y[i] - my;
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
}
return sxy / Math.sqrt(sxx * syy);
}
function simulateRObs(n, trueRho, seed) {
const rng = mulberry32(seed);
const gauss = makeGaussian(rng);
const x = new Array(n), y = new Array(n);
const s = Math.sqrt(1 - trueRho * trueRho);
for (let i = 0; i < n; i++) {
x[i] = gauss();
y[i] = trueRho * x[i] + s * gauss();
}
return pearson(x, y);
}
const GRID_N = 1201;
function makeGrid() {
const lo = -0.9995, hi = 0.9995;
const xs = new Array(GRID_N);
for (let i = 0; i < GRID_N; i++) xs[i] = lo + ((hi - lo) * i) / (GRID_N - 1);
return xs;
}
const XS = makeGrid();
function computeState(params) {
const { trueRho, n, ropeRange, ciWidth, priorChoice, seed } = params;
const rscale = RSCALE[priorChoice];
const a = 1 / rscale;
const rObs = simulateRObs(n, trueRho, seed);
const priorPdfUnnorm = XS.map((rho) => Math.exp(priorLogDensity(rho, a)));
const priorPdf = trapNormalize(XS, priorPdfUnnorm);
const likPdfUnnorm = XS.map((rho) => Math.exp(likelihoodLogDensity(rho, rObs, n)));
const likPdf = trapNormalize(XS, likPdfUnnorm);
const postLogK = XS.map((rho) => posteriorLogKernel(rho, rObs, n, a));
const maxLog = Math.max(...postLogK);
const postUnnorm = postLogK.map((l) => Math.exp(l - maxLog));
const postPdf = trapNormalize(XS, postUnnorm);
const postCdf = cumulativeTrap(XS, postPdf);
const median = quantile(XS, postCdf, 0.5);
const ciLow = quantile(XS, postCdf, (1 - ciWidth) / 2);
const ciHigh = quantile(XS, postCdf, 1 - (1 - ciWidth) / 2);
const massAtZero = interpCdf(XS, postCdf, 0);
const posMass = 1 - massAtZero;
const pdPct = Math.max(posMass, 1 - posMass) * 100;
const pdDirection = posMass >= 0.5 ? 1 : -1;
const ropeLow = -ropeRange, ropeHigh = ropeRange;
const ropePct = (interpCdf(XS, postCdf, ropeHigh) - interpCdf(XS, postCdf, ropeLow)) * 100;
const medianDensity = densityAt(XS, postPdf, median);
return {
xs: XS, priorPdf, likPdf, postPdf,
median, medianDensity, ciLow, ciHigh, pdPct, pdDirection,
ropeLow, ropeHigh, ropePct, rObs,
};
}
/* =========================================================================
* UI
* ========================================================================= */
const root = document.createElement("div");
root.className = "bayes-widget";
const params = {
trueRho: 0.5,
n: 25,
ropeRange: 0.1,
ciWidth: 0.95,
priorChoice: "medium",
seed: 609,
};
let highlightMode = "none"; // none | median | ci | pd | rope
function fmt(v, d) {
return Number(v).toFixed(d);
}
function tickLabels(min, max, step, count) {
const labels = [];
for (let i = 0; i < count; i++) {
let v = min + ((max - min) * i) / (count - 1);
// snap to step grid for clean labels
v = Math.round(v / step) * step;
labels.push(v);
}
return labels;
}
function slider({ key, label, min, max, step, decimals, tickCount = 9 }) {
const wrap = document.createElement("div");
wrap.className = "control";
const lab = document.createElement("label");
lab.textContent = label;
const valueRow = document.createElement("div");
valueRow.className = "value-row";
const input = document.createElement("input");
input.type = "range";
input.min = min; input.max = max; input.step = step; input.value = params[key];
const bubble = document.createElement("span");
bubble.className = "bubble";
bubble.textContent = fmt(params[key], decimals);
valueRow.appendChild(input);
valueRow.appendChild(bubble);
const ticks = document.createElement("div");
ticks.className = "ticks";
tickLabels(min, max, step, tickCount).forEach((v) => {
const s = document.createElement("span");
s.textContent = fmt(v, decimals);
ticks.appendChild(s);
});
input.addEventListener("input", () => {
params[key] = +input.value;
bubble.textContent = fmt(params[key], decimals);
update();
});
wrap.appendChild(lab);
wrap.appendChild(valueRow);
wrap.appendChild(ticks);
return { wrap, input, bubble };
}
const panel = document.createElement("div");
panel.className = "control-panel";
const row1 = document.createElement("div");
row1.className = "control-row";
const sTrueRho = slider({ key: "trueRho", label: "True correlation", min: -0.99, max: 0.99, step: 0.01, decimals: 2 });
const sN = slider({ key: "n", label: "Sample size (n)", min: 5, max: 500, step: 1, decimals: 0 });
const sRope = slider({ key: "ropeRange", label: "ROPE range (+/-)", min: 0.01, max: 0.5, step: 0.01, decimals: 2 });
const sCi = slider({ key: "ciWidth", label: "Credible interval width", min: 0.5, max: 0.99, step: 0.01, decimals: 2 });
row1.appendChild(sTrueRho.wrap);
row1.appendChild(sN.wrap);
row1.appendChild(sRope.wrap);
row1.appendChild(sCi.wrap);
const row2 = document.createElement("div");
row2.className = "control-row row2";
const priorDescriptions = {
"medium.narrow": "correlations stronger than about ±0.6 become increasingly implausible.",
"medium": "correlations stronger than about ±0.7 become increasingly implausible.",
"wide": "correlations stronger than about ±0.85 become increasingly implausible, though even these aren't ruled out.",
"ultrawide": "every possible correlation, from -1 to 1, is considered equally plausible before seeing the data.",
};
const priorWrap = document.createElement("div");
priorWrap.className = "control";
const priorLabelRow = document.createElement("div");
priorLabelRow.className = "label-row";
const priorLabel = document.createElement("label");
priorLabel.textContent = "Prior Distribution";
const infoWrap = document.createElement("span");
infoWrap.className = "info-wrap";
const infoIcon = document.createElement("span");
infoIcon.className = "info-icon";
infoIcon.textContent = "i";
infoIcon.tabIndex = 0;
const infoTooltip = document.createElement("span");
infoTooltip.className = "info-tooltip";
infoWrap.appendChild(infoIcon);
infoWrap.appendChild(infoTooltip);
priorLabelRow.appendChild(priorLabel);
priorLabelRow.appendChild(infoWrap);
function updatePriorTooltip() {
infoTooltip.textContent = `The current prior says that ${priorDescriptions[params.priorChoice]}`;
}
updatePriorTooltip();
const prioSelect = document.createElement("select");
["medium.narrow", "medium", "wide", "ultrawide"].forEach((choice) => {
const opt = document.createElement("option");
opt.value = choice; opt.textContent = choice;
if (choice === params.priorChoice) opt.selected = true;
prioSelect.appendChild(opt);
});
prioSelect.addEventListener("change", () => {
params.priorChoice = prioSelect.value;
updatePriorTooltip();
update();
});
priorWrap.appendChild(priorLabelRow);
priorWrap.appendChild(prioSelect);
const sSeed = slider({ key: "seed", label: "Random seed", min: 1, max: 1000, step: 1, decimals: 0 });
const randWrap = document.createElement("div");
randWrap.className = "control randomize-cell";
const randSpacerLabel = document.createElement("label");
randSpacerLabel.textContent = "Random seed";
randSpacerLabel.style.visibility = "hidden";
const randBtn = document.createElement("button");
randBtn.className = "btn";
randBtn.textContent = "Random Seed";
randBtn.addEventListener("click", () => {
params.seed = 1 + Math.floor(Math.random() * 1000);
sSeed.input.value = params.seed;
sSeed.bubble.textContent = fmt(params.seed, 0);
update();
});
randWrap.appendChild(randSpacerLabel);
randWrap.appendChild(randBtn);
row2.appendChild(priorWrap);
row2.appendChild(sSeed.wrap);
row2.appendChild(randWrap);
panel.appendChild(row1);
panel.appendChild(row2);
root.appendChild(panel);
// ---- chart ----
const chartWrap = document.createElement("div");
chartWrap.className = "chart-wrap";
root.appendChild(chartWrap);
const W = 1200, H = 634;
const MARGIN = { top: 20, right: 30, bottom: 40, left: 30 };
const ARROWS_H = 118;
const plotTop = MARGIN.top;
const plotBottom = H - MARGIN.bottom - ARROWS_H;
const plotHeight = plotBottom - plotTop;
const axisY = plotBottom + 24;
const arrowRowY = [axisY + 55, axisY + 80, axisY + 105];
const svg = d3.select(chartWrap)
.append("svg")
.attr("id", "triplot")
.attr("viewBox", `0 0 ${W} ${H}`);
const xScale = d3.scaleLinear().domain([-1, 1]).range([MARGIN.left, W - MARGIN.right]);
let yScale = d3.scaleLinear().range([plotBottom, plotTop]);
// defs: arrowheads
const defs = svg.append("defs");
function addMarker(id, color) {
defs.append("marker")
.attr("id", id)
.attr("viewBox", "0 0 10 10")
.attr("refX", 5).attr("refY", 5)
.attr("markerWidth", 6).attr("markerHeight", 6)
.attr("orient", "auto-start-reverse")
.append("path")
.attr("d", "M0,0 L10,5 L0,10 Z")
.attr("fill", color);
}
addMarker("arrow-grey", "#888");
addMarker("arrow-blue", "#2b6cb0");
// gridlines
const gGridX = svg.append("g").attr("class", "gridlines");
const gGridY = svg.append("g").attr("class", "gridlines");
// rope band
const ropeRect = svg.append("rect").attr("fill", "#808080");
// density areas + lines
const areaGen = () =>
d3.area()
.x((d, i) => xScale(XS[i]))
.y0(plotBottom)
.y1((d) => yScale(d));
const lineGen = () =>
d3.line()
.x((d, i) => xScale(XS[i]))
.y((d) => yScale(d));
const distGroups = {};
["prior", "likelihood", "posterior"].forEach((key) => {
const g = svg.append("g").attr("class", `dist-${key}`);
const area = g.append("path").attr("class", "density-area");
const line = g.append("path").attr("class", "density-path");
distGroups[key] = { g, area, line };
});
const highlightArea = svg.append("path")
.attr("class", "highlight-area")
.attr("fill", "#619CFF")
.attr("fill-opacity", 0);
const zeroLine = svg.append("line").attr("stroke-width", 1.3);
const ciLine1 = svg.append("line").attr("stroke-width", 1.2).attr("stroke-dasharray", "5,4");
const ciLine2 = svg.append("line").attr("stroke-width", 1.2).attr("stroke-dasharray", "5,4");
const medianDot = svg.append("circle")
.attr("r", 7)
.attr("fill", "black")
.attr("stroke", "white")
.attr("stroke-width", 2.2);
const gAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(0, ${axisY})`);
svg.append("text")
.attr("class", "axis-title")
.attr("x", W / 2)
.attr("y", axisY + 34)
.attr("text-anchor", "middle")
.text("ρ"); // rho
// arrow rows (CI, ROPE, PD)
function makeArrowRow(y, labelText) {
const g = svg.append("g");
const label = g.append("text")
.attr("class", "arrow-label")
.attr("x", MARGIN.left - 4)
.attr("y", y + 4)
.attr("text-anchor", "start")
.text(labelText);
const line = g.append("line")
.attr("y1", y).attr("y2", y)
.attr("stroke", "#888")
.attr("stroke-width", 2);
return { g, line, label };
}
const ciArrow = makeArrowRow(arrowRowY[0], "CI");
const ropeArrow = makeArrowRow(arrowRowY[1], "ROPE");
const pdArrow = makeArrowRow(arrowRowY[2], "PD");
ciArrow.line.attr("x1", MARGIN.left + 26);
ropeArrow.line.attr("x1", MARGIN.left + 26);
pdArrow.line.attr("x1", MARGIN.left + 26);
ciArrow.label.attr("x", MARGIN.left + 26);
ropeArrow.label.attr("x", MARGIN.left + 26);
pdArrow.label.attr("x", MARGIN.left + 26);
// reposition labels to sit just left of the plot, arrows start at plot left edge
ciArrow.label.attr("x", 2).attr("text-anchor", "start");
ropeArrow.label.attr("x", 2).attr("text-anchor", "start");
pdArrow.label.attr("x", 2).attr("text-anchor", "start");
ciArrow.line.attr("x1", xScale(-1));
ropeArrow.line.attr("x1", xScale(-1));
pdArrow.line.attr("x1", xScale(-1));
// legend
const legend = document.createElement("div");
legend.className = "legend";
legend.innerHTML = `
<span><span class="swatch" style="background:var(--prior)"></span>Prior</span>
<span><span class="swatch" style="background:var(--likelihood)"></span>Likelihood</span>
<span><span class="swatch" style="background:var(--posterior)"></span>Posterior</span>
`;
chartWrap.appendChild(legend);
// ---- stats box ----
const statsBox = document.createElement("div");
statsBox.className = "stats-box";
root.appendChild(statsBox);
const statsRow = document.createElement("div");
statsRow.className = "stats-row";
statsBox.appendChild(statsRow);
function statBlock(mode, labelText) {
const block = document.createElement("div");
block.className = "stat-block";
const btn = document.createElement("button");
btn.className = "stat-btn";
btn.textContent = labelText;
const val = document.createElement("div");
val.className = "stat-value";
btn.addEventListener("click", () => {
highlightMode = highlightMode === mode ? "none" : mode;
render(lastState);
});
block.appendChild(btn);
block.appendChild(val);
statsRow.appendChild(block);
return { block, btn, val };
}
const stMedian = statBlock("median", "Median of the Posterior Distribution");
const stCi = statBlock("ci", "Credible Interval");
const stPd = statBlock("pd", "Probability of Direction");
const stRope = statBlock("rope", "% in Region of Practical Equivalence");
const resetRow = document.createElement("div");
resetRow.className = "reset-row";
const resetBtn = document.createElement("button");
resetBtn.className = "btn primary";
resetBtn.textContent = "Show All";
resetBtn.addEventListener("click", () => {
highlightMode = "none";
render(lastState);
});
resetRow.appendChild(resetBtn);
statsBox.appendChild(resetRow);
// ---- render ----
let lastState = null;
function cssVar(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function render(state) {
lastState = state;
const yMax = d3.max(state.postPdf) * 1.15;
yScale = d3.scaleLinear().domain([0, yMax]).range([plotBottom, plotTop]);
// theme-aware line colors (read live so a Quarto dark/light toggle
// without a page reload still updates the chart's reference lines)
const inkColor = cssVar("--ink") || "#222";
const mutedColor = cssVar("--muted") || "#555";
zeroLine.attr("stroke", inkColor);
ciLine1.attr("stroke", mutedColor);
ciLine2.attr("stroke", mutedColor);
// gridlines
gGridX.selectAll("line")
.data(xScale.ticks(5))
.join("line")
.attr("class", "gridline")
.attr("x1", (d) => xScale(d)).attr("x2", (d) => xScale(d))
.attr("y1", plotTop).attr("y2", plotBottom);
gGridY.selectAll("line")
.data(yScale.ticks(4))
.join("line")
.attr("class", "gridline")
.attr("x1", MARGIN.left).attr("x2", W - MARGIN.right)
.attr("y1", (d) => yScale(d)).attr("y2", (d) => yScale(d));
const mode = highlightMode;
const baseAlpha = mode === "none" ? 0.4 : 0.2;
const ciLineAlpha = mode === "none" || mode === "ci" ? 0.7 : 0.08;
const zeroLineAlpha = mode === "none" || mode === "pd" ? 1 : 0.08;
const medianAlpha = mode === "none" || mode === "median" ? 1 : 0.1;
const ropeAlpha = mode === "none" || mode === "rope" ? 0.35 : 0.05;
const colors = { prior: "#F8766D", likelihood: "#00BA38", posterior: "#619CFF" };
const dataByKey = { prior: state.priorPdf, likelihood: state.likPdf, posterior: state.postPdf };
Object.entries(distGroups).forEach(([key, g]) => {
const d = dataByKey[key];
g.area.datum(d)
.attr("d", areaGen())
.attr("fill", colors[key])
.attr("fill-opacity", baseAlpha);
g.line.datum(d)
.attr("d", lineGen())
.attr("fill", "none")
.attr("stroke", colors[key])
.attr("stroke-opacity", baseAlpha + 0.3);
});
// rope band
ropeRect
.attr("x", xScale(state.ropeLow))
.attr("width", Math.max(0, xScale(state.ropeHigh) - xScale(state.ropeLow)))
.attr("y", plotTop)
.attr("height", plotHeight)
.attr("fill-opacity", ropeAlpha);
// highlight slice (ci / pd / rope)
let hiXs = [], hiYs = [];
if (mode === "ci") {
hiXs = XS.filter((x) => x >= state.ciLow && x <= state.ciHigh);
} else if (mode === "pd") {
hiXs = state.pdDirection === 1 ? XS.filter((x) => x > 0) : XS.filter((x) => x < 0);
} else if (mode === "rope") {
hiXs = XS.filter((x) => x >= state.ropeLow && x <= state.ropeHigh);
}
if (hiXs.length > 1) {
const idxLo = XS.indexOf(hiXs[0]);
const idxHi = idxLo + hiXs.length;
const sliceY = state.postPdf.slice(idxLo, idxHi);
const area = d3.area()
.x((d, i) => xScale(hiXs[i]))
.y0(plotBottom)
.y1((d) => yScale(d));
highlightArea.datum(sliceY).attr("d", area).attr("fill-opacity", 0.7);
} else {
highlightArea.attr("fill-opacity", 0);
}
// lines
zeroLine
.attr("x1", xScale(0)).attr("x2", xScale(0))
.attr("y1", plotTop).attr("y2", plotBottom)
.attr("stroke-opacity", zeroLineAlpha);
ciLine1
.attr("x1", xScale(state.ciLow)).attr("x2", xScale(state.ciLow))
.attr("y1", plotTop).attr("y2", plotBottom)
.attr("stroke-opacity", ciLineAlpha);
ciLine2
.attr("x1", xScale(state.ciHigh)).attr("x2", xScale(state.ciHigh))
.attr("y1", plotTop).attr("y2", plotBottom)
.attr("stroke-opacity", ciLineAlpha);
medianDot
.attr("cx", xScale(state.median))
.attr("cy", yScale(state.medianDensity))
.attr("opacity", medianAlpha);
// axis
gAxis.call(d3.axisBottom(xScale).ticks(10));
// annotation arrows
const ciActive = mode === "none" || mode === "ci";
const ropeActive = mode === "none" || mode === "rope";
const pdActive = mode === "none" || mode === "pd";
ciArrow.line
.attr("x1", xScale(state.ciLow)).attr("x2", xScale(state.ciHigh))
.attr("stroke", ciActive ? "#2b6cb0" : "#ccc")
.attr("marker-start", "url(#arrow-blue)").attr("marker-end", "url(#arrow-blue)")
.attr("opacity", ciActive ? 1 : 0.35);
ciArrow.label.text(`CI [${fmt(state.ciLow, 2)}, ${fmt(state.ciHigh, 2)}]`).attr("opacity", ciActive ? 1 : 0.35);
ropeArrow.line
.attr("x1", xScale(state.ropeLow)).attr("x2", xScale(state.ropeHigh))
.attr("stroke", ropeActive ? "#666" : "#ccc")
.attr("marker-start", "url(#arrow-grey)").attr("marker-end", "url(#arrow-grey)")
.attr("opacity", ropeActive ? 1 : 0.35);
ropeArrow.label.text(`ROPE [${fmt(state.ropeLow, 2)}, ${fmt(state.ropeHigh, 2)}]`).attr("opacity", ropeActive ? 1 : 0.35);
const pdTarget = state.pdDirection === 1 ? Math.max(state.ciHigh, 0.05) : Math.min(state.ciLow, -0.05);
pdArrow.line
.attr("x1", xScale(0)).attr("x2", xScale(pdTarget))
.attr("stroke", pdActive ? "#2b6cb0" : "#ccc")
.attr("marker-start", null).attr("marker-end", "url(#arrow-blue)")
.attr("opacity", pdActive ? 1 : 0.35);
pdArrow.label.text(`PD ${fmt(state.pdPct, 1)}% (${state.pdDirection === 1 ? "+" : "−"})`).attr("opacity", pdActive ? 1 : 0.35);
// stat button active/value states
[stMedian, stCi, stPd, stRope].forEach((s) => s.btn.classList.remove("active"));
if (mode === "median") stMedian.btn.classList.add("active");
if (mode === "ci") stCi.btn.classList.add("active");
if (mode === "pd") stPd.btn.classList.add("active");
if (mode === "rope") stRope.btn.classList.add("active");
stMedian.val.textContent = fmt(state.median, 2);
stCi.btn.firstChild.textContent = `${Math.round(params.ciWidth * 100)}% Credible Interval`;
stCi.val.textContent = `[${fmt(state.ciLow, 2)}, ${fmt(state.ciHigh, 2)}]`;
stPd.val.textContent = `${fmt(state.pdPct, 1)}%`;
stRope.val.textContent = `${fmt(state.ropePct, 1)}%`;
}
function update() {
const state = computeState(params);
render(state);
}
update();
// Quarto's dark/light toggle flips html[data-bs-theme] without a page
// reload; CSS-driven elements update instantly, but the SVG's
// getComputedStyle-based line colors need an explicit re-render.
new MutationObserver(() => {
if (lastState) render(lastState);
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-bs-theme"] });
return root;
}