• Welcome
  • Start/Sit
  • Projections
  • Edge Finder
  • Track Record
  • About
    • About FFHedge
    • Methodology
    • Media

    • reluctant criminologists

Start/Sit

Should you start X or Y? The odds each clears its floor, target, and ceiling, with an honest verdict when it’s a coin flip.

Pick two players in the same week and compare their projected distributions and threshold probabilities side by side.

This page helps with start/sit decisions by comparing the probability that each player will clear a position-specific floor, target, or ceiling scoring threshold — and in many cases by showing that the choice between two players is close enough to decide by flipping a coin.

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, TE: 0.286, QB: 0.085 })

// 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","dual_threat_rb"]
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",
  dual_threat_rb: "dual-threat back"
})
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",
  dual_threat_rb: "dual-threat"
})

// TE archetype set (7 flags; target-share based — see te_archetype_flags.md).
teFlagKeys = ["emerging_player_elevation","late_season_expansion","recent_role_change",
              "is_rookie","low_sample","stable_veteran","star_returning"]
teArchetypeLabel = ({
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  is_rookie: "rookie",
  low_sample: "low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})
teCompactLabel = ({
  emerging_player_elevation:"emerging", late_season_expansion:"late-exp",
  recent_role_change:"role-chg", is_rookie:"rookie", low_sample:"low-smp",
  stable_veteran:"stable", star_returning:"star-ret"
})

// QB archetype set (8 flags; usage + ECR-tier based — see qb_archetype_flags.md).
// stable_starter is RANK-based (trailing-4wk mean ECR in the top 12), distinct from
// the usage-based WR/TE/RB stable_veteran; rushing_qb is the QB-specific dual-threat
// flag (a stable type from prior-season carries/game — see methodology).
qbFlagKeys = ["stable_starter","rushing_qb","new_starter","emerging_elevation",
              "late_season_expansion","star_returning","is_rookie","low_sample"]
qbArchetypeLabel = ({
  stable_starter: "stable starter",
  rushing_qb: "dual-threat QB",
  new_starter: "new starter",
  emerging_elevation: "emerging role",
  late_season_expansion: "late-season expansion",
  star_returning: "star returning",
  is_rookie: "rookie",
  low_sample: "low sample"
})
qbCompactLabel = ({
  stable_starter:"stable", rushing_qb:"dual-threat", new_starter:"new-str",
  emerging_elevation:"emerging", late_season_expansion:"late-exp",
  star_returning:"star-ret", is_rookie:"rookie", low_sample:"low-smp"
})

// 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 } },
  TE: { TE1: { floor: 5, target: 8, ceiling: 12 },
        WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  QB: { QB1: { floor: 15, target: 20, ceiling: 25 } },
  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 === "TE") return ["TE1", "Flex"];
  if (position === "QB") return ["QB1"];
  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 : rowPosition === "TE" ? teFlagKeys : rowPosition === "QB" ? qbFlagKeys : FLAG_KEYS_WR;
compactLabelForRow = (rowPosition) => rowPosition === "RB" ? rbCompactLabel : rowPosition === "TE" ? teCompactLabel : rowPosition === "QB" ? qbCompactLabel : compactLabelWR;
fullLabelForRow = (rowPosition) => rowPosition === "RB" ? rbArchetypeLabel : rowPosition === "TE" ? teArchetypeLabel : rowPosition === "QB" ? qbArchetypeLabel : 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"
})

// Biggest within-tier predictive surprise: among players of `position` whose ECR
// rank falls in [lo, hi] (one tier), the pair the experts ranked furthest apart
// that the deployed blend (cross_blend) still calls a coin flip — within `margin`
// on the floor, target, AND ceiling of `slot` (the roster slot you'd start that
// tier in). Ranks the position pool by true ECR each week; both players must sit in
// the tier. Falls back to the closest in-tier pair if no coin flip exists; null if
// the tier is empty. Returns { week, a, b, gapRanks, slot } with a/b =
// { id, name, team, position, ecr, rank, blend }.
mostSurprisingCoinflip = function (rows, weeks, position, lo, hi, slot, margin = 0.03) {
  const col = t => `p_${slot}_${t}`;
  let best = null, closest = null;
  for (const wk of weeks) {
    const wkRows = rows.filter(d => Number(d.week) === Number(wk) && (d.position ?? "WR") === position);
    const byPlayer = d3.group(wkRows, d => d.player_id);
    const pool = [];
    for (const [id, rs] of byPlayer) {
      const blend = rs.find(r => r.predictive === "cross_blend");
      if (!blend || rs[0].ecr_rank == null) continue;
      pool.push({ id, name: rs[0].player_display_name, team: rs[0].team,
                  position: rs[0].position ?? "WR", ecr: rs[0].ecr_rank, blend });
    }
    pool.sort((a, b) => d3.ascending(a.ecr, b.ecr));
    pool.forEach((p, i) => p.rank = i + 1);
    const tier = pool.filter(p => p.rank >= lo && p.rank <= hi);
    for (let i = 0; i < tier.length; i++) {
      for (let j = i + 1; j < tier.length; j++) {
        const a = tier[i], b = tier[j];
        const dF = Math.abs(a.blend[col("floor")] - b.blend[col("floor")]);
        const dT = Math.abs(a.blend[col("target")] - b.blend[col("target")]);
        const dC = Math.abs(a.blend[col("ceiling")] - b.blend[col("ceiling")]);
        if ([dF, dT, dC].some(v => v == null || isNaN(v))) continue;
        const gap = Math.abs(a.rank - b.rank);
        const comb = dF + dT + dC;
        if (dF < margin && dT < margin && dC < margin) {
          if (!best || gap > best.gapRanks) best = { week: wk, a, b, gapRanks: gap, slot };
        }
        if (!closest || comb < closest._comb) closest = { week: wk, a, b, gapRanks: gap, slot, _comb: comb };
      }
    }
  }
  return best ?? closest;
}

