html`<style>
.${ojs_ns} div label {
background-color: #eef2f6;
padding: 0.2rem 0.5rem;
border-radius: 0.4rem;
margin-bottom: 0.25rem;
width: auto;
}
.${ojs_ns} select,
.${ojs_ns} input[type="text"],
.${ojs_ns} input[type="file"] {
background-color: #fff;
border: 1px solid #d8dee4;
border-radius: 0.4rem;
padding: 0.2rem 0.4rem;
}
.${ojs_ns} input[type="checkbox"] {
accent-color: #2780e3;
margin-bottom: 0;
}
</style>`Explore any MetaLab dataset interactively: effect sizes across age, moderator analyses, funnel and forest plots, and the underlying multilevel meta-analytic model — all computed from the released data.
Loading data…
loess_points = (data, { x = "x", y = "y", w = null, span = 1, grid = 80 } = {}) => {
const wf = typeof w === "function" ? w : w ? (d) => +d[w] : () => 1;
const pts = data
.map((d) => ({ x: +d[x], y: +d[y], w: wf(d) }))
.filter((d) => Number.isFinite(d.x) && Number.isFinite(d.y) && Number.isFinite(d.w))
.sort((a, b) => a.x - b.x);
if (pts.length < 4) return [];
const k = Math.max(2, Math.ceil(span * pts.length));
const x0 = pts[0].x, x1 = pts[pts.length - 1].x;
const out = [];
for (let i = 0; i <= grid; i++) {
const xt = x0 + (x1 - x0) * (i / grid);
const dists = pts.map((p) => Math.abs(p.x - xt)).sort((a, b) => a - b);
const dmax = dists[Math.min(k, dists.length) - 1] || 1e-9;
let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
for (const p of pts) {
const u = Math.abs(p.x - xt) / dmax;
if (u >= 1) continue;
const tw = Math.pow(1 - Math.pow(u, 3), 3) * p.w;
sw += tw; swx += tw * p.x; swy += tw * p.y;
swxx += tw * p.x * p.x; swxy += tw * p.x * p.y;
}
if (sw <= 0) continue;
const denom = sw * swxx - swx * swx;
let yt;
if (Math.abs(denom) < 1e-12) yt = swy / sw;
else {
const b = (sw * swxy - swx * swy) / denom;
const a = (swy - b * swx) / sw;
yt = a + b * xt;
}
out.push({ [x]: xt, [y]: yt });
}
return out;
}
// weighted least squares line, matching geom_smooth(method="lm",
// aes(weight = 1/es_var)); returns endpoints
wls_points = (data, { x = "x", y = "y", w = null } = {}) => {
const wf = typeof w === "function" ? w : w ? (d) => +d[w] : () => 1;
const pts = data
.map((d) => ({ x: +d[x], y: +d[y], w: wf(d) }))
.filter((d) => Number.isFinite(d.x) && Number.isFinite(d.y) && Number.isFinite(d.w));
if (pts.length < 3) return [];
let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
for (const p of pts) {
sw += p.w; swx += p.w * p.x; swy += p.w * p.y;
swxx += p.w * p.x * p.x; swxy += p.w * p.x * p.y;
}
const denom = sw * swxx - swx * swx;
if (Math.abs(denom) < 1e-12) return [];
const b = (sw * swxy - swx * swy) / denom;
const a = (swy - b * swx) / sw;
const xs = pts.map((p) => p.x);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
return [{ [x]: x0, [y]: a + b * x0 }, { [x]: x1, [y]: a + b * x1 }];
}download_csv = (rows, filename) => {
if (!rows || !rows.length) return html`<span></span>`;
const cols = Object.keys(rows[0]);
const esc = (v) => {
const s = String(v ?? "");
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const csv = [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
return html`<a class="btn btn-sm btn-outline-primary" href=${url} download=${filename}>Download CSV</a>`;
}registry = d3.json("slices/datasets.json")
es_choices = new Map([
["Hedges' g", "g"], ["Cohen's d", "d"], ["Pearson's r", "r"], ["Log odds", "log_odds"]
])
es_labels = ({ g: "Hedges' g", d: "Cohen's d", r: "Pearson's r", log_odds: "Log odds" })
solarized = ["#268bd2", "#cb4b16", "#859900", "#6c71c4", "#2aa198", "#b58900",
"#d33682", "#dc322f"]
domain_titles = new Map([
["early_language", "Early language"],
["cognitive_development", "Cognitive development"]
])
standard_mods = ["mean_age", "response_mode", "exposure_phase"]
mod_labels = ({ mean_age: "Age", response_mode: "Response mode",
exposure_phase: "Exposure phase" })
url_dataset = new URLSearchParams(location.search).get("dataset")unpack_vcov = (lower, p) => {
const V = Array.from({ length: p }, () => new Array(p).fill(0));
let idx = 0;
for (let j = 0; j < p; j++)
for (let i = j; i < p; i++) {
V[i][j] = V[j][i] = lower[idx++];
}
return V;
}
// design vector for one data row (or synthetic row of moderator values)
design_row = (row, combo, mods) => {
return combo.coefs.map((c) => {
if (c.name === "intrcpt") return 1;
if (mods.includes(c.name)) return +row[c.name]; // numeric moderator
for (const m of Object.keys(combo.levels ?? {})) {
if (c.name.startsWith(m))
return String(row[m]) === c.name.slice(m.length) ? 1 : 0;
}
return 0;
});
}
dot = (a, b) => a.reduce((s, v, i) => s + v * b[i], 0)
quad_form = (x, V) => {
let s = 0;
for (let i = 0; i < x.length; i++)
for (let j = 0; j < x.length; j++) s += x[i] * V[i][j] * x[j];
return s;
}
// R's make.unique: append .1, .2, ... to repeats
make_unique = (names) => {
const seen = new Map();
return names.map((n) => {
if (!seen.has(n)) { seen.set(n, 0); return n; }
const k = seen.get(n) + 1; seen.set(n, k);
return `${n}.${k}`;
});
}
fmt_p = (p) => p < 0.0001 ? "< .0001" : "= " + p.toFixed(4).replace(/^0/, "")
days_per_month = 365.2425 / 12viewof domain = Inputs.select([...domain_titles.keys()], {
label: "Domain",
format: (d) => domain_titles.get(d),
value: url_dataset
? registry.find((d) => d.short_name === url_dataset)?.domain
: "early_language"
})
viewof dataset_name = Inputs.select(
registry.filter((d) => d.domain === domain).map((d) => d.name),
{ label: "Dataset",
value: url_dataset
? registry.find((d) => d.short_name === url_dataset)?.name
: undefined }
)dataset_meta = registry.find((d) => d.name === dataset_name)
viewof es_type = Inputs.select(es_choices, { label: "Effect size type", value: "g" })
viewof subset_choice = {
const subsets = dataset_meta.subset ?? [];
if (!subsets.length) {
// no subsets for this dataset: fixed value, no control shown
const el = html`<div></div>`;
el.value = "All data";
return el;
}
return Inputs.radio(["All data", ...subsets], {
label: "Subset",
value: "All data"
});
}slice_all = d3.json(`slices/es/${dataset_meta.short_name}.json`)
models_all = d3.json(`slices/models/${dataset_meta.short_name}.json`)
slice = subset_choice === "All data"
? slice_all
: slice_all.filter((d) => d[subset_choice] === true)
es_col = `${es_type}_calc`
es_var_col = `${es_type}_var_calc`
// legacy app filters: age < 4000 days; ES + variance defined for this type
plot_data = slice.filter((d) =>
(d.mean_age_months === null || +d.mean_age_months < 4000 / 30.44) &&
Number.isFinite(+d[es_col]) && Number.isFinite(+d[es_var_col]))
// moderator pool mirrors the precompute: standard + dataset-specific
// moderators with >1 observed value in the current subset
mod_pool = {
const custom = (dataset_meta.moderators ?? []).map(String);
const pool = [...new Set([...standard_mods, ...custom])];
return pool.filter((m) => {
const vals = new Set(plot_data.map((d) => d[m]).filter((v) => v !== null && v !== undefined && v !== ""));
return vals.size > 1;
});
}viewof moderators_raw = Inputs.checkbox(mod_pool, {
label: "Moderators (up to 3)",
format: (m) => mod_labels[m] ?? m.replace(/_/g, " ")
})
moderators = moderators_raw.slice(0, 3)
mod_warning = moderators_raw.length > 3
? html`<div style="color:#a15c00; font-size:0.8rem;">Only the first three
selected moderators are used — precomputed models cover up to three.</div>`
: html``categorical_mods = {
// a moderator is categorical unless its values are numeric (mirrors the
// legacy spec-type test; mean_age and num_trials-style mods are numeric)
return moderators.filter((m) => {
const vals = plot_data.map((d) => d[m]).filter((v) => v !== null && v !== "");
return !vals.every((v) => Number.isFinite(+v));
});
}
viewof curve_type = Inputs.radio(new Map([["LOESS", "loess"], ["Linear", "lm"]]), {
label: "Smoothing curve",
value: categorical_mods.length > 0 ? "lm" : "loess",
disabled: categorical_mods.length > 0
})html`<div style="font-size: 0.82rem; color: #555; margin-top: 0.75rem;">
<p>${dataset_meta.short_desc ?? ""}</p>
<p><em>Citation:</em> ${dataset_meta.full_citation ?? dataset_meta.citation ?? ""}
${dataset_meta.link ? html` <a href="${dataset_meta.link}">[source paper]</a>` : ""}</p>
<p><a href="resources/csv/${dataset_meta.filename}.csv" download>Download full dataset (CSV)</a></p>
</div>`mod_data = plot_data.filter((d) => moderators.every((m) =>
d[m] !== null && d[m] !== undefined && d[m] !== ""))
mods_key = mod_pool.filter((m) => moderators.includes(m)).join(",")
combo = models_all[`${subset_choice}|${es_type}|${mods_key}`]
combo_nomod = models_all[`${subset_choice}|${es_type}|`]
// group column for coloring: combined categorical moderators
mod_group_of = (d) => categorical_mods.length === 0 ? "all"
: categorical_mods.map((m) => d[m]).join(" / ")
mod_groups = [...new Set(mod_data.map(mod_group_of))].sort()
group_color = new Map(mod_groups.map((g, i) => [g, mod_groups.length === 1 ? "#268bd2" : solarized[i % solarized.length]]))
// fitted value + CI per row from the precomputed coefficients
row_predictions = {
if (!combo || !combo.converged) return null;
const V = unpack_vcov(combo.vcov_lower, combo.coefs.length);
const est = combo.coefs.map((c) => c.est);
return mod_data.map((d) => {
const X = design_row(d, combo, moderators);
const fit = dot(X, est);
const se = Math.sqrt(Math.max(quad_form(X, V), 0));
return { fit, ci_lb: fit - 1.959964 * se, ci_ub: fit + 1.959964 * se };
});
}value_boxes = {
clear_loading();
const est = combo_nomod?.coefs?.[0];
const k_note = combo && combo.k !== mod_data.length
? html`<div style="color:#c62828; font-size:0.75rem;">row-count mismatch vs precompute (${combo.k} vs ${mod_data.length})</div>`
: "";
return html`<div style="display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.75rem;">
<div class="value-box"><div class="vb-value">${plot_data.length}</div>
<div class="vb-label">Conditions</div></div>
<div class="value-box"><div class="vb-value">${est ? est.est.toFixed(2) : "—"}</div>
<div class="vb-label">Meta-analytic effect size<br>(multilevel REML)</div></div>
<div class="value-box"><div class="vb-value">${est ? est.se.toFixed(2) : "—"}</div>
<div class="vb-label">Effect size SE</div></div>
${k_note}
</div>`;
}Effect sizes across age
scatter_plot = {
if (dataset_meta.longitudinal)
return html`<p style="color:#777;"><em>This is a longitudinal dataset;
effect sizes do not correspond to single test ages, so the age scatter
is not shown.</em></p>`;
const data = mod_data.filter((d) => Number.isFinite(+d.mean_age_months));
const inv_var = (d) => 1 / Math.max(+d[es_var_col], 1e-9);
const groups = categorical_mods.length === 0 ? ["all"] : mod_groups;
const curves = groups.flatMap((g) => {
const rows = g === "all" ? data : data.filter((d) => mod_group_of(d) === g);
const pts = curve_type === "loess"
? loess_points(rows, { x: "mean_age_months", y: es_col, w: inv_var, span: 1 })
: wls_points(rows, { x: "mean_age_months", y: es_col, w: inv_var });
return pts.map((p) => ({ ...p, group: g }));
});
return Plot.plot({
width: 780, height: 430,
marginLeft: 55,
x: { label: "Mean subject age (months)" },
y: { label: `Effect size (${es_labels[es_type]})` },
color: { legend: categorical_mods.length > 0,
domain: groups, range: groups.map((g) => group_color.get(g)) },
marks: [
Plot.ruleY([0], { strokeDasharray: "4,3", stroke: "#999" }),
Plot.dot(data, {
x: "mean_age_months", y: es_col, r: (d) => Math.sqrt(+d.n || 1),
fill: (d) => mod_group_of(d), fillOpacity: 0.5,
title: (d) => `${d.short_cite}\nexpt ${d.expt_num ?? ""} · n=${d.n}\n${es_labels[es_type]} = ${(+d[es_col]).toFixed(2)}`,
tip: true
}),
Plot.line(curves, { x: "mean_age_months", y: es_col, z: "group",
stroke: "group", strokeWidth: 2 })
]
});
}Inputs.table(mod_data.map((d) => ({
study: d.short_cite, expt: d.expt_num, n: d.n,
age_months: d.mean_age_months === null ? null : +(+d.mean_age_months).toFixed(1),
es: +(+d[es_col]).toFixed(3), es_var: +(+d[es_var_col]).toFixed(4),
...Object.fromEntries(moderators.map((m) => [m, d[m]]))
})), { rows: 18 })Violin plot of effect-size density
violin_plot = {
const groups = categorical_mods.length === 0 ? ["all"] : mod_groups;
const centers = new Map(groups.map((g, i) => [g, i + 1]));
// gaussian KDE per group over the ES values
const violins = [];
for (const g of groups) {
const vals = (g === "all" ? mod_data : mod_data.filter((d) => mod_group_of(d) === g))
.map((d) => +d[es_col]).filter(Number.isFinite).sort(d3.ascending);
if (vals.length < 2) continue;
const sd = d3.deviation(vals) || 0.1;
const iqr = (d3.quantile(vals, 0.75) - d3.quantile(vals, 0.25)) / 1.349 || sd;
const bw = 0.9 * Math.min(sd, iqr) * Math.pow(vals.length, -0.2) || 0.1;
const lo = vals[0] - 3 * bw, hi = vals[vals.length - 1] + 3 * bw;
const grid = d3.range(lo, hi, (hi - lo) / 80);
let dens = grid.map((x) => ({
x, d: d3.mean(vals, (v) => Math.exp(-0.5 * ((x - v) / bw) ** 2)) / (bw * Math.sqrt(2 * Math.PI))
}));
const dmax = d3.max(dens, (p) => p.d) || 1;
dens.forEach((p) => violins.push({ group: g, es: p.x, off: 0.42 * p.d / dmax }));
}
const jittered = mod_data.map((d) => ({
es: +d[es_col], group: mod_group_of(d),
y: centers.get(mod_group_of(d)) + (Math.random() - 0.5) * 0.25,
cite: d.short_cite, expt: d.expt_num
}));
return Plot.plot({
width: 780, height: Math.max(200, groups.length * 130 + 70),
marginLeft: 120,
x: { label: `Effect size (${es_labels[es_type]})` },
y: { domain: [0.4, groups.length + 0.6],
ticks: groups.map((g, i) => i + 1),
tickFormat: (t) => {
const g = groups[t - 1] ?? "";
return g === "all" ? "all data" : g;
},
label: null },
color: { domain: groups, range: groups.map((g) => group_color.get(g)) },
marks: [
Plot.ruleX([0], { strokeDasharray: "4,3", stroke: "#999" }),
Plot.areaY(violins, { x: "es",
y1: (d) => centers.get(d.group) - d.off,
y2: (d) => centers.get(d.group) + d.off,
z: "group", fill: "group", fillOpacity: 0.4, curve: "basis" }),
Plot.dot(jittered, { x: "es", y: "y", fill: "group", r: 2.5,
fillOpacity: 0.6,
title: (d) => `${d.cite}\nexpt ${d.expt ?? ""}`, tip: true })
]
});
}Inputs.table(mod_groups.length === 0 ? [] :
[...d3.rollup(mod_data,
(v) => ({
n: v.length,
median: d3.median(v, (d) => +d[es_col]),
mean: d3.mean(v, (d) => +d[es_col]),
sd: d3.deviation(v, (d) => +d[es_col])
}),
mod_group_of)].map(([g, s]) => ({
group: g === "all" ? "all data" : g, n: s.n,
median: +s.median.toFixed(3), mean: +s.mean.toFixed(3),
sd: s.sd === undefined ? null : +s.sd.toFixed(3)
})), { rows: 12 })Forest plot
Black points are the observed effect sizes (with 95% CIs); colored triangles are the model’s estimated effect sizes given the selected moderators (one color per moderator group — with no moderators selected, every study shares the overall meta-analytic estimate).
forest_plot = {
if (!combo || !combo.converged || !row_predictions)
return html`<p style="color:#777;"><em>No converged model for this
selection${combo?.error ? ` (${combo.error})` : ""}.</em></p>`;
const labels = make_unique(mod_data.map((d) => d.short_cite ?? d.study_ID));
let rows = mod_data.map((d, i) => ({
label: labels[i],
effect: +d[es_col],
var: +d[es_var_col],
se: Math.sqrt(+d[es_var_col]),
weight: 1 / Math.max(+d[es_var_col], 1e-9),
estimate: row_predictions[i].fit,
est_lb: row_predictions[i].ci_lb,
est_ub: row_predictions[i].ci_ub,
group: mod_group_of(d),
study_ID: d.study_ID, year: d.year === "Inf" ? Infinity : +d.year,
expt: d.expt_num
}));
const keys = ({ variances: (d) => -d.weight, effects: (d) => -d.effect,
estimate: (d) => -d.estimate, study_ID: (d) => d.study_ID,
year: (d) => d.year });
rows = d3.sort(rows, keys[forest_sort]);
return Plot.plot({
width: 780, height: Math.max(220, rows.length * 13 + 90),
marginLeft: 210,
x: { label: `Effect size (${es_labels[es_type]})` },
y: { domain: rows.map((d) => d.label), label: null,
tickSize: 0 },
color: { domain: mod_groups.length ? mod_groups : ["all"],
range: (mod_groups.length ? mod_groups : ["all"]).map((g) => group_color.get(g)) },
marks: [
Plot.ruleX([0], { strokeDasharray: "4,3", stroke: "#999" }),
Plot.ruleY(rows, { y: "label", x1: (d) => d.effect - 1.959964 * d.se,
x2: (d) => d.effect + 1.959964 * d.se, stroke: "#333" }),
Plot.dot(rows, { y: "label", x: "effect",
r: (d) => 1 + 2 * Math.sqrt(d.weight / d3.max(rows, (r) => r.weight)),
fill: "#333",
title: (d) => `${d.label}\nexpt ${d.expt ?? ""}\nes = ${d.effect.toFixed(2)} [${(d.effect - 1.959964 * d.se).toFixed(2)}, ${(d.effect + 1.959964 * d.se).toFixed(2)}]`,
tip: true }),
Plot.ruleY(rows, { y: "label", x1: "est_lb", x2: "est_ub",
stroke: "group", strokeOpacity: 0.85 }),
Plot.dot(rows, { y: "label", x: "estimate", symbol: "triangle",
fill: "group", r: 3.5 })
]
});
}Meta-analytic model summary
model_summary_plot = {
if (!combo || !combo.converged)
return html`<p style="color:#777;"><em>No converged model for this selection.</em></p>`;
const pretty = (name) => {
if (name === "intrcpt") return "intercept";
for (const m of Object.keys(combo.levels ?? {}))
if (name.startsWith(m)) return `${m.replace(/_/g, " ")}: ${name.slice(m.length)}`;
return name.replace(/_/g, " ");
};
const rows = combo.coefs.map((c) => ({ ...c, label: pretty(c.name) }));
return Plot.plot({
width: 780, height: Math.max(140, rows.length * 34 + 70),
marginLeft: 210,
x: { label: `Coefficient (${es_labels[es_type]})` },
y: { domain: rows.map((d) => d.label), label: null },
marks: [
Plot.ruleX([0], { strokeDasharray: "4,3", stroke: "#999" }),
Plot.ruleY(rows, { y: "label", x1: "ci_lb", x2: "ci_ub", stroke: "#333" }),
Plot.dot(rows, { y: "label", x: "est", fill: "#333", r: 4,
title: (d) => `${d.label}\n${d.est.toFixed(3)} [${d.ci_lb.toFixed(3)}, ${d.ci_ub.toFixed(3)}]\np ${fmt_p(d.pval)}`,
tip: true })
]
});
}model_summary_table = {
if (!combo || !combo.converged) return html``;
const tab = Inputs.table(combo.coefs.map((c) => ({
coefficient: c.name === "intrcpt" ? "intercept" : c.name,
estimate: +c.est.toFixed(4), se: +c.se.toFixed(4),
z: +c.zval.toFixed(3), p: +c.pval.toFixed(4),
ci_lower: +c.ci_lb.toFixed(4), ci_upper: +c.ci_ub.toFixed(4)
})), { rows: 12 });
return html`<div>
<p style="font-size:0.85rem; color:#555;">Multilevel random-effects model
(REML) with effect sizes nested in infant groups nested in papers:
<code>rma.mv(yi, vi, random = ~1 | paper / infant group / row)</code>,
k = ${combo.k}. Variance components σ²:
${combo.sigma2.map((s) => s.toFixed(3)).join(" (paper), ") }
${combo.sigma2.length === 3 ? "(paper, infant group, row)" : ""}.</p>
${tab}</div>`;
}Funnel plot of bias in effect sizes
funnel_plot = {
const residual = moderators.length > 0 && combo && combo.converged && row_predictions;
const pts = residual
? mod_data.map((d, i) => ({
es: +d[es_col] - row_predictions[i].fit,
se: Math.sqrt(+d[es_var_col] + d3.sum(combo.sigma2)),
cite: d.short_cite, expt: d.expt_num, group: mod_group_of(d) }))
: plot_data.map((d) => ({
es: +d[es_col], se: Math.sqrt(+d[es_var_col]),
cite: d.short_cite, expt: d.expt_num, group: "all" }));
const center = residual ? 0 : d3.mean(pts, (d) => d.es);
const L = 1.05 * d3.max(pts, (d) => d.se);
const grid = d3.range(0, L * 1.0001, L / 60);
const tri95 = grid.map((s) => ({ se: s, x1: center - 1.959964 * s, x2: center + 1.959964 * s }));
const tri99 = grid.map((s) => ({ se: s, x1: center - 2.575829 * s, x2: center + 2.575829 * s }));
const xmin = Math.min(center - 2.575829 * L, d3.min(pts, (d) => d.es));
const xmax = Math.max(center + 2.575829 * L, d3.max(pts, (d) => d.es));
const groups = residual && mod_groups.length ? mod_groups : ["all"];
return Plot.plot({
width: 780, height: 430,
marginLeft: 55,
x: { label: residual ? "Residual effect size" : `Effect size (${es_labels[es_type]})`,
domain: [xmin, xmax] },
y: { label: residual ? "Residual standard error" : "Standard error",
domain: [0, L], reverse: true },
color: { domain: groups, range: groups.map((g) => group_color.get(g)) },
marks: [
Plot.rect([{}], { x1: xmin, x2: xmax, y1: 0, y2: L, fill: "#e6e6e6" }),
Plot.areaX(tri99, { y: "se", x1: "x1", x2: "x2", fill: "#fff", fillOpacity: 0.5 }),
Plot.areaX(tri95, { y: "se", x1: "x1", x2: "x2", fill: "#fff", fillOpacity: 0.5 }),
Plot.ruleX([center], { strokeDasharray: "2,2", stroke: "#333" }),
Plot.dot(pts, { x: "es", y: "se", fill: "group", fillOpacity: 0.7,
title: (d) => `${d.cite}\nexpt ${d.expt ?? ""}\n${d.es.toFixed(2)} (se ${d.se.toFixed(2)})`,
tip: true })
]
});
}egger_note = {
const e = combo?.egger;
if (!e) return html``;
return html`<p style="font-size: 0.9rem; color: #444;">Egger-style test for
funnel-plot asymmetry (√v<sub>i</sub> added as a moderator to the
multilevel model): z = ${e.z.toFixed(2)}, p ${fmt_p(e.p)}.
${e.p < 0.05 ? "Interpret with caution: asymmetry may also reflect confounding moderators." : ""}</p>`;
}