From c31420cbf922ad634d00052e58b6c8840736c0ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 17:38:59 +0000 Subject: [PATCH 1/2] Price the cutline gap as a finish, and add a head-to-head panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cutline panel said "137 short" and stopped there, which is a true number nobody can act on. It now draws the line where it lands in a kind season and a cruel one — the 10th and 90th percentiles either side of the median — and a table under it prices all three as a finishing place at the closing event: the worst place that still carries a player over each line. The two annotated gaps and the tick readout carry the same figure. The arithmetic has to run through each player's own counting pools rather than off the points table, because a result is only worth what it beats: a player whose best two playoff finishes are already banked keeps only the difference, which is why the same gap costs two players different finishes. `poolSum` and `countingPools` move up out of the leverage panel, since two panels need them now. The new panel answers the other half of the question. Pick any two players still in contention and it walks the leader's weekend down the points curve — a win, a podium, a 20th, dead last — giving the finish the chaser needs and how many places clear of the leader that is. The margin is not fixed, because the curve is top-heavy; against a lead the event cannot pay it says so outright rather than printing a column of dashes. Two pickers over the possibility cloud's list, not a matrix: every pairing of 52 players is 1,326 answers nobody reads. Both are exact arithmetic on the published curve rather than a simulation, and hold two things still: the rest of the season where it stands (exact with one event left, a ceiling before that), and the frozen cutline the row expander already runs on. Verified against the published MPO and FPO bundles at three viewport widths: six panels render, no page errors, no horizontal overflow, and the not-in-the-field paths checked by withdrawing contenders from the field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JsBC4pyUqbxW9y5o9tAeRr --- docs/CLAUDE.md | 2 +- docs/how-it-works.html | 1 + docs/js/core.js | 4 + docs/js/possible.js | 442 +++++++++++++++++++++++++++++++++++------ docs/style.css | 31 +++ 5 files changed, 419 insertions(+), 61 deletions(-) diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 8cdcc199..87b7e8ce 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -20,7 +20,7 @@ tab wiring. Everything else lives in `js/`: | `sim.js` | the client-side Monte Carlo: the cutline replay, the per-event points draws, and the 2027 drop-in | | `movers.js` | the biggest-movers panel | | `forecast.js` | the standings table, column guide, expanded row, what-if | -| `possible.js` | the five "what's left" panels | +| `possible.js` | the six "what's left" panels | | `race.js` | the event-odds chart and table | | `projection.js` | the two experimental tables (2027, EuroTour) | | `whatif.js` | the What-if tab: sliders for an invented player, dropped into the 2027 field | diff --git a/docs/how-it-works.html b/docs/how-it-works.html index afa5f65a..c6b155df 100644 --- a/docs/how-it-works.html +++ b/docs/how-it-works.html @@ -297,6 +297,7 @@

How the DGPT points system works

Changelog
+ diff --git a/docs/js/core.js b/docs/js/core.js index 77f53205..9408eca3 100644 --- a/docs/js/core.js +++ b/docs/js/core.js @@ -9,6 +9,10 @@ const state = { div: "mpo", view: "forecast", data: {}, sort: { key: "p_champ", dir: "desc" }, colsMode: "auto", permalink: null, moverWin: "week", moversOpen: false, cloudMode: "chart", lev: {}, raceAxis: "time", raceWin: null, + // the head-to-head pair, per division: a PDGA number picked in + // MPO means nothing in the FPO list, so the panel validates + // what it restores against the division it is drawing + h2h: {}, // the experimental tabs load their own bundles and keep their // own sort, so switching tabs never disturbs the forecast table proj: {}, projSort: {}, diff --git a/docs/js/possible.js b/docs/js/possible.js index 0f10cf9d..864d8061 100644 --- a/docs/js/possible.js +++ b/docs/js/possible.js @@ -1,9 +1,10 @@ -/* The "what's left" tab: five reads of the remaining possibility space. +/* The "what's left" tab: six reads of the remaining possibility space. - Panels 1-4 are pure re-reads of fields the bundle already ships. Panel 5 - re-runs the cutline replay under a fixed result at one event, which is why - it is computed lazily, chunked one player per tick, and cached per - division. */ + Panels 1-5 are pure re-reads of fields the bundle already ships; 3 and 4 + also price a gap as a finishing place, which is arithmetic on the closing + event's own curve rather than a simulation. Panel 6 re-runs the cutline + replay under a fixed result at one event, which is why it is computed + lazily, chunked one player per tick, and cached per division. */ import { $, fmtPct, fmtPts, ordinal, state } from "./core.js"; import { probe, tipAttrs } from "./tooltip.js"; @@ -11,15 +12,17 @@ import { CLS_LABEL, liveTidSet, nameCell, playerLink, playoffNote, POOL_BY_CLS, import { samplePoints } from "./sim.js"; /* ========================================================================== - "What's left" view — five reads of the remaining possibility space. + "What's left" view — six reads of the remaining possibility space. The forecast table answers "will this player get in" one row at a time. These answer the shape questions it can't: how wide is everyone's range, which positions are actually still contested, how far the chasers are from - the line, which door they walk through, and where their season gets decided. + the line and what closing that gap costs, what it takes to pass the player + one row up, which door they walk through, and where their season gets + decided. - Panels 1-4 are pure re-reads of fields the bundle already ships (hist, - cutline, p_cut, p_mvp_qual, att) — no pipeline change. Panel 5 re-runs the + Panels 1-5 are pure re-reads of fields the bundle already ships (hist, + cutline, p_cut, p_mvp_qual, att) — no pipeline change. Panel 6 re-runs the cutline replay under a fixed result at one event, which is why it is computed lazily, chunked, and cached per division. ========================================================================== */ @@ -34,6 +37,40 @@ function contenders(d, max = 26) { .slice(0, max); } +// the rug labels, the gap annotations and the head-to-head summary all want a +// name short enough to sit next to a number +const lastName = (p) => p.name.split(/\s+/).pop(); + +/* ---------- counting pools ---------- */ + +/* Exact top-`cap` sum for one counting pool after adding `adds`. + + The single-add case — every pool but the playoffs today — is the whole point + of this view: a new result only counts for what it beats, so it is worth + `value − the pool's current floor`, which is why the same event is worth 300 + to one player and 90 to the next. */ +function poolSum(pl, adds) { + if (!adds.length) return pl.sum; + if (adds.length === 1) return pl.sum + Math.max(0, adds[0] - pl.floor); + const all = pl.kept.concat(adds).sort((a, b) => b - a); + let s = 0; + for (let i = 0; i < pl.cap && i < all.length; i++) s += all[i]; + return s; +} + +function countingPools(d, p) { + const m = d.meta; + const banked = { dgpt: [], playoff: [], major: [], jomez: [] }; + for (const b of p.banked) banked[POOL_BY_CLS[b.cls] || "dgpt"].push(b.pts); + const pools = { jomez: banked.jomez.reduce((s, x) => s + x, 0) }; + for (const [k, cap] of [["dgpt", m.count_dgpt], ["playoff", m.count_playoff], ["major", m.majors_counted]]) { + const kept = banked[k].sort((a, b) => b - a).slice(0, cap); + pools[k] = { kept, cap, sum: kept.reduce((s, x) => s + x, 0), + floor: kept.length >= cap ? kept[cap - 1] : 0 }; + } + return pools; +} + /* ---------- 1 · possibility cloud ---------- */ /* Rows of the cloud: everyone with a live claim, plus enough of the table @@ -180,14 +217,106 @@ function contestedHtml(d) { /* Points only ever go up, so a player's current total is a hard floor and the distance to the cutline is a real, closeable number. The axis has to reach down to the chasers rather than just covering the cutline — the horizontal - distance between the two IS the chart. */ -function cutlineHtml(d) { - const m = d.meta, cl = d.cutline; + distance between the two IS the chart. + + A distance in points is only half of what a chaser is asking, though: the + other half is what it costs. So every gap here is also priced as a finishing + place at the closing event — the worst finish that still carries them over + the line. */ + +/* The event a chaser's season comes down to: the MVP Open while it is still + unplayed, and otherwise whatever is last on the calendar. */ +function closingEvent(d) { + return d.events.find((e) => e.tid === d.meta.mvp_tid) || d.events[d.events.length - 1] || null; +} + +/* The three lines worth pricing against. "Low" and "high" are the cutline's + 10th and 90th percentiles — the same season in its kind and its cruel + version — and the median between them is the headline. Named rather than + numbered because a table column headed "10th pct" full of ordinals ("10th + pct: 12th") is two different kinds of place in four words. */ +const CUT_BANDS = [ + { f: 0.1, label: "low", head: "Low", pct: "10th percentile", blurb: "a season where the line lands low" }, + { f: 0.5, label: "median", head: "Median", pct: "median", blurb: "where the line usually lands" }, + { f: 0.9, label: "high", head: "High", pct: "90th percentile", blurb: "a season where the line lands high" }, +]; +const cutBands = (q) => CUT_BANDS.map((b) => ({ ...b, pts: q(b.f) })); + +/* What a finish at `ev` is worth to `p`, place by place, after the per-class + counting caps — a result only counts for what it beats. The Jomez bonus is + the one uncapped pool and pays face value. + + Null when they are not in that field: an event that cannot move their season + is a different answer from one worth zero, and the callers say so. */ +function pricer(d, ev, p) { + const ei = ev ? d.events.indexOf(ev) : -1; + if (ei < 0 || !((p.live && p.live[ev.tid]) || p.att[ei] > 0.02)) return null; + const key = POOL_BY_CLS[ev.cls] || "dgpt"; + const pool = key === "jomez" ? null : countingPools(d, p)[key]; + return (place) => { + const v = place <= ev.curve.length ? ev.curve[place - 1] : 0; + return pool ? poolSum(pool, [v]) - pool.sum : v; + }; +} + +/* The worst finish at `ev` that still carries `p` past `target` season points. + + Walks the event's own curve to the back of its field, so "any finish does + it" and "a win is not enough" are real answers rather than a place number + clipped at either end. + + Two things are held still. The rest of the season stays where it stands, + which is exact with one event left and a ceiling before that — anything + banked elsewhere only lowers the bar. And the line itself is the one the + base simulation drew, when a player who wins here takes points off the + people it is made of: the same frozen-cutline approximation the row + expander runs on, and for the same reason. */ +function needAt(d, ev, p, target, price = pricer(d, ev, p)) { + if (p.points > target) return { kind: "clear" }; + if (!price) return { kind: "out" }; + const size = Math.max(1, Math.round(ev.field_size)); + const need = target - p.points; + let worst = 0; + for (let k = 1; k <= size; k++) if (price(k) > need) worst = k; + if (!worst) return { kind: "never" }; + return { kind: worst >= size ? "any" : "place", place: worst }; +} + +/* How a needed finish reads in prose — the gap annotations and the tick + readout — and in a table cell, where the column head carries the sentence. */ +function needText(n) { + switch (n.kind) { + case "clear": return "already clear"; + case "any": return "any finish does it"; + case "never": return "a win is not enough"; + case "out": return "not in the field"; + default: return `needs ${n.place === 1 ? "the win" : ordinal(n.place)}`; + } +} +function needShort(n) { + switch (n.kind) { + case "clear": return "clear"; + case "any": return "any"; + case "never": return "—"; + case "out": return "n/a"; + default: return ordinal(n.place); + } +} +function needCell(n) { + if (n.kind === "out") { + return ``; + } + if (n.kind === "never") return ``; + if (n.kind === "clear" || n.kind === "any") return ``; + return ``; +} + +function cutlineHtml(d, q) { + const cl = d.cutline; if (!cl || cl.length < 100) return ""; const rug = contenders(d); - const sorted = [...cl].sort((a, b) => a - b); - const q = (f) => sorted[Math.min(sorted.length - 1, Math.floor(f * sorted.length))]; - const p50 = q(0.5), blo = q(0.005), bhi = q(0.995); + const bands = cutBands(q), ev = closingEvent(d); + const p50 = bands[1].pts, blo = q(0.005), bhi = q(0.995); const NB = 44, bwPts = (bhi - blo) / NB; const bins = new Array(NB).fill(0); for (const x of cl) if (x >= blo && x <= bhi) bins[Math.min(NB - 1, Math.floor((x - blo) / bwPts))]++; @@ -218,26 +347,216 @@ function cutlineHtml(d) { .filter((p, i, a) => p && a.indexOf(p) === i) .forEach((p, i) => { const x = X(p.points), y = T + ph + 50 + i * 17, wide = med - x > 120; + // the gap and its price, on one line: the distance is the chart, the + // finish it takes is what the reader came for + const n = ev ? needAt(d, ev, p, p50) : { kind: "out" }; + const cost = n.kind === "out" || n.kind === "clear" ? "" : ` · ${needText(n)}`; ticks += ` ${p.name.split(/\s+/).pop()} · ${Math.round(p50 - p.points)} short`; + class="pv-gaplabel">${lastName(p)} · ${Math.round(p50 - p.points)} short${cost}`; }); let axis = ""; for (let t = Math.ceil(xlo / 50) * 50; t <= bhi; t += 50) { axis += `${t}`; } + /* The low and high marks, dashed so the median keeps the chart's one solid + anchor. Each gets a label only where there is room for one: on a narrow + distribution all three land within a few pixels and the labels would stack + on top of each other, and three unreadable numbers are worse than one. */ + let guides = ""; + bands.forEach((b, i) => { + if (i === 1) return; + const x = X(b.pts), right = i > 1; + guides += ``; + if (Math.abs(x - med) < 62) return; + guides += `${b.label} ${Math.round(b.pts)}`; + }); return `
- ${bars} + ${bars}${guides} - median cutline ${Math.round(p50)} + median ${Math.round(p50)} ${ticks}${axis}
`; } -/* ---------- 4 · the ways in ---------- */ +/* The chart's table twin: the same three lines, priced. A gap in points is + abstract until it is a finishing place, which is the unit the weekend is + actually played in. + + Everyone the rug draws who is not already past the high line — a player + clear of all three has nothing to read here, and a row of three "clear"s + would be the only thing most of the table said in September. */ +function needsTableHtml(d, q) { + const ev = closingEvent(d); + if (!ev) return ""; + const bands = cutBands(q), evName = shortName(ev.name); + const rows = contenders(d).filter((p) => p.points <= bands[2].pts); + if (!rows.length) { + return `

Every contender still in play is already clear of the cutline's high end.

`; + } + const head = bands.map((b) => + `
`).join(""); + const body = rows.map((p) => { + const gap = Math.round(bands[1].pts - p.points); + return ` + + + ${bands.map((b) => needCell(needAt(d, ev, p, b.pts))).join("")}`; + }).join(""); + return `
Sep 22A gap to the cutline now comes with a price, and there is a head-to-head panel. "137 short" is a true number that nobody can act on. On the What's left tab, the cutline chart now also draws the line where it lands in a kind season and in a cruel one — its 10th and 90th percentiles either side of the median — and a table under it prices all three as a finishing place at the last event on the calendar: the worst place that still carries a player over each line. The reason the same gap costs two players different finishes is the 2026 counting caps. A result is only worth what it beats, so a player whose best two playoff finishes are already banked keeps only the difference, and the arithmetic has to run through their own pools rather than off the points table. Below it, Head to head takes any two players still in contention and answers the other question: not how far from the line, but how far from each other. Pick a pair and it walks the leader's weekend down the points curve — a win, a podium, a 20th, dead last — and for each one gives the finish the chaser needs and how many places clear of them that is. The margin is not a fixed number, because the curve is brutally top-heavy: catching someone who finishes 3rd is a different task from catching the same player finishing 30th, and against a big enough lead the panel says so outright rather than printing a column of dashes. Both are exact arithmetic on the published curve, not a simulation — they say what has to happen, not how likely it is, which is what the rest of the tab is for.
Sep 20The event-odds tab now shows who went out, who came back, and when. Under the win-probability chart there is a tally of skulls — one for each player who, at that moment, took none of the ten thousand simulated tournaments. That is the strongest thing this model can say about anybody: with 10,000 runs it is not "unlikely", it is the resolution floor. They are sparse on Thursday and a wall of them on Sunday afternoon. Climbing back out takes ten times that, 0.1%, which is what the rest of the tab means by alive — do it and you get a phoenix. The gap between the two thresholds is deliberate: on a single one, a player sitting on the floor flickers between marks every few minutes, and across the races on file the gap holds the worst case to two deaths for any one player while keeping the bird genuinely rare, about one an event. The table below is no longer cut at 0.1% either — it carries the whole field, everyone still alive first and then the players who have fallen through, most recently out at the top, with a new Out column giving the moment each one went. A player who was never above the floor reads "never" rather than a time, because being recorded for your place on the leaderboard is not the same as having been in it. The thresholds are each player's own odds, not the fork chart's lines: those are scores, and a player slips under one without playing a bad hole whenever the field ahead of them birdies.
Sep 20Fixed: the event-odds charts were unreachable past halfway on a phone. They are held to a minimum width so a dozen labelled lines stay readable, which on a 390px screen leaves a little over half the chart off the side of its own scroll box — and the tap-to-read handler had claimed the horizontal drag, so no gesture was left that could scroll to the rest. A sideways swipe now moves the chart and a tap still reads it out. Nothing changed for a mouse, where hovering scrubs exactly as before.
Sep 20The event-odds charts no longer spend most of their width on nobody playing. The time axis was real wall-clock, which sounds like the honest choice and mostly wasn't: scoring is recorded about every six minutes while a round is on and then not at all until the next morning, so across the four races on file between 69% and 81% of the axis was dead ground and the tournament itself was squeezed into the gaps between the nights. Any stretch longer than twenty-five minutes is now drawn at twenty-five minutes, with a break mark on the axis showing where the clock was cut. Nothing is hidden by that: a block is only recorded when the scoring actually moved, so every line crossed those hours as a single straight segment either way, and inside a round the spacing is untouched — a frantic finish is still wide and a slow stretch still narrow. The chart also now labels each run of play by its day rather than ruling off at midnight, and a new picker crops both charts to one day, rescaling them to it. Neither the day picker nor the axis moves a number. There is deliberately no drag-to-zoom: horizontal drag is how these charts are read on a phone, and a zoom box would have to fight the readout for the same finger.
n/a—${needShort(n)}${needShort(n)}${b.head}` + + `${Math.round(b.pts)}
${p.rank}${nameCell(p)}${fmtPts(p.points)}${gap > 0 ? gap : `+${-gap}`}
+ + + ${head}${body}
#PlayerPointsShort
`; +} + +/* ---------- 4 · head to head ---------- */ + +/* The cutline is one opponent; the player one row up is the other. What it + takes to pass someone is not a property of either player alone — it depends + on what THEY do that weekend, and on the counting caps, which can make the + identical finish worth 250 points to one of them and 40 to the other. + + Deliberately a pair rather than a matrix. Every combination of 52 players is + 1,326 answers nobody reads; two dropdowns over the same curated list the + possibility cloud draws is the one answer someone actually came for. */ + +/* The pair the panel opens on: the two players either side of the automatic-bid + cut, which is the argument the rest of the page is about. */ +function defaultPair(d) { + const rows = cloudRows(d); + const i = rows.findIndex((p) => p.rank > d.meta.cut); + if (i > 0) return [rows[i].pdga, rows[i - 1].pdga]; + return rows.length > 1 ? [rows[1].pdga, rows[0].pdga] : []; +} + +/* The reader's pick, or the default — validated against THIS division, since + the selection survives a division switch and a PDGA number does not. */ +function h2hPick(d) { + const rows = cloudRows(d), saved = state.h2h[state.div] || {}; + const has = (n) => rows.some((p) => p.pdga === n); + const [da, db] = defaultPair(d); + const a = has(saved.a) ? saved.a : da, b = has(saved.b) ? saved.b : db; + return a === b ? [da, db] : [a, b]; +} + +/* The leader's finishes to price the chase against. A points curve is brutally + top-heavy — the first three places are worth more than places 20 through 45 + put together — so an even ladder would spend most of its rows on the flat + part where nothing moves. Last place is always included: the bottom of the + curve is the scenario a chaser is hoping for. */ +const H2H_PLACES = [1, 2, 3, 5, 8, 12, 20, 30, 45, 60]; +function h2hScenarios(ev) { + const size = Math.max(1, Math.round(ev.field_size)); + return H2H_PLACES.filter((k) => k < size).concat(size); +} + +/* How many places clear of the leader the chaser has to finish. + + `k − needed` is the arithmetic, but the two are in the same field and cannot + share a place, so a margin of zero — "you need the place they just took" — + is really one place ahead. Negative means the caps have eaten the leader's + result and the chaser can finish behind them and still pass. */ +function h2hMargin(k, n) { + if (n.kind !== "place" && n.kind !== "any") return null; + const m = k - n.place; + return m >= 0 ? `${Math.max(1, m)} ahead` : `${-m} behind is fine`; +} + +const byPdga = (rows, n) => rows.find((p) => p.pdga === n); + +function h2hHtml(d) { + const rows = cloudRows(d); + if (rows.length < 2 || !closingEvent(d)) return ""; + const [aN, bN] = h2hPick(d); + const sel = (id, picked) => ``; + return `
${sel("h2h-a", aN)} + vs${sel("h2h-b", bN)}
+
${h2hBodyHtml(d, byPdga(rows, aN), byPdga(rows, bN))}
`; +} + +function h2hBodyHtml(d, pa, pb) { + const ev = closingEvent(d); + if (!pa || !pb || !ev) return ""; + if (pa.pdga === pb.pdga) return `

Pick two different players.

`; + // who chases whom is the standings' answer, not the dropdowns' + const [low, high] = pa.points <= pb.points ? [pa, pb] : [pb, pa]; + const evName = shortName(ev.name), gap = high.points - low.points; + const priceLow = pricer(d, ev, low), priceHigh = pricer(d, ev, high); + // PDGA attribution: an