Thomas Reinke
htl.html`<section class="team-top"><div class="tc" style="gap:18px">${logo(tRow.id, 64)}<div>
<h1 class="disp">${tRow.team}</h1>
<span style="color:var(--ink-2)">${[tRow.conf, `${tRow.wins ?? 0}–${tRow.losses ?? 0}`, tRow.ap ? `AP #${tRow.ap}` : null, tRow.seed ? `NCAA ${tRow.seed} seed` : null, tRow.elig ? null : "Not NCAA-tournament eligible"].filter(Boolean).join(" · ")}</span></div></div></section>`viewof raceConf = {
const confs = [...new Set(d1.map(d => d.conf))].filter(c => c && !/Indep/.test(c)).sort()
const pref = d1.find(d => d.id === pickedTeam)?.conf
return Inputs.select(confs, { label: "Conference", value: confs.includes(pref) ? pref : confs.includes("Big Ten") ? "Big Ten" : confs[0] })
}R = transpose(ranks_raw)
G = transpose(games_raw)
S = transpose(snaps_raw)
TEAMS = new Map(transpose(teams_raw).map(t => [t.id, t]))
seasons = [...new Set(S.map(d => d.s))].sort((a, b) => b - a)
seasonLabel = s => `${s - 1}–${String(s % 100).padStart(2, "0")}`
mutable pickedTeam = null
mutable mResSort = "Resume ±"TABS = [["rankings", "Rankings"], ["team", "Team sheet"], ["matchup", "Matchup"], ["games", "Games"], ["bracketology", "Bracketology"],
["trends", "Trends"], ["rvp", "Resume vs. Power"], ["conference", "Conference"], ["polls", "Polls"]]
tab = Generators.observe(notify => {
const known = new Set(TABS.map(t => t[0]))
const read = () => { const h = location.hash.slice(1); notify(known.has(h) ? h : "rankings") }
read(); addEventListener("hashchange", read)
return () => removeEventListener("hashchange", read)
})
tabEffect = { document.body.dataset.tab = tab; window.scrollTo(0, 0); return tab }
nav = {
tabEffect
const links = () => TABS.map(([k, label]) => htl.html`<a href=${"#" + k} aria-current=${k === tab ? "page" : null}>${label}</a>`)
return htl.html`<div><nav class="nav nav-wide" aria-label="Dashboard sections">${links()}</nav>
<details class="nav-menu"><summary><span class="eb">Section</span><strong>${new Map(TABS).get(tab)}</strong>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<nav class="nav-grid" aria-label="Dashboard sections">${links()}</nav></details></div>`
}
goToTeam = id => { mutable pickedTeam = id; location.hash = "team" }
themeButton = {
const sun = htl.svg`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>`
const moon = htl.svg`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>`
const b = htl.html`<button type="button" class="icon-btn"></button>`
const paint = () => {
const dark = document.documentElement.dataset.theme === "dark"
b.replaceChildren(dark ? sun : moon)
b.setAttribute("aria-label", dark ? "Switch to light theme" : "Switch to dark theme")
}
b.onclick = () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"
document.documentElement.dataset.theme = next
try { localStorage.setItem("cbb-theme", next) } catch (e) {}
paint()
}
paint()
return b
}fmtSigned = (x, d = 1) => x == null ? "–" : (x > 0 ? "+" : x < 0 ? "−" : "") + Math.abs(x).toFixed(d)
fmtPct = x => x == null ? "–" : x >= 0.995 ? ">99%" : x < 0.005 ? (x > 0 ? "<1%" : "0%") : Math.round(x * 100) + "%"
ord = n => { const s = ["th", "st", "nd", "rd"], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]) }
toRoman = n => [[10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]].reduce((acc, [v, r]) => { while (n >= v) { acc += r; n -= v } return acc }, "")
listAnd = xs => xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`
dayFmt = iso => new Date(iso + "T12:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
genAt = new Date(cfg.generated.slice(0, 16).replace(" ", "T"))
dateLong = genAt.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
updatedAt = `${genAt.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).replace("AM", "a.m.").replace("PM", "p.m.")} ${cfg.generated.slice(17)}`
logo = (id, s = 30) => {
const t = TEAMS.get(id)
if (!t?.logo) return htl.html`<span class="logo-none" style=${{ width: s + "px", height: s + "px" }}></span>`
return htl.html`<img class="logo lg-light" src=${t.logo} width=${s} height=${s} alt="" loading="lazy" onerror=${e => e.target.style.visibility = "hidden"}><img class="logo lg-dark" src=${t.logo_dark} width=${s} height=${s} alt="" loading="lazy" onerror=${e => { e.target.src = t.logo }}>`
}
teamLink = d => htl.html`<button type="button" class="tc-n" onclick=${() => goToTeam(d.id)}>${d.team}</button>`
teamCell = (d, sub, s = 30, link = true) => htl.html`<span class="tc">${logo(d.id, s)}<span class="tc-t">${link && byId.has(d.id) ? teamLink(d) : htl.html`<span class="tc-n">${d.team}</span>`}${sub ? htl.html`<span class="tc-s">${sub}</span>` : ""}</span></span>`
move = (now, before) => {
if (now == null || before == null) return htl.html`<span class="mv flat">–</span>`
const m = before - now
return m > 0 ? htl.html`<span class="mv up" title=${`Up ${m}`}>▲${m}</span>` : m < 0 ? htl.html`<span class="mv down" title=${`Down ${-m}`}>▼${-m}</span>` : htl.html`<span class="mv flat">–</span>`
}
pbar = (p, color, w = 100) => p == null ? htl.html`<span class="muted">–</span>` :
htl.html`<span class="pb"><span class="pb-t" style=${{ width: w + "px" }}><span style=${{ width: `${Math.max(0, Math.min(1, p)) * 100}%`, background: color }}></span></span><span class="pb-v">${fmtPct(p)}</span></span>`
eyebrow = (t, color = "var(--link)") => htl.html`<div class="eb" style=${{ color }}>${t}</div>`
kv = (k, v, s) => htl.html`<div class="kv"><span class="k">${k}</span><span class="v">${v}</span>${s ? htl.html`<span class="s">${s}</span>` : ""}</div>`
secH = (title, right) => htl.html`<div class="sec-h"><h2 class="disp">${title}</h2>${right ?? ""}</div>`
ibar = (v, lo, hi, [min, max], color) => {
const x = d3.scaleLinear([min, max], [6, 114]).clamp(true)
return htl.svg`<svg width="120" height="16" viewBox="0 0 120 16" aria-hidden="true">
${min < 0 && max > 0 ? htl.svg`<line x1=${x(0)} x2=${x(0)} y1="2" y2="14" stroke="var(--field)"/>` : ""}
<line x1=${x(lo)} x2=${x(hi)} y1="8" y2="8" stroke=${color} stroke-opacity="0.3" stroke-width="4" stroke-linecap="round"/>
<circle cx=${x(v)} cy="8" r="5" fill=${color} stroke="var(--paper)" stroke-width="1.5"/></svg>`
}
// Win chance from a per-100 margin: Student-t with the snapshot's game noise (normal approximation)
normCdf = 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.781478 + t * (-1.821256 + t * 1.330274))));
return z > 0 ? 1 - p : p }
POWER_CONFS = new Set(["ACC", "Big Ten", "Big 12", "SEC", "Big East", "Pac-12"])
grpOf = d => POWER_CONFS.has(d.conf) && !(d.conf === "Pac-12" && season >= 2025) ? "Power" : "Mid"
GRPVAR = ({ Power: "--g-power", Mid: "--g-group" })
grpName = g => g === "Power" ? "High-major conferences" : "Everyone else"
confKeys = rows => ["all", "grp:Power", "grp:Mid", ...[...new Set(rows.map(d => d.conf))].filter(Boolean).sort().map(c => "conf:" + c)]
confFmt = k => k === "all" ? "All D1" : k === "grp:Power" ? "— High majors —" : k === "grp:Mid" ? "— Everyone else —" : k.slice(5)
inConf = (d, k) => k === "all" || (k.startsWith("grp:") ? grpOf(d) === k.slice(4) : d.conf === k.slice(5))
teamPicker = (rows, key, label, preferred) => {
const opts = rows.filter(d => inConf(d, key)).sort((a, b) => a.team.localeCompare(b.team))
const best = [...opts].sort((a, b) => a.prk - b.prk)
const value = opts.some(d => d.id === preferred) ? preferred : best[0]?.id
return Inputs.select(opts.map(d => d.id), { label, format: id => opts.find(d => d.id === id).team, value })
}seasonSnaps = S.filter(d => d.s === season).sort((a, b) => a.w - b.w)
snap = seasonSnaps.find(d => d.w === week) ?? seasonSnaps.at(-1)
prevSnap = seasonSnaps.filter(d => d.w < snap.w).at(-1)
d1 = R.filter(d => d.s === season && d.w === snap.w)
byId = new Map(d1.map(d => [d.id, d]))
prevRows = prevSnap ? new Map(R.filter(d => d.s === season && d.w === prevSnap.w).map(d => [d.id, d])) : new Map()
hasResume = d1.some(d => d.rrk != null)
hasOdds = d1.some(d => d.p_field != null)
byRes = hasResume ? [...d1].sort((a, b) => a.rrk - b.rrk) : []
byPow = [...d1].sort((a, b) => a.prk - b.prk)
histOf = id => R.filter(d => d.s === season && d.id === id && d.w <= snap.w).sort((a, b) => a.w - b.w)
nAtLarge = snap.field - new Set(d1.filter(d => d.elig).map(d => d.conf)).size + (d1.some(d => /Indep/.test(d.conf)) ? 1 : 0)
// Projected field at this snapshot: each conference's likeliest automatic bid, then the
// likeliest at-large teams; seeded by projected WAB
proj = {
if (!hasOdds) return { field: [], autos: [], atl: [], out: [] }
const confs = d3.groups(d1.filter(d => d.elig && !/Indep/.test(d.conf)), d => d.conf)
const autos = confs.map(([c, t]) => t.reduce((a, b) => (b.p_auto ?? 0) > (a.p_auto ?? 0) ? b : a))
const autoIds = new Set(autos.map(d => d.id))
const cand = d1.filter(d => d.elig && !autoIds.has(d.id)).sort((a, b) => (b.p_al ?? 0) - (a.p_al ?? 0) || (b.proj_wab ?? -99) - (a.proj_wab ?? -99))
const n = snap.field - autos.length
const atl = cand.slice(0, n), out = cand.slice(n)
const field = [...autos, ...atl].sort((a, b) => (b.proj_wab ?? -99) - (a.proj_wab ?? -99))
return { field, autos, atl, out, autoIds }
}SORTS = [
{ key: "resume", label: "Resume", col: "rrk", prev: "rrk" },
{ key: "power", label: "Power", col: "prk", prev: "prk" },
{ key: "off", label: "Offense", col: "off_rk" },
{ key: "def", label: "Defense", col: "def_rk" },
{ key: "sos", label: "Schedule", col: "sos_rk" },
{ key: "ap", label: "AP poll", col: "ap" }
].filter(s => hasResume || !["resume", "sos"].includes(s.key)).filter(s => d1.some(d => d[s.col] != null))
rankingsHeadline = {
if (!hasResume) return `${byPow[0].team} opens No. 1 in power`
const r1 = byRes[0], p1 = byPow[0], was = prevRows.get(r1.id)?.rrk
const a = `${r1.team} ${was === 1 ? "holds" : was ? "climbs to" : "opens at"} No. 1`
// Biggest fall among the last snapshot's top five
const fall = [...prevRows.values()].filter(d => d.rrk <= 5 && byId.get(d.id)).map(d => ({ d: byId.get(d.id), from: d.rrk }))
.filter(x => x.d.rrk - x.from >= 4).sort((x, y) => (y.d.rrk - y.from) - (x.d.rrk - x.from))[0]
if (fall) return `${a}; ${fall.d.team} falls to ${ord(fall.d.rrk)}`
return p1.id === r1.id ? a : `${a}; ${p1.team} tops power`
}
briefs = {
const brief = (label, color, d, s) => htl.html`<div class="brief">${eyebrow(label, color)}
<div class="who">${logo(d.id, 34)}<span class="disp">${d.team}</span></div><div class="s">${s}</div></div>`
if (!hasResume) return htl.html`<section class="briefs"><div>${brief("Top power rating", "var(--pow-t)", byPow[0], htl.html`<strong>${fmtSigned(byPow[0].pw)}</strong> per 100 · preseason rating`)}</div></section>`
const r1 = byRes[0], p1 = byPow[0]
const rise = byRes.slice(0, 25).filter(d => prevRows.get(d.id)?.rrk != null).map(d => ({ d, m: prevRows.get(d.id).rrk - d.rrk })).sort((a, b) => b.m - a.m)[0]
const was = prevRows.get(r1.id)?.rrk
const bub = hasOdds ? d1.filter(d => d.elig && (d.p_auto ?? 0) < 0.5).sort((a, b) => Math.abs((a.p_field ?? 0) - 0.5) - Math.abs((b.p_field ?? 0) - 0.5))[0] : null
return htl.html`<section class="briefs" aria-label="At a glance"><div>
${brief("No. 1 Resume", "var(--res-t)", r1, htl.html`<strong>${fmtSigned(r1.wab, 2)} WAB</strong> · ${r1.wins}–${r1.losses} · ${was == null ? "first ranking" : was === 1 ? "No. 1 again" : `up from #${was}`}`)}
${brief("Top power rating", "var(--pow-t)", p1, htl.html`<strong>${fmtSigned(p1.pw)}</strong> per 100 · ${p1.wins}–${p1.losses} · ${p1.rrk === 1 ? "also No. 1 on resume" : `#${p1.rrk} on resume`}`)}
${rise && rise.m > 0 ? brief("Biggest riser", "var(--ink-3)", rise.d, htl.html`<strong class="up">▲ ${rise.m}</strong> · #${rise.d.rrk + rise.m} → #${rise.d.rrk} on resume`) : ""}
${bub ? brief("On the bubble", "var(--ink-3)", bub, htl.html`<strong>${fmtPct(bub.p_field)}</strong> to make the field · ${bub.wins}–${bub.losses} · WAB ${fmtSigned(bub.wab, 2)}`) : ""}
</div></section>`
}
rankingsTable = {
const sort = SORTS.find(s => s.key === sortKey) ?? SORTS[0]
const q = search.trim().toLowerCase()
const val = d => d[sort.col] ?? 9999
const rows = d1.filter(d => inConf(d, confPick) && (!q || d.team.toLowerCase().includes(q)))
.sort((a, b) => val(a) - val(b) || (a.rrk ?? a.prk) - (b.rrk ?? b.prk))
const wDom = [Math.min(-12, d3.min(d1, d => d.wab_lo) ?? -12), Math.max(4, d3.max(d1, d => d.wab_hi) ?? 4)]
const pDom = d3.extent(d1.flatMap(d => [d.pw_lo, d.pw_hi]))
const hc = k => sort.key === k ? "var(--navy)" : "var(--ink-3)"
const rng = (lo, hi) => lo == null || lo === hi ? "" : htl.html`<div class="note" style="font-size:12px;font-weight:400;line-height:1.1">${lo}–${hi}</div>`
const showCut = sort.key === "resume" && confPick === "all" && !q && hasResume
const cutAfter = showCut ? rows.filter(d => d.wab > 0).at(-1)?.id : null
const row = d => {
const now = d[sort.col], before = sort.prev ? prevRows.get(d.id)?.[sort.prev] : null
return htl.html`<div class="rk-row" role="row">
<div class="stick s1 rk-num" role="cell"><span>${now ?? "–"}${sort.prev ? htl.html`<span class="mv-xs">${move(now, before)}</span>` : ""}</span></div>
<div class="stick s2" role="cell">${sort.prev ? move(now, before) : ""}</div>
<div class="stick s3" role="cell">${logo(d.id, 30)}<span class="tc-t">${teamLink(d)}<span class="tc-s">${d.conf}${d.elig ? "" : " · ineligible"}</span></span></div>
<div class="c r" role="cell">${d.wins ?? 0}–${d.losses ?? 0}</div>
<div class="rk-val" role="cell">${hasResume ? [htl.html`<span class="res-t">${fmtSigned(d.wab, 2)}</span>`, ibar(d.wab, d.wab_lo, d.wab_hi, wDom, "var(--res)")] : htl.html`<span class="muted">–</span>`}</div>
<div class="c b r" role="cell">${d.rrk ?? "–"}${rng(d.rrk_lo, d.rrk_hi)}</div>
<div class="c r" role="cell" title=${d.p_auto != null ? `Automatic bid ${fmtPct(d.p_auto)} · at-large ${fmtPct(d.p_al)}` : ""}>${hasOdds ? fmtPct(d.p_field) : "–"}</div>
<div class="rk-val" role="cell"><span class="pow-t">${fmtSigned(d.pw)}</span>${ibar(d.pw, d.pw_lo, d.pw_hi, pDom, "var(--pow)")}</div>
<div class="c b r" role="cell">${d.prk}${rng(d.prk_lo, d.prk_hi)}</div>
<div class="c r" role="cell" title=${d.off == null ? "" : `${d.off.toFixed(1)} pts per 100 vs. an average D1 defense`}>${d.off_rk ?? "–"}</div>
<div class="c r" role="cell" title=${d.def == null ? "" : `${d.def.toFixed(1)} pts per 100 allowed vs. an average D1 offense`}>${d.def_rk ?? "–"}</div>
<div class="c r" role="cell">${d.sos_rk ?? "–"}</div>
<div class="c r" role="cell">${d.ap ?? "–"}</div>
</div>${d.id === cutAfter ? htl.html`<div class="rk-cut" role="row"><span><span class="eb">Bubble line · WAB 0</span>Teams above have won more than the last team into the field would have with their schedule.</span></div>` : ""}`
}
return htl.html`<div>
<div class="rk-box"><div class="rk" role="table" aria-labelledby="d1-title">
<div class="rk-head-rows">
<div class="rk-row rk-groups" role="presentation">
<div style="grid-column:5 / span 3;color:var(--res-t);border-bottom:2px solid var(--res)">Resume</div>
<div style="grid-column:8 / span 4;color:var(--pow-t);border-bottom:2px solid var(--pow)">Power</div>
<div style="grid-column:12 / span 2;color:var(--ink-3);border-bottom:2px solid var(--field)">Context</div>
</div>
<div class="rk-row rk-cols" role="row">
<div class="stick s1" role="columnheader">#</div>
<div class="stick s2" role="columnheader"><span class="sr-only">Change</span></div>
<div class="stick s3" role="columnheader">Team</div>
<div class="r" role="columnheader">Record</div>
<div role="columnheader" style=${{ color: hc("resume") }}>WAB<span class="hide-xs"> · 80% range</span></div>
<div class="r" role="columnheader" style=${{ color: hc("resume") }}>Rank</div>
<div class="r" role="columnheader" title="Chance to make the NCAA tournament field (automatic bid or at-large), from the simulated rest of the season">Field</div>
<div role="columnheader" style=${{ color: hc("power") }}>Rating<span class="hide-xs"> · 80% range</span></div>
<div class="r" role="columnheader" style=${{ color: hc("power") }}>Rank</div>
<div class="r" role="columnheader" style=${{ color: hc("off") }}>Off</div>
<div class="r" role="columnheader" style=${{ color: hc("def") }}>Def</div>
<div class="r" role="columnheader" style=${{ color: hc("sos") }}>SOS</div>
<div class="r" role="columnheader" style=${{ color: hc("ap") }}>AP</div>
</div>
</div>
<div class="rk-body">${rows.map(row)}</div>
${rows.length ? "" : htl.html`<div style="padding:40px 24px;text-align:center;color:var(--ink-2)">No teams match. Try a shorter name or pick All D1.</div>`}
</div></div>
<div class="rk-foot">
<span>${snap.passed ? "✓ Model converged" : "⚠ Convergence checks failed"} · home court ${fmtSigned(snap.hca)} pts</span>
<span>WAB is wins above what the last team into the field would have on the same schedule. Rating is points per 100 possessions vs. an average D1 team. Dot is the median; bars and ranges are 80% intervals. Field is the chance to make the NCAA tournament.</span>
</div></div>`
}// Rank chart: every team's resume rank and power-rating rank as a dumbbell on one 1–N axis, with
// offense (sword) and defense (shield) as a smaller dumbbell underneath. Same sort and filters as
// the table. The shaded band is the top of the resume ranking down to the bubble (the benchmark's rank).
rankChart = {
const sort = SORTS.find(s => s.key === sortKey) ?? SORTS[0]
const q = search.trim().toLowerCase()
const val = d => d[sort.col] ?? 9999
const rows = d1.filter(d => inConf(d, confPick) && (!q || d.team.toLowerCase().includes(q)))
.sort((a, b) => val(a) - val(b) || (a.rrk ?? a.prk) - (b.rrk ?? b.prk))
const n = d1.length
const x = r => r == null ? null : (r - 1) / (n - 1) * 100
const pc = v => `${v.toFixed(2)}%`
const bench = Math.round(snap.bench_rank ?? (snap.field - 31))
// The sword and shield are 14px wide. Estimate the track's width (it matches the CSS grid below)
// so icons closer than that can be pushed apart, the better rank to the left
const phone = width <= 520
const box = Math.max(phone ? 900 : 1180, Math.min(width, 1280) - (width <= 820 ? 32 : 64))
const fixed = phone ? 28 + 110 + 50 + 44 + 26 * 3 + 7 * 6 + 8 + 16 : 40 + 210 + 56 + 52 + 40 * 2 + 36 + 7 * 12 + 12 + 16
const ppr = (box - fixed) / (n - 1)
const nudge = (o, e) => {
const p = Math.max(0, (15 - Math.abs(o - e) * ppr) / 2), s = o <= e ? -1 : 1
return [-7 + s * p, -7 - s * p]
}
const sword = (v, m) => htl.svg`<svg class="rc-ico" viewBox="0 0 24 24" aria-hidden="true" style=${{ left: pc(v), marginLeft: `${m.toFixed(1)}px` }}>
<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/></svg>`
const shield = (v, m) => htl.svg`<svg class="rc-ico def" viewBox="0 0 24 24" aria-hidden="true" style=${{ left: pc(v), marginLeft: `${m.toFixed(1)}px` }}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`
const span = (a, b) => ({ left: pc(Math.min(a, b)), width: pc(Math.abs(a - b)) })
const row = d => {
const r = x(d.rrk), p = x(d.prk), o = x(d.off_rk), e = x(d.def_rk)
const [om, em] = o != null && e != null ? nudge(d.off_rk, d.def_rk) : [-7, -7]
return htl.html`<div class="rc-row" role="row">
<div class="stick s1 rk-num" role="cell">${d[sort.col] ?? "–"}</div>
<div class="stick s3" role="cell">${logo(d.id, 30)}<span class="tc-t">${teamLink(d)}</span></div>
<div class="rc-track" role="cell" aria-label=${`Resume rank ${d.rrk ?? "–"}, power rating rank ${d.prk}, offense ${d.off_rk ?? "–"}, defense ${d.def_rk ?? "–"}`}>
<span class="rc-band"></span>
${r != null ? htl.html`<span class="rc-bar" style=${span(r, p)}></span>` : ""}
${o != null ? htl.html`<span class="rc-tick" style=${{ left: pc(p) }}></span>` : ""}
${o != null && e != null ? htl.html`<span class="rc-sub" style=${span(o, e)}></span>` : ""}
${o != null ? sword(o, om) : ""}${e != null ? shield(e, em) : ""}
${r != null ? htl.html`<span class="rc-dot res" style=${{ left: pc(r) }}></span>` : ""}
<span class="rc-dot pow" style=${{ left: pc(p) }}></span>
</div>
<div class="c b r res-t" role="cell">${d.rrk ?? "–"}</div>
<div class="c b r pow-t" role="cell">${d.prk}</div>
<div class="c r" role="cell">${d.off_rk ?? "–"}</div>
<div class="c r" role="cell">${d.def_rk ?? "–"}</div>
<div class="c r" role="cell">${d.ap ?? "–"}</div>
</div>`
}
const ticks = [1, 50, 100, 150, 200, 250, 300, n]
const sw = htl.svg`<svg class="rc-key-ico" viewBox="0 0 24 24" aria-hidden="true"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/></svg>`
const sh = htl.svg`<svg class="rc-key-ico def" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`
return htl.html`<div>
<div class="rk-box"><div class="rc" role="table" aria-labelledby="d1-title" style=${{ "--band": pc((bench - 0.5) / (n - 1) * 100) }}>
<div class="rc-row rc-head" role="row">
<div class="stick s1" role="columnheader">#</div>
<div class="stick s3" role="columnheader">Team</div>
<div class="rc-axis" role="columnheader"><span class="sr-only">Resume rank and power-rating rank, 1 to ${n}</span>
${ticks.map(t => htl.html`<span aria-hidden="true" style=${{ left: pc(x(t)) }}>${t}</span>`)}</div>
<div class="r res-t" role="columnheader">Resume</div>
<div class="r pow-t" role="columnheader">Power</div>
<div class="r" role="columnheader">Off</div>
<div class="r" role="columnheader">Def</div>
<div class="r" role="columnheader">AP</div>
</div>
<div class="rc-body">${rows.map(row)}</div>
${rows.length ? "" : htl.html`<div style="padding:40px 24px;text-align:center;color:var(--ink-2)">No teams match. Try a shorter name or pick All D1.</div>`}
</div></div>
<div class="rk-foot">
<span>${snap.passed ? "✓ Model converged" : "⚠ Convergence checks failed"} · home court ${fmtSigned(snap.hca)} pts</span>
<span class="key">
${hasResume ? htl.html`<span><span class="dot" style="background:var(--res)"></span>Resume rank</span>` : ""}
<span><span class="dot" style="background:var(--pow)"></span>Power rating, ranked</span>
<span>${sw}Offense</span><span>${sh}Defense</span>
<span><span class="rc-key-band"></span>Top ${bench} (the bubble)</span>
</span>
</div></div>`
}tRow = d1.find(d => d.id === teamId) ?? d1[0]
tGames = G.filter(d => d.s === season && d.id === tRow.id).sort((a, b) => a.date.localeCompare(b.date))
teamBody = {
const n = d1.length, pctl = rk => 1 - (rk - 1) / (n - 1)
const rating = (k, v, rk, color, sub) => rk == null ? "" : htl.html`<div class="rating"><b>${k}</b><b style=${{ color }}>${v}</b>
<span class="bar"><svg width="150" height="14" aria-hidden="true" style="flex:0 0 auto"><rect x="0" y="5" width="150" height="4" rx="2" fill="var(--chip)"/>
<rect x="0" y="5" width=${pctl(rk) * 150} height="4" rx="2" fill=${color} fill-opacity="0.35"/><circle cx=${pctl(rk) * 140 + 5} cy="7" r="5" fill=${color} stroke="var(--paper)" stroke-width="1.5"/></svg>
<span class="note">#${rk} of ${n}${sub ? " · " + sub : ""}</span></span></div>`
const pm = prevRows.get(tRow.id)
const mvTxt = (a, b) => b == null || a === b ? "" : a < b ? `▲${b - a} since last` : `▼${a - b} since last`
const hist = histOf(tRow.id).filter(d => d.rrk != null)
const mini = hist.length < 2 ? "" : Plot.plot({
width: 320, height: 230, marginLeft: 34, marginRight: 16, marginTop: 12, marginBottom: 26,
style: { background: "transparent", fontSize: "12px" },
x: { label: null, ticks: [], tickFormat: () => "" },
y: { reverse: true, domain: [1, Math.max(60, d3.max(hist, d => Math.max(d.rrk, d.prk)) + 2)], ticks: [1, 25, 50, 100, 200, 300].filter(t => t <= Math.max(60, d3.max(hist, d => Math.max(d.rrk, d.prk)) + 2)), label: null, grid: true },
marks: [
Plot.line(hist, { x: "w", y: "prk", stroke: "var(--pow)", strokeWidth: 2.5 }),
Plot.line(hist, { x: "w", y: "rrk", stroke: "var(--res)", strokeWidth: 2.5 }),
Plot.dot(hist, { x: "w", y: "prk", fill: "var(--pow)", r: 3, stroke: "var(--paper)" }),
Plot.dot(hist, { x: "w", y: "rrk", fill: "var(--res)", r: 3, stroke: "var(--paper)" }),
Plot.tip(hist, Plot.pointerX({ x: "w", y: "rrk", title: d => `${seasonSnaps.find(s => s.w === d.w)?.label}\nResume #${d.rrk} · Power #${d.prk}` }))
]
})
const q = [1, 2, 3, 4].map(k => [tRow[`q${k}w`], tRow[`q${k}l`]])
const sheet = q.every(([w]) => w == null) ? "" : htl.html`<div style="padding-top:16px">${eyebrow("Team sheet", "var(--ink-3)")}
<table class="t qtbl" style="margin-top:6px"><thead><tr><th></th>${[1, 2, 3, 4].map(k => htl.html`<th class="n">Q${k}</th>`)}</tr></thead>
<tbody><tr><td class="muted">W–L</td>${q.map(([w, l]) => htl.html`<td class="q">${w}–${l}</td>`)}</tr></tbody></table>
<div class="outlook">${kv("SOR rank", tRow.sor_rk ?? "–")}${kv("Luck", tRow.luck == null ? "–" : fmtSigned(tRow.luck, 1))}${kv("Colley rank", tRow.colley_rk ?? "–")}${kv("SOS rank", tRow.sos_rk ?? "–")}</div>
<p class="note">Quadrants use our power ranks with the NET cut-offs (Q1: home 1–30, neutral 1–50, away 1–75). Luck: wins minus the wins the current ratings expected.</p></div>`
const outlook = hasOdds ? htl.html`<div class="outlook">${kv("NCAA field", fmtPct(tRow.p_field))}${kv("Auto bid", fmtPct(tRow.p_auto))}
${kv("Proj. WAB", fmtSigned(tRow.proj_wab, 1))}${kv("Conf. title", fmtPct(tRow.p_reg))}</div>` : htl.html`<p class="note">${(snap.n_games ?? 0) > 0 ? "No season simulation at this snapshot." : "Odds start once games are played."}</p>`
const tr = g => {
const o = byId.get(g.opp_id) ?? { id: g.opp_id, team: g.opp }
const played = g.result != null, win = g.result === "W"
const mm = played ? g.pre_margin : g.fut_margin, wp = played ? g.pre_wp : g.fut_wp
return htl.html`<tr>
<td class="muted" style="width:70px">${dayFmt(g.date).replace(/^\w+, /, "")}</td>
<td>${teamCell(o, `${g.venue === "A" ? "Away" : g.venue === "N" ? "Neutral" : "Home"}${o.prk ? ` (${o.rrk ?? "–"}/${o.prk})` : " · non-D1"}${g.tourn ? ` · ${g.tourn}` : ""}`, 28)}</td>
<td>${played ? htl.html`<strong class=${win ? "up" : "down"}>${win ? "W" : "L"} ${g.pts}–${g.opp_pts}${g.ot ? " OT" : ""}</strong>${g.missing >= 0.1 ? htl.html` <span class="note" title="Share of usual minutes missing">· short ${Math.round(g.missing * 100)}%</span>` : ""}` : htl.html`<span class="muted">to play</span>`}</td>
<td class="n">${mm == null ? "–" : fmtSigned(-mm)}</td>
<td class="n muted hide-sm">${g.line == null ? "–" : fmtSigned(-g.line)}</td>
<td class="hide-xs">${wp == null ? "–" : pbar(wp, played ? "var(--field)" : "var(--pow)", 80)}</td>
<td class="n">${played && g.wab_game != null ? htl.html`<strong style=${{ color: g.wab_game >= 0 ? "var(--res-t)" : "var(--down)" }}>${fmtSigned(g.wab_game, 2)}</strong>` : htl.html`<span class="muted">–</span>`}</td>
</tr>`
}
const played = tGames.filter(g => g.result != null && g.date < snap.date), left = tGames.filter(g => !(g.result != null && g.date < snap.date))
return htl.html`<div class="team-grid">
<aside>
<div>${eyebrow("Ratings, as percentiles of D1", "var(--ink-3)")}
${hasResume ? rating("Resume", `${fmtSigned(tRow.wab, 2)} WAB`, tRow.rrk, "var(--res)", mvTxt(tRow.rrk, pm?.rrk)) : ""}
${rating("Power", `${fmtSigned(tRow.pw)} per 100`, tRow.prk, "var(--pow)", mvTxt(tRow.prk, pm?.prk))}
${tRow.off == null ? "" : rating("Offense", `${tRow.off.toFixed(1)} per 100`, tRow.off_rk, "var(--navy)", "points scored")}
${tRow.def == null ? "" : rating("Defense", `${tRow.def.toFixed(1)} per 100`, tRow.def_rk, "var(--navy)", "points allowed")}
${tRow.tempo == null ? "" : rating("Tempo", `${tRow.tempo.toFixed(1)} poss.`, tRow.tempo_rk, "var(--ink-3)", "per 40 minutes")}</div>
<div style="padding-top:16px">${eyebrow("Outlook", "var(--ink-3)")}${outlook}</div>
${sheet}
${mini ? htl.html`<div style="padding-top:22px;margin-top:18px;border-top:1px solid var(--rule)">
<div style="display:flex;justify-content:space-between;align-items:baseline">${eyebrow("Rank by snapshot", "var(--ink-3)")}
<span style="display:flex;gap:12px;font-size:13px;font-weight:700"><span class="res-t">Resume #${tRow.rrk}</span><span class="pow-t">Power #${tRow.prk}</span></span></div>${mini}</div>` : ""}
</aside>
<section style="min-width:0">
${secH("Schedule", htl.html`<span class="note">Model and Vegas: ${tRow.team}'s line before tip-off</span>`)}
<div class="tbl-wrap"><table class="t"><thead><tr><th>Date</th><th>Opponent<br><span style="font-weight:400">(resume/power)</span></th><th>Result</th><th class="n pow-t">Model</th><th class="n hide-sm">Vegas</th><th class="hide-xs">Win chance</th><th class="n res-t">Resume ±</th></tr></thead>
<tbody>${played.map(tr)}${left.length ? htl.html`<tr class="grp"><td colspan="7"><span class="eb" style="color:var(--ink-3)">After this snapshot</span></td></tr>` : ""}${left.map(tr)}</tbody></table></div>
${played.some(g => g.missing >= 0.1) ? htl.html`<p class="note">"Short" marks games played without rotation players (share of usual minutes missing), known only afterwards.</p>` : ""}
</section></div>`
}mTeams = [...d1].sort((a, b) => a.team.localeCompare(b.team))
// Team sheet → Matchup: this team as A, the next team up the resume (or down, for No. 1) as B,
// ready to change
goCompare = id => {
const list = hasResume ? byRes : byPow, i = list.findIndex(d => d.id === id)
const set = (v, x) => { v.value = x; v.dispatchEvent(new Event("input", { bubbles: true })) }
set(viewof teamA, id)
set(viewof teamB, list[i <= 0 ? 1 : i - 1].id)
set(viewof mView, hasResume ? "Resume" : "Power")
location.hash = "matchup"
window.scrollTo(0, 0)
}
matchup = {
const a = byId.get(teamA), b = byId.get(teamB), v = venue
const poss = a.tempo != null && b.tempo != null ? a.tempo + b.tempo - snap.mu_p : snap.mu_p
const m100 = a.pw - b.pw + snap.hca100 * v
const mean = m100 * poss / 100
const sd = Math.sqrt((snap.sigma_game / Math.sqrt(poss / 70)) ** 2 + a.pw_sd ** 2 + b.pw_sd ** 2) * poss / 100
const hasScore = a.off != null && b.off != null && a.def != null
const lg = d3.mean(d1, d => (d.off + d.def) / 2)
const ptsA = hasScore ? poss * (a.off + b.def - lg) / 100 + snap.hca * v / 2 : null
const ptsB = hasScore ? poss * (b.off + a.def - lg) / 100 - snap.hca * v / 2 : null
return { a, b, mean, sd, poss, pA: normCdf(mean / sd), ptsA, ptsB }
}
favTxt = m => `${m.mean >= 0 ? m.a.team : m.b.team} by ${Math.abs(m.mean).toFixed(1)}`
matchupHeadline = {
const { a, b, mean } = matchup
if (a.id === b.id) return "Pick two different teams"
if (mView === "Resume") {
if (!hasResume || a.wab == null || b.wab == null) return `${a.team} vs. ${b.team}`
const [lead, trail] = a.wab >= b.wab ? [a, b] : [b, a]
return `${lead.team} leads ${trail.team} by ${(lead.wab - trail.wab).toFixed(2)} WAB`
}
if (Math.abs(mean) < 1.5) return `${a.team}, ${b.team} a toss-up`
const [fav, dog] = mean >= 0 ? [a, b] : [b, a]
return `${fav.team} by ${Math.abs(mean).toFixed(1)} over ${dog.team}`
}
matchupBody = {
// Site only matters for the power view
const vr = document.querySelector(".venue-row")
if (vr) vr.style.display = mView === "Power" ? "" : "none"
return mView === "Power" ? powerBody : resumeBody
}
mSide = (d, cls) => htl.html`<div class=${"side " + cls}>${logo(d.id, 96)}<h2 class="disp">${d.team}</h2>
<span style="color:var(--ink-2)">${d.wins ?? 0}–${d.losses ?? 0} · Power #${d.prk}${d.rrk ? ` · Resume #${d.rrk}` : ""}</span></div>`
resumeBody = {
const a = byId.get(teamA), b = byId.get(teamB)
if (!hasResume || a.wab == null || b.wab == null) return htl.html`<p class="note">Resume rankings start after the first games.</p>`
const played = d => G.filter(g => g.s === season && g.id === d.id && g.result != null && g.date < snap.date)
const row = (lab, av, bv, better) => htl.html`<div class="tape-row"><span class=${better === "a" ? "w" : "l"}>${av}</span>
<span class="eb">${lab}</span><span class=${better === "b" ? "w" : "l"}>${bv}</span></div>`
const cmp = (x, y, hi = true) => x == null || y == null || x === y ? null : (hi ? x > y : x < y) ? "a" : "b"
const rng = d => d.rrk_lo == null ? "" : htl.html` <span class="note">${d.rrk_lo}–${d.rrk_hi}</span>`
const quad = (d, k) => d[`q${k}w`] == null ? "–" : `${d[`q${k}w`]}–${d[`q${k}l`]}`
const tape = [
row("WAB", htl.html`${fmtSigned(a.wab, 2)} <span class="note">#${a.rrk}</span>`, htl.html`<span class="note">#${b.rrk}</span> ${fmtSigned(b.wab, 2)}`, cmp(a.wab, b.wab)),
row("Rank · 80%", htl.html`#${a.rrk}${rng(a)}`, htl.html`#${b.rrk}${rng(b)}`, cmp(a.rrk, b.rrk, false)),
...(hasOdds ? [row("NCAA field", fmtPct(a.p_field), fmtPct(b.p_field), cmp(a.p_field, b.p_field))] : []),
row("SOR", `#${a.sor_rk ?? "–"}`, `#${b.sor_rk ?? "–"}`, cmp(a.sor_rk, b.sor_rk, false)),
row("Schedule", `#${a.sos_rk ?? "–"}`, `#${b.sos_rk ?? "–"}`, cmp(a.sos_rk, b.sos_rk, false)),
row("Record", `${a.wins ?? 0}–${a.losses ?? 0}`, `${b.wins ?? 0}–${b.losses ?? 0}`, null),
...[1, 2, 3, 4].map(k => row(`Quad ${k}`, quad(a, k), quad(b, k), null))
]
const [lead, trail] = a.wab >= b.wab ? [a, b] : [b, a]
const oppPow = g => byId.get(g.opp_id)?.prk ?? 999
const order = { "Date": (x, y) => x.date.localeCompare(y.date), "Resume ±": (x, y) => y.wab_game - x.wab_game, "Opponent power": (x, y) => oppPow(x) - oppPow(y) }[mResSort]
const sortCtl = Inputs.radio(["Date", "Resume ±", "Opponent power"], { label: "Sort games", value: mResSort })
sortCtl.addEventListener("input", () => { mutable mResSort = sortCtl.value })
const games = d => {
const gs = played(d).filter(g => g.wab_game != null).sort(order)
return htl.html`<section style="min-width:0">${secH(d.team)}
<div class="tbl-wrap"><table class="t"><thead><tr><th>Date</th><th>Opponent<br><span style="font-weight:400">(power rank)</span></th><th>Result</th><th class="n res-t">Resume ±</th></tr></thead>
<tbody>${gs.length ? gs.map(g => {
const o = byId.get(g.opp_id) ?? { id: g.opp_id, team: g.opp }
return htl.html`<tr><td class="muted" style="width:70px">${dayFmt(g.date).replace(/^\w+, /, "")}</td><td>${teamCell(o, `${g.venue === "A" ? "Away" : g.venue === "N" ? "Neutral" : "Home"}${o.prk ? ` · #${o.prk}` : " · non-D1"}`, 24)}</td>
<td><strong class=${g.result === "W" ? "up" : "down"}>${g.result} ${g.pts}–${g.opp_pts}${g.ot ? " OT" : ""}</strong></td>
<td class="n"><strong style=${{ color: g.wab_game >= 0 ? "var(--res-t)" : "var(--down)" }}>${fmtSigned(g.wab_game, 2)}</strong></td></tr>` })
: htl.html`<tr><td colspan="4" class="muted">No games yet</td></tr>`}</tbody></table></div></section>`
}
return htl.html`<div style="display:flex;flex-direction:column;gap:36px">
<section class="faceoff">${mSide(a, "a")}
<div class="verdict">${eyebrow("Resume", "var(--res-t)")}
<span class="disp">${lead.team} by ${(lead.wab - trail.wab).toFixed(2)}</span>
<span style="font-size:15px;color:var(--ink-2)">WAB gap · wins above the last team into the field on the same schedule</span>
</div>${mSide(b, "b")}</section>
<section class="tape">${tape}</section>
<div style="display:flex;flex-direction:column;gap:12px"><div class="ctl seg">${sortCtl}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:24px 36px">${games(a)}${games(b)}</div></div></div>`
}
powerBody = {
const m = matchup, { a, b } = m
const side = mSide
const stats = [["Power", "pw", "prk", v => fmtSigned(v), true], ["Offense", "off", "off_rk", v => v.toFixed(1), true],
["Defense", "def", "def_rk", v => v.toFixed(1), false], ["Tempo", "tempo", "tempo_rk", v => v.toFixed(1), null], ["Resume", "wab", "rrk", v => fmtSigned(v, 2), true]]
.filter(([, k]) => a[k] != null && b[k] != null)
const tape = stats.map(([lab, k, rk, f, hi]) => {
const aw = hi == null ? null : hi ? a[k] > b[k] : a[k] < b[k]
return htl.html`<div class="tape-row"><span class=${aw === true ? "w" : "l"}>${f(a[k])} <span class="note">#${a[rk]}</span></span>
<span class="eb">${lab}</span><span class=${aw === false ? "w" : "l"}><span class="note">#${b[rk]}</span> ${f(b[k])}</span></div>`
})
const lim = 40
const xs = d3.range(-lim, lim + 0.5, 0.5).map(x => ({ x, y: Math.exp(-0.5 * ((x - m.mean) / m.sd) ** 2) / (m.sd * Math.sqrt(2 * Math.PI)) }))
const ymax = d3.max(xs, d => d.y)
const chart = Plot.plot({
width: Math.min(width, 1216), height: 320, marginLeft: 30, marginRight: 30, marginBottom: 50,
style: { background: "transparent", fontSize: "13px" },
x: { label: `${a.team} margin (points) →`, labelAnchor: "center", labelOffset: 44, domain: [-lim, lim] }, y: { axis: null, domain: [0, ymax * 1.15] },
marks: [
Plot.areaY(xs.filter(d => d.x >= 0), { x: "x", y: "y", fill: "var(--pow)", fillOpacity: 0.28 }),
Plot.areaY(xs.filter(d => d.x <= 0), { x: "x", y: "y", fill: "var(--res)", fillOpacity: 0.22 }),
Plot.line(xs, { x: "x", y: "y", stroke: "var(--navy)", strokeWidth: 2 }),
Plot.ruleX([0], { stroke: "var(--ink-2)", strokeDasharray: "3,3" }),
Plot.ruleY([0], { stroke: "var(--navy)" }),
...(width < 600 ? [] : [Plot.text([{ x: 20, y: ymax * 0.8 }], { x: "x", y: "y", text: () => `${a.team} wins ${fmtPct(m.pA)}`, fill: "var(--pow-t)", fontWeight: 700, fontSize: 14, textAnchor: "start" }),
Plot.text([{ x: -20, y: ymax * 0.8 }], { x: "x", y: "y", text: () => `${b.team} wins ${fmtPct(1 - m.pA)}`, fill: "var(--res-t)", fontWeight: 700, fontSize: 14, textAnchor: "end" })])
]
})
return htl.html`<div style="display:flex;flex-direction:column;gap:36px">
<section class="faceoff">${side(a, "a")}
<div class="verdict">${eyebrow(venue === 0 ? "Neutral floor" : `At ${venue > 0 ? a.team : b.team}`, "var(--ink-3)")}
<span class="disp">${favTxt(m)}</span>
<span class="split"><span style=${{ width: `${m.pA * 100}%`, background: "var(--pow)" }}></span><span style="flex:1;background:var(--res)"></span></span>
<span style="display:flex;justify-content:space-between;width:100%;font-weight:700"><span class="pow-t">${fmtPct(m.pA)}</span><span class="res-t">${fmtPct(1 - m.pA)}</span></span>
${m.ptsA == null ? htl.html`<span style="font-size:15px;color:var(--ink-2)">${m.poss.toFixed(0)} possessions</span>` : htl.html`<span style="font-size:15px;color:var(--ink-2)">Projected score <strong style="color:var(--ink)">${Math.round(m.ptsA)}–${Math.round(m.ptsB)}</strong> · ${m.poss.toFixed(0)} possessions</span>`}
</div>${side(b, "b")}</section>
<section class="tape">${tape}<div class="tape-row"><span class="l">${a.wins ?? 0}–${a.losses ?? 0}</span><span class="eb">Record</span><span class="l">${b.wins ?? 0}–${b.losses ?? 0}</span></div></section>
<section><div class="chart-scroll">${chart}</div></section></div>`
}gameRows = day => d3.groups(G.filter(d => d.s === season && d.date === day), d => d.game_id)
.map(([, sides]) => sides.find(d => d.venue === "H") ?? sides.sort((a, b) => a.id - b.id)[0])
gm = g => g.pre_margin ?? g.fut_margin
gwp = g => g.pre_wp ?? g.fut_wp
shortName = (id, name) => TEAMS.get(id)?.abbr ?? name
favLine = (g, x) => x == null ? "–" : `${x >= 0 ? shortName(g.id, g.team) : shortName(g.opp_id, g.opp)} −${Math.abs(x).toFixed(1)}`
isTop = g => [byId.get(g.id), byId.get(g.opp_id)].some(d => d && ((d.rrk ?? 999) <= 50 || d.prk <= 50))
rankKey = g => d3.min([byId.get(g.id), byId.get(g.opp_id)].filter(Boolean), d => Math.min(d.rrk ?? 999, d.prk + 5)) ?? 999
seasonPlayed = d3.groups(G.filter(d => d.s === season && d.date <= gamesDay && d.result != null && d.pre_margin != null), d => d.game_id).map(([, s]) => s[0])
vsVegas = {
const r = seasonPlayed.filter(g => g.line != null)
const act = g => g.pts - g.opp_pts
const decided = r.filter(g => act(g) !== 0 && gm(g) !== 0 && g.line !== 0)
return { n: r.length,
mae: d3.mean(r, g => Math.abs(gm(g) - act(g))), maeV: d3.mean(r, g => Math.abs(g.line - act(g))),
acc: d3.mean(decided, g => Math.sign(gm(g)) === Math.sign(act(g)) ? 1 : 0), accV: d3.mean(decided, g => Math.sign(g.line) === Math.sign(act(g)) ? 1 : 0),
ats: d3.mean(r.filter(g => act(g) !== g.line && gm(g) !== g.line), g => Math.sign(act(g) - g.line) === Math.sign(gm(g) - g.line) ? 1 : 0) }
}
gamesUp = gameRows(gamesDay).filter(g => g.result == null || g.date >= snap.date)
gamesFinal = gameRows(gamesDay).filter(g => g.result != null)
gamesHeadline = {
const pool = gameRows(gamesDay).filter(g => byId.get(g.id) && byId.get(g.opp_id) && gm(g) != null && g.line != null)
const where = g => g.venue === "N" ? "vs." : "at"
const flip = pool.filter(isTop).filter(g => Math.sign(gm(g)) !== Math.sign(g.line) && Math.abs(gm(g) - g.line) >= 2)
.sort((a, b) => Math.abs(gm(b) - b.line) - Math.abs(gm(a) - a.line))[0]
if (flip) return `Model, Vegas split on ${flip.opp} ${where(flip)} ${flip.team}`
const big = [...pool].sort((a, b) => Math.abs(gm(b) - b.line) - Math.abs(gm(a) - a.line))[0]
if (big) return `Model ${Math.abs(gm(big) - big.line).toFixed(1)} off Vegas on ${big.opp} ${where(big)} ${big.team}`
return gameRows(gamesDay).length ? `${gameRows(gamesDay).length} games on ${dayFmt(gamesDay)}` : "No games this day"
}
gamesScorecard = {
const v = vsVegas
if (!v.n) return htl.html``
const diff = (a, b, dd) => { const x = a - b; return htl.html`<strong style=${{ color: x > 0 ? "var(--down)" : "var(--up)" }}>${x > 0 ? "+" : "−"}${Math.abs(x).toFixed(dd)}</strong>` }
return htl.html`<section style="border-top:3px double var(--navy);padding-top:14px">
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:6px 16px;margin-bottom:12px">${eyebrow(`Model vs. Vegas · ${seasonLabel(season)} through ${dayFmt(gamesDay)} · ${v.n.toLocaleString()} games with a closing line`, "var(--navy)")}<span class="note">Predictions made before tip-off</span></div>
<div class="cols" style="border-top:0;padding-top:0">
${kv("Average miss", htl.html`${v.mae.toFixed(2)} <small>vs ${v.maeV.toFixed(2)}</small>`, htl.html`Points off the final margin · model ${diff(v.mae, v.maeV, 2)}`)}
${kv("Picked the winner", htl.html`${fmtPct(v.acc)} <small>vs ${fmtPct(v.accV)}</small>`, "Share of games the favorite won")}
${kv("Against the spread", fmtPct(v.ats), "Share of games on the model's side of the closing line (52.4% breaks even)")}
</div></section>`
}
gamesBody = {
const keep = g => ((byId.get(g.id) && inConf(byId.get(g.id), gamesConf)) || (byId.get(g.opp_id) && inConf(byId.get(g.opp_id), gamesConf)))
&& (gamesShow === "All games" || isTop(g))
const sub = id => { const d = byId.get(id); return d ? ((d.rrk ?? 999) <= 25 ? `#${d.rrk}` : "") : "non-D1" }
const nameRow = (id, name, right, cls = "") => htl.html`<div class=${"gteam " + cls}>${logo(id, 28)}<span class="nm"><span class="tc-n">${name}</span> <span class="note">${sub(id)}</span></span>${right}</div>`
const projPts = (g, home) => {
const tot = g.pre_total ?? g.fut_total, m = gm(g)
if (tot == null || m == null) return null
return home ? (tot + m) / 2 : (tot - m) / 2
}
const upCard = g => {
const e = g.line == null || gm(g) == null ? null : gm(g) - g.line
return htl.html`<article class="gcard up-next">
<div class="top"><span class="tag">${dayFmt(g.date)}${g.tourn ? " · " + g.tourn : ""}</span><span class="note">${g.venue === "N" ? "Neutral site" : `at ${shortName(g.id, g.team)}`}</span></div>
${nameRow(g.opp_id, g.opp, htl.html`<span class="proj">${projPts(g, false) == null ? "" : Math.round(projPts(g, false))}</span>`)}
${nameRow(g.id, g.team, htl.html`<span class="proj">${projPts(g, true) == null ? "" : Math.round(projPts(g, true))}</span>`)}
${gwp(g) == null ? "" : htl.html`<div class="wpbar"><span><span style=${{ width: `${(1 - gwp(g)) * 100}%`, background: "var(--pow)" }}></span></span>${shortName(g.opp_id, g.opp)} ${fmtPct(1 - gwp(g))}</div>`}
<div class="gline"><span><span class="note">Model</span> <strong>${favLine(g, gm(g))}</strong></span><span><span class="note">Vegas</span> ${favLine(g, g.line)}${g.ou ? htl.html` <span class="note">· O/U ${g.ou}</span>` : ""}</span>
${e != null && Math.abs(e) >= 4 ? htl.html`<span class="pow-t" style="font-weight:700">Split ${Math.abs(e).toFixed(1)}</span>` : ""}</div></article>`
}
const finCard = g => {
const hw = g.pts > g.opp_pts, act = g.pts - g.opp_pts, mm = gm(g)
const em = mm == null ? null : Math.abs(mm - act), ev = g.line == null ? null : Math.abs(g.line - act)
const closer = em == null || ev == null ? null : em < ev ? "Model" : em > ev ? "Vegas" : "Tie"
return htl.html`<article class="gcard final">
<div class="top"><span class="tag final">Final${g.ot ? " · OT" : ""}</span><span class="note">${dayFmt(g.date)}${g.tourn ? " · " + g.tourn : ""}</span></div>
${nameRow(g.opp_id, g.opp, htl.html`<span class="pts">${g.opp_pts}</span>`, hw ? "lost" : "")}
${nameRow(g.id, g.team, htl.html`<span class="pts">${g.pts}</span>`, hw ? "" : "lost")}
<div class="gline"><span><span class="note">Model</span> ${favLine(g, mm)}</span><span><span class="note">Vegas</span> ${favLine(g, g.line)}</span>
${closer ? htl.html`<span style=${{ fontWeight: 700, color: closer === "Model" ? "var(--up)" : "var(--ink-3)" }}>${closer === "Model" ? "✓ Model closer" : closer === "Vegas" ? "Vegas closer" : "Tie"}</span>` : ""}</div></article>`
}
const up = gameRows(gamesDay).filter(g => g.result == null).filter(keep).sort((a, b) => rankKey(a) - rankKey(b))
const fin = gamesFinal.filter(keep).sort((a, b) => rankKey(a) - rankKey(b))
const empty = t => htl.html`<p class="note">${t}</p>`
return htl.html`<div style="display:flex;flex-direction:column;gap:36px">
${up.length || gameRows(gamesDay).some(g => g.result == null) ? htl.html`<section><div class="sec-h"><div>${eyebrow("Upcoming · not played yet", "var(--pow-t)")}<h2 class="disp">${dayFmt(gamesDay)}</h2></div>
<span class="note">Italic numbers are projected scores. Bar: the away team's chance to win.</span></div>
${up.length ? htl.html`<div class="cards">${up.map(upCard)}</div>` : empty("No games match these filters.")}</section>` : ""}
${gamesFinal.length ? htl.html`<section><div class="sec-h"><div>${eyebrow("Final · already played", "var(--navy)")}<h2 class="disp">${dayFmt(gamesDay)} results</h2></div>
<span class="note">Model: the latest snapshot before tip-off. Grey: the losing side.</span></div>
${fin.length ? htl.html`<div class="cards">${fin.map(finCard)}</div>` : empty("No games match these filters.")}</section>` : ""}
</div>`
}bracketHeadline = {
if (!hasOdds) return (snap.n_games ?? 0) > 0 ? "No field odds at this snapshot" : "Bracketology starts once games are played"
const lock = proj.field.filter(d => d.p_field >= 0.99).length
const last = proj.atl.at(-1), first = proj.out[0]
return last ? `${last.team} last in, ${first?.team ?? "nobody"} first out` : `${lock} teams all but in`
}
bracketTiles = {
if (!hasOdds) return htl.html``
const who = d => d ? htl.html`${logo(d.id, 34)}<span>${d.team}</span>` : "–"
const one = proj.field[0]
const expMid = d3.sum(d1.filter(d => grpOf(d) === "Mid"), d => d.p_field ?? 0)
return htl.html`<div class="cols">
${kv("Top overall seed", who(one), `projected WAB ${fmtSigned(one?.proj_wab, 1)}`)}
${kv("Last team in", who(proj.atl.at(-1)), `${fmtPct(proj.atl.at(-1)?.p_field)} to make the field`)}
${kv("First team out", who(proj.out[0]), `${fmtPct(proj.out[0]?.p_field)} to make the field`)}
${kv("Field", `${snap.field} teams`, `${proj.autos.length} automatic bids · ${snap.field - proj.autos.length} at-large · ${expMid.toFixed(1)} expected from outside the high majors`)}
</div>`
}
bracketBubble = {
if (!hasOdds) return htl.html``
const col = (t, rows, note) => htl.html`<section><h2 class="disp" style="font-size:24px">${t}</h2><span class="note">${note}</span>
<div style="margin-top:8px">${rows.map(d => htl.html`<div class="poll-row"><span></span>${logo(d.id, 22)}<span class="nm">${teamLink(d)}</span><span class="r">${fmtPct(d.p_field)}</span></div>`)}</div></section>`
const n = proj.atl.length
return htl.html`<div>${secH("The bubble", htl.html`<span class="note">At-large teams by their chance to be in the field</span>`)}<div class="bubble">
${col("Last four byes", proj.atl.slice(Math.max(0, n - 8), n - 4), "Safely in, for now")}
${col("Last four in", proj.atl.slice(Math.max(0, n - 4)), "The First Four, if it ended today")}
${col("First four out", proj.out.slice(0, 4), "Closest to getting in")}
${col("Next four out", proj.out.slice(4, 8), "Need help")}</div></div>`
}
bracketSeeds = {
if (!hasOdds) return htl.html``
const lines = d3.groups(proj.field.map((d, i) => ({ ...d, line: Math.min(16, Math.floor(i / 4) + 1) })), d => d.line)
const actual = d1.some(d => d.seed != null)
return htl.html`<section>${secH("Seed list", htl.html`<span class="note">${actual ? "Committee seed after the name · " : ""}Field ordered by projected WAB, four to a line (the last lines hold the First Four)</span>`)}
<div class="seedlines">${lines.map(([k, ts]) => htl.html`<div class="seedline"><span class="n">${k}</span><div class="tms">${ts.map(d => htl.html`<span class="tm">${logo(d.id, 20)}<button type="button" class="tc-n" onclick=${() => goToTeam(d.id)}>${d.team}</button>${proj.autoIds.has(d.id) ? htl.html`<span class="auto">AUTO</span>` : ""}${d.seed ? htl.html`<span class="note">(${d.seed})</span>` : ""}</span>`)}</div></div>`)}</div></section>`
}
bracketOdds = {
if (!hasOdds) return htl.html`<p class="note">${(snap.n_games ?? 0) > 0 ? "This snapshot has no season simulation." : "No odds before the first games."}</p>`
const rows = d1.filter(d => inConf(d, oddsConf) && (oddsShow === "All" || (d.p_field ?? 0) >= 0.01)).sort((a, b) => (b.p_field ?? 0) - (a.p_field ?? 0) || (a.rrk ?? 999) - (b.rrk ?? 999))
const tiers = [["Locks", 0.95, 1.01], ["Likely", 0.7, 0.95], ["On the bubble", 0.3, 0.7], ["Need help", 0.05, 0.3], ["Long shots", -1, 0.05]]
const body = tiers.flatMap(([t, lo, hi]) => { const ts = rows.filter(d => (d.p_field ?? 0) >= lo && (d.p_field ?? 0) < hi); if (!ts.length) return []
return [htl.html`<tr class="grp"><td colspan="7"><span class="disp" style="font-size:22px">${t}</span> <span class="note">· ${ts.length} team${ts.length === 1 ? "" : "s"}</span></td></tr>`,
...ts.map(d => htl.html`<tr><td>${teamCell(d, `${d.conf} · ${d.wins}–${d.losses}`, 26)}</td><td>${pbar(d.p_field, "var(--pow)", 100)}</td>
<td class="n">${fmtPct(d.p_auto)}</td><td class="n hide-xs">${fmtPct(d.p_al)}</td><td class="n">${fmtSigned(d.wab, 2)}</td><td class="n hide-sm">${fmtSigned(d.proj_wab, 1)}</td><td class="n muted hide-sm">${d.rrk ?? "–"}</td></tr>`)] })
return htl.html`<div><div class="tbl-wrap tbl-box"><table class="t sticky-head"><thead><tr><th>Team</th><th>In the field</th><th class="n">Auto bid</th><th class="n hide-xs">At-large</th><th class="n">WAB now</th><th class="n hide-sm">Proj. WAB</th><th class="n hide-sm">Resume #</th></tr></thead>
<tbody>${body}</tbody></table></div>
</div>`
}trendsHeadline = {
const hist0 = R.filter(d => d.s === season && d.w <= snap.w && d.rrk != null)
const ws = [...new Set(hist0.map(d => d.w))]
if (ws.length < 2) return { text: "Trends start after the second snapshot", id: byPow[0].id }
const first = d => histOf(d.id).find(x => x.rrk != null)
const climb = byRes.slice(0, 10).map(d => ({ d, from: first(d)?.rrk })).filter(x => x.from != null).sort((a, b) => (b.from - b.d.rrk) - (a.from - a.d.rrk))[0]
let t = `${climb.d.team} up from ${ord(climb.from)} to ${ord(climb.d.rrk)} since the first snapshot`
const fall = [...prevRows.values()].filter(p => p.rrk <= 10 && byId.get(p.id)).map(p => ({ d: byId.get(p.id), from: p.rrk }))
.sort((a, b) => (b.d.rrk - b.from) - (a.d.rrk - a.from))[0]
if (fall && fall.d.rrk - fall.from >= 5) t += `; ${fall.d.team} drops ${fall.d.rrk - fall.from}`
return { text: t, id: climb.d.id }
}
trendsChart = {
const key = trendBy === "Resume" && hasResume ? "rrk" : "prk"
const color = key === "rrk" ? "var(--res)" : "var(--pow)"
const all = trendConf === "all"
const top = new Set(d1.filter(d => all ? d[key] <= 25 : inConf(d, trendConf)).map(d => d.id))
top.add(trendTeam)
const hist0 = R.filter(d => d.s === season && d.w <= snap.w && top.has(d.id) && d[key] != null)
if (!hist0.length) return htl.html`<p class="note">No snapshots to show yet.</p>`
const yMax = all ? 50 : Math.max(26, d3.max(hist0, d => d[key]) + 2)
const hist = hist0.map(d => ({ ...d, v: d[key] <= yMax ? d[key] : NaN }))
const lastW = d3.max(hist, d => d.w), me = hist.filter(d => d.id === trendTeam)
const labels = new Map(seasonSnaps.map(d => [d.w, d.label]))
const narrow = width < 600
const ticks = [...new Set(hist.map(d => d.w))].filter((w, i, a) => a.length < 12 || i % 2 === 0)
return htl.html`<section style="border-top:3px double var(--navy);padding-top:12px"><div class="chart-scroll">${Plot.plot({
width: Math.min(width, 1216), height: narrow ? 580 : 780, marginRight: narrow ? 92 : 210, marginLeft: 40, marginBottom: 40,
style: { background: "transparent", fontSize: "13px" },
x: { label: null, ticks, tickFormat: w => labels.get(w) },
y: { reverse: true, domain: [1, yMax], label: null, grid: true, ticks: [1, 5, 10, 15, 20, 25, 30, 40, 50].filter(t => t <= yMax) },
marks: [
Plot.line(hist, { x: "w", y: "v", z: "id", stroke: color, strokeOpacity: 0.22, strokeWidth: 1.5 }),
Plot.line(me, { x: "w", y: "v", stroke: color, strokeWidth: 3.5 }),
Plot.dot(me, { x: "w", y: "v", fill: color, r: 4.5, stroke: "var(--paper)", strokeWidth: 1.5 }),
Plot.dot(hist.filter(d => d.w === lastW), { x: "w", y: "v", fill: color, r: 3.5 }),
Plot.text(hist.filter(d => d.w === lastW), { x: "w", y: "v", dx: 10, textAnchor: "start", fill: "var(--ink)", fontSize: narrow ? 12 : 13,
fontWeight: d => d.id === trendTeam ? 700 : 400, text: d => narrow ? `${d[key]} ${TEAMS.get(d.id)?.abbr ?? d.team}` : `${d[key]}. ${d.team}` }),
Plot.tip(hist, Plot.pointer({ x: "w", y: "v", title: d => `${d.team}\n${trendBy} #${d[key]} · ${labels.get(d.w)}\n${d.wins}–${d.losses}` }))
]
})}</div><p class="note">${all ? `Current ${trendBy.toLowerCase()} top 25. Ranks below 50 leave the chart. ` : ""}Highlighted: ${byId.get(trendTeam)?.team}.</p></section>`
}rvpHeadline = {
if (!hasResume) return "Resume rankings start after the first games"
const gap = byPow.slice(0, 8).filter(d => d.rrk - d.prk >= 12)[0]
if (gap) return `${gap.team}: ${ord(gap.prk)} in power, ${ord(gap.rrk)} on resume`
const rev = byRes.slice(0, 8).filter(d => d.prk - d.rrk >= 12)[0]
if (rev) return `${rev.team}: ${ord(rev.rrk)} on resume, ${ord(rev.prk)} in power`
return "Resume, power agree at the top"
}
// Label placement in the spirit of ggrepel: each label tries spots around its dot, nearest
// first, then rings further out, and takes the first that clears the plot edges, every label
// placed so far and every highlighted dot. Labels that had to move away get a leader line.
// Text widths are measured with the page's font. Positions come back in data units.
placeLabels = (rows, x, y, dots = [], taken = []) => {
const [x0, x1] = x.range(), [y1, y0] = y.range()
const ctx = document.createElement("canvas").getContext("2d")
ctx.font = "600 12.5px 'Atkinson Hyperlegible Next', system-ui, sans-serif"
const boxes = [...taken, ...dots.map(d => [x(d.pw) - 5, y(d.wab) - 5, x(d.pw) + 5, y(d.wab) + 5])]
const hit = b => b[0] < x0 || b[2] > x1 || b[1] < y0 || b[3] > y1 || boxes.some(o => b[0] < o[2] && b[2] > o[0] && b[1] < o[3] && b[3] > o[1])
const dirs = [[1, 0], [-1, 0], [0, -1], [0, 1], [1, -1], [1, 1], [-1, -1], [-1, 1]]
const spots = [9, 22, 36, 52].flatMap(r => dirs.map(([ux, uy]) => [ux * r, uy * (r + 4), r]))
return [...rows].sort((a, b) => a.rrk - b.rrk).map(d => {
const px = x(d.pw), py = y(d.wab), w = ctx.measureText(d.team).width + 4
const at = ([dx, dy]) => { const anchor = dx > 0 ? "start" : dx < 0 ? "end" : "middle"
const bx = anchor === "start" ? px + dx : anchor === "end" ? px + dx - w : px + dx - w / 2
return { anchor, box: [bx, py + dy - 8, bx + w, py + dy + 8], lx: x.invert(px + dx), ly: y.invert(py + dy) } }
const k = spots.findIndex(s => !hit(at(s).box))
if (k < 0) return null // no room: the tooltip still names the team
const p = at(spots[k]); boxes.push(p.box)
return { ...d, anchor: p.anchor, lx: p.lx, ly: p.ly, far: spots[k][2] > 9 }
}).filter(Boolean)
}
// One text mark per anchor (Plot takes textAnchor as a constant), plus leader lines
labelMarks = (labs, opts) => [
Plot.link(labs.filter(d => d.far), { x1: "pw", y1: "wab", x2: "lx", y2: "ly", stroke: "var(--ink-3)", strokeOpacity: 0.6, strokeWidth: 1 }),
...["start", "end", "middle"].map(a => Plot.text(labs.filter(d => d.anchor === a), { ...opts, x: "lx", y: "ly", textAnchor: a }))
]
rvpBody = {
if (!hasResume) return htl.html`<p class="note">Resume ratings start after the first games.</p>`
const W = Math.min(width, 1216), side = W >= 1000, PW = side ? W - 420 : W, PH = side ? 640 : 480
const on = d => inConf(d, rvpConf), hi = d1.filter(on)
const inField = new Set(proj.field.map(d => d.id))
const lab0 = rvpConf === "all" ? d1.filter(d => d.rrk <= 6 || d.prk <= 6 || (Math.abs(d.rrk - d.prk) >= 30 && (d.rrk <= 30 || d.prk <= 30)))
: hi.length <= 20 ? hi : hi.filter(d => d.rrk <= 12 || d.prk <= 12)
const m = { l: 56, r: 24, t: 24, b: 46 }
const xd = d3.extent(d1, d => d.pw), yd = d3.extent(d1, d => d.wab)
const x = d3.scaleLinear([xd[0] - 2, xd[1] + 2], [m.l, PW - m.r]), y = d3.scaleLinear([yd[0] - 0.5, Math.max(yd[1] + 0.5, 1)], [PH - m.b, m.t])
// The bubble caption at the left end of the zero line is off limits too
const labs = placeLabels(lab0, x, y, hi, [[m.l, y(0) - 22, m.l + 260, y(0) - 2]])
const plot = Plot.plot({
width: PW, height: PH, marginLeft: m.l, marginRight: m.r, marginTop: m.t, marginBottom: m.b,
style: { background: "transparent", fontSize: "13px" }, grid: true, color: { type: "identity" },
x: { domain: x.domain(), label: "Power rating (points per 100 possessions vs. average D1) →" },
y: { domain: y.domain(), label: "↑ Wins above the bubble" },
marks: [
Plot.ruleY([0], { stroke: "var(--res-t)", strokeDasharray: "5,4" }),
Plot.text([[x.domain()[0], 0]], { dx: 6, dy: -8, textAnchor: "start", text: () => "Bubble: the last team into the field", fill: "var(--res-t)", fontSize: 12 }),
Plot.dot(d1.filter(d => !on(d)), { x: "pw", y: "wab", r: 4, fill: d => `var(${GRPVAR[grpOf(d)]})`, fillOpacity: 0.12 }),
Plot.dot(hi, { x: "pw", y: "wab", r: d => inField.has(d.id) ? 6 : 3.5, fill: d => `var(${GRPVAR[grpOf(d)]})`, fillOpacity: d => inField.has(d.id) ? 0.95 : 0.4, stroke: "var(--paper)", strokeWidth: 1 }),
...labelMarks(labs, { text: "team", fontSize: 12.5, fontWeight: 600, fill: "var(--ink)", stroke: "var(--paper)", strokeWidth: 3, paintOrder: "stroke" }),
Plot.tip(d1, Plot.pointer({ x: "pw", y: "wab", title: d => `${d.team} (${d.wins}–${d.losses})\n${d.conf}\nResume #${d.rrk} · WAB ${fmtSigned(d.wab, 2)}\nPower #${d.prk} · ${fmtSigned(d.pw)} per 100` }))
]
})
const confs = d3.groups(d1.filter(d => !/Indep/.test(d.conf)), d => d.conf).map(([conf, t]) => ({
conf, n: t.length, pw: d3.mean(t, d => d.pw), wab: d3.mean(t, d => d.wab), bids: d3.sum(t, d => d.p_field ?? 0),
best: t.reduce((a, b) => a.rrk <= b.rrk ? a : b) })).sort((a, b) => b.pw - a.pw)
const pick = k => { const v = viewof rvpConf; v.value = k; v.dispatchEvent(new Event("input", { bubbles: true })) }
const table = htl.html`<aside style="min-width:0"><h2 class="disp" style="font-size:28px;margin-bottom:6px">By conference</h2>
<div class="tbl-wrap"><table class="t"><thead><tr><th>Conference</th><th class="n">Power</th><th class="n">WAB</th><th class="n">Bids</th></tr></thead><tbody>
${confs.map(r => htl.html`<tr class=${rvpConf === "conf:" + r.conf ? "sel" : ""}><td><button type="button" class="tc-n" onclick=${() => pick(rvpConf === "conf:" + r.conf ? "all" : "conf:" + r.conf)}>${r.conf}</button><div class="note">Best: ${r.best.team} #${r.best.rrk}</div></td>
<td class="n">${fmtSigned(r.pw)}</td><td class="n">${fmtSigned(r.wab, 2)}</td><td class="n">${hasOdds ? r.bids.toFixed(1) : "–"}</td></tr>`)}</tbody></table></div>
<p class="note">Averages over each conference's teams; bids = expected NCAA bids. Pick one to highlight it.</p></aside>`
return htl.html`<div style=${{ display: "grid", gridTemplateColumns: side ? "minmax(0,1fr) 380px" : "minmax(0,1fr)", gap: "36px", borderTop: "3px double var(--navy)", paddingTop: "16px" }}>
<div class="chart-scroll">${plot}</div>${table}</div>`
}confRows = {
const games = G.filter(d => d.s === season && d.conf_game && d.result != null && d.date < snap.date && !d.tourn)
const rec = d3.rollup(games, v => ({ w: v.filter(g => g.result === "W").length, l: v.filter(g => g.result === "L").length }), d => d.id)
return d1.filter(d => d.conf === raceConf).map(d => ({ ...d, cw: rec.get(d.id)?.w ?? 0, cl: rec.get(d.id)?.l ?? 0 }))
.sort((a, b) => (b.p_reg ?? 0) - (a.p_reg ?? 0) || (b.cw - b.cl) - (a.cw - a.cl) || a.prk - b.prk)
}
confHeadline = {
const t = confRows[0]
if (!t || t.p_reg == null) return `${raceConf}`
const tt = [...confRows].sort((a, b) => (b.p_auto ?? 0) - (a.p_auto ?? 0))[0]
return t.id === tt.id ? `${t.team} ${fmtPct(t.p_reg)} to win the ${raceConf}`
: `${t.team} leads the ${raceConf} race; ${tt.team} tournament favorite`
}
confBody = {
if (!confRows.length) return htl.html``
const bars = (r, a, b, ca, cb) => htl.html`<span class="bar"><span style=${{ width: `${(a ?? 0) * 100}%`, background: ca }}></span>${b == null ? "" : htl.html`<span style=${{ width: `${Math.max(0, (b ?? 0) - (a ?? 0)) * 100}%`, background: cb }}></span>`}</span>`
const row = r => htl.html`<div class="race-row"><span class="tc">${logo(r.id, 26)}${teamLink(r)}<span class="note">${r.cw}–${r.cl}</span></span>
${bars(r, r.p_reg, null, "var(--navy)")}<span class="r">${fmtPct(r.p_reg)}</span><strong class="r">${fmtPct(r.p_auto)}</strong></div>`
return htl.html`<div style="display:flex;flex-direction:column;gap:36px">
<section>${secH(`Who wins the ${raceConf}`, htl.html`<span class="key"><span><span class="sw" style="background:var(--navy)"></span>Regular-season title (ties count for the team ranked first)</span></span>`)}
<div class="race-row race-head"><span>Team · conf. record</span><span></span><span class="r">Season</span><span class="r">Tourn.</span></div>
${confRows.map(row)}
<p class="note">Tourn. is the chance to win the conference tournament, the automatic bid (a team not yet eligible cannot take it). Standings come from the simulated rest of the season, ties to the better rating.</p></section>
<section>${secH("NCAA bids", htl.html`<span class="note">${hasOdds ? `${d3.sum(confRows, d => d.p_field ?? 0).toFixed(1)} expected bids` : ""}</span>`)}
<div class="tbl-wrap"><table class="t"><thead><tr><th>Team</th><th>In the field</th><th class="n">Power #</th><th class="n">Resume #</th></tr></thead>
<tbody>${[...confRows].sort((a, b) => (b.p_field ?? 0) - (a.p_field ?? 0)).map(r => htl.html`<tr><td>${teamCell(r, `${r.wins}–${r.losses}`, 24)}</td><td>${pbar(r.p_field, "var(--pow)", 120)}</td><td class="n">${r.prk}</td><td class="n">${r.rrk ?? "–"}</td></tr>`)}</tbody></table></div></section>
</div>`
}pollsHeadline = {
if (!d1.some(d => d.ap != null)) return "No AP poll for this snapshot"
if (!hasResume) return "Preseason polls are in"
const top = byRes.slice(0, 25), shared = top.filter(d => d.ap != null && d.ap <= 25).length
return `AP poll, resume share ${shared} of top 25`
}
pollsBody = {
const games = G.filter(d => d.s === season && d.conf_game && d.result != null && d.date < snap.date && !d.tourn)
const crec = d3.rollup(games, v => [v.filter(g => g.result === "W").length, v.filter(g => g.result === "L").length], d => d.id)
const confAbbr = { "Mountain West": "MW", "Big Ten": "B1G", "Big 12": "B12", "Big East": "BE", "Sun Belt": "SBC", "Am. East": "AE", "American": "AAC",
"Big Sky": "BSky", "Big South": "BSth", "Big West": "BW", "Southland": "SLC", "Patriot": "PL", "Summit": "Sum", "Horizon": "HL" }
const rec = d => { const c = crec.get(d.id); return `${confAbbr[d.conf] ?? d.conf} · ${d.wins ?? 0}–${d.losses ?? 0}${c ? ` (${c[0]}–${c[1]})` : ""}` }
const sys = [["ap", "AP poll", "Media voters"], ["coaches", "Coaches poll", "D1 head coaches"], ["prk", "Our power", "How good, per 100 possessions"],
["colley_rk", "Colley matrix", "Record and schedule only"], ["sor_rk", "Strength of record", "Chance a top-25 team does as well"]]
const col = ([k, t, sub]) => {
const rows = d1.filter(d => d[k] != null).sort((a, b) => a[k] - b[k]).slice(0, pollDepth)
const head = htl.html`<h2 class="disp" style="font-size:24px">${t}</h2><span class="note">${sub}</span>`
if (!rows.length) return htl.html`<section style="border-top:3px double var(--navy);padding-top:12px">${head}<div class="empty">Not available for this snapshot.</div></section>`
return htl.html`<section style="border-top:3px double var(--navy);padding-top:12px">${head}<div style="margin-top:8px">${rows.map(d => {
const far = hasResume && Math.abs(d.rrk - d[k]) >= 10
return htl.html`<div class="poll-row"><span style="text-align:right;font-weight:700">${d[k]}</span>${logo(d.id, 22)}<span class="nm">${teamLink(d)}<span class="rec">${rec(d)}</span></span>
<span class=${far ? "r far" : "r"}>${hasResume ? `R #${d.rrk}` : ""}</span></div>` })}</div></section>`
}
return htl.html`<div class="polls">${sys.map(col)}</div>`
}