// Visitor-facing warning shown when the projection week fell back to draft-value
// (redraft) rankings because week-specific weekly rankings weren't available
// (ffpros scrape empty AND no nflverse-mirror weekly snapshot yet). Driven by
// rankings_fallback in the exported live config; pages call this from their
// "rankings as of" line only when that flag is true on the live-season view.
rankingsFallbackBanner = function () {
  return html`<div style="font-size:0.82rem;line-height:1.4;border-left:3px solid #d97706;background:rgba(217,119,6,0.10);padding:0.5rem 0.7rem;border-radius:4px;margin:-0.3rem 0 0.8rem;">
    <strong>⚠ Draft-value rankings.</strong> Week-specific weekly expert rankings aren't posted yet, so these projections use season-long <em>redraft</em> rankings — they don't yet reflect this week's matchups, injuries, or inactives. They sharpen once weekly rankings refresh.</div>`;
}

// Multi-select "filter by situation" chip row. Returns a viewof-compatible
// element whose .value is the array of selected archetype keys for `position`
// (empty when `hidden`, e.g. the season/in-dev views). OR semantics: a table row
// matches if it carries any selected flag. Used by Projections and Edge Finder.
archFilterChips = function (position, hidden) {
  if (hidden) { const e = html`<div></div>`; e.value = []; return e; }
  const keys = flagKeysForRow(position);
  const labels = fullLabelForRow(position);
  const sel = new Set();
  const box = html`<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0.3rem 0 0.5rem;"></div>`;
  box.appendChild(html`<span style="font-size:0.8rem;color:var(--rc-muted);margin-right:2px;">Filter by situation:</span>`);
  const paint = (chip, on) => { chip.style.cssText = `cursor:pointer;font-size:0.75rem;padding:2px 9px;border-radius:11px;border:1px solid var(--rc-sand-panel);background:${on ? "rgba(147,197,75,0.22)" : "var(--rc-sand)"};color:${on ? "#3e3f3a" : "#6b6b6b"};font-weight:${on ? 600 : 400};`; };
  for (const k of keys) {
    const chip = html`<button type="button">${labels[k]}</button>`;
    paint(chip, false);
    chip.onclick = () => { sel.has(k) ? sel.delete(k) : sel.add(k); paint(chip, sel.has(k)); box.value = Array.from(sel); box.dispatchEvent(new CustomEvent("input", { bubbles: true })); };
    box.appendChild(chip);
  }
  box.value = [];
  return box;
}

// ---------------------------------------------------------------------------
// Beta scoring toggle (Standard / Half-PPR / Full-PPR). Half-PPR is the deployed
// default and its data path is byte-identical to the pre-toggle dashboard.
// Standard and Full-PPR are a beta option served from models trained natively on
// each scoring for WR, RB, TE and Flex; QB is essentially scoring-invariant and
// stays on the half-PPR model. scoringKeyOf maps the label to the payload suffix
// the export writes ("" for half, "_std", "_ppr"); pages build their live
// FileAttachment paths from it, so switching scoring swaps only the live files.
// ---------------------------------------------------------------------------
scoringOptions = ["Standard", "Half-PPR", "Full-PPR"]
// Turn a locked-config [floor, target, ceiling] slot array into the {floor,
// target, ceiling} shape the pages read; null when malformed so callers fall back
// to the shared half thresholds.
slotToThresh = (s) => (s && s.length === 3) ? ({ floor: s[0], target: s[1], ceiling: s[2] }) : null
scoringKeyOf = function (s) { return s === "Standard" ? "std" : s === "Full-PPR" ? "ppr" : "half"; }
scoringSuffixOf = function (s) { const k = scoringKeyOf(s); return k === "half" ? "" : "_" + k; }
scoringShort = function (s) { return s === "Standard" ? "Std" : s === "Full-PPR" ? "PPR" : "Half"; }
isHalfScoring = function (s) { return scoringKeyOf(s) === "half"; }

// Small BETA pill for the non-default scorings; half renders nothing.
betaBadge = function (s) {
  if (scoringKeyOf(s) === "half") return html``;
  return html`<span style="display:inline-block;font-size:0.62rem;font-weight:700;letter-spacing:0.04em;padding:1px 6px;border-radius:9px;background:#e6ac00;color:#3e3f3a;vertical-align:middle;margin-left:6px;">Beta</span>`;
}

// Scoring radio, instantiated per page so the control renders in-page. The choice
// persists across visits via localStorage (unlike position/season, which are
// session-scoped) so a Standard/Full-PPR player's format survives a new visit;
// any storage failure falls back to Half-PPR. Each page wraps this as
// `viewof scoring = makeScoringInput()`.
makeScoringInput = function () {
  let saved = null;
  try { saved = (typeof localStorage !== "undefined") ? localStorage.getItem("ffhedge_scoring") : null; } catch (e) { saved = null; }
  const init = scoringOptions.includes(saved) ? saved : "Half-PPR";
  const radio = Inputs.radio(scoringOptions, { value: init, label: "Scoring" });
  radio.addEventListener("input", () => { try { localStorage.setItem("ffhedge_scoring", radio.value); } catch (e) {} });
  return radio;
}

// Honest one-line holdout read per scoring, from the pre-registered 2025 six-cell
// result: the native-scoring models tracked the half-PPR model almost exactly on
// the do-no-harm bar (every ranking difference within ~0.001 of zero), with a
// small genuine edge only at running back in full-PPR. Deliberately unshowy.
scoringVerdictNote = ({
  std: "In a check against the 2025 season, the Standard models' probabilities were about as well calibrated as the half-PPR model's, and their rankings were essentially tied with the half-PPR order, which is what I would expect when only the scoring format changes.",
  ppr: "In a check against the 2025 season, the full-PPR models' probabilities were about as well calibrated as the half-PPR model's, and their rankings were essentially tied with the half-PPR order. The one place format should matter most is elite receivers, where the experts' full-PPR rankings, which these models build on, carried some extra information."
})

// Beta banner shown above the data on non-half scorings: beta status, the honest
// holdout verdict, and a pointer to Methodology. Half renders nothing.
scoringBetaBanner = function (s) {
  const k = scoringKeyOf(s);
  if (k === "half") return html``;
  return html`<div style="font-size:0.85rem;line-height:1.45;border-left:3px solid #e6ac00;background:rgba(230,172,0,0.10);padding:0.6rem 0.85rem;border-radius:4px;margin:0.4rem 0 0.9rem;">
    <strong>${s} scoring (beta).</strong> These projections come from models built specifically for ${s} scoring at WR, RB, TE, and Flex, so the points and the floor, target, and ceiling probabilities match your league's format. Half-PPR remains the default and the most thoroughly tested view. ${scoringVerdictNote[k]} See the <a href="methodology.html#standard-and-full-ppr-scoring-beta">Methodology page</a> for details.</div>`;
}

// Empty-state card for a page/scoring with no data yet (Std/PPR projections not
// published this week; Std/PPR track record / edge finder during the beta). Renders
// the caller's message (Jon-approved copy); the beta banner above it names the
// scoring and carries the verdict.
scoringEmptyState = function (s, body) {
  return html`<div style="padding:1rem 1.15rem;margin:0.8rem 0;border:1px dashed var(--rc-sand-panel);border-radius:6px;background:var(--rc-sand);color:var(--rc-muted);font-size:0.9rem;line-height:1.55;">${body}</div>`;
}
Loading most recent projections…
Code
// Hide the static loading placeholder once the position's projections resolve.
hideLoading = { predictives; const el = document.getElementById("ffhedge-loading"); if (el) el.style.display = "none"; }
Code
// Position selector (WR / RB / Flex), persisted within the session.
viewof position = (() => {
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_position") : null) ?? "WR";
  const radio = Inputs.radio(["QB","WR","RB","TE","Flex"], { value: saved, label: "Position" });
  radio.addEventListener("input", () => {
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
})()
Code
// Beta scoring toggle (Standard / Half-PPR / Full-PPR). Half-PPR is the deployed
// default; switching swaps only the live payload files loaded below.
viewof scoring = makeScoringInput()
Code
// Persistent beta caption under the toggle (the pill also appears once a beta
// scoring is active). Prose is placeholder for Jon's final wording.
html`<div style="font-size:0.78rem;color:var(--rc-muted);margin:-0.4rem 0 0.6rem;">Standard and full-PPR scoring are available as a beta option; half-PPR is the default and the most thoroughly tested view.${betaBadge(scoring)}</div>`
Code
scoringKey = scoringKeyOf(scoring)
Code
// A beta scoring whose live payloads are still empty seeds (no projections
// published yet) is treated as "no data": the page shows a beta empty state
// instead of the in-development notice, and the controls (including this toggle)
// stay visible so a reader can switch back. Half is never empty here.
scoringEmpty = !isHalfScoring(scoring) && (wrPredictives.length + rbPredictives.length + tePredictives.length) === 0
Code
// Season selector. The seasons present in the data (newest first) plus any
// upcoming season flagged "(in development)". Defaults to the newest season with
// data (2025 today; auto-advances when a new season's projections land), and is
// persisted across pages within the session like the position selector.
viewof season = (() => {
  const UPCOMING = [2026];
  const inData = Array.from(new Set(wrPredictives.map(d => Number(d.season ?? 2025)))).sort((a, b) => b - a);
  const upcoming = UPCOMING.filter(y => !inData.includes(y));
  const options = upcoming.concat(inData);
  const dflt = inData[0];
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_season") : null);
  const init = (saved != null && options.includes(Number(saved))) ? Number(saved) : dflt;
  const fmt = y => upcoming.includes(y) ? `${y} (in development)` : `${y}`;
  const sel = Inputs.select(options, { value: init, label: "Season", format: fmt });
  sel.addEventListener("input", () => { try { sessionStorage.setItem("ffhedge_season", sel.value); } catch (e) {} });
  return sel;
})()
Code
dataSeasons = Array.from(new Set(wrPredictives.map(d => Number(d.season ?? 2025)))).sort((a, b) => b - a)
latestDataSeason = dataSeasons[0]
seasonInDev = !dataSeasons.includes(Number(season))
Code
scoringEmpty
  ? html`<div>${scoringBetaBanner(scoring)}${scoringEmptyState(scoring, "The " + scoring + " models are set up, but their projections for this week have not been published yet. Switch Scoring back to Half-PPR in the meantime.")}</div>`
  : seasonInDev
    ? html`<div style="padding:0.9rem 1.1rem;margin:0.8rem 0;border:1px solid #e6ac00;border-radius:6px;background:#fff8e6;"><strong>${season} is in development.</strong> Live ${season} projections arrive with the NFL schedule. Switch Season back to ${latestDataSeason} to explore the validation archive.</div>`
    : html``
Code
// Beta scoring context: an honest banner on the non-half scorings, plus a note
// that the quarterback comparison stays on the half-PPR model. Suppressed when
// the beta scoring has no data (the empty-state cell above shows the banner).
{
  if (isHalfScoring(scoring) || scoringEmpty) return html``;
  const banner = scoringBetaBanner(scoring);
  if (position === "QB") {
    return html`<div>${banner}<div style="font-size:0.85rem;line-height:1.45;border-left:3px solid var(--rc-sand-panel);background:var(--rc-sand);padding:0.55rem 0.8rem;border-radius:4px;margin:-0.4rem 0 0.9rem;">Quarterback scoring barely changes across formats, so the half-PPR quarterback model is shown here. Switch Position to WR, RB, TE, or Flex to compare players under ${scoring} scoring.</div></div>`;
  }
  return banner;
}
Code
dbArchive = DuckDBClient.of({
  wrPred: FileAttachment("data/predictives.parquet"),
  rbPred: FileAttachment("data/rb_predictives.parquet"),
  tePred: FileAttachment("data/te_predictives.parquet"),
  qbPred: FileAttachment("data/qb_predictives.parquet")
})
// Live predictives per scoring (static literals; only the selected scoring's files
// are fetched below — the lazy-load). QB is served from the half-PPR model.
livePredHandles = ({
  half: { wr: FileAttachment("data/predictives_live.parquet"),     rb: FileAttachment("data/rb_predictives_live.parquet"),     te: FileAttachment("data/te_predictives_live.parquet") },
  std:  { wr: FileAttachment("data/predictives_std_live.parquet"), rb: FileAttachment("data/rb_predictives_std_live.parquet"), te: FileAttachment("data/te_predictives_std_live.parquet") },
  ppr:  { wr: FileAttachment("data/predictives_ppr_live.parquet"), rb: FileAttachment("data/rb_predictives_ppr_live.parquet"), te: FileAttachment("data/te_predictives_ppr_live.parquet") }
})
liveCfgHandles = ({
  half: { wr: FileAttachment("data/locked_config_live.json"),     rb: FileAttachment("data/rb_locked_config_live.json"),     te: FileAttachment("data/te_locked_config_live.json") },
  std:  { wr: FileAttachment("data/locked_config_std_live.json"), rb: FileAttachment("data/rb_locked_config_std_live.json"), te: FileAttachment("data/te_locked_config_std_live.json") },
  ppr:  { wr: FileAttachment("data/locked_config_ppr_live.json"), rb: FileAttachment("data/rb_locked_config_ppr_live.json"), te: FileAttachment("data/te_locked_config_ppr_live.json") }
})
qbPredLHandle = FileAttachment("data/qb_predictives_live.parquet")
qbCfgL = FileAttachment("data/qb_locked_config_live.json").json()
// Live tables for the SELECTED scoring only (rebuilds on toggle; archive untouched).
dbLive = {
  const P = livePredHandles[scoringKey];
  return DuckDBClient.of({ wrPredL: P.wr, rbPredL: P.rb, tePredL: P.te, qbPredL: qbPredLHandle });
}
ARCHIVE_SEASON = 2025
seasonOf = (d) => Number(d.season ?? ARCHIVE_SEASON)
// Half pools the frozen 2025 archive with the live season; the beta scorings show
// the live season only. db.query() returns a PROMISE, so both are awaited before
// concat (chaining .concat() onto a promise throws and breaks every cell below).
loadPair = async (t) => {
  const live = await dbLive.query(`SELECT * FROM ${t}L`);
  if (!isHalfScoring(scoring)) return live;
  const arch = await dbArchive.query(`SELECT * FROM ${t}`);
  return arch.concat(live);
}
wrPredictives = loadPair("wrPred")
rbPredictives = loadPair("rbPred")
tePredictives = loadPair("tePred")
qbPredictives = loadPair("qbPred")
// Position-active comparison pool. Flex mixes WR, RB, and TE (TE rides the shared Flex bars).
predictivesPool = position === "RB" ? rbPredictives
            : position === "TE" ? tePredictives
            : position === "QB" ? qbPredictives
            : position === "Flex" ? wrPredictives.concat(rbPredictives).concat(tePredictives)
            : wrPredictives
// One season filter here keeps every downstream week filter season-correct.
predictives = seasonInDev ? [] : predictivesPool.filter(d => seasonOf(d) === Number(season))
// Archive config (2025 lock, half-only); live config follows the selected scoring,
// so the beta slot thresholds/weights come from its lock. QB stays half in every scoring.
cfgA = position === "RB" ? FileAttachment("data/rb_locked_config.json").json()
    : position === "TE" ? FileAttachment("data/te_locked_config.json").json()
    : position === "QB" ? FileAttachment("data/qb_locked_config.json").json()
                        : FileAttachment("data/locked_config.json").json()
liveCfg = { const C = liveCfgHandles[scoringKey]; return { wr: await C.wr.json(), rb: await C.rb.json(), te: await C.te.json() }; }
cfgL = position === "RB" ? liveCfg.rb
    : position === "TE" ? liveCfg.te
    : position === "QB" ? qbCfgL
                        : liveCfg.wr
// Deployed weights differ between the archive lock and the live relock.
cfg = Number(season) === Number(cfgL?.focal_season ?? -1) ? cfgL : cfgA
// Slot thresholds for display. Half uses the shared hard-coded set (byte-identical
// to before); a beta scoring overlays the active position's native slot values
// from its live config, falling back to the half set for anything missing. The
// beta exceedance probabilities are computed against these same native thresholds.
thresholds = {
  if (isHalfScoring(scoring)) return positionThresholds;
  const S = cfg?.slots ?? {}, base = positionThresholds, t = (k) => slotToThresh(S?.[k]);
  const out = { WR: base.WR, RB: base.RB, TE: base.TE, QB: base.QB, Flex: base.Flex };
  if (position === "WR") out.WR = { WR1: t("WR1") ?? base.WR.WR1, WR2: t("WR2") ?? base.WR.WR2, Flex: t("Flex") ?? base.WR.Flex };
  else if (position === "RB") out.RB = { RB1: t("RB1") ?? base.RB.RB1, RB2: t("RB2") ?? base.RB.RB2, Flex: t("Flex") ?? base.RB.Flex };
  else if (position === "TE") out.TE = { TE1: t("TE1") ?? base.TE.TE1, WR1: base.TE.WR1, WR2: base.TE.WR2, Flex: t("Flex") ?? base.TE.Flex };
  else if (position === "Flex") out.Flex = { "WR1/RB1": t("WR1") ?? base.Flex["WR1/RB1"], "WR2/RB2": t("WR2") ?? base.Flex["WR2/RB2"], Flex: t("Flex") ?? base.Flex.Flex };
  return out;
}
weekOptions = Array.from(new Set(predictives.map(d => Number(d.week)))).sort((a, b) => b - a)
// Latest week of the SELECTED season: 17 for the 2025 archive, the current
// projection week once live. weekOptions is sorted descending, so [0] is it.
mutable selectedWeek = weekOptions[0] ?? null
Code
{
  const sel = Inputs.select(weekOptions, { value: selectedWeek, label: "Week" });
  sel.addEventListener("input", () => { mutable selectedWeek = sel.value; });
  return sel;
}
Code
// "Rankings as of" — the ECR snapshot the live projections were built on. Shown
// only on the live-season view; early-view flag until a week is scored.
{
  const showing_live = Number(season) === Number(cfgL?.focal_season ?? -1);
  const asof = (showing_live && cfg?.rankings_asof && cfg.rankings_asof !== "NA") ? cfg.rankings_asof : null;
  if (!asof) return html``;
  const d = new Date(asof + "T12:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
  const scored = predictives.some(r => r.realized_fp != null);
  const fallback = showing_live && cfg?.rankings_fallback === true;
  return html`<div>
    <div style="font-size:0.82rem;color:var(--rc-muted);margin:-0.2rem 0 0.7rem;">
      ${scored ? `Live ${season}, updated weekly` : `Live ${season} — early view`} · expert rankings as of <strong>${d}</strong>.</div>
    ${fallback ? rankingsFallbackBanner() : ``}</div>`;
}
Code
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 blend = rs.find(r => r.predictive === "cross_blend");
    arr.push({ id, name: rs[0].player_display_name, team: rs[0].team, ecr: rs[0].ecr_rank, position: rs[0].position ?? "WR", blendMean: blend ? blend.mean : null });
  }
  // Sort by the deployed blend projection (best first), matching the Projections
  // board, so a mixed Flex pool reads as one cross-position list rather than
  // interleaving each position's ECR ranks.
  arr.sort((a, b) => d3.descending(a.blendMean ?? -Infinity, b.blendMean ?? -Infinity) || d3.ascending(a.name, b.name));
  addPosRank(arr);
  return arr;
}
Code
rankFmt = p => `${ecrDisp(p.position, p.posRank, position === "Flex")} ${p.name} (${p.team ?? "—"})`
Code
// Seed Start/Sit with the same "biggest surprise coin flip" the Welcome page
// features: among the startable pool (top 36 WR/RB by ECR, top 18 TE), the two
// players the experts rank furthest apart that the deployed blend still calls a
// coin flip, for the current position and week. Player A is the higher-ranked of
// the two (matching the Welcome card). Flex (mixed pool) and "all weeks" have no
// single-position coin-flip band, so they fall back to the top-two-by-blend
// default (defaultPair = null -> the selects keep their first-option default).
defaultPair = {
  if (selectedWeek === "all weeks" || position === "Flex") return null;
  const band = { WR: { lo: 1, hi: 36, slot: "Flex" },
                 RB: { lo: 1, hi: 36, slot: "Flex" },
                 QB: { lo: 1, hi: 12, slot: "QB1" },
                 TE: { lo: 1, hi: 18, slot: "TE1" } }[position];
  if (!band) return null;
  const cf = mostSurprisingCoinflip(predictives, [selectedWeek], position, band.lo, band.hi, band.slot);
  if (!cf) return null;
  let a = cf.a, b = cf.b;
  if (a.rank > b.rank) { const t = a; a = b; b = t; }
  // Map the helper's own a/b objects back onto the weekPlayers option objects by
  // id; Inputs.select matches its `value:` by object identity.
  const pa = weekPlayers.find(p => p.id === a.id);
  const pb = weekPlayers.find(p => p.id === b.id);
  return (pa && pb) ? { a: pa, b: pb } : null;
}
Code
viewof searchA = Inputs.search(weekPlayers, { placeholder: "Search Player A by name or rank…", label: "Player A" })
Code
viewof playerA = Inputs.select(searchA, { format: rankFmt, label: " ", value: defaultPair ? defaultPair.a : undefined })
Code
eligibleB = weekPlayers.filter(p => p.id !== playerA?.id)
Code
viewof searchB = Inputs.search(eligibleB, { placeholder: "Search Player B by name or rank…", label: "Player B" })
Code
viewof playerB = Inputs.select(searchB, { format: rankFmt, label: " ", value: defaultPair ? defaultPair.b : undefined })
Code
viewof cmpSource = Inputs.radio(["Blend", "Expert", "Data"], { value: "Blend", label: "Source" })
Code
viewof cmpSlot = Inputs.radio(slotOptionsFor(position), { value: position === "QB" ? "QB1" : position === "TE" ? "TE1" : slotOptionsFor(position)[2], label: "Roster slot" })
Code
// Expert<->Data lean slider (w = weight on Expert), shared across the explorer
// and compare pages via sessionStorage. Default 0.50 is the deployed hedge, so
// the Blend source matches the stored cross_blend at the default.
viewof lean = (() => {
  const s0 = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_lean") : null);
  const s = (s0 != null && !isNaN(+s0)) ? +s0 : 0.5;
  const rng = html`<input type=range min=0 max=1 step=0.05 value=${s} style="width:240px;accent-color:#3e3f3a;">`;
  const val = html`<span style="font-size:0.78rem;font-weight:600;min-width:34px;text-align:right;">${(+s).toFixed(2)}</span>`;
  const box = html`<div style="display:flex;align-items:center;gap:10px;margin:0.5rem 0;">
    <span style="font-size:0.82rem;font-weight:700;color:${palette.data};">Data</span>
    ${rng}
    <span style="font-size:0.82rem;font-weight:700;color:${palette.expert};">Expert</span>
    ${val}
  </div>`;
  box.value = +rng.value;
  rng.addEventListener("input", () => {
    box.value = +rng.value;
    val.textContent = (+rng.value).toFixed(2);
    try { sessionStorage.setItem("ffhedge_lean", rng.value); } catch (e) {}
    box.dispatchEvent(new CustomEvent("input", { bubbles: true }));
  });
  return box;
})()
Code
{
  const ref = (position === "WR" || position === "RB" || position === "TE" || position === "QB")
    ? `; 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
{
  if (seasonInDev) return html``;
  // Position-level fallback (all-weeks mode, or before both players are picked).
  const fallback = {
    WR: "For receivers, Expert and Data are both well-calibrated and stay close at every line — the lean is essentially a toss-up.",
    RB: "For running backs, the Data side is the better-calibrated read of the floor.",
    TE: "For tight ends, the Data side is the better-calibrated read at every line — floor, target, and ceiling.",
    QB: "For quarterbacks, the Data side is the better-calibrated read of the floor.",
    Flex: "In the flex pool it depends on the player: Data reads running back and tight end floors better, while receivers are about a toss-up."
  }[position];
  const caveat = " Either way, the lean is a tiebreaker, not a rule: that calibration edge is a season-long average, and the Expert consensus can be pricing in a demotion, injury, or role change the usage data hasn't caught up to.";
  const wrap = msg => html`<div style="display:block;font-size:0.8rem;line-height:1.4;color:var(--rc-muted);margin:0.5rem 0 0.8rem;">${msg}</div>`;

  if (selectedWeek === "all weeks" || !playerA || !playerB)
    return wrap(html`The lean matters most where Expert and Data disagree. ${fallback}${caveat}`);

  const calBetter = { WR: null, RB: "Data", TE: "Data", QB: "Data" };
  const posWord  = { WR: "receiver", RB: "running back", TE: "tight end", QB: "quarterback" };
  const gapOf = (pid) => {
    const rs = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === pid);
    const em = rs.find(r => r.predictive === "expert_marginal");
    const dm = rs.find(r => r.predictive === "data_marginal");
    return (em && dm && em.mean != null && dm.mean != null) ? dm.mean - em.mean : null;
  };
  const gA = gapOf(playerA.id), gB = gapOf(playerB.id);
  const AGREE_FP = 2;

  if (gA == null || gB == null)
    return wrap(html`The lean matters most where Expert and Data disagree. ${fallback}${caveat}`);

  if (Math.abs(gA) < AGREE_FP && Math.abs(gB) < AGREE_FP)
    return wrap(html`Expert and Data agree closely on both <strong>${playerA.name}</strong> and <strong>${playerB.name}</strong> this week (within ${AGREE_FP} fp), so the slider barely changes this call.`);

  const big = Math.abs(gA) >= Math.abs(gB) ? { p: playerA, g: gA } : { p: playerB, g: gB };
  const reliable = calBetter[big.p.position];
  const pw = posWord[big.p.position] ?? "player";
  const rec = reliable
    ? html`For a ${pw} floor read, the <strong>${reliable}</strong> side is the more reliable one, so lean that way for the better-calibrated floor.`
    : html`For receivers the two sides are about equally calibrated, so this is a judgment call — weigh the matchup more than the lean.`;
  return wrap(html`Expert and Data split most on <strong>${big.p.name}</strong> — about ${Math.abs(big.g).toFixed(1)} fp apart this week. ${rec}${caveat}`);
}
Code
SRC_KEY = ({ Blend: "cross_blend", Expert: "expert_marginal", Data: "data_marginal" })

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 }
  ];
}

// Abramowitz & Stegun approximation for the standard normal CDF.
normCDF = function(z) {
  const t = 1 / (1 + 0.2316419 * Math.abs(z));
  const d = 0.3989423 * Math.exp(-z * z / 2);
  const p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.7814779 + t * (-1.8212560 + t * 1.3302744))));
  return z > 0 ? 1 - p : p;
}

// Approximate std from 10th and 90th percentiles: (p90 - p10) / (2 * qnorm(0.9) = 2.564).
stdFromPercentiles = function(r) {
  if (r.p90 == null || r.p10 == null || r.p90 <= r.p10) return 1;
  return (r.p90 - r.p10) / 2.564;
}

// Difference distribution quantiles (A − B) under independence, normal approximation.
diffQuantiles = function(rA, rB) {
  if (!rA || !rB || rA.mean == null || rB.mean == null) return null;
  const mu = rA.mean - rB.mean;
  const sig = Math.sqrt(stdFromPercentiles(rA) ** 2 + stdFromPercentiles(rB) ** 2);
  const pAgtB = sig > 0 ? normCDF(mu / sig) : (mu > 0 ? 1 : mu < 0 ? 0 : 0.5);
  return {
    mean: mu,
    p10: mu - 1.282 * sig,
    p25: mu - 0.674 * sig,
    p50: mu,
    p75: mu + 0.674 * sig,
    p90: mu + 1.282 * sig,
    pAgtB
  };
}
Code
{
  if (seasonInDev) return html``;
  if (selectedWeek === "all weeks" || !playerA || !playerB)
    return html`<div style="color:var(--rc-muted);margin-top:0.5rem;">Select a week and two players above.</div>`;

  const key = SRC_KEY[cmpSource];
  const th = thresholds[position][cmpSlot];
  const colA = palette.accent, colB = palette.data;
  const MARGIN = 0.03;

  const rowsA = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === playerA.id);
  const rowsB = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === playerB.id);
  const mapA = new Map(rowsA.map(r => [r.predictive, r]));
  const mapB = new Map(rowsB.map(r => [r.predictive, r]));
  const rowA = mapA.get(key);
  const rowB = mapB.get(key);

  if (!rowA || !rowB)
    return html`<div style="color:var(--rc-muted);">No ${cmpSource} projection for both players in week ${selectedWeek}.</div>`;

  // For the Blend source, threshold bars and probability numbers use the slider's
  // lean mix of each player's two marginals (blendField); Expert/Data read their
  // own row. Density and difference-distribution visuals keep each source's own
  // percentiles (at lean=0.5 the stored cross_blend already equals the mix).
  // rowLead/rowLose in the takeaway are object-identical to rowA/rowB.
  const margOf = new Map([[rowA, [mapA.get("expert_marginal"), mapA.get("data_marginal")]],
                          [rowB, [mapB.get("expert_marginal"), mapB.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 pctTxt = v => v != null ? Math.round(100 * v) + "%" : "—";

  // ---- same-team warning ----------------------------------------------------
  const sameTeam = playerA.team && playerB.team && playerA.team === playerB.team;
  const teamWarning = sameTeam
    ? html`<div style="background:#fff3cd;border:1px solid #e6ac00;border-radius:4px;padding:0.65rem 1rem;margin-bottom:1rem;font-size:0.85rem;">
        <strong>⚠️ Same-team comparison (${playerA.team}).</strong> ${playerA.name} and ${playerB.name} share a game environment and compete for targets, so their scores are positively correlated on game conditions and negatively correlated on target share. The difference distribution below assumes independence and will be narrower than the true distribution — treat the contrast as a rough guide only.
      </div>`
    : html``;

  // ---- probability bars -----------------------------------------------------
  const pAF = pget(rowA,"floor"), pBF = pget(rowB,"floor");
  const pAT = pget(rowA,"target"), pBT = pget(rowB,"target");
  const pAC = pget(rowA,"ceiling"), pBC = pget(rowB,"ceiling");

  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:14px;">
        <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.78rem;">${pctTxt(v)}</span>
    </div>`;
  };
  const pairBar = (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></div>
    <div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:2px;">${playerA.name}</div>${bar(pget(rowA,t), colA)}
    <div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:2px;">${playerB.name}</div>${bar(pget(rowB,t), colB)}
  </div>`;

  // ---- individual density plot ----------------------------------------------
  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 indivMarks = [
    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) indivMarks.push(Plot.ruleX([rowA.realized_fp], { stroke:colA, strokeWidth:3 }));
  if (rowB.realized_fp != null) indivMarks.push(Plot.ruleX([rowB.realized_fp], { stroke:colB, strokeWidth:3 }));
  const indivPlot = Plot.plot({
    width:700, height:175, marginTop:4, marginBottom:28, marginLeft:10, marginRight:10,
    x:{ domain:xdom, label:"fantasy points (0.5 PPR) →", labelAnchor:"center" },
    y:{ axis:null, domain:[0, ymax*1.15] },
    marks: indivMarks
  });

  // ---- difference distribution ----------------------------------------------
  const dq = diffQuantiles(rowA, rowB);
  let diffPanel = html``;
  if (dq) {
    const diffDens = quantileDensity(dq);
    const xlo = Math.floor(dq.p10 - 1), xhi = Math.ceil(dq.p90 + 1);
    const xdiff = [Math.min(xlo, -1), Math.max(xhi, 1)];
    const dymax = d3.max(diffDens, d => d.y) ?? 1;
    // Interpolate density y at x=0 so both shaded areas meet at one point,
    // producing a single continuous distribution rather than two separate humps.
    const y0 = (() => {
      for (let i = 0; i < diffDens.length - 1; i++) {
        const lo = diffDens[i], hi = diffDens[i + 1];
        if (lo.x <= 0 && hi.x >= 0) {
          if (hi.x === lo.x) return (lo.y + hi.y) / 2;
          return lo.y + (0 - lo.x) / (hi.x - lo.x) * (hi.y - lo.y);
        }
      }
      return 0;
    })();
    const splitPt = { x: 0, y: y0 };
    const negArea = [...diffDens.filter(d => d.x < 0), splitPt];
    const posArea = [splitPt, ...diffDens.filter(d => d.x > 0)];
    const diffMarks = [
      Plot.areaY(negArea, { x:"x", y:"y", fill:"#aaa", fillOpacity:0.40, curve:"linear" }),
      Plot.areaY(posArea, { x:"x", y:"y", fill:colA, fillOpacity:0.30, curve:"linear" }),
      Plot.lineY(diffDens, { x:"x", y:"y", stroke:"#555", strokeWidth:1.5, curve:"basis" }),
      Plot.ruleX([0], { stroke:"#bbb", strokeWidth:1 }),
      Plot.ruleX([dq.mean], { stroke:colA, strokeDasharray:"4,3", strokeWidth:1.5 }),
      Plot.ruleY([0], { stroke:"#ddd" })
    ];
    const diffPlot = Plot.plot({
      width:700, height:130, marginTop:4, marginBottom:28, marginLeft:10, marginRight:10,
      x:{ domain:xdiff, label:"Player A − Player B (fantasy points) →", labelAnchor:"center" },
      y:{ axis:null, domain:[0, dymax*1.2] },
      marks: diffMarks
    });
    const pAgtBPct = Math.round(100 * dq.pAgtB);
    const meanSign = dq.mean >= 0 ? "+" : "";
    diffPanel = html`<div style="margin-top:1.2rem;">
      <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.35rem;">${cmpSource} Expected FP Difference Distribution (Player A − Player B)</div>
      <div style="font-size:0.9rem;margin-bottom:0.4rem;">
        <strong>P(${playerA.name} outscores ${playerB.name}): ~${pAgtBPct}%</strong>
        <span style="color:var(--rc-muted);margin-left:0.7rem;font-size:0.78rem;">normal approximation · independence assumed${sameTeam ? " · same-team caveat applies" : ""}</span>
      </div>
      ${diffPlot}
      <div style="font-size:0.74rem;color:var(--rc-muted);margin-top:2px;">This curve shows the full range of possible score gaps for this week. Green shading covers the outcomes where Player A outscores Player B — that region's share of the total is the P(A outscores B) figure above. Grey covers the reverse. Dashed line: mean projected difference (${meanSign}${dq.mean.toFixed(1)} fp).</div>
    </div>`;
  }

  // ---- three-case takeaway --------------------------------------------------
  const dF = pAF - pBF, dT = pAT - pBT, dC = pAC - pBC;
  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;

  let takeaway;
  if (allSmall) {
    takeaway = `At the ${cmpSlot} position, ${playerA.name} and ${playerB.name} have nearly identical projected odds at every threshold — all differences fall within the model's calibration margin of roughly two to three percentage points. This is a coin flip; go with your gut or consider matchup context not captured by the model.`;
  } else if (divergent) {
    // Floor leader and ceiling leader are always opposite in this branch.
    const floorName  = floorLeadsA   ? playerA.name : playerB.name;
    const ceilName   = ceilingLeadsA ? playerA.name : playerB.name;
    const pF  = floorLeadsA   ? Math.round(100*pAF) : Math.round(100*pBF);
    const pFo = floorLeadsA   ? Math.round(100*pBF) : Math.round(100*pAF);
    const pC  = ceilingLeadsA ? Math.round(100*pAC) : Math.round(100*pBC);
    const pCo = ceilingLeadsA ? Math.round(100*pBC) : Math.round(100*pAC);
    if (Math.abs(dT) >= MARGIN) {
      // Target gap is meaningful — name a clearer safer vs upside pick.
      const saferName  = targetLeadsA ? playerA.name : playerB.name;
      const upsideName = targetLeadsA ? playerB.name : playerA.name;
      const saferT  = targetLeadsA ? Math.round(100*pAT) : Math.round(100*pBT);
      const upsideT = targetLeadsA ? Math.round(100*pBT) : Math.round(100*pAT);
      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.`;
    } else {
      // Target within margin — coin flip on target, but profiles differ.
      takeaway = `These two are a coin flip at the ${cmpSlot} target (${th.target} fp) — that difference falls within the model's calibration margin of roughly two to three percentage points. 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.`;
    }
  } else {
    const leadsA = (dF + dT + dC) >= 0;
    const rowLead = leadsA ? rowA : rowB;
    const rowLose = leadsA ? rowB : rowA;
    const leaderName = leadsA ? playerA.name : playerB.name;
    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 overall.`;
    if (bigGap) {
      txt += ` The clearest edge is at the ${bigGap.label} (${bigGap.threshold} fp): ${pctTxt(pget(rowLead, bigGap.label))} vs ${pctTxt(pget(rowLose, bigGap.label))}.`;
    }
    txt += ` Differences of less than two to three percentage points are within the model's calibration margin.`;
    takeaway = txt;
  }

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

  const caveatNote = html`<div style="background:#e8f4f8;border-left:4px solid #5bc0de;border-radius:0 4px 4px 0;padding:0.6rem 1rem;margin:0.9rem 0 0.5rem;font-size:0.83rem;">
    <strong>Note — beta approximation.</strong> The difference distribution below assumes the two players' scores are statistically independent. This is reasonable for players on different teams; for teammates, the true distribution is wider (shared game environment, competing targets). The normal approximation is fit to five percentile points, so treat it as an indication of direction and rough magnitude rather than a precise probability.
  </div>`;

  const diffBox = dq
    ? html`<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:1rem 1.1rem;margin-top:0.2rem;">${diffPanel}</div>`
    : html``;

  return html`<div class="plot-panel" style="margin-top:0.8rem;">
    <div style="font-weight:700;font-size:1.1rem;margin-bottom:0.3rem;">Week ${selectedWeek}: ${playerA.name} <span style="font-weight:400;color:var(--rc-muted);">(ECR #${ecrDisp(playerA.position, playerA.posRank, position === "Flex")})</span> vs ${playerB.name} <span style="font-weight:400;color:var(--rc-muted);">(ECR #${ecrDisp(playerB.position, playerB.posRank, position === "Flex")})</span></div>
    ${legend}
    <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.4rem;">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>
        ${indivPlot}
        <div style="font-size:0.76rem;color:var(--rc-muted);margin-top:2px;">Dashed line: projected mean — <span style="color:${colA}">${playerA.name} ${fmtFp(rowA.mean)} fp</span> and <span style="color:${colB}">${playerB.name} ${fmtFp(rowB.mean)} fp</span>. Solid line: realized score (completed weeks only).</div>
      </div>
    </div>
    <p style="margin-top:0.8rem;">${takeaway}</p>
    ${caveatNote}
    ${teamWarning}
    ${diffBox}
  </div>`;
}
Glossary — the terms used on this page
  • fp — fantasy points, scored half-PPR (half a point per reception).
  • ECR — Expert Consensus Rank, the FantasyPros average of expert rankings; the published order before any modeling.
  • Floor / Target / Ceiling — three scoring lines for a roster slot: a baseline week you can live with (floor), a strong startable week (target), and an elite, league-winning week (ceiling).
  • Expert — the model anchored to expert consensus, recalibrated against past results.
  • Data — the model built from player usage and game environment (snaps, targets, air yards, Vegas lines).
  • Blend — the deployed projection: an even 50/50 hedge of Expert and Data.
  • Chance of clearing (a line) — the probability a player scores at or above that floor, target, or ceiling.
  • 10–90% — the 10th-to-90th percentile range of the projected score: the middle 80% of likely outcomes.
  • Situation flags — the small badges (stable veteran, rookie, role change…) marking a player’s usage situation; hover a badge for its full name.

FFHedge · Bayesian fantasy projections that hedge the experts against the data · a reluctant criminologists project.