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