{
const tv = cfg.thresholds[calSlot][calThr];
const rows = calibration
.filter(d => d.slot === calSlot && d.threshold === calThr && SRC_LABEL[d.predictive] != null)
.map(d => ({ ...d, src: SRC_LABEL[d.predictive] }))
.sort((a, b) => d3.ascending(a.src, b.src) || d3.ascending(a.pred_mid, b.pred_mid));
if (!rows.length) return html`<em>No calibration bins for this slot and threshold.</em>`;
const N_MIN = 5;
const denseRows = rows.filter(d => d.n >= N_MIN);
const sparseRows = rows.filter(d => d.n < N_MIN);
const plot = Plot.plot({
width: 540, height: 520, aspectRatio: 1, marginLeft: 54, marginBottom: 46,
x: { label: "Predicted P(clear) →", domain: [0, 1], tickFormat: "%", grid: true },
y: { label: "↑ Observed frequency", domain: [0, 1], tickFormat: "%", grid: true },
color: { legend: true, domain: SRC_DOMAIN, range: SRC_RANGE },
marks: [
Plot.line([{ x: 0, y: 0 }, { x: 1, y: 1 }], { x: "x", y: "y", stroke: "#bbb", strokeDasharray: "4,4" }),
// bootstrap bands and connected line only for bins with n ≥ 5
Plot.areaY(denseRows, { x: "pred_mid", y1: "boot_lo", y2: "boot_hi", fill: "src", z: "src", fillOpacity: 0.15, curve: "linear" }),
Plot.line(denseRows, { x: "pred_mid", y: "obs_freq", stroke: "src", z: "src", strokeWidth: 1.5, curve: "linear" }),
// dense dots (filled, full color)
Plot.dot(denseRows, { x: "pred_mid", y: "obs_freq", fill: "src", r: 3.5, title: d => `${d.src}: predicted ${Math.round(100*d.pred_mid)}%, observed ${Math.round(100*d.obs_freq)}% (n=${d.n})` }),
// sparse dots (hollow grey, labeled with n)
Plot.dot(sparseRows, { x: "pred_mid", y: "obs_freq", stroke: "#aaa", fill: "white", r: 3.5, title: d => `${d.src}: predicted ${Math.round(100*d.pred_mid)}%, observed ${Math.round(100*d.obs_freq)}% (n=${d.n} — sparse bin)` }),
Plot.text(sparseRows, { x: "pred_mid", y: "obs_freq", text: d => `n=${d.n}`, dy: -10, fontSize: 9, fill: "#999" })
]
});
// n-weighted mean |predicted − observed| per model for this slot/threshold
const eceByModel = d3.rollup(rows,
v => { const N = d3.sum(v, d => d.n); return N > 0 ? d3.sum(v, d => d.n * Math.abs(d.pred_mid - d.obs_freq)) / N : null; },
d => d.src
);
const eceLine = SRC_DOMAIN.map(s => {
const v = eceByModel.get(s);
const col = SRC_RANGE[SRC_DOMAIN.indexOf(s)];
return html`<span style="margin-right:1.1rem;white-space:nowrap;">
<span style="display:inline-block;width:10px;height:10px;background:${col};border-radius:2px;margin-right:3px;vertical-align:middle;"></span>
<strong>${s}</strong> ${v != null ? (v * 100).toFixed(1) + " pp" : "—"}
</span>`;
});
return html`<div>
<div style="font-size:0.85rem;color:var(--rc-muted);margin-bottom:0.4rem;">Clearing the <strong>${calSlot} ${calThr}</strong> line (${tv} fp). Points on the diagonal are perfectly calibrated. Filled dots and shaded bands: bins with n ≥ 5. Hollow grey dots with n labels: sparse bins (n < 5) — lines and bands not drawn through these.</div>
${plot}
<div style="font-size:0.82rem;margin-top:0.5rem;color:var(--rc-muted);">Average prediction error for this slot/threshold (n-weighted mean |predicted − observed|): ${eceLine}</div>
</div>`;
}