pmcmcDemo = {
// ══════════════════════════════════════════════════════════════════════
// Wrapper + styles
// ══════════════════════════════════════════════════════════════════════
const wrapper = document.createElement("div");
wrapper.className = "pm-wrap";
wrapper.style.cssText = "font-family:system-ui,-apple-system,sans-serif;max-width:940px;margin:0 auto;";
wrapper.appendChild(injectStyle());
const css = document.createElement("style");
css.textContent = `
.pm-wrap .step-box { transition: fill .22s, stroke-width .22s; }
.pm-wrap .step-lbl { transition: fill .22s; }
.pm-wrap .theta-dot { transition: cx .45s cubic-bezier(.4,0,.2,1), cy .45s cubic-bezier(.4,0,.2,1), r .22s, fill .25s, opacity .22s; }
.pm-wrap .trajectory{ transition: opacity .28s, stroke .28s; }
.pm-wrap .propose-el{ transition: opacity .22s; }
.pm-wrap .accept-el { transition: opacity .22s, fill .22s; }
.pm-wrap .meter-bar { transition: width .35s ease-out, fill .22s; }
.pm-wrap .chain-line{ transition: opacity .25s; }
`;
wrapper.appendChild(css);
// ══════════════════════════════════════════════════════════════════════
// Deterministic RNG (LCG) + Box–Muller
// ══════════════════════════════════════════════════════════════════════
function makeRng(seed) {
let s = seed >>> 0;
const rng = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
const rnorm = (mu, sig) => {
const u1 = Math.max(rng(), 1e-12), u2 = rng();
return mu + sig * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
};
return { rng, rnorm };
}
// ══════════════════════════════════════════════════════════════════════
// Toy state-space model
// xₜ = xₜ₋₁ + θ + 𝒩(0, σ_p²) latent drift
// yₜ = xₜ + 𝒩(0, σ_o²) noisy obs
// ══════════════════════════════════════════════════════════════════════
const T = 20, X0 = 0, THETA_TRUE = 0.5;
const SIG_P = 0.7, SIG_O = 1.2;
function generateObs() {
const { rnorm } = makeRng(11);
const xs = [X0], ys = [];
for (let t = 1; t <= T; t++) {
const x = xs[t - 1] + THETA_TRUE + rnorm(0, SIG_P);
xs.push(x);
ys.push(x + rnorm(0, SIG_O));
}
return { xs: xs.slice(1), ys };
}
const { xs: xsTrue, ys: yObs } = generateObs();
// Bootstrap particle filter → log-marginal-likelihood estimate + trajectories
function particleFilter(theta, N, seed) {
const { rng, rnorm } = makeRng(seed);
let parts = d3.range(N).map(() => ({ x: X0, path: [X0] }));
let logL = 0;
for (let t = 0; t < T; t++) {
// Predict
parts.forEach(p => { p.x += theta + rnorm(0, SIG_P); p.path.push(p.x); });
// Weight
const ws = parts.map(p => Math.exp(-0.5 * ((p.x - yObs[t]) / SIG_O) ** 2));
const sumW = ws.reduce((a, b) => a + b, 0);
logL += Math.log(Math.max(sumW / N, 1e-300));
// Resample (multinomial)
const cum = []; let acc = 0;
for (const w of ws) { acc += w / sumW; cum.push(acc); }
const next = [];
for (let i = 0; i < N; i++) {
const u = rng();
let j = 0;
while (j < N - 1 && cum[j] < u) j++;
next.push({ x: parts[j].x, path: parts[j].path.slice() });
}
parts = next;
}
return { logL, parts };
}
// Reference log-likelihood profile on a θ grid (used as background orientation)
const THETA_MIN = -0.25, THETA_MAX = 1.25;
const GRID_N = 40;
const thetaGrid = d3.range(GRID_N + 1).map(i => THETA_MIN + (THETA_MAX - THETA_MIN) * i / GRID_N);
const logLref = thetaGrid.map(th => particleFilter(th, 150, 999).logL);
const llMax = d3.max(logLref), llMin = d3.min(logLref);
// ══════════════════════════════════════════════════════════════════════
// PMCMC → list of (sub-phase) frames
// ══════════════════════════════════════════════════════════════════════
function buildFrames({ nSteps, propSD, pfN, seed }) {
const { rng, rnorm } = makeRng(seed);
const frames = [];
let theta = -0.05; // deliberately off-peak
const pf0 = particleFilter(theta, pfN, seed + 3);
let logL = pf0.logL;
const chain = [{ theta, logL, accepted: true }];
frames.push({
phase: "init", step: 0, theta, thetaProp: null,
logL, logLprop: null, accepted: null,
trajs: pf0.parts.slice(0, 18).map(p => p.path),
chain: chain.slice()
});
for (let n = 1; n <= nSteps; n++) {
const thetaProp = theta + rnorm(0, propSD);
frames.push({
phase: "propose", step: n, theta, thetaProp,
logL, logLprop: null, accepted: null,
trajs: [], chain: chain.slice()
});
const pf = particleFilter(thetaProp, pfN, seed + n * 17 + 101);
const logLprop = pf.logL;
frames.push({
phase: "pfilter", step: n, theta, thetaProp,
logL, logLprop, accepted: null,
trajs: pf.parts.slice(0, 18).map(p => p.path),
chain: chain.slice()
});
const logA = Math.min(0, logLprop - logL); // symmetric q → cancels
const alpha = Math.exp(logA);
const u = rng();
const accept = u < alpha;
if (accept) { theta = thetaProp; logL = logLprop; }
chain.push({ theta, thetaProp, logL, accepted: accept });
frames.push({
phase: "decide", step: n, theta, thetaProp,
logL, logLprop, accepted: accept, alpha, u,
trajs: pf.parts.slice(0, 18).map(p => p.path),
chain: chain.slice()
});
}
return frames;
}
// ══════════════════════════════════════════════════════════════════════
// Layout
// ══════════════════════════════════════════════════════════════════════
const W = 920, H = 620;
const M = { top: 12, bottom: 12, left: 16, right: 16 };
const FLOW_H = 48, DEC_H = 78;
const panelY0 = M.top + FLOW_H + 12;
const panelH = H - M.bottom - DEC_H - panelY0;
const LW = 430, RW = 440, GAP = 20;
const svgSel = d3.create("svg")
.attr("viewBox", `0 0 ${W} ${H}`)
.style("width", "100%").style("max-width", `${W}px`)
.style("height", "auto").style("display", "block");
const svg = svgSel.node();
const defs = svgSel.append("defs");
const arrow = defs.append("marker").attr("id", "pm-arrow")
.attr("viewBox", "0 -5 10 10").attr("refX", 9).attr("refY", 0)
.attr("markerWidth", 5).attr("markerHeight", 5).attr("orient", "auto");
arrow.append("path").attr("d", "M0,-4L9,0L0,4").attr("fill", "#7c3aed");
// ── Step-flow bar (top) ──
const steps = [
{ key: "init", label: "① Initialise", color: "#475569" },
{ key: "propose", label: "② Propose θ*", color: "#7c3aed" },
{ key: "pfilter", label: "③ Run particle filter", color: "#3b82f6" },
{ key: "decide", label: "④ Accept / reject", color: "#16a34a" }
];
const flowG = svgSel.append("g").attr("transform", `translate(${M.left}, ${M.top})`);
const flowW = W - M.left - M.right;
const bw = (flowW - 6 * 3) / 4;
const stepNodes = [];
steps.forEach((s, i) => {
const g = flowG.append("g").attr("transform", `translate(${i * (bw + 6)}, 0)`);
const box = g.append("rect").attr("class", "step-box")
.attr("width", bw).attr("height", FLOW_H).attr("rx", 8)
.attr("fill", "#f8fafc").attr("stroke", s.color).attr("stroke-width", 1.2);
const lbl = g.append("text").attr("class", "step-lbl")
.attr("x", bw / 2).attr("y", FLOW_H / 2 + 5)
.attr("text-anchor", "middle")
.style("font-size", "13px").style("font-weight", 700).style("fill", s.color)
.text(s.label);
if (i < steps.length - 1) {
g.append("path")
.attr("d", `M${bw + 1},${FLOW_H/2} L${bw + 4.5},${FLOW_H/2} M${bw + 3},${FLOW_H/2 - 2} L${bw + 5.5},${FLOW_H/2} L${bw + 3},${FLOW_H/2 + 2}`)
.attr("fill", "none").attr("stroke", "#94a3b8").attr("stroke-width", 1);
}
stepNodes.push({ box, lbl, key: s.key, color: s.color });
});
// Loop arrow ④ → ②
const xPropBox = 1 * (bw + 6) + bw / 2;
const xDecBox = 3 * (bw + 6) + bw / 2;
flowG.append("path")
.attr("d", `M${xDecBox},${FLOW_H + 2} C${xDecBox},${FLOW_H + 14} ${xPropBox},${FLOW_H + 14} ${xPropBox},${FLOW_H + 2}`)
.attr("fill", "none").attr("stroke", "#94a3b8").attr("stroke-dasharray", "3,2");
flowG.append("path")
.attr("d", `M${xPropBox - 3},${FLOW_H + 3} L${xPropBox},${FLOW_H - 1} L${xPropBox + 3},${FLOW_H + 3} Z`)
.attr("fill", "#94a3b8");
// ══════════════════════════════════════════════════════════════════════
// LEFT PANEL — outer MCMC (parameter space θ)
// ══════════════════════════════════════════════════════════════════════
const LG = svgSel.append("g").attr("transform", `translate(${M.left}, ${panelY0})`);
LG.append("rect").attr("width", LW).attr("height", panelH)
.attr("fill", "#fcfcfd").attr("stroke", "#e2e8f0").attr("stroke-width", 1).attr("rx", 8);
LG.append("text").attr("x", 14).attr("y", 20)
.style("font-size", "13px").style("font-weight", 700).style("fill", "#0f172a")
.text("Outer MCMC — parameter space θ");
const padL = { t: 34, r: 18, b: 16, l: 42 };
const xT = d3.scaleLinear().domain([THETA_MIN, THETA_MAX]).range([padL.l, LW - padL.r]);
// Top half: log-likelihood reference profile
const llH = 104;
const yLL = d3.scaleLinear().domain([llMin, llMax])
.range([padL.t + llH, padL.t + 8]);
LG.append("path")
.attr("d", d3.area().x((_, i) => xT(thetaGrid[i])).y0(padL.t + llH).y1(d => yLL(d))(logLref))
.attr("fill", "#e0e7ff").attr("opacity", 0.75);
LG.append("path")
.attr("d", d3.line().x((_, i) => xT(thetaGrid[i])).y(d => yLL(d))(logLref))
.attr("fill", "none").attr("stroke", "#6366f1").attr("stroke-width", 1.5);
LG.append("text").attr("x", padL.l + 4).attr("y", padL.t + 14)
.style("font-size", "10.5px").style("fill", "#6366f1").style("font-weight", 700)
.text("log L(θ) — reference (grid eval)");
// θ_true marker (spans top profile and trace)
LG.append("line").attr("x1", xT(THETA_TRUE)).attr("x2", xT(THETA_TRUE))
.attr("y1", padL.t + 2).attr("y2", panelH - padL.b)
.attr("stroke", "#0f172a").attr("stroke-width", 1).attr("stroke-dasharray", "2,3").attr("opacity", 0.45);
LG.append("text").attr("x", xT(THETA_TRUE) + 4).attr("y", panelH - padL.b - 4)
.style("font-size", "10px").style("fill", "#0f172a").style("font-weight", 700)
.text(`θ = ${THETA_TRUE}`);
// θ axis between profile and trace
LG.append("g").attr("transform", `translate(0, ${padL.t + llH})`)
.style("font-size", "9.5px").style("color", "#94a3b8")
.call(d3.axisBottom(xT).ticks(6));
// Trace plot (n increases downward)
const traceY0 = padL.t + llH + 34;
const traceY1 = panelH - padL.b - 8;
LG.append("text").attr("x", 14).attr("y", traceY0 - 8)
.style("font-size", "11px").style("font-weight", 700).style("fill", "#334155")
.text("Chain trace — iteration n runs downward");
LG.append("rect")
.attr("x", padL.l).attr("y", traceY0)
.attr("width", LW - padL.r - padL.l).attr("height", traceY1 - traceY0)
.attr("fill", "#ffffff").attr("stroke", "#e2e8f0");
const yN = d3.scaleLinear();
const chainLine = LG.append("path").attr("class", "chain-line")
.attr("fill", "none").attr("stroke", "#16a34a").attr("stroke-width", 1.6)
.attr("opacity", 0.8);
const chainG = LG.append("g");
// Proposal fan (faint Gaussian bump centred at current θ)
const propFan = LG.append("path").attr("class", "propose-el")
.attr("fill", "#7c3aed").attr("opacity", 0);
// Proposal arrow + dots
const propArrow = LG.append("line").attr("class", "propose-el")
.attr("stroke", "#7c3aed").attr("stroke-width", 1.8)
.attr("marker-end", "url(#pm-arrow)").attr("opacity", 0);
const currDot = LG.append("circle").attr("class", "theta-dot")
.attr("r", 7).attr("fill", "#0f172a").attr("stroke", "#fff").attr("stroke-width", 2);
const propDot = LG.append("circle").attr("class", "theta-dot propose-el")
.attr("r", 7).attr("fill", "#7c3aed").attr("stroke", "#fff").attr("stroke-width", 2).attr("opacity", 0);
// ══════════════════════════════════════════════════════════════════════
// RIGHT PANEL — inner particle filter
// ══════════════════════════════════════════════════════════════════════
const RG = svgSel.append("g").attr("transform", `translate(${M.left + LW + GAP}, ${panelY0})`);
RG.append("rect").attr("width", RW).attr("height", panelH)
.attr("fill", "#fcfcfd").attr("stroke", "#e2e8f0").attr("stroke-width", 1).attr("rx", 8);
RG.append("text").attr("x", 14).attr("y", 20)
.style("font-size", "13px").style("font-weight", 700).style("fill", "#0f172a")
.text("Inner particle filter — simulate x₁:T, score against y₁:T");
const padR = { t: 34, r: 18, b: 92, l: 42 };
const xTime = d3.scaleLinear().domain([0, T]).range([padR.l, RW - padR.r]);
const allY = xsTrue.concat(yObs, [X0]);
const yMinN = d3.min(allY) - 2, yMaxN = d3.max(allY) + 3;
const yState = d3.scaleLinear().domain([yMinN, yMaxN])
.range([panelH - padR.b, padR.t + 10]);
RG.append("g").attr("transform", `translate(0, ${panelH - padR.b})`)
.style("font-size", "9.5px").style("color", "#94a3b8")
.call(d3.axisBottom(xTime).ticks(8));
RG.append("g").attr("transform", `translate(${padR.l}, 0)`)
.style("font-size", "9.5px").style("color", "#94a3b8")
.call(d3.axisLeft(yState).ticks(6));
RG.append("text").attr("x", (padR.l + RW - padR.r) / 2).attr("y", panelH - padR.b + 28)
.attr("text-anchor", "middle").style("font-size", "10.5px").style("fill", "#64748b")
.text("time t");
// Trajectory pool (pre-allocated)
const MAX_TRAJ = 18;
const trajG = RG.append("g");
const trajPaths = [];
for (let i = 0; i < MAX_TRAJ; i++) {
trajPaths.push(trajG.append("path").attr("class", "trajectory")
.attr("fill", "none").attr("stroke", "#3b82f6").attr("stroke-width", 1.2)
.attr("opacity", 0).node());
}
// Observations (red dots) — always visible
const obsG = RG.append("g");
yObs.forEach((y, i) => {
obsG.append("circle")
.attr("cx", xTime(i + 1)).attr("cy", yState(y)).attr("r", 3.3)
.attr("fill", "#dc2626").attr("stroke", "#fff").attr("stroke-width", 0.8);
});
// Legend
RG.append("circle").attr("cx", RW - padR.r - 94).attr("cy", padR.t + 12)
.attr("r", 3.3).attr("fill", "#dc2626");
RG.append("text").attr("x", RW - padR.r - 87).attr("y", padR.t + 15)
.style("font-size", "10px").style("fill", "#334155").text("obs yₜ");
RG.append("line").attr("x1", RW - padR.r - 42).attr("x2", RW - padR.r - 28)
.attr("y1", padR.t + 12).attr("y2", padR.t + 12)
.attr("stroke", "#3b82f6").attr("stroke-width", 1.5);
RG.append("text").attr("x", RW - padR.r - 22).attr("y", padR.t + 15)
.style("font-size", "10px").style("fill", "#334155").text("particles");
// Log-likelihood meters
const metersY = panelH - padR.b + 44;
RG.append("text").attr("x", padR.l).attr("y", metersY - 4)
.style("font-size", "10px").style("font-weight", 700).style("fill", "#334155")
.text("log p̂(y₁:T | θ) — noisy likelihood estimate from the PF");
const barMaxW = RW - padR.l - padR.r - 100;
const meterCurr = RG.append("g").attr("transform", `translate(${padR.l + 70}, ${metersY + 2})`);
meterCurr.append("rect").attr("width", barMaxW).attr("height", 9)
.attr("fill", "#eef2f7").attr("rx", 2);
const meterCurrBar = meterCurr.append("rect").attr("class", "meter-bar")
.attr("height", 9).attr("fill", "#475569").attr("rx", 2);
RG.append("text").attr("x", padR.l).attr("y", metersY + 10)
.style("font-size", "10px").style("fill", "#475569").style("font-weight", 700)
.text("current θ");
const meterCurrVal = RG.append("text").attr("x", padR.l + 70 + barMaxW + 4).attr("y", metersY + 10)
.style("font-size", "10px").style("fill", "#475569").style("font-weight", 700)
.style("font-variant-numeric", "tabular-nums");
const meterProp = RG.append("g").attr("transform", `translate(${padR.l + 70}, ${metersY + 18})`);
meterProp.append("rect").attr("width", barMaxW).attr("height", 9)
.attr("fill", "#eef2f7").attr("rx", 2);
const meterPropBar = meterProp.append("rect").attr("class", "meter-bar")
.attr("height", 9).attr("fill", "#7c3aed").attr("rx", 2);
RG.append("text").attr("x", padR.l).attr("y", metersY + 26)
.style("font-size", "10px").style("fill", "#7c3aed").style("font-weight", 700)
.text("proposed θ*");
const meterPropVal = RG.append("text").attr("x", padR.l + 70 + barMaxW + 4).attr("y", metersY + 26)
.style("font-size", "10px").style("fill", "#7c3aed").style("font-weight", 700)
.style("font-variant-numeric", "tabular-nums");
// ══════════════════════════════════════════════════════════════════════
// Decision strip (bottom)
// ══════════════════════════════════════════════════════════════════════
const DG = svgSel.append("g").attr("transform", `translate(${M.left}, ${H - DEC_H + 4})`);
const decW = W - M.left - M.right;
DG.append("rect").attr("width", decW).attr("height", DEC_H - M.bottom - 4)
.attr("fill", "#f8fafc").attr("stroke", "#e2e8f0").attr("rx", 8);
const decTitle = DG.append("text").attr("x", 14).attr("y", 18)
.style("font-size", "12px").style("font-weight", 700).style("fill", "#334155")
.text("Metropolis–Hastings decision");
const decFormula = DG.append("text").attr("x", 14).attr("y", 36)
.style("font-size", "11px").style("fill", "#475569")
.style("font-family", '"Latin Modern Math","STIX Two Math","Cambria Math",serif');
const decAlpha = DG.append("text").attr("x", 14).attr("y", 52)
.style("font-size", "11px").style("fill", "#334155")
.style("font-family", '"Latin Modern Math","STIX Two Math","Cambria Math",serif');
const badgeW = 120;
const decBadge = DG.append("g").attr("transform", `translate(${decW - badgeW - 12}, 12)`);
const decBadgeRect = decBadge.append("rect").attr("class", "accept-el")
.attr("width", badgeW).attr("height", 36).attr("rx", 6)
.attr("fill", "#e2e8f0").attr("opacity", 0);
const decBadgeText = decBadge.append("text").attr("class", "accept-el")
.attr("x", badgeW / 2).attr("y", 23).attr("text-anchor", "middle")
.style("font-size", "15px").style("font-weight", 800).style("fill", "#fff").attr("opacity", 0);
// ══════════════════════════════════════════════════════════════════════
// Controls
// ══════════════════════════════════════════════════════════════════════
const controls = document.createElement("div");
controls.style.cssText = "display:flex;gap:16px;margin:10px 0;flex-wrap:wrap;align-items:flex-end;";
const SL = {
nSteps: createSlider("chain length", 10, 120, 10, 50, "#16a34a", "green"),
propSD: createSlider("proposal σ", 0.02, 0.5, 0.01, 0.15,"#7c3aed", "purple"),
pfN: createSlider("PF particles", 20, 200, 10, 60, "#3b82f6", "blue"),
speed: createSlider("speed", 0.3, 4, 0.1, 1.2, "#d97706", "amber")
};
styleMathLabel(SL.propSD);
for (const s of Object.values(SL)) controls.appendChild(s.el);
const btnRow = document.createElement("div");
btnRow.style.cssText = "display:flex;gap:8px;margin:0 0 10px;max-width:440px;";
const btnPlay = createButton("▶ Play", "go");
const btnStep = createButton("Step ▸", "step");
const btnReset = createButton("↻ Reset", "reset");
btnRow.appendChild(btnPlay.el);
btnRow.appendChild(btnStep.el);
btnRow.appendChild(btnReset.el);
const scrubWrap = document.createElement("div");
scrubWrap.style.cssText = "margin-top:4px;";
const scrub = createSlider("frame", 0, 200, 1, 0, "#0891b2", "teal");
scrubWrap.appendChild(scrub.el);
// ══════════════════════════════════════════════════════════════════════
// State
// ══════════════════════════════════════════════════════════════════════
let frames = buildFrames({
nSteps: SL.nSteps.val(), propSD: SL.propSD.val(),
pfN: SL.pfN.val(), seed: 42
});
let fIdx = 0;
let playing = false;
let rafId = null;
scrub.update(0, 0, frames.length - 1);
// ══════════════════════════════════════════════════════════════════════
// Draw
// ══════════════════════════════════════════════════════════════════════
const llScale = d3.scaleLinear()
.domain([llMin - 2, llMax + 1]).range([2, barMaxW]).clamp(true);
function draw() {
const f = frames[fIdx];
const nChain = SL.nSteps.val();
yN.domain([0, nChain]).range([traceY0 + 6, traceY1 - 6]);
// ── Proposal fan (Gaussian bump around current θ, only in propose phase) ──
if (f.phase === "propose") {
const sd = SL.propSD.val();
const pts = d3.range(49).map(i => {
const x = f.theta - 3 * sd + 6 * sd * i / 48;
return [x, Math.exp(-0.5 * ((x - f.theta) / sd) ** 2)];
});
const yCurr = yN(f.chain.length - 1);
const fanH = 20;
propFan.attr("d",
d3.area().x(d => xT(d[0])).y0(yCurr).y1(d => yCurr - d[1] * fanH)(pts))
.attr("opacity", 0.28);
} else {
propFan.attr("opacity", 0);
}
// ── Current / proposal dots ──
const yCurr = yN(f.chain.length - 1);
currDot.attr("cx", xT(f.theta)).attr("cy", yCurr).attr("opacity", 1);
if (f.thetaProp != null && (f.phase === "propose" || f.phase === "pfilter")) {
const yProp = yN(f.step);
propDot.attr("cx", xT(f.thetaProp)).attr("cy", yProp).attr("opacity", 1);
propArrow
.attr("x1", xT(f.theta)).attr("y1", yCurr)
.attr("x2", xT(f.thetaProp)).attr("y2", yProp)
.attr("opacity", 0.9);
} else {
propDot.attr("opacity", 0);
propArrow.attr("opacity", 0);
}
// ── Chain markers: accepted = green dot at θ; rejected = gray × at θ* ──
chainG.selectAll("*").remove();
f.chain.forEach((c, i) => {
if (i === 0 || c.accepted) {
chainG.append("circle")
.attr("cx", xT(c.theta)).attr("cy", yN(i))
.attr("r", 3).attr("fill", "#16a34a").attr("opacity", 0.9);
} else if (c.thetaProp != null) {
// gray × at the rejected proposal location
const cx = xT(c.thetaProp), cy = yN(i), r = 3;
chainG.append("line")
.attr("x1", cx - r).attr("y1", cy - r)
.attr("x2", cx + r).attr("y2", cy + r)
.attr("stroke", "#94a3b8").attr("stroke-width", 1.3).attr("opacity", 0.75);
chainG.append("line")
.attr("x1", cx - r).attr("y1", cy + r)
.attr("x2", cx + r).attr("y2", cy - r)
.attr("stroke", "#94a3b8").attr("stroke-width", 1.3).attr("opacity", 0.75);
}
});
// step-after line = chain trajectory (flat segments encode rejections)
chainLine.attr("d",
d3.line().x(d => xT(d.theta)).y((_, i) => yN(i)).curve(d3.curveStepAfter)(f.chain));
// ── Particle trajectories ──
const lineT = d3.line().x((_, i) => xTime(i)).y(d => yState(d));
for (let i = 0; i < MAX_TRAJ; i++) {
const showPhase = f.phase === "pfilter" || f.phase === "decide" || f.phase === "init";
if (i < f.trajs.length && showPhase) {
trajPaths[i].setAttribute("d", lineT(f.trajs[i]));
trajPaths[i].setAttribute("opacity",
f.phase === "init" ? 0.40 :
f.phase === "pfilter" ? 0.55 :
f.accepted ? 0.55 : 0.20
);
trajPaths[i].setAttribute("stroke",
f.phase === "init" ? "#94a3b8" :
f.phase === "decide" && !f.accepted ? "#cbd5e1" :
f.phase === "decide" && f.accepted ? "#16a34a" :
"#3b82f6"
);
} else {
trajPaths[i].setAttribute("opacity", 0);
}
}
// ── Log-likelihood meters ──
meterCurrBar.attr("width", llScale(f.logL));
meterCurrVal.text(f.logL.toFixed(2));
if (f.logLprop != null) {
meterPropBar.attr("width", llScale(f.logLprop)).attr("opacity", 1);
meterPropVal.text(f.logLprop.toFixed(2)).attr("opacity", 1);
} else {
meterPropBar.attr("width", 0);
meterPropVal.text("");
}
// ── Step-flow highlight ──
stepNodes.forEach(s => {
const active = s.key === f.phase;
s.box.attr("fill", active ? s.color : "#f8fafc").attr("stroke-width", active ? 2 : 1.2);
s.lbl.style("fill", active ? "#ffffff" : s.color);
});
// ── Decision strip ──
if (f.phase === "init") {
decFormula.text(`Seeding chain: θ⁽⁰⁾ = ${f.theta.toFixed(3)}, log p̂(y|θ⁽⁰⁾) = ${f.logL.toFixed(2)}.`);
decAlpha.text(`A full particle filter is run once here so the first link of the chain has a likelihood to compare against.`);
decBadgeRect.attr("opacity", 0);
decBadgeText.attr("opacity", 0);
} else if (f.phase === "propose") {
decFormula.text(`② θ*⁽${f.step}⁾ ~ q(·|θ⁽${f.step-1}⁾ = ${f.theta.toFixed(3)}). Drew θ* = ${f.thetaProp.toFixed(3)} (q is 𝒩 with σ_q = ${SL.propSD.val().toFixed(2)}).`);
decAlpha.text("Symmetric proposal → the q ratio in the MH acceptance cancels.");
decBadgeRect.attr("opacity", 0);
decBadgeText.attr("opacity", 0);
} else if (f.phase === "pfilter") {
decFormula.text(`③ Running particle filter at θ* = ${f.thetaProp.toFixed(3)} with ${SL.pfN.val()} particles over T = ${T} time steps.`);
decAlpha.text(`→ log p̂(y|θ*) = ${f.logLprop.toFixed(2)} (this estimate is noisy — that noise is fine! PMCMC still targets the exact posterior.)`);
decBadgeRect.attr("opacity", 0);
decBadgeText.attr("opacity", 0);
} else { // decide
const ratio = Math.exp(f.logLprop - f.logL);
const alphaShown = Math.min(1, ratio);
const ratStr = ratio > 1e4 ? ratio.toExponential(2) : ratio.toFixed(3);
decFormula.text(`④ α = min(1, p̂(y|θ*) / p̂(y|θ) ) = min(1, exp(${f.logLprop.toFixed(2)} − ${f.logL.toFixed(2)}) ) = min(1, ${ratStr}) = ${alphaShown.toFixed(3)}`);
decAlpha.text(`drew u ~ U(0,1) = ${f.u.toFixed(3)} ⇒ ${f.accepted ? `u < α: move to θ* = ${f.thetaProp.toFixed(3)}` : `u ≥ α: stay at θ = ${f.theta.toFixed(3)}`}`);
decBadgeRect.attr("opacity", 1).attr("fill", f.accepted ? "#16a34a" : "#dc2626");
decBadgeText.attr("opacity", 1).text(f.accepted ? "ACCEPT ✓" : "REJECT ✗");
}
if (+scrub.input.value !== fIdx) {
scrub.input.value = fIdx;
scrub.sync();
}
}
// ══════════════════════════════════════════════════════════════════════
// Animation loop + events
// ══════════════════════════════════════════════════════════════════════
let lastTick = 0;
function loop(now) {
if (!playing) { rafId = null; return; }
const step = 700 / SL.speed.val();
if (now - lastTick >= step) {
lastTick = now;
fIdx = (fIdx + 1) % frames.length;
draw();
}
rafId = requestAnimationFrame(loop);
}
function recompute() {
frames = buildFrames({
nSteps: SL.nSteps.val(), propSD: SL.propSD.val(),
pfN: SL.pfN.val(), seed: 42
});
if (fIdx >= frames.length) fIdx = frames.length - 1;
scrub.update(fIdx, 0, frames.length - 1);
draw();
}
SL.nSteps.input.addEventListener("input", () => { SL.nSteps.sync(); recompute(); });
SL.propSD.input.addEventListener("input", () => { SL.propSD.sync(); recompute(); });
SL.pfN.input.addEventListener("input", () => { SL.pfN.sync(); recompute(); });
SL.speed.input.addEventListener("input", () => { SL.speed.sync(); });
scrub.input.addEventListener("input", () => {
scrub.sync();
fIdx = +scrub.input.value;
draw();
});
function setPlaying(on) {
playing = on;
btnPlay.setText(on ? "⏸ Pause" : "▶ Play");
btnPlay.el.className = `sl-btn sl-btn-${on ? "pause" : "go"}`;
if (on && rafId == null) { lastTick = 0; rafId = requestAnimationFrame(loop); }
}
btnPlay.el.addEventListener("click", () => setPlaying(!playing));
btnStep.el.addEventListener("click", () => { setPlaying(false); fIdx = (fIdx + 1) % frames.length; draw(); });
btnReset.el.addEventListener("click", () => { setPlaying(false); fIdx = 0; draw(); });
// ══════════════════════════════════════════════════════════════════════
// Assemble
// ══════════════════════════════════════════════════════════════════════
wrapper.appendChild(controls);
wrapper.appendChild(btnRow);
wrapper.appendChild(svg);
wrapper.appendChild(scrubWrap);
draw();
return wrapper;
}