db = DuckDBClient.of({
wrPred: FileAttachment("data/predictives.parquet"),
wrArch: FileAttachment("data/archetypes.parquet"),
rbPred: FileAttachment("data/rb_predictives.parquet"),
rbArch: FileAttachment("data/rb_archetypes.parquet")
})
wrPredictives = db.query(`SELECT * FROM wrPred`)
wrArchetypes = db.query(`SELECT * FROM wrArch`)
rbPredictives = db.query(`SELECT * FROM rbPred`)
rbArchetypes = db.query(`SELECT * FROM rbArch`)
wrCfg = FileAttachment("data/locked_config.json").json()
rbCfg = FileAttachment("data/rb_locked_config.json").json()
// Position-active datasets. Flex concatenates the WR and RB pools (no tier
// filtering); each row already carries a `position` field. Threshold lookups
// use positionThresholds (from _ojs_helpers) rather than cfg so the Flex
// combined slots resolve cleanly.
predictives = position === "RB" ? rbPredictives
: position === "Flex" ? wrPredictives.concat(rbPredictives)
: wrPredictives
archetypes = position === "RB" ? rbArchetypes
: position === "Flex" ? wrArchetypes.concat(rbArchetypes)
: wrArchetypes
cfg = position === "RB" ? rbCfg : wrCfg
// ---- lookups --------------------------------------------------------------
nameById = new Map(predictives.map(d => [d.player_id, d.player_display_name]))
slugify = function (s) {
return (s ?? "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
slugById = new Map([...nameById].map(([id, nm]) => [id, slugify(nm)]))
idBySlug = {
const m = new Map();
for (const [id, nm] of nameById) { const s = slugify(nm); if (!m.has(s)) m.set(s, id); }
return m;
}
allPlayersByBestEcr = {
const groups = d3.rollups(predictives,
v => ({ name: v[0].player_display_name, team: v[0].team, bestEcr: d3.min(v, d => d.ecr_rank) }),
d => d.player_id);
const arr = groups.map(([id, o]) => ({ id, ...o }));
arr.sort((a, b) => d3.ascending(a.bestEcr ?? 9999, b.bestEcr ?? 9999) || d3.ascending(a.name, b.name));
return arr;
}
// ---- constants ------------------------------------------------------------
PRED_ORDER = ["pure_A_zinb","pure_B_zinb","stacked_zinb",
"pure_A_gamma","pure_B_gamma","stacked_gamma","cross_blend"]
predLabel = ({
pure_A_zinb: "Expert · ZINB (Model A)",
pure_B_zinb: "Data · ZINB (Model B)",
stacked_zinb: "Within-family blend · ZINB",
pure_A_gamma: "Expert · Gamma (Model A)",
pure_B_gamma: "Data · Gamma (Model B)",
stacked_gamma: "Within-family blend · Gamma",
cross_blend: "Cross-family blend (deployed)"
})
FLAG_KEYS = ["fill_in_situation","emerging_player_elevation","late_season_expansion",
"recent_role_change","rookie_or_low_sample","stable_veteran","star_returning"]
compactLabel = ({
fill_in_situation:"fill-in", emerging_player_elevation:"emerging",
late_season_expansion:"late-exp", recent_role_change:"role-chg",
rookie_or_low_sample:"rookie/ls", stable_veteran:"stable", star_returning:"star-ret"
})
ACCENT_TINT = "rgba(147, 197, 75, 0.18)" // --rc-accent at low opacity
// ---- shareable URL hash (#week=5&player=garrett-wilson) -------------------
initialHash = {
try {
const raw = (typeof location !== "undefined" ? location.hash : "").replace(/^#/, "");
const u = new URLSearchParams(raw);
const w = u.get("week");
let wk = 1;
if (w === "all weeks" || w === "all") wk = "all weeks";
else if (w != null && !isNaN(+w) && +w >= 1 && +w <= 17) wk = +w;
return { week: wk, playerSlug: u.get("player") };
} catch (e) { return { week: 1, playerSlug: null }; }
}
hashPlayerId = initialHash.playerSlug ? (idBySlug.get(initialHash.playerSlug) ?? null) : null
// ---- selection state ------------------------------------------------------
mutable selectedId = null
// Players active in the chosen week (specific-week mode), sorted by ECR.
weekPlayers = {
if (selectedWeek === "all weeks") return [];
const rows = predictives.filter(d => Number(d.week) === Number(selectedWeek));
const byPlayer = d3.group(rows, d => d.player_id);
const arr = [];
for (const [id, rs] of byPlayer) {
const m = new Map(rs.map(r => [r.predictive, r]));
const meta0 = rs[0];
arr.push({
id, name: meta0.player_display_name, team: meta0.team, ecr: meta0.ecr_rank,
realized: meta0.realized_fp, position: meta0.position ?? "WR",
expert: m.get("expert_marginal")?.mean ?? null, data: m.get("data_marginal")?.mean ?? null, blend: m.get("cross_blend")?.mean ?? null
});
}
arr.sort((a, b) => d3.ascending(a.ecr ?? 9999, b.ecr ?? 9999) || d3.ascending(a.name, b.name));
addPosRank(arr);
return arr;
}
archByWeek = {
if (selectedWeek === "all weeks") return new Map();
return new Map(archetypes.filter(d => Number(d.week) === Number(selectedWeek)).map(a => [a.player_id, a]));
}
// Effective selection: keep the user's pick if active this week, else fall back
// to the top-ranked player (or the URL-hash player on first load).
effectiveId = {
const pool = (selectedWeek === "all weeks") ? allPlayersByBestEcr : weekPlayers;
const ids = new Set(pool.map(p => p.id));
if (selectedId != null && ids.has(selectedId)) return selectedId;
if (selectedId == null && hashPlayerId != null && ids.has(hashPlayerId)) return hashPlayerId;
return pool.length ? pool[0].id : null;
}
selRows = (effectiveId == null || selectedWeek === "all weeks")
? []
: predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === effectiveId)
byPred = new Map(selRows.map(r => [r.predictive, r]))
detailMeta = {
if (effectiveId == null) return null;
if (selectedWeek !== "all weeks" && selRows.length) return selRows[0];
const rs = predictives.filter(d => d.player_id === effectiveId);
return rs.length ? rs.reduce((a, b) => (a.ecr_rank ?? 9999) <= (b.ecr_rank ?? 9999) ? a : b) : null;
}
detailArch = (selectedWeek === "all weeks" || effectiveId == null) ? null : (archByWeek.get(effectiveId) ?? null)
// ---- pure helpers ---------------------------------------------------------
// Approximate a predictive density from its 10/25/50/75/90 percentiles. Interval
// heights are mass/width; node heights average neighbouring intervals; tails
// taper to zero. Widths are floored at 0.5 fp so the integer-valued ZINB does not
// spike on tied quantiles. A display approximation, not a posterior.
quantileDensity = function (r) {
const q = [r.p10, r.p25, r.p50, r.p75, r.p90];
if (q.some(v => v == null || isNaN(v))) return [];
const minW = 0.5;
const w = [Math.max(q[1]-q[0],minW), Math.max(q[2]-q[1],minW), Math.max(q[3]-q[2],minW), Math.max(q[4]-q[3],minW)];
const dens = [0.15/w[0], 0.25/w[1], 0.25/w[2], 0.15/w[3]];
return [
{ x: q[0] - 0.6*w[0], y: 0 },
{ x: q[0], y: dens[0] },
{ x: q[1], y: (dens[0]+dens[1])/2 },
{ x: q[2], y: (dens[1]+dens[2])/2 },
{ x: q[3], y: (dens[2]+dens[3])/2 },
{ x: q[4], y: dens[3] },
{ x: q[4] + 0.6*w[3], y: 0 }
];
}
densityPanelEl = function (r, xdom, label) {
if (!r) return html`<div class="density-panel"><strong>${label}</strong>
<div style="color:var(--rc-muted);font-size:0.85rem;">no data</div></div>`;
const dens = quantileDensity(r);
const ymax = d3.max(dens, d => d.y) || 1;
const marks = [
Plot.areaY(dens, { x:"x", y:"y", fill: palette.accent, fillOpacity: 0.30, curve: "basis" }),
Plot.lineY(dens, { x:"x", y:"y", stroke: palette.accentDark, strokeWidth: 1.25, curve: "basis" }),
Plot.ruleX([r.mean], { stroke: palette.mixture, strokeWidth: 1.5, strokeDasharray: "4,3" }),
Plot.ruleY([0], { stroke: "#ddd" })
];
if (r.realized_fp != null) marks.push(Plot.ruleX([r.realized_fp], { stroke: palette.accent, strokeWidth: 3 }));
const plot = Plot.plot({
width: 700, height: 96, marginTop: 4, marginBottom: 26, marginLeft: 10, marginRight: 10,
x: { domain: xdom, label: "fantasy points (0.5 PPR) →", labelAnchor: "center" },
y: { axis: null, domain: [0, ymax * 1.15] },
marks
});
return html`<div class="density-panel">
<div style="display:flex;justify-content:space-between;align-items:baseline;font-size:0.85rem;margin-bottom:1px;">
<strong>${label}</strong>
<span style="color:var(--rc-muted)">mean ${fmtFp(r.mean)} · median ${fmtFp(r.p50)} · 10–90% ${fmtFp(r.p10)}–${fmtFp(r.p90)}</span>
</div>${plot}</div>`;
}
// Default view reads the shipped marginals directly (exact); the all-variants
// toggle shows the seven raw model predictives.
densityColumns = function (showAllVariants, bp) {
if (showAllVariants) return PRED_ORDER.map(p => ({ row: bp.get(p), label: predLabel[p] }));
return [
{ row: bp.get("expert_marginal"), label: "Expert (consensus-anchored)" },
{ row: bp.get("data_marginal"), label: "Data-driven" },
{ row: bp.get("cross_blend"), label: "Deployed blend" }
];
}
buildHeader = function (m, ar, wk) {
const keys = flagKeysForRow(m?.position);
const fullLab = fullLabelForRow(m?.position);
const flags = keys.filter(k => ar && ar[k]);
const badgeEls = flags.length
? flags.map(k => html`<span class="archetype-badge">${fullLab[k]}</span>`)
: [html`<span class="archetype-badge muted">no situational flags</span>`];
const tier = ar ? ar.ecr_tier : null;
// Dense positional rank from the week pool; the "all weeks" view has no
// single-week pool, so fall back to the rounded continuous ECR there.
const posRank = weekPlayers.find(p => p.id === effectiveId)?.posRank;
const rankVal = posRank != null ? posRank : (m.ecr_rank != null ? Math.round(m.ecr_rank) : null);
const ecrTxt = (wk === "all weeks")
? "season view"
: `ECR ${ecrDisp(m.position, rankVal, position === "Flex")}${tier ? ` (tier ${tier})` : ""} · week ${wk}`;
return html`<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;margin-top:1rem;">
<div style="flex:1 1 auto;min-width:240px;">
<div style="font-size:1.4rem;font-weight:700;">${m.player_display_name}</div>
<div style="color:var(--rc-muted);margin:0.15rem 0 0.5rem;">${m.team ?? "—"} · ${ecrTxt}</div>
<div>${badgeEls}</div>
</div>
<div class="info-card" style="text-align:center;min-width:130px;">
<h4>realized</h4>
<div style="font-size:1.6rem;font-weight:700;color:var(--rc-ink);">${(wk !== "all weeks" && m.realized_fp != null) ? fmtFp(m.realized_fp) : "—"}</div>
<div style="font-size:0.8rem;color:var(--rc-muted);">fantasy pts (0.5 PPR)</div>
</div>
</div>`;
}
buildAllWeeks = function (pid) {
const byWeek = d3.group(predictives.filter(d => d.player_id === pid), d => Number(d.week));
let sE = 0, sD = 0, sB = 0, sR = 0, sErr = 0, anyR = false;
const trs = [];
for (let w = 1; w <= 17; w++) {
const rs = byWeek.get(w);
if (!rs) { trs.push(html`<tr><td style="padding:3px 8px;">${w}</td><td colspan="5" style="padding:3px 8px;color:var(--rc-muted);">—</td></tr>`); continue; }
const m = new Map(rs.map(r => [r.predictive, r]));
const E = m.get("expert_marginal")?.mean ?? null, D = m.get("data_marginal")?.mean ?? null, B = m.get("cross_blend")?.mean ?? null;
const R = rs[0].realized_fp;
if (E != null) sE += E; if (D != null) sD += D; if (B != null) sB += B;
let errB = null, closest = null;
if (R != null) {
anyR = true; sR += R;
const errs = [["E", Math.abs((E ?? 1e9) - R)], ["D", Math.abs((D ?? 1e9) - R)], ["B", Math.abs((B ?? 1e9) - R)]];
errs.sort((a, b) => a[1] - b[1]); closest = errs[0][0];
errB = (B != null) ? Math.abs(B - R) : null; if (errB != null) sErr += errB;
}
const cell = (val, key) => {
const tint = (closest === key) ? `background:${ACCENT_TINT};` : "";
return html`<td style="padding:3px 8px;text-align:right;${tint}">${val != null ? fmtFp(val) : "—"}</td>`;
};
trs.push(html`<tr>
<td style="padding:3px 8px;">${w}</td>
${cell(E, "E")}${cell(D, "D")}${cell(B, "B")}
<td style="padding:3px 8px;text-align:right;">${R != null ? fmtFp(R) : "—"}</td>
<td style="padding:3px 8px;text-align:right;color:var(--rc-muted);">${errB != null ? fmtFp(errB) : "—"}</td>
</tr>`);
}
const summary = html`<tr style="border-top:2px solid var(--rc-sand-panel);font-weight:700;">
<td style="padding:3px 8px;">Season</td>
<td style="padding:3px 8px;text-align:right;">${fmtFp(sE)}</td>
<td style="padding:3px 8px;text-align:right;">${fmtFp(sD)}</td>
<td style="padding:3px 8px;text-align:right;">${fmtFp(sB)}</td>
<td style="padding:3px 8px;text-align:right;">${anyR ? fmtFp(sR) : "—"}</td>
<td style="padding:3px 8px;text-align:right;color:var(--rc-muted);">${anyR ? fmtFp(sErr) : "—"}</td>
</tr>`;
return html`<div>
<div style="font-size:0.85rem;color:var(--rc-muted);margin-bottom:0.5rem;">
Mean projection per week; each week the lightly tinted cell is the closest of Expert / Data / Blend to the realized result. The |err| column is the deployed blend's absolute error. The Season row sums each column.
</div>
<table style="border-collapse:collapse;font-size:0.85rem;">
<thead><tr>
<th style="padding:3px 8px;text-align:left;border-bottom:2px solid var(--rc-sand-panel);">wk</th>
<th style="padding:3px 8px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">Expert</th>
<th style="padding:3px 8px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">Data</th>
<th style="padding:3px 8px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">Blend</th>
<th style="padding:3px 8px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">Realized</th>
<th style="padding:3px 8px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">|err|</th>
</tr></thead>
<tbody>${trs}${summary}</tbody>
</table>
</div>`;
}
// Season-accuracy summary for the "all weeks" view: point-error metrics (MAE,
// season totals, season absolute error, times-closest) per source over the
// player's played weeks. Distributional/probabilistic scoring (CRPS, calibration)
// is deferred to the Calibration page.
seasonAccuracyPanel = function (pid) {
const byWeek = d3.group(predictives.filter(d => d.player_id === pid), d => Number(d.week));
const acc = { E: { pred: 0, abs: 0 }, D: { pred: 0, abs: 0 }, B: { pred: 0, abs: 0 } };
let N = 0, sumR = 0, expertCloser = 0, dataCloser = 0;
for (const [wk, rs] of byWeek) {
const m = new Map(rs.map(r => [r.predictive, r]));
const E = m.get("expert_marginal")?.mean, D = m.get("data_marginal")?.mean, B = m.get("cross_blend")?.mean;
const R = rs[0].realized_fp;
if (E == null || D == null || B == null || R == null) continue;
N++; sumR += R;
acc.E.pred += E; acc.D.pred += D; acc.B.pred += B;
const eE = Math.abs(E - R), eD = Math.abs(D - R), eB = Math.abs(B - R);
acc.E.abs += eE; acc.D.abs += eD; acc.B.abs += eB;
if (eE <= eD) expertCloser++; else dataCloser++; // two-way; ties to Expert so counts sum to N
}
if (N === 0) return html`<div style="color:var(--rc-muted);">No played weeks with both a projection and a realized score for this receiver.</div>`;
const rows = [
{ key: "E", name: "Expert", color: palette.expert, close: expertCloser },
{ key: "D", name: "Data", color: palette.data, close: dataCloser },
{ key: "B", name: "Blend", color: palette.mixture, close: null }
].map(s => {
const a = acc[s.key];
return { ...s, mae: a.abs / N, pred: a.pred, real: sumR, absErr: Math.abs(a.pred - sumR) };
});
const minMAE = Math.min(...rows.map(r => r.mae));
const minAbs = Math.min(...rows.map(r => r.absErr));
const maxClose = Math.max(expertCloser, dataCloser);
const eq = (a, b) => Math.abs(a - b) < 1e-9;
const tint = on => on ? `background:${ACCENT_TINT};` : "";
const cell = (txt, on) => html`<td style="padding:3px 10px;text-align:right;${tint(on)}">${txt}</td>`;
const body = rows.map(r => html`<tr>
<td style="padding:3px 10px;text-align:left;"><span style="display:inline-block;width:10px;height:10px;background:${r.color};border-radius:2px;margin-right:6px;vertical-align:middle;"></span>${r.name}</td>
${cell(fmtFp(r.mae), eq(r.mae, minMAE))}
${cell(fmtFp(r.pred), eq(r.absErr, minAbs))}
${cell(fmtFp(r.real), false)}
${cell(fmtFp(r.absErr), eq(r.absErr, minAbs))}
${cell(r.close == null ? "—" : r.close, r.close != null && r.close === maxClose && maxClose > 0)}
</tr>`);
// ---- adaptive takeaway: split clause + hedge clause + payoff clause ----
const expert = rows.find(r => r.key === "E"), data = rows.find(r => r.key === "D"), blend = rows.find(r => r.key === "B");
const Xe = fmtFp(expert.mae), Xd = fmtFp(data.mae), Xb = fmtFp(blend.mae);
let split;
if (Math.min(expertCloser, dataCloser) >= N / 3) {
split = `Expert was the closer call in ${expertCloser} of ${N} weeks and Data in ${dataCloser} — and there is rarely a way to know which in advance.`;
} else {
const hiName = expertCloser >= dataCloser ? "Expert" : "Data";
const loName = expertCloser >= dataCloser ? "Data" : "Expert";
const hi = Math.max(expertCloser, dataCloser), lo = Math.min(expertCloser, dataCloser);
split = `${hiName} was closer more often (${hi} of ${N} weeks), though ${loName} still won ${lo} — so even here the better source wasn't fully predictable.`;
}
const hedge = `The blend is built for exactly that uncertainty: it rarely wins a single week, but it hedges against committing to the wrong source when which one is closer keeps changing.`;
const lt = (a, b) => a < b - 1e-9, gt = (a, b) => a > b + 1e-9;
let payoff;
if (lt(blend.mae, expert.mae) && lt(blend.mae, data.mae)) {
payoff = `Here the hedge paid off — across the season the blend's average weekly error (${Xb} fp) was lower than both Expert (${Xe}) and Data (${Xd}).`;
} else if (gt(blend.mae, expert.mae) && gt(blend.mae, data.mae)) {
payoff = `Across the season the blend's average weekly error (${Xb} fp) was higher than both for this player — a reminder the hedge doesn't always win in a single small sample.`;
} else {
payoff = `Across the season the blend's average weekly error (${Xb} fp) landed between Expert (${Xe}) and Data (${Xd}), the cost of hedging when one source happened to lead this player.`;
}
const takeaway = `${split} ${hedge} ${payoff}`;
return html`<div style="margin-bottom:1rem;">
<h4 style="margin:0.5rem 0 0.3rem;">How the three views tracked ${nameById.get(pid)} this season</h4>
<table style="border-collapse:collapse;font-size:0.85rem;">
<thead><tr>
<th style="padding:3px 10px;text-align:left;border-bottom:2px solid var(--rc-sand-panel);">source</th>
<th style="padding:3px 10px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">MAE</th>
<th style="padding:3px 10px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">season pred</th>
<th style="padding:3px 10px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">season real</th>
<th style="padding:3px 10px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">season |err|</th>
<th style="padding:3px 10px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">times closest</th>
</tr></thead>
<tbody>${body}</tbody>
</table>
<div style="font-size:0.74rem;color:var(--rc-muted);margin-top:2px;">"Times closest" counts the weeks Expert versus Data was nearer the realized score (ties to Expert); the blend sits between them by construction, so it rarely wins a single week — its value shows in the MAE column instead.</div>
<p style="margin:0.6rem 0 0.2rem;">${takeaway}</p>
<div style="font-size:0.78rem;color:var(--rc-muted);">These are one player's ${N} weeks, a descriptive track record rather than proof; the population-level comparison across all players will live on the Calibration page.</div>
</div>`;
}