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"
})