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