• Player explorer
  • Compare players
  • Disagreement
  • Surprises
  • About
    • About FFHedge
    • Methodology
    • Calibration
  • reluctant criminologists

On this page

  • Player detail
    • This week’s outlook
    • Head-to-head
    • Distributions
    • Season track record

Player projections

Fantasy projections that hedge expert rankings with player usage and game environment data.
Showing WRs and RBs from the 2025 NFL season (validation archive). Planning for live 2026 projections to arrive with the schedule.

What does this week hold for Player X? Pick a position, a player, and a roster slot, and the model tells you the probability they clear a useful floor, target, or ceiling score — based on expert consensus rankings, player usage trends, and game environment, combined and calibrated on three seasons of data. The Flex view pools wide receivers and running backs into a single ranked list.

Weekly rankings

Choose a position (WR, RB, or the combined Flex pool), then a week, to see every active player ranked by expert consensus, with the expert-driven, data-driven, and deployed-blend mean projections side by side. Click any player to open the detail view below: their three predictive distributions (or all seven behind a toggle), a week-by-week table for the season, and the probability of clearing each roster-slot threshold. The selected player stays selected as you move between weeks, so you can follow one player across the season.

Code
palette = ({
  accent: "#93c54b",
  accentDark: "#7aa83c",
  expert: "#b5651d",   // warm brown for the expert-driven (Model A) signal
  data:   "#3a6ea5",   // muted blue for the data-driven (Model B) signal
  mixture: "#3e3f3a",  // ink for the blended predictive
  sand: "#f8f5f0",
  bust: "#c9b8a3",
  held: "#dfe7c8",
  strong: "#b9d68a",
  leagueWinner: "#93c54b"
})

// Probability formatting with the "no false precision" rule: anything that
// would round to a flat tiny number is shown as "<1%"; high values mirror it.
fmtPct = function (p) {
  if (p == null || isNaN(p)) return "—";
  if (p < 0.01) return "<1%";
  if (p > 0.99) return ">99%";
  return (100 * p).toFixed(0) + "%";
}

// Fantasy-point formatting to one decimal.
fmtFp = function (x) {
  if (x == null || isNaN(x)) return "—";
  return x.toFixed(1);
}

// Four narrative-bin probabilities from the three exceedance probabilities.
// Inputs are P(exceed floor), P(exceed target), P(exceed ceiling).
narrativeProbs = function (pFloor, pTarget, pCeiling) {
  return {
    bust: Math.max(0, 1 - pFloor),
    held_up: Math.max(0, pFloor - pTarget),
    strong: Math.max(0, pTarget - pCeiling),
    league_winner: Math.max(0, pCeiling)
  };
}

// Map an ECR rank to its tier label (matches the export's ecr_tier bins).
ecrTier = function (ecr) {
  if (ecr == null || isNaN(ecr)) return null;
  if (ecr <= 5) return "1-5";
  if (ecr <= 12) return "6-12";
  if (ecr <= 24) return "13-24";
  if (ecr <= 48) return "25-48";
  if (ecr <= 96) return "49-96";
  return "97+";
}

// Position-prefixed ECR label. In Flex mode WR and RB ranks collide (both
// have a "#6"), so we prefix with the player's position (RB6 / WR6); in
// single-position mode the bare rank is unambiguous. The middle argument is a
// rank (the dense posRank from addPosRank below), not the raw continuous ECR.
ecrDisp = function (pos, ecr, isFlex) {
  if (ecr == null || isNaN(ecr)) return "—";
  const n = Math.round(ecr);
  return isFlex ? `${pos ?? "WR"}${n}` : `${n}`;
}

// Dense positional ECR rank (1..N within each position for a week's active
// pool, gapless). ecr_rank is the continuous FantasyPros average rank, so
// rounding it for display yields duplicate/skipped integers; dense-ranking
// recovers a clean ordinal rank. Mutates rows (adds `posRank`), returns rows.
addPosRank = function (rows) {
  const byPos = d3.group(rows, d => d.position ?? "WR");
  for (const [, ps] of byPos) {
    ps.slice()
      .sort((a, b) => d3.ascending(a.ecr ?? 9999, b.ecr ?? 9999))
      .forEach((p, i) => { p.posRank = i + 1; });
  }
  return rows;
}

// Linear mix of the Expert and Data marginals at lean w (w = weight on Expert).
// Exact for exceedance probabilities and the mean; use for all Blend numbers.
// (Percentiles are NOT linear — never synthesize blend percentiles with this.)
blendField = (em, dm, w, field) => {
  const e = em?.[field], d = dm?.[field];
  if (e == null || d == null) return e ?? d ?? null;
  return w * e + (1 - w) * d;
}

// Reference: the data-optimal stacked weight per position (what we used to
// deploy before Stage 1's 0.50 hedge), for the slider caption. Flex omitted
// (mixed pool, no single stacked weight).
stackedLean = ({ WR: 0.378, RB: 0.077 })

// Human-readable archetype labels for badges (WR archetype set).
archetypeLabel = ({
  fill_in_situation: "fill-in situation",
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  rookie_or_low_sample: "rookie / low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})

// RB archetype set. The RB build ships a different seven flags: fill_in_rb,
// is_rookie, and low_sample come straight from the feature table; the other four
// are carry-share analogs of the WR snap-share archetypes (see methodology).
rbFlagKeys = ["fill_in_rb","is_rookie","low_sample","late_season_expansion",
              "recent_role_change","stable_veteran","star_returning"]
rbArchetypeLabel = ({
  fill_in_rb: "fill-in (handcuff)",
  is_rookie: "rookie",
  low_sample: "low sample",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})
rbCompactLabel = ({
  fill_in_rb: "fill-in", is_rookie: "rookie", low_sample: "low-smp",
  late_season_expansion: "late-exp", recent_role_change: "role-chg",
  stable_veteran: "stable", star_returning: "star-ret"
})

// Slot thresholds by position. Decoupled from the locked-config files so the
// Flex position's combined slots (WR1/RB1, WR2/RB2) resolve to a single set of
// thresholds — the WR and RB tiers share identical floor/target/ceiling values.
positionThresholds = ({
  WR: { WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  RB: { RB1: { floor: 12, target: 16, ceiling: 20 },
        RB2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  Flex: { "WR1/RB1": { floor: 12, target: 16, ceiling: 20 },
          "WR2/RB2": { floor: 10, target: 12, ceiling: 15 },
          Flex: { floor: 6, target: 10, ceiling: 15 } }
})

// Slot dropdown options per position. WR/RB keep their own tiers; Flex mixes the
// two pools with combined tier labels (no position-specific tier filtering).
slotOptionsFor = function (position) {
  if (position === "RB") return ["RB1", "RB2", "Flex"];
  if (position === "Flex") return ["WR1/RB1", "WR2/RB2", "Flex"];
  return ["WR1", "WR2", "Flex"];
}

// Map an exceedance-probability slot to the per-row column name. RB and WR rows
// carry the same slot column names as their own position; the Flex combined
// slots read the underlying-position column on each row (WR1/RB1 -> WR1 on a WR
// row, RB1 on an RB row).
slotColFor = function (slot, rowPosition) {
  if (slot === "WR1/RB1") return rowPosition === "RB" ? "RB1" : "WR1";
  if (slot === "WR2/RB2") return rowPosition === "RB" ? "RB2" : "WR2";
  return slot;
}

// Per-row archetype keys and compact/full labels, branching on the row's
// position. Used in Flex mode where WR and RB rows are interleaved.
flagKeysForRow = (rowPosition) => rowPosition === "RB" ? rbFlagKeys : FLAG_KEYS_WR;
compactLabelForRow = (rowPosition) => rowPosition === "RB" ? rbCompactLabel : compactLabelWR;
fullLabelForRow = (rowPosition) => rowPosition === "RB" ? rbArchetypeLabel : archetypeLabel;

// WR flag keys / compact labels live here too so the per-row resolvers above
// work on every page without each page having to define the WR set first.
FLAG_KEYS_WR = ["fill_in_situation","emerging_player_elevation","late_season_expansion",
                "recent_role_change","rookie_or_low_sample","stable_veteran","star_returning"]
compactLabelWR = ({
  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"
})
Code
// Position selector (WR / RB / Flex). sessionStorage gives within-session memory
// so the choice persists as you move between pages without leaking across visits.
viewof position = (() => {
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_position") : null) ?? "WR";
  const radio = Inputs.radio(["WR","RB","Flex"], { value: saved, label: "Position" });
  radio.addEventListener("input", () => {
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
})()
Code
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>`;
}
Code
// Keep the URL hash in sync for shareable links (no hashchange listener, so no
// reactive loop). replaceState avoids polluting browser history.
{
  try {
    if (typeof location === "undefined") return html``;
    const slug = effectiveId ? slugById.get(effectiveId) : null;
    const h = `#week=${selectedWeek}` + (slug ? `&player=${slug}` : "");
    if (location.hash !== h) history.replaceState(null, "", h);
  } catch (e) {}
  return html``;
}
Code
weekOptions = ["all weeks"].concat(d3.range(1, 18))
// Shared source of truth for the chosen week. Both the top selector here and the
// in-detail selector lower down write this mutable and re-render off it, so the
// two stay in lockstep; downstream cells only read it, so there is no loop.
mutable selectedWeek = initialHash.week
Code
// Top week selector: reads the shared mutable (re-rendering to match when the
// in-detail copy changes it) and writes it on input.
{
  const sel = Inputs.select(weekOptions, { value: selectedWeek, label: "Week (2025)" });
  sel.addEventListener("input", () => { mutable selectedWeek = sel.value; });
  return sel;
}
Code
// Filters the specific-week ranked table by name (case-insensitive substring);
// ignored by the "all weeks" dropdown.
viewof playerQuery = Inputs.text({ label: "Search", placeholder: "Filter receivers by name…" })
Code
// Ranked player table (specific week) or a season-view player picker (all weeks).
// Clicking a row sets the selection; the highlighted row reflects the current pick.
{
  if (selectedWeek === "all weeks") {
    const opts = allPlayersByBestEcr.map(p =>
      html`<option value="${p.id}" ${p.id === effectiveId ? "selected" : ""}>${p.name} (${p.team ?? "—"})</option>`);
    const sel = html`<select>${opts}</select>`;
    sel.onchange = () => {
      mutable selectedId = sel.value;
      requestAnimationFrame(() => document.getElementById("player-detail")?.scrollIntoView({ behavior: "smooth", block: "start" }));
    };
    return html`<div style="margin:0.5rem 0;">
      <label style="font-size:0.9rem;color:var(--rc-muted);margin-right:0.5rem;">Player</label>${sel}
      <div style="font-size:0.85rem;color:var(--rc-muted);margin-top:0.3rem;">Season view: pick a receiver to see the full-season table below. Choose a specific week above for the ranked table.</div>
    </div>`;
  }
  const players = weekPlayers;
  if (!players.length) return html`<em>No active receivers for this week.</em>`;
  const q = (playerQuery ?? "").trim().toLowerCase();
  const filtering = q.length > 0;
  const rows = [];
  players.forEach((p, i) => {
    if (filtering && !p.name.toLowerCase().includes(q)) return;   // keep original rank i
    // Tier separators only on the full single-position list; the every-12
    // "Tier N" bands are meaningless for the merged WR+RB Flex pool, so suppress
    // them there.
    if (!filtering && position !== "Flex" && i % 12 === 0) {
      const tierN = Math.floor(i / 12) + 1;
      const pfx = position === "RB" ? "RB" : "WR";
      const label = tierN >= 5 ? `${pfx}5+` : `${pfx}${tierN}`;
      rows.push(html`<tr class="tier-sep"><td colspan="8">${label}</td></tr>`);
    }
    const ar = archByWeek.get(p.id);
    const rowKeys = flagKeysForRow(p.position);
    const rowLab = compactLabelForRow(p.position);
    const badges = ar ? rowKeys.filter(k => ar[k]).map(k => html`<span class="archetype-badge compact">${rowLab[k]}</span>`) : [];
    // ECR column only in Flex, where the far-left "#" no longer doubles as the
    // dense positional rank (the Flex pool mixes WR and RB); in single-position
    // mode "#" already is the dense rank, so the column is dropped.
    const tr = html`<tr class="data-row ${p.id === effectiveId ? "selected" : ""}">
      <td>${i + 1}</td>
      <td class="left">${p.name}</td>
      <td class="left">${p.team ?? "—"}</td>
      ${position === "Flex" ? html`<td>${ecrDisp(p.position, p.posRank, true)}</td>` : ""}
      <td class="left">${badges}</td>
      <td>${fmtFp(p.expert)}</td>
      <td style="background:rgba(147,197,75,0.10);">${fmtFp(p.blend)}</td>
      <td>${fmtFp(p.data)}</td>
      <td>${p.realized != null ? fmtFp(p.realized) : ""}</td>
    </tr>`;
    tr.onclick = () => {
      mutable selectedId = p.id;
      requestAnimationFrame(() => document.getElementById("player-detail")?.scrollIntoView({ behavior: "smooth", block: "start" }));
    };
    rows.push(tr);
  });
  if (filtering && rows.length === 0) return html`<div style="color:var(--rc-muted);margin:0.5rem 0;">No receivers match "${playerQuery}".</div>`;
  return html`<div style="max-height:420px;overflow-y:auto;border:1px solid var(--rc-sand-panel);border-radius:4px;">
    <table class="ranked-table">
    <thead><tr>
      <th>#</th><th class="left">player</th><th class="left">team</th>${position === "Flex" ? html`<th>ECR</th>` : ""}<th class="left">flags</th>
      <th>Expert</th><th style="background:rgba(147,197,75,0.10);" title="our preferred 50/50 hedge">Blend</th><th>Data</th><th>realized</th>
    </tr></thead>
    <tbody>${rows}</tbody>
  </table>
  </div>`;
}

Player detail

Code
{
  if (effectiveId == null || detailMeta == null) return html`<em>Select a receiver above.</em>`;
  return buildHeader(detailMeta, detailArch, selectedWeek);
}
Code
// In-detail week selector: a synced copy of the top selector. Same shared mutable,
// so changing either one moves the other and re-runs every downstream cell. Lets
// you switch weeks without scrolling back up to the ranked table.
{
  const sel = Inputs.select(weekOptions, { value: selectedWeek, label: "Week (2025)" });
  sel.addEventListener("input", () => { mutable selectedWeek = sel.value; });
  return sel;
}

This week’s outlook

Code
{
  if (selectedWeek === "all weeks" || effectiveId == null || !selRows.length) return html``;
  return html`<p>A start-or-sit call often comes down to one question: if you put this player in a given roster slot, how likely are they to clear a score that actually helps you there? The model answers that directly. Pick the slot you would start them in, and you'll see the chance they clear that slot's floor, target, and ceiling, along with the shape of the week — how the probability splits across a bust, a useful week, a strong week, and a league-winner.</p>`;
}
Code
defaultSlot = {
  const opts = slotOptionsFor(position);
  const e = detailMeta?.ecr_rank;
  if (e == null) return opts[2];
  if (e <= 12) return opts[0];
  if (e <= 24) return opts[1];
  return opts[2];
}
viewof slot = Inputs.radio(slotOptionsFor(position), { value: defaultSlot, label: "Roster slot" })
Code
// Expert<->Data lean slider (w = weight on Expert). Default 0.50 is the deployed
// hedge, so at the default the displayed Blend exactly equals the stored
// cross_blend. Persists across the explorer and compare pages via sessionStorage.
viewof lean = (() => {
  const s0 = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_lean") : null);
  const s = (s0 != null && !isNaN(+s0)) ? +s0 : 0.5;
  const r = Inputs.range([0, 1], { step: 0.05, value: s, label: "Blend lean (Data ↔ Expert)" });
  r.addEventListener("input", () => { try { sessionStorage.setItem("ffhedge_lean", r.value); } catch (e) {} });
  return r;
})()
Code
{
  const ref = (position === "WR" || position === "RB")
    ? `; the data-optimal stacked weight was ${Math.round(100 * stackedLean[position])}% expert.`
    : ".";
  return html`<div style="font-size:0.8rem;color:var(--rc-muted);margin:-0.2rem 0 0.6rem;">
    Blend = ${Math.round(100 * lean)}% expert / ${Math.round(100 * (1 - lean))}% data. Default 0.50 is the deployed hedge${ref}</div>`;
}
Code
html`<div style="display:block;font-size:0.8rem;line-height:1.4;color:var(--rc-muted);margin:0.5rem 0 0.8rem;">
  The lean matters most where Expert and Data disagree. For a running back you're weighing as a flex floor play in particular, the Data side is the better-calibrated read of the 6 fp line, so slide toward it for that call.</div>`
Code
// Threshold definitions, cumulative P(exceed) bars (Expert/Data/Blend), the
// risk-profile stacked bar, and the plain-language disagreement sentence. The
// Blend is the lean mix of the Expert and Data marginals (blendField); at
// lean=0.50 this reproduces the stored cross_blend exactly.
{
  if (selectedWeek === "all weeks" || effectiveId == null || !selRows.length) return html``;
  const th = positionThresholds[position][slot];
  const colSlot = slotColFor(slot, detailMeta?.position);
  const em = byPred.get("expert_marginal"), dm = byPred.get("data_marginal");
  const pget = (row, t) => row ? row[`p_${colSlot}_${t}`] : null;
  // Deployed Blend at the slider lean: exact linear mix of the two marginals.
  const pblend = (t) => blendField(em, dm, lean, `p_${colSlot}_${t}`);
  const pctTxt = v => v != null ? Math.round(100 * v) + "%" : "—";
  const defs = { floor: "a baseline expectation at this position", target: "a strong weekly score", ceiling: "an elite weekly score" };

  const cumBar = (label, v, color, tint = false) => {
    const w = v != null ? 100 * v : 0;
    const rowTint = tint ? "background:rgba(147,197,75,0.10);border-radius:3px;" : "";
    return html`<div style="display:flex;align-items:center;gap:8px;margin:2px 0;${rowTint}">
      <span style="width:52px;font-size:0.78rem;color:var(--rc-muted);">${label}</span>
      <div style="flex:1;background:var(--rc-sand-panel);border-radius:3px;height:14px;">
        <div style="width:${w.toFixed(1)}%;background:${color};height:100%;border-radius:3px;"></div>
      </div>
      <span style="width:42px;text-align:right;font-size:0.78rem;font-weight:600;">${pctTxt(v)}</span>
    </div>`;
  };
  // Blend sits between Expert and Data and is tinted as the preferred 50/50 hedge.
  const thrGroup = (t) => html`<div style="margin-bottom:0.7rem;">
    <div style="font-size:0.85rem;margin-bottom:2px;"><strong>${t[0].toUpperCase() + t.slice(1)} — ${th[t]} fp</strong> <span style="color:var(--rc-muted);">${defs[t]}</span></div>
    ${cumBar("Expert", pget(em, t), palette.expert)}
    ${cumBar("Blend", pblend(t), palette.mixture, true)}
    ${cumBar("Data", pget(dm, t), palette.data)}
  </div>`;

  const segDefs = [["bust","bust",palette.bust],["held up","held_up",palette.held],["strong","strong",palette.strong],["league-winner","league_winner",palette.leagueWinner]];
  // riskBar takes an exceedance getter getp(t) so the Blend row can use the lean mix.
  const riskBar = (name, getp) => {
    const pr = narrativeProbs(getp("floor"), getp("target"), getp("ceiling"));
    return html`<div style="margin:3px 0;">
      <div style="font-size:0.78rem;color:var(--rc-muted);margin-bottom:1px;">${name}</div>
      <div style="display:flex;height:18px;border-radius:3px;overflow:hidden;border:1px solid var(--rc-sand-panel);">
        ${segDefs.map(([lab,key,col]) => {
          const w = 100 * (pr[key] ?? 0);
          return w > 0 ? html`<div title="${lab}: ${Math.round(w)}%" style="width:${w.toFixed(1)}%;background:${col};display:flex;align-items:center;justify-content:center;font-size:0.66rem;color:var(--rc-ink);">${w >= 9 ? Math.round(w) + "%" : ""}</div>` : null;
        })}
      </div>
    </div>`;
  };
  const legend = html`<div style="font-size:0.72rem;color:var(--rc-muted);margin-top:3px;">
    ${segDefs.map(([lab,key,col]) => html`<span><span style="display:inline-block;width:10px;height:10px;background:${col};border-radius:2px;margin:0 3px 0 8px;vertical-align:middle;"></span>${lab}</span>`)}
  </div>`;

  const E = pget(em,"target"), D = pget(dm,"target"), B = pblend("target");
  const moreless = (D != null && E != null && D >= E) ? "more" : "less";
  const sentence = (E != null && D != null && B != null)
    ? html`<p style="margin-top:0.7rem;">By the blend, ${detailMeta.player_display_name} clears the ${slot} target about ${Math.round(100*B)}% of the time; the data-driven view is ${moreless} optimistic (${Math.round(100*D)}%) than the expert-anchored view (${Math.round(100*E)}%).</p>`
    : html``;

  return html`<div>
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start;">
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.3rem;">Chance of clearing each line</div>
        ${["floor","target","ceiling"].map(thrGroup)}
      </div>
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.3rem;">Shape of the week</div>
        ${riskBar("Expert", t => pget(em, t))}${riskBar("Blend", pblend)}${riskBar("Data", t => pget(dm, t))}
        ${legend}
      </div>
    </div>
    ${sentence}
  </div>`;
}
Code
viewof showSurvival = Inputs.toggle({ label: "Show full survival curve", value: false })
Code
// The complete P(score > x) view the bars summarize: each source's exact points
// (five percentiles plus the slot's three threshold exceedances), thresholds marked.
{
  if (selectedWeek === "all weeks" || effectiveId == null || !selRows.length || !showSurvival) return html``;
  const th = positionThresholds[position][slot];
  const colSlot = slotColFor(slot, detailMeta?.position);
  const em = byPred.get("expert_marginal"), dm = byPred.get("data_marginal");
  // Each component's survival points: five percentiles + the slot's three exceedances.
  const survPts = (r) => !r ? [] : [
      [r.p10,0.90],[r.p25,0.75],[r.p50,0.50],[r.p75,0.25],[r.p90,0.10],
      [th.floor, r[`p_${colSlot}_floor`]],[th.target, r[`p_${colSlot}_target`]],[th.ceiling, r[`p_${colSlot}_ceiling`]]
    ].filter(d => d[0] != null && d[1] != null).map(d => ({ x: d[0], s: d[1] }))
     .sort((a,b) => d3.ascending(a.x, b.x));
  const eP = survPts(em), dP = survPts(dm);
  const pts = [];
  eP.forEach(p => pts.push({ src: "Expert", x: p.x, s: p.s }));
  dP.forEach(p => pts.push({ src: "Data", x: p.x, s: p.s }));
  // Blend survival is the lean mix of the two component survivals — exact, since
  // S(x) (unlike a percentile) is linear in the mixture weight at any fixed x.
  // Interpolate each component's S at the union of x-points (clamp at the ends),
  // then S_blend(x) = lean*S_exp(x) + (1-lean)*S_data(x). Never blend percentiles.
  const interpS = (arr, x) => {
    if (!arr.length) return null;
    if (x <= arr[0].x) return arr[0].s;
    if (x >= arr[arr.length - 1].x) return arr[arr.length - 1].s;
    for (let i = 0; i < arr.length - 1; i++) {
      const a = arr[i], b = arr[i + 1];
      if (x >= a.x && x <= b.x) return b.x === a.x ? (a.s + b.s) / 2 : a.s + (x - a.x) / (b.x - a.x) * (b.s - a.s);
    }
    return arr[arr.length - 1].s;
  };
  if (eP.length && dP.length) {
    const xs = Array.from(new Set([...eP.map(p => p.x), ...dP.map(p => p.x), th.floor, th.target, th.ceiling]))
      .sort((a, b) => d3.ascending(a, b));
    xs.forEach(x => pts.push({ src: "Blend", x, s: lean * interpS(eP, x) + (1 - lean) * interpS(dP, x) }));
  }
  const plot = Plot.plot({
    width: 700, height: 230, marginBottom: 34,
    x: { label: "fantasy points (0.5 PPR) →", domain: [0, Math.ceil(th.ceiling + 12)] },
    y: { label: "P(score > x)", domain: [0, 1], tickFormat: "%", grid: true },
    color: { legend: true, domain: ["Expert","Data","Blend"], range: [palette.expert, palette.data, palette.mixture] },
    marks: [
      Plot.ruleX([th.floor, th.target, th.ceiling], { stroke: "#9a9a9a", strokeDasharray: "3,3" }),
      Plot.line(pts, { x: "x", y: "s", stroke: "src", strokeWidth: 1.5, curve: "monotone-x" }),
      Plot.dot(pts, { x: "x", y: "s", fill: "src", r: 2 }),
      Plot.ruleY([0])
    ]
  });
  return html`<div class="plot-panel"><div style="font-size:0.8rem;color:var(--rc-muted);margin-bottom:0.3rem;">The complete distribution the bars above summarize: the probability of exceeding any score, with the floor, target, and ceiling marked.</div>${plot}</div>`;
}

Head-to-head

Code
bpFor = (id, wk) => new Map(predictives.filter(d => Number(d.week) === Number(wk) && d.player_id === id).map(r => [r.predictive, r]))
SRC_KEY = ({ Blend: "cross_blend", Expert: "expert_marginal", Data: "data_marginal" })
viewof compareMode = Inputs.toggle({ label: "Compare with another player", value: false })
Code
mutable playerB = null
cmpInputs = {
  if (!compareMode || selectedWeek === "all weeks" || effectiveId == null) return null;
  const srcRadio = Inputs.radio(["Blend", "Expert", "Data"], { value: "Blend", label: "Source" });
  const slotOpts = slotOptionsFor(position);
  const slotRadio = Inputs.radio(slotOpts, { value: slotOpts[2], label: "Slot" });
  return { srcRadio, slotRadio };
}
cmpSource = cmpInputs ? Generators.input(cmpInputs.srcRadio) : "Blend"
cmpSlot = cmpInputs ? Generators.input(cmpInputs.slotRadio) : "Flex"
Code
// Player A identity label, above the controls.
{
  if (!cmpInputs) return html``;
  return html`<div style="margin:0.4rem 0;">
    <div style="font-size:0.78rem;color:var(--rc-muted);">Player A</div>
    <div style="font-weight:700;">${detailMeta ? detailMeta.player_display_name : "—"}</div>
  </div>`;
}
Code
// Player B selector: ranked native select, works reliably on desktop and mobile.
{
  if (!compareMode || selectedWeek === "all weeks" || effectiveId == null) return html``;
  const eligible = weekPlayers.filter(p => p.id !== effectiveId);
  if (!eligible.length) return html`<div style="color:var(--rc-muted);">No other active receivers this week.</div>`;
  const fmt = p => `${ecrDisp(p.position, p.posRank, position === "Flex")} ${p.name} (${p.team ?? "—"})`;
  const current = eligible.find(p => p.id === playerB) ?? eligible[0];
  const sel = Inputs.select(eligible, { format: fmt, value: current, label: "Player B" });
  sel.addEventListener("input", () => { mutable playerB = sel.value?.id ?? null; });
  if (playerB !== current.id) mutable playerB = current.id;
  return sel;
}
Code
// Source and slot radios stack below the Player B autocomplete.
{
  if (!cmpInputs) return html``;
  return html`<div>${cmpInputs.srcRadio}${cmpInputs.slotRadio}</div>`;
}
Code
{
  if (!compareMode || selectedWeek === "all weeks" || effectiveId == null || !selRows.length || playerB == null) return html``;
  const bpA = bpFor(effectiveId, selectedWeek);
  let bId = playerB;
  let bpB = bpFor(bId, selectedWeek);
  if (bId === effectiveId) {                                    // guard: fall back to next-ranked
    const alt = weekPlayers.find(p => p.id !== effectiveId);
    if (alt) { bId = alt.id; bpB = bpFor(bId, selectedWeek); }
  }
  const nameA = detailMeta ? detailMeta.player_display_name : nameById.get(effectiveId);
  const nameB = nameById.get(bId);
  if (!bpB || bpB.size === 0) return html`<div class="info-card" style="color:var(--rc-muted);">No data for ${nameB ?? "the selected player"} in week ${selectedWeek}.</div>`;
  const key = SRC_KEY[cmpSource];
  const rowA = bpA.get(key), rowB = bpB.get(key);
  if (!rowA || !rowB) return html`<div class="info-card" style="color:var(--rc-muted);">No ${cmpSource} projection for both players in week ${selectedWeek}.</div>`;
  const th = positionThresholds[position][cmpSlot];
  const colA = palette.accent, colB = palette.data;
  // For the Blend source, threshold bars and probability numbers use the slider's
  // lean mix of each player's two marginals; Expert/Data read their own row. The
  // density and mean visuals keep each source's own percentiles (at lean=0.5 the
  // stored cross_blend already equals the mix). rowLead/rowLose used in the
  // takeaway are object-identical to rowA/rowB, so this lean-aware pget covers them.
  const margOf = new Map([[rowA, [bpA.get("expert_marginal"), bpA.get("data_marginal")]],
                          [rowB, [bpB.get("expert_marginal"), bpB.get("data_marginal")]]]);
  const pget = (row, t) => {
    const col = `p_${slotColFor(cmpSlot, row.position)}_${t}`;
    if (cmpSource === "Blend") { const m = margOf.get(row) || []; return blendField(m[0], m[1], lean, col); }
    return row[col];
  };

  const pairBar = (t) => {
    const bar = (v, col) => {
      const w = v != null ? 100 * v : 0;
      return html`<div style="display:flex;align-items:center;gap:6px;margin:1px 0;">
        <div style="flex:1;background:var(--rc-sand-panel);border-radius:3px;height:13px;"><div style="width:${w.toFixed(1)}%;background:${col};height:100%;border-radius:3px;"></div></div>
        <span style="width:38px;text-align:right;font-size:0.76rem;">${v != null ? Math.round(100 * v) + "%" : "—"}</span>
      </div>`;
    };
    return html`<div style="margin-bottom:0.55rem;">
      <div style="font-size:0.82rem;margin-bottom:1px;"><strong>${t[0].toUpperCase() + t.slice(1)} — ${th[t]} fp</strong></div>
      ${bar(pget(rowA, t), colA)}${bar(pget(rowB, t), colB)}
    </div>`;
  };

  const densA = quantileDensity(rowA), densB = quantileDensity(rowB);
  const xs = [];
  for (const r of [rowA, rowB]) { if (r.p90 != null) xs.push(r.p90); if (r.mean != null) xs.push(r.mean); if (r.realized_fp != null) xs.push(r.realized_fp); }
  const xdom = [0, Math.ceil((d3.max(xs) ?? 40) + 2)];
  const ymax = Math.max(d3.max(densA, d => d.y) ?? 1, d3.max(densB, d => d.y) ?? 1);
  const marks = [
    Plot.areaY(densA, { x: "x", y: "y", fill: colA, fillOpacity: 0.18, curve: "basis" }),
    Plot.areaY(densB, { x: "x", y: "y", fill: colB, fillOpacity: 0.18, curve: "basis" }),
    Plot.lineY(densA, { x: "x", y: "y", stroke: colA, strokeWidth: 1.5, curve: "basis" }),
    Plot.lineY(densB, { x: "x", y: "y", stroke: colB, strokeWidth: 1.5, curve: "basis" }),
    Plot.ruleX([rowA.mean], { stroke: colA, strokeDasharray: "4,3" }),
    Plot.ruleX([rowB.mean], { stroke: colB, strokeDasharray: "4,3" }),
    Plot.ruleY([0], { stroke: "#ddd" })
  ];
  if (rowA.realized_fp != null) marks.push(Plot.ruleX([rowA.realized_fp], { stroke: colA, strokeWidth: 3 }));
  if (rowB.realized_fp != null) marks.push(Plot.ruleX([rowB.realized_fp], { stroke: colB, strokeWidth: 3 }));
  const plot = Plot.plot({
    width: 700, height: 200, marginTop: 6, marginBottom: 30, marginLeft: 12, marginRight: 12,
    x: { domain: xdom, label: "fantasy points (0.5 PPR) →", labelAnchor: "center" },
    y: { axis: null, domain: [0, ymax * 1.15] },
    marks
  });

  const taF = pget(rowA, "floor"),   tbF = pget(rowB, "floor");
  const taT = pget(rowA, "target"),  tbT = pget(rowB, "target");
  const taC = pget(rowA, "ceiling"), tbC = pget(rowB, "ceiling");
  const MARGIN = 0.02;
  const dF = taF - tbF, dT = taT - tbT, dC = taC - tbC;
  const allSmall = Math.abs(dF) < MARGIN && Math.abs(dT) < MARGIN && Math.abs(dC) < MARGIN;
  const floorLeadsA = dF >= 0, targetLeadsA = dT >= 0, ceilingLeadsA = dC >= 0;
  // Divergent: floor and ceiling favour different players (any magnitude).
  const divergent = !allSmall && floorLeadsA !== ceilingLeadsA;
  const teamB = weekPlayers.find(p => p.id === bId)?.team;
  const sameTeamNote = (detailMeta?.team && teamB && detailMeta.team === teamB)
    ? ` (Same-team comparison — difference assumes independence and will understate true variance.)`
    : "";
  let takeaway;
  if (allSmall) {
    takeaway = `At the ${cmpSlot} position, ${nameA} and ${nameB} have nearly identical projected odds at every threshold — all differences fall within the model's ~2 percentage-point calibration margin. This is a coin flip; go with your gut or consider matchup context not captured by the model.${sameTeamNote}`;
  } else if (divergent) {
    const floorName  = floorLeadsA   ? nameA : nameB;
    const ceilName   = ceilingLeadsA ? nameA : nameB;
    const pF  = floorLeadsA   ? Math.round(100*taF) : Math.round(100*tbF);
    const pFo = floorLeadsA   ? Math.round(100*tbF) : Math.round(100*taF);
    const pC  = ceilingLeadsA ? Math.round(100*taC) : Math.round(100*tbC);
    const pCo = ceilingLeadsA ? Math.round(100*tbC) : Math.round(100*taC);
    if (Math.abs(dT) >= MARGIN) {
      const saferName  = targetLeadsA ? nameA : nameB;
      const upsideName = targetLeadsA ? nameB : nameA;
      const saferT  = targetLeadsA ? Math.round(100*taT) : Math.round(100*tbT);
      const upsideT = targetLeadsA ? Math.round(100*tbT) : Math.round(100*taT);
      takeaway = `Floor-vs-ceiling tradeoff at the ${cmpSlot} position. ${saferName} is the safer play — ${saferT}% vs ${upsideT}% chance of clearing the target (${th.target} fp) — and the more reliable floor (${pF}% vs ${pFo}% at ${th.floor} fp). ${upsideName} carries more upside: ${pC}% vs ${pCo}% at the ceiling (${th.ceiling} fp). Start ${saferName} if you need a dependable week; lean ${upsideName} if you're chasing a spike.${sameTeamNote}`;
    } else {
      takeaway = `These two are a coin flip at the ${cmpSlot} target (${th.target} fp) — that difference falls within the model's ~2 percentage-point calibration margin. Their profiles differ, though: ${floorName} has the edge as a floor play (${pF}% vs ${pFo}% at ${th.floor} fp), while ${ceilName} carries more upside (${pC}% vs ${pCo}% at the ceiling). Lean ${floorName} if you need a safe start; lean ${ceilName} if you're chasing a spike week.${sameTeamNote}`;
    }
  } else {
    const leadsA = (dF + dT + dC) >= 0;
    const rowLead = leadsA ? rowA : rowB;
    const rowLose = leadsA ? rowB : rowA;
    const leaderName = leadsA ? nameA : nameB;
    const p2 = v => v != null ? Math.round(100 * v) + "%" : "—";
    const gaps = [
      { label:"floor",   threshold:th.floor,   diff:Math.abs(dF) },
      { label:"target",  threshold:th.target,  diff:Math.abs(dT) },
      { label:"ceiling", threshold:th.ceiling, diff:Math.abs(dC) }
    ].filter(g => g.diff >= MARGIN).sort((a,b) => b.diff - a.diff);
    const bigGap = gaps[0];
    let txt = `${leaderName} projects as the stronger ${cmpSlot} play across all thresholds.`;
    if (bigGap) txt += ` The clearest edge is at the ${bigGap.label} (${bigGap.threshold} fp): ${p2(pget(rowLead, bigGap.label))} vs ${p2(pget(rowLose, bigGap.label))}.`;
    txt += ` Differences smaller than ~2 percentage points are within the model's calibration margin.${sameTeamNote}`;
    takeaway = txt;
  }

  const legend = html`<div style="font-size:0.74rem;color:var(--rc-muted);margin-bottom:0.4rem;">
    <span style="display:inline-block;width:10px;height:10px;background:${colA};border-radius:2px;margin-right:4px;vertical-align:middle;"></span>${nameA}
    <span style="display:inline-block;width:10px;height:10px;background:${colB};border-radius:2px;margin:0 4px 0 12px;vertical-align:middle;"></span>${nameB}
  </div>`;

  return html`<div class="plot-panel" style="margin-top:0.6rem;">
    <div style="font-weight:700;font-size:1.05rem;margin-bottom:0.2rem;">Week ${selectedWeek}: ${nameA} vs ${nameB}</div>
    ${legend}
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.4rem;align-items:start;">
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.3rem;">Chance of clearing each line (${cmpSource})</div>
        ${["floor", "target", "ceiling"].map(pairBar)}
      </div>
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.3rem;">${cmpSource} Expected FP Distributions</div>
        ${plot}
      </div>
    </div>
    <p style="margin-top:0.6rem;">${takeaway}</p>
  </div>`;
}

Distributions

Code
viewof showAll = Inputs.toggle({ label: "Show all 7 distributions", value: false })
Code
{
  if (selectedWeek === "all weeks" || effectiveId == null || !selRows.length) return html``;
  const cols = densityColumns(showAll, byPred);
  const xs = [];
  for (const c of cols) { const r = c.row; if (r) { if (r.p90 != null) xs.push(r.p90); if (r.mean != null) xs.push(r.mean); } }
  if (detailMeta && detailMeta.realized_fp != null) xs.push(detailMeta.realized_fp);
  const xdom = [0, Math.ceil((d3.max(xs) ?? 40) + 2)];
  return html`<div>
    <div style="font-size:0.85rem;color:var(--rc-muted);margin-bottom:0.5rem;">
      ${showAll ? "All seven model variants." : "Expert / Data / Blend."} Dashed line: mean.${detailMeta && detailMeta.realized_fp != null ? ` Thick green line: realized (${fmtFp(detailMeta.realized_fp)} fp).` : ""}
    </div>
    ${cols.map(c => densityPanelEl(c.row, xdom, c.label))}
  </div>`;
}
Code
// Read-the-curves caveat: collapsible, weekly view only, directly under the densities.
{
  if (selectedWeek === "all weeks" || effectiveId == null || !selRows.length) return html``;
  return html`<details>
    <summary>How to read these curves (and what they aren't)</summary>
    <p style="margin-top:0.5rem;">Across the 2025 holdout the cross-family blend did not rank receivers any better than the expert consensus on either top-12 or top-24 weekly hit rate, so none of these curves should be read as a way to out-pick the experts; what these panels offer instead is a clear look at where the expert-driven and data-driven models disagree, which is where a start-or-sit decision is genuinely hard. The thick green line marks what the player actually scored, and it is a single draw from the same uncertainty these distributions describe rather than the answer the model was aiming at, so a distribution that placed real mass near that outcome did its job even when its mean sat somewhere else.</p>
  </details>`;
}

Season track record

Code
{
  if (effectiveId == null) return html``;
  return html`<div style="font-size:0.85rem;color:var(--rc-muted);">The running week-by-week log of each view's mean projection against what the player actually scored — a quick calibration reference for how the projections have tracked this season.</div>`;
}
Code
// Season-accuracy panel — "all weeks" view only, above the week-by-week table.
{
  if (selectedWeek !== "all weeks" || effectiveId == null) return html``;
  return seasonAccuracyPanel(effectiveId);
}
Code
{
  if (effectiveId == null) return html``;
  return buildAllWeeks(effectiveId);
}
How Expert, Data, and Blend are defined Expert, Data, and Blend are model-based summaries built from the deployed model’s posterior draws, not raw expert projections. Blend is the deployed model. Expert and Data are the two halves of that same model: Expert is the part of the blend contributed by the consensus-anchored model, whose main input is the published expert consensus recalibrated against past outcomes, and Data is the part contributed by the usage-driven model, built from snap and target usage and game environment. The blend is an equal, 50/50 average of the two — a deliberate hedge rather than a tuned lean, with the slider above to shift it either way. The even split is a choice rather than a measurement: the stacking weights that would have set the lean proved too poorly identified to trust, so the deployed default hedges them evenly. Every number shown — means, percentiles, and the probability of clearing each threshold — is computed directly from the model’s draws, and the density curves are drawn through those exact percentiles.

FFHedge · 2025 season validation archive · a reluctant criminologists project.