• David Nield
  • Blog
  • Openings
  • Resume

Who plays your opening?

Lichess opening popularity, split into White’s choices and Black’s — and how often you’d meet it compared with your opponent

Play a line on the board, or pick one of the 3,810 named openings below it. True reach is the share of games that reach the position by any move order. Because moves alternate, the share along your move order factorises exactly into W, the product of White’s choices, times B, the product of Black’s. The same factors answer a second question: if you always play the line, you get it whenever your opponent cooperates.

VERSION = "5c094bbcf53d"
asset = (path) => new URL(`${path}?v=${VERSION}`, document.baseURI).href
dashMod = import(asset("dash.js"))
boardMod = import(asset("board.js"))
controlsMod = import(asset("controls.js"))
exportMod = import(asset("export.js"))
makeStore = dashMod.makeStore
toPlain = dashMod.toPlain
monthDate = dashMod.monthDate
monthLabel = dashMod.monthLabel
createBoard = boardMod.createBoard
multiSelect = controlsMod.multiSelect
bandSummary = controlsMod.bandSummary
chartWithDownload = exportMod.chartWithDownload
exportChart = exportMod.exportChart
EXPORT_FONT = exportMod.EXPORT_FONT
slugify = exportMod.slug
Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.16/+esm")
ChessLib = import("https://cdn.jsdelivr.net/npm/chess.js@1.4.0/dist/esm/chess.js")
store = makeStore("data", VERSION)
ix = store.loadIndex()
COL = ({ white: "#2a78d6", black: "#eb6834", draw: "#b9b8b0", reach: "#1baf7a", prod: "#52514e", ink: "#0b0b0b", muted: "#898781", grid: "#e1e0d9" })
M = ix.n_months
openingRows = ix.openings.map((o) => ({ id: o.id, eco: o.eco, name: o.name, moves: ix.pgn(o.moves), line: o.moves, plies: o.moves.length, rank: o.rank }))
ITALIAN = ["e4", "e5", "Nf3", "Nc6", "Bc4"]
// Shareable state in the URL hash:
//   #moves=e4,e5,Nf3&speeds=Blitz,Rapid&bands=1600,1800&from=2019-01&to=2021-12&smooth=3&metric=results&zoom=score&view=levels
//   (from= defaults to 2017-01 and to= to the latest month; both are written only when they differ;
//   metric=results opens panel 1 on Win percentage and is written only then: Popularity is the default;
//   zoom=score opens Win percentage on "Score only (zoomed)", is written only then, and is ignored without metric=results)
// (#opening=<id> from v1 links still works: it sets the board to that line)
hashParams = new URLSearchParams(location.hash.slice(1))
hashList = (k) => hashParams.has(k) ? hashParams.get(k).split(",").filter(Boolean) : null
monthIndex = (s) => {
  const m = /^(\d{4})-(\d{2})$/.exec(s ?? "");
  const [y0, mm0] = ix.first_month.split("-").map(Number);
  return m ? Math.max(0, Math.min(M - 1, (+m[1] - y0) * 12 + (+m[2] - mm0))) : null;
}
// The window opens on 2017-01 .. latest month by default (2013-2016 is the early,
// thin Lichess era); #from= / #to= override it. A link that gives only an early
// #to= (v1.4 omitted from= when it was the first month) keeps meaning "from the start".
DEFAULT_FROM = monthIndex("2017-01") ?? 0
hashMonth = (k, dflt) => monthIndex(hashParams.get(k)) ?? dflt
fromDefault = (hashMonth("to", M - 1) < DEFAULT_FROM) ? 0 : DEFAULT_FROM
initialMoves = hashList("moves") ?? (hashParams.has("opening") ? ix.byId.get(+hashParams.get("opening"))?.moves : null) ?? ITALIAN
viewof speeds = multiSelect({ label: "Speed", options: ix.speeds,
  value: hashList("speeds")?.filter((s) => ix.speeds.includes(s)) ?? ix.speeds })
viewof bands = multiSelect({ label: "Rating", options: ix.bands,
  value: hashList("bands")?.map(Number).filter((b) => ix.bands.includes(b)) ?? ix.bands,
  format: (b) => ix.band_labels[ix.bands.indexOf(b)], summary: bandSummary(ix.bands, ix.band_labels) })
viewof period = Inputs.form({
  from: Inputs.select(d3.range(M), { label: "From", value: hashMonth("from", fromDefault), format: (i) => monthLabel(ix, i) }),
  to: Inputs.select(d3.range(M), { label: "To", value: hashMonth("to", M - 1), format: (i) => monthLabel(ix, i) }),
})
viewof pooled = Inputs.toggle({ label: "3-month pooling", value: hashParams.get("smooth") === "3" })
viewof line = createBoard({ Chess: ChessLib.Chess, moves: initialMoves, pieceBase: "pieces/", version: VERSION })
{
  // Title: the board position's own name (by position, so transpositions are
  // named); off the tree, the deepest named position before it, "+ k moves".
  const re = offTree ? await reentry : null;
  const named = re && re.o ? { o: re.o, at: line.length } : namedAlong;
  const depth = re && re.o ? line.length : (offTree ? line.length : treeLine.length);
  let title = named ? `<span class="eco">${esc(named.o.eco)}</span>${esc(named.o.name)}` : "Start position";
  if (named && named.at < depth) title += ` <span class="dash-after">+ ${depth - named.at} move${depth - named.at > 1 ? "s" : ""}</span>`;
  let note = "";
  if (offTree) {
    const at = `${Math.floor(inTree / 2) + 1}.${inTree % 2 ? ".." : ""} ${esc(line[inTree])}`;
    note = `<div class="dash-off"><b>Outside the data.</b> ${at} leaves the named-opening tree: the data cover the 7,855
      positions on the 3,810 named lines and every move between them, nothing else. The panels below follow your line
      up to ${inTree ? `<span class="dash-moves">${esc(lineText(treeLine))}</span>` : "the start position"}.</div>`;
    if (re && re.o) note += `<div class="dash-off back"><b>Back in the tree:</b> this position is
      <b>${esc(re.o.eco)} ${esc(re.o.name)}</b>, but your move order passes through positions outside the tree, so W and B
      can't be followed along it. <button type="button" class="dash-link" data-opening="${re.o.id}">Show it by its named order</button></div>`;
  }
  const d = D(`<div class="dash-chosen">${title}</div><div class="dash-moves">${esc(lineText(line))}</div>${note}`);
  d.querySelectorAll("button[data-opening]").forEach((b) => b.addEventListener("click", () => viewof line.setLine(ix.byId.get(+b.dataset.opening).moves)));
  return d;
}
{
  if (offTree) return D(`<p class="dash-note">No explorer data here: this position is outside the named-opening tree.</p>`);
  const ex = await store.explorer(res.line.node, speeds, bands, m0, m1);
  const rowsEx = [...ex.rows].sort((a, b) => b.n - a.n);
  const fmt = d3.format(",");
  const wdlBar = (w) => {
    const t = w[0] + w[1] + w[2];
    if (!t) return `<span class="dash-wdl empty"></span>`;
    const seg = (x, cls) => x / t >= 0.12 ? `<span class="${cls}" style="width:${100 * x / t}%">${Math.round(100 * x / t)}%</span>` : `<span class="${cls}" style="width:${100 * x / t}%"></span>`;
    return `<span class="dash-wdl" title="All-time results for the selected speeds and ratings: White ${pct(w[0] / t)}, draws ${pct(w[1] / t)}, Black ${pct(w[2] / t)}">${seg(w[0], "w")}${seg(w[1], "d")}${seg(w[2], "b")}</span>`;
  };
  const body = rowsEx.map((r) => `<tr><td><button type="button" class="dash-mv" data-san="${esc(r.san)}">${esc(r.san)}</button></td>
      <td class="num">${fmt(r.n)}</td><td class="num">${pct(r.share)}</td><td>${wdlBar(r.wdl)}</td></tr>`).join("")
    + (ex.T ? `<tr class="other"><td>other moves</td><td class="num">${fmt(ex.other)}</td><td class="num">${pct(ex.other_share)}</td><td></td></tr>` : "");
  const d = D(`<table class="dash-ex"><thead><tr><th>Move</th><th class="num">Games</th><th class="num">Share</th><th>White · draw · Black</th></tr></thead>
    <tbody>${body || `<tr><td colspan="4" class="dash-note">No continuations in the data for this selection.</td></tr>`}</tbody></table>
    <div class="dash-note">${fmt(ex.T)} games continued from this position (${monthLabel(ix, m0)}–${monthLabel(ix, m1)}, selected speeds and ratings).
    Moves listed are those leading to another position in the named-opening tree; "other moves" is everything else.
    Result bars are all-time for the selected speeds and ratings.</div>`);
  d.querySelectorAll("button[data-san]").forEach((b) => b.addEventListener("click", () => viewof line.setLine([...line, b.dataset.san])));
  return d;
}
html`<h3 class="dash-h3">Or pick a named opening</h3>`
viewof q = Inputs.search(openingRows, { placeholder: "Search ECO, name or moves (e.g. B90, Berlin, d4 d5 Bf4)…", columns: ["eco", "name", "moves"], width: 520 })
viewof pick = Inputs.table(q, {
  columns: ["eco", "name", "moves", "rank"],
  header: { eco: "ECO", name: "Opening", moves: "Moves", rank: "Reach rank" },
  width: { eco: 50, rank: 90 },
  sort: "rank", multiple: false, value: null, rows: 6, layout: "auto",
})
// picking a named opening sets the board to its line
{ if (pick) viewof line.setLine(pick.line); }
m0 = Math.min(period.from, period.to)
m1 = Math.max(period.from, period.to)
smooth = pooled ? 3 : 1
res = store.compute(line, speeds, bands, m0, m1, smooth)
inTree = res.line.inTree
treeLine = line.slice(0, inTree)
offTree = inTree < line.length
// the named opening at a node: prefer the one whose moves are exactly this
// line; else the shortest named line ending there (all share the position)
nameAt = (node, moves) => {
  const c = ix.namesAt.get(node);
  if (!c) return null;
  const key = moves.join(" ");
  return c.find((o) => o.moves.join(" ") === key) ?? [...c].sort((a, b) => a.moves.length - b.moves.length || a.rank - b.rank)[0];
}
// deepest named position along the in-tree part of the line
namedAlong = {
  let v = ix.root, best = null;
  const nodes = [ix.root, ...res.line.edges.map((e) => e.child)];
  for (let i = nodes.length - 1; i >= 0 && !best; i--) {
    const o = nameAt(nodes[i], treeLine.slice(0, i));
    if (o) best = { o, at: i };
  }
  return best;
}
// a line that left the tree may have come back into it (a transposition
// through positions outside S): recognise the position by its FEN
reentry = offTree ? store.nodeOfFen(viewof line.fen()).then((v) => (v === null ? null : { node: v, o: nameAt(v, []) })) : null
rows = res.cnt ? d3.range(m0, m1 + 1).map((m, j) => {
  const s = res.ser, a = res.att;
  const r = { m, date: monthDate(ix, m), label: monthLabel(ix, m) };
  for (const k of ["W", "B", "WxB", "reach", "gap", "E_white", "E_black", "edge_white", "edge_black",
                   "edge_white_reach", "edge_black_reach", "W_lo", "W_hi", "B_lo", "B_hi", "reach_lo", "reach_hi",
                   "win_w", "win_d", "win_b", "score", "score_lo", "score_hi", "res_n",
                   "spec_edge_white", "spec_edge_black", "spec_edge_white_lo", "spec_edge_white_hi",
                   "spec_edge_black_lo", "spec_edge_black_hi"])
    r[k] = Number.isFinite(s[k][m]) ? s[k][m] : NaN;
  r.c_W = a.c_W[j]; r.c_B = a.c_B[j]; r.c_total = a.c_total[j];
  return r;
}) : []
win = res.cnt ? res.win : ({ c: [], T: [], n: [] })
popNote = {
  const sp = speeds.length === ix.speeds.length ? "all speeds" : (speeds.join(" + ") || "no speeds");
  const bl = bands.length === ix.bands.length ? "all ratings" : (bands.map((b) => ix.band_labels[ix.bands.indexOf(b)]).join(", ") || "no ratings");
  return `Lichess rated standard games (no arena/swiss, no bots), ${monthLabel(ix, m0)}–${monthLabel(ix, m1)}, ${sp}, ${bl}. `
    + (smooth === 3 ? "Monthly, counts pooled over 3 months." : "Monthly.");
}
pct = (x, d = 1) => !Number.isFinite(x) ? "—" : x >= 0.1 ? `${(100 * x).toFixed(d)}%` : x >= 0.001 ? `${(100 * x).toFixed(d + 1)}%` : `${(100 * x).toPrecision(2)}%`
tickPct = (d) => d3.format("~%")(+d.toPrecision(6))
// Log-axis ticks at 1/2/3/5, 1/3 or 1 x 10^k depending on the span, so a wide
// range never draws a thicket of unlabeled minor ticks.
logTicks = (vals) => {
  const v = vals.filter((x) => Number.isFinite(x) && x > 0);
  if (!v.length) return undefined;
  const lo = d3.min(v), hi = d3.max(v), span = Math.log10(hi / lo);
  if (span < 0.6) return d3.ticks(lo, hi, 5).filter((t) => t > 0);   // narrow range: plain nice ticks
  const mult = span > 2.5 ? [1] : span > 1.2 ? [1, 3] : [1, 2, 3, 5];
  const out = [];
  for (let e = Math.floor(Math.log10(lo)) - 1; e <= Math.ceil(Math.log10(hi)); e++)
    for (const mlt of mult) { const t = +(mlt * 10 ** e).toPrecision(6); if (t >= lo / 1.15 && t <= hi * 1.15) out.push(t); }
  return out.length >= 2 ? out : undefined;
}
oneIn = (p) => !Number.isFinite(p) || p <= 0 ? "—" : p >= 0.5 ? `1 in ${(1 / p).toFixed(1)}` : `1 in ${d3.format(",")(Math.round(1 / p))}`
// the specialist edge, two decimals below 10 (1.87×, 0.53×)
times2 = (x) => !Number.isFinite(x) ? "—" : x >= 10 ? `${d3.format(",")(Math.round(x))}×` : `${x.toFixed(2)}×`
key = (color, kind = "solid") => `<span class="dash-key ${kind}" style="border-color:${color};${kind === "box" ? `background:${color}` : ""}"></span>`
esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])
D = (s) => { const d = document.createElement("div"); d.innerHTML = s; return d; }
font = ({ fontFamily: "'Atkinson Hyperlegible', sans-serif", fontSize: "12px", background: "transparent", overflow: "visible" })
lineText = (moves) => moves.length ? ix.pgn(moves) : "the start position"
// keep the URL in step with the state, so any view can be shared
{
  const p = new URLSearchParams();
  p.set("moves", line.join(","));
  if (speeds.length !== ix.speeds.length) p.set("speeds", speeds.join(","));
  if (bands.length !== ix.bands.length) p.set("bands", bands.join(","));
  if (m0 !== DEFAULT_FROM) p.set("from", monthLabel(ix, m0));
  if (m1 !== M - 1) p.set("to", monthLabel(ix, m1));
  if (smooth === 3) p.set("smooth", "3");
  if (metric1 === "Win percentage") p.set("metric", "results");
  if (zoom1) p.set("zoom", "score");
  if (view2 === "Levels") p.set("view", "levels");
  history.replaceState(null, "", "#" + p.toString().replace(/%2C/g, ","));
}
chartName = {
  if (!treeLine.length) return { title: "Start position", subtitle: "Move order: none (the start position)", slug: "start-position" };
  const nm = namedAlong;
  const exact = nm && nm.at === treeLine.length && nm.o.moves.join(" ") === treeLine.join(" ");
  const base = nm ? `${nm.o.eco} ${nm.o.name}` : "Unnamed line";
  let subtitle = `Move order: ${ix.pgn(treeLine)}`;
  if (offTree) subtitle += `. The entered line, ${ix.pgn(line)}, leaves the named-opening tree after this; the chart follows it up to here.`;
  return {
    title: exact ? base : `${base} (via ${ix.pgn(treeLine)})`,
    subtitle,
    slug: nm ? `${nm.o.eco}_${slugify(nm.o.name)}` : "line",
  };
}
exportSpec = (panel, panelSlug, build, legend, footNote) => ({
  build, legend,
  title: `${chartName.title} — ${panel}`,
  subtitle: chartName.subtitle,
  footer: footNote ? `${popNote} ${footNote}` : popNote,
  source: "Source: dnield.com/openings · Lichess open database",
  filename: `opening_${chartName.slug}_${panelSlug}_${monthLabel(ix, m0)}_${monthLabel(ix, m1)}.png`,
})
// On-screen charts are built with no preset (P undefined) and keep their fixed size and fonts.
// A PNG export passes its preset: the banner keeps the v1.5 look exactly; the others get
// phone-readable type (P.f.axis px) and margins/ticks sized to it, and the height the layout
// leaves. mlK: widest y tick label, in em, so marginLeft follows the font size.
plotLayout = (P, h, w, { hDefault, mlBanner, mlK }) => {
  if (!P) return { height: hDefault, marginLeft: mlBanner, marginTop: 30, style: font };
  if (P.legacy) return { height: hDefault, marginLeft: mlBanner, marginTop: 30, style: { ...font, fontFamily: EXPORT_FONT, fontSize: "13px" } };
  const ml = Math.round(P.f.axis * mlK);
  return { height: h, marginLeft: ml, marginTop: Math.round(P.f.axis * 2.9), marginBottom: Math.round(P.f.axis * 2.5),
           style: { ...font, fontFamily: EXPORT_FONT, fontSize: `${P.f.axis}px` } };
}
// fewer x ticks on a narrow export, so the year/month labels do not collide
// y ticks: at most one per 2.4 lines of text in the plot's height (a thinned subset of the list)
yMax = (P, L) => P && !P.legacy ? Math.max(2, Math.floor((L.height - L.marginTop - L.marginBottom) / (P.f.axis * 2.4))) : Infinity
thin = (a, k) => !a || a.length <= k ? a : Array.from({ length: k }, (_, i) => a[Math.round(i * (a.length - 1) / (k - 1))])
xTicks = (P, w, ml) => P && !P.legacy ? { ticks: Math.max(2, Math.floor((w - ml - 24) / (P.f.axis * 6.2))), tickPadding: Math.round(P.f.axis * 0.45) } : {}
// gate-4 hook: the page's own numbers for any line, read back through the browser pane
{
  window.dashCompute = async (moves, sp, bd, a, b, sm) => {
    const r = await store.compute(moves, sp, bd, a, b, sm);
    const out = toPlain(r, a, b);
    out.explorer = await store.explorer(r.line.node, sp, bd, a, b);
    return out;
  };
  window.dashIndex = ix;
  return true;
}
res.cnt ? html`` : D(`<p class="dash-callout">Make a move on the board (or pick a named opening) to see its popularity, who drives it, and how often each side meets it.</p>`)
// the panel's title follows the metric
html`<h2 class="dash-h2">${results1 ? "1 · How does it score?" : "1 · How popular is it?"}</h2>`
viewof metric1 = Inputs.radio(["Popularity", "Win percentage"], { label: "Metric",
  value: hashParams.get("metric") === "results" ? "Win percentage" : "Popularity" })
viewof show1 = Inputs.radio(["All results", "Score only (zoomed)"], { label: "Show",
  value: hashParams.get("metric") === "results" && hashParams.get("zoom") === "score" ? "Score only (zoomed)" : "All results" })
viewof scale1 = Inputs.radio(["log", "linear"], { label: "Scale", value: "log" })
results1 = metric1 === "Win percentage"
// Win percentage has a linear axis only: the Scale choice is hidden (it is kept,
// and panel 2's Levels chart still follows it)
{ viewof scale1.style.display = results1 ? "none" : ""; }
{
  viewof show1.style.display = results1 ? "" : "none";
  const cell = viewof show1.closest(".dash-radios > .cell");
  if (cell) cell.style.display = results1 ? "" : "none";
}
zoom1 = results1 && show1 === "Score only (zoomed)"
// results ship only at the named openings' final positions
hasResults = !!(res.cnt && res.ser.hasResults)
COL_DRAW = "#8f8d86"   // COL.draw darkened so a thin line holds up on white
sc = (x) => Number.isFinite(x) ? x.toFixed(3) : "—"
games = (x) => !Number.isFinite(x) ? "—" : x >= 1e6 ? `${(x / 1e6).toFixed(2)} M` : d3.format(",")(x)
RESULTS_FOOT = "Results of games reaching this position by any move order; score = (wins + ½ draws) / games, White's view."
!results1 ? D(`<div class="dash-legend">
  <span>${key(COL.reach)} True reach: any move order</span>
  <span>${key(COL.prod, "dashed")} W×B: this move order only</span>
  <span>${key(COL.reach, "box").replace("background:", "opacity:.25;background:")} 95% interval</span></div>`)
: !hasResults ? html``
: zoom1 ? D(`<div class="dash-legend">
  <span>${key(COL.ink, "thick")} Average score (White's view)</span>
  <span>${key(COL.ink, "box").replace("background:", "opacity:.18;background:")} 95% interval on the score</span></div>`)
: D(`<div class="dash-legend">
  <span>${key(COL.white)} White wins</span><span>${key(COL_DRAW)} Draws</span><span>${key(COL.black)} Black wins</span>
  <span>${key(COL.ink, "thick")} Average score (White's view)</span>
  <span>${key(COL.ink, "box").replace("background:", "opacity:.18;background:")} 95% interval on the score</span></div>`)
chart1 = (w, h, P) => { const L = plotLayout(P, h, w, { hDefault: 300, mlBanner: 68, mlK: 4.9 }); return Plot.plot({
  width: w, ...L,
  x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)], ...xTicks(P, w, L.marginLeft) },
  y: { type: scale1, label: "share of games", tickFormat: tickPct, grid: true, ...(scale1 === "log" ? { ticks: thin(logTicks(rows.flatMap((d) => [d.reach_lo, d.reach_hi, d.WxB])), yMax(P, L)) } : { nice: true, ...(P && !P.legacy ? { ticks: yMax(P, L) } : {}) }) },
  marks: [
    Plot.areaY(rows, { x: "date", y1: "reach_lo", y2: "reach_hi", fill: COL.reach, fillOpacity: 0.18, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "reach", stroke: COL.reach, strokeWidth: 2, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "WxB", stroke: COL.prod, strokeWidth: 2, strokeDasharray: "5,4", curve: "monotone-x" }),
    Plot.ruleX(rows, Plot.pointerX({ x: "date", stroke: COL.muted, strokeWidth: 1 })),
    Plot.tip(rows, Plot.pointerX({ x: "date", y: "reach",
      title: (d) => `${d.label}\nTrue reach  ${pct(d.reach)}  (${pct(d.reach_lo)}–${pct(d.reach_hi)})\nW×B         ${pct(d.WxB)}\nThis order carries ${pct(d.gap, 0)}` })),
  ],
}) }
// Win percentage: results of the games reaching the final position by any move order.
// The score (W + D/2)/A shares the percentage axis (a score of 0.5 sits at 50%).
chart1r = (w, h, P) => { const L = plotLayout(P, h, w, { hDefault: 300, mlBanner: 68, mlK: 3.6 }); return Plot.plot({
  width: w, ...L,
  x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)], ...xTicks(P, w, L.marginLeft) },
  y: { type: "linear", label: "share of games / score", tickFormat: tickPct, grid: true, nice: true,
       domain: [0, Math.max(0.6, d3.max(rows.flatMap((d) => [d.win_w, d.win_b, d.score_hi]).filter(Number.isFinite)) ?? 0)],
       ...(P && !P.legacy ? { ticks: yMax(P, L) } : {}) },
  marks: [
    Plot.areaY(rows, { x: "date", y1: "score_lo", y2: "score_hi", fill: COL.ink, fillOpacity: 0.18, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "win_d", stroke: COL_DRAW, strokeWidth: 1.75, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "win_b", stroke: COL.black, strokeWidth: 1.75, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "win_w", stroke: COL.white, strokeWidth: 1.75, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "score", stroke: COL.ink, strokeWidth: 2.75, curve: "monotone-x" }),
    Plot.ruleX(rows, Plot.pointerX({ x: "date", stroke: COL.muted, strokeWidth: 1 })),
    Plot.tip(rows, Plot.pointerX({ x: "date", y: "score",
      title: (d) => `${d.label}\nWhite wins  ${pct(d.win_w)}\nDraws       ${pct(d.win_d)}\nBlack wins  ${pct(d.win_b)}\nScore       ${sc(d.score)}  (${sc(d.score_lo)}–${sc(d.score_hi)})\n${games(d.res_n)} games` })),
  ],
}) }
zoomDomain = {
  const v = rows.flatMap((d) => [d.score_lo, d.score_hi]).filter(Number.isFinite);
  const lo = v.length ? d3.min(v) : 0.5, hi = v.length ? d3.max(v) : 0.5, padY = 0.1 * (hi - lo);
  let a = lo - padY, b = hi + padY;
  if (b - a < 0.04) { const mid = (lo + hi) / 2; a = mid - 0.02; b = mid + 0.02; }
  if (a < 0) { b = Math.min(1, b - a); a = 0; }
  if (b > 1) { a = Math.max(0, a - (b - 1)); b = 1; }
  return [a, b];
}
// ticks on whole hundredths (two decimals, as the summary's "average score 0.522"):
// the finest of 0.01 / 0.02 / 0.05 / 0.1 / 0.2 that gives at most k ticks
zoomTicks = (k) => {
  const [a, b] = zoomDomain;
  for (const st of [0.01, 0.02, 0.05, 0.1, 0.2]) {
    const t = d3.range(Math.ceil(a / st - 1e-9), Math.floor(b / st + 1e-9) + 1).map((i) => +(i * st).toFixed(2));
    if (t.length <= k || st === 0.2) return t;
  }
}
chart1z = (w, h, P) => { const L = plotLayout(P, h, w, { hDefault: 300, mlBanner: 68, mlK: 3.6 }); return Plot.plot({
  width: w, ...L,
  x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)], ...xTicks(P, w, L.marginLeft) },
  y: { type: "linear", label: "average score (White's view)", domain: zoomDomain, grid: true,
       ticks: zoomTicks(Math.min(6, yMax(P, L))), tickFormat: d3.format(".2f") },
  marks: [
    ...(zoomDomain[0] <= 0.5 && 0.5 <= zoomDomain[1] ? [
      Plot.ruleY([0.5], { stroke: COL.muted, strokeWidth: 1, strokeDasharray: "4,3", ariaLabel: () => "even-rule" }),
      Plot.text([0.5], { y: (d) => d, text: () => "even", frameAnchor: "right", textAnchor: "end", lineAnchor: "bottom", dy: -3,
        fill: COL.prod, ariaLabel: () => "even-label" })] : []),
    Plot.areaY(rows, { x: "date", y1: "score_lo", y2: "score_hi", fill: COL.ink, fillOpacity: 0.18, curve: "monotone-x" }),
    Plot.lineY(rows, { x: "date", y: "score", stroke: COL.ink, strokeWidth: 2.75, curve: "monotone-x" }),
    Plot.ruleX(rows, Plot.pointerX({ x: "date", stroke: COL.muted, strokeWidth: 1 })),
    Plot.tip(rows, Plot.pointerX({ x: "date", y: "score",
      title: (d) => `${d.label}
Score  ${sc(d.score)}  (${sc(d.score_lo)}–${sc(d.score_hi)})
${games(d.res_n)} games` })),
  ],
}) }
{
  if (!results1)
    return chartWithDownload(chart1(Math.max(240, Math.min(width, 1100))), () => exportSpec("How popular is it?", "popularity",
      (w, h, P) => chart1(w, h, P), [
        { label: "True reach: any move order", color: COL.reach, kind: "line" },
        { label: "W×B: this move order only", color: COL.prod, kind: "dashed" },
        { label: "95% interval", color: COL.reach, kind: "area" }]));
  if (!hasResults) {
    // no results here: a note in the chart's place, and its Download disabled
    const box = chartWithDownload(D(`<p class="dash-callout dash-unavail">Monthly results are available for the named openings'
      final positions. Enter or pick a named opening.</p>`), () => null);
    const b = box.querySelector(".dash-dl");
    b.disabled = true; b.classList.add("off");
    b.title = "Nothing to download: monthly results are available for the named openings' final positions";
    return box;
  }
  if (zoom1)
    return chartWithDownload(chart1z(Math.max(240, Math.min(width, 1100))), () => exportSpec("How does it score? (score only)", "results-score",
      (w, h, P) => chart1z(w, h, P), [
        { label: "Average score (White's view)", color: COL.ink, kind: "line" },
        { label: "95% interval on the score", color: COL.ink, kind: "area" }], RESULTS_FOOT));
  return chartWithDownload(chart1r(Math.max(240, Math.min(width, 1100))), () => exportSpec("How does it score?", "results",
    (w, h, P) => chart1r(w, h, P), [
      { label: "White wins", color: COL.white, kind: "line" },
      { label: "Draws", color: COL_DRAW, kind: "line" },
      { label: "Black wins", color: COL.black, kind: "line" },
      { label: "Average score (White's view)", color: COL.ink, kind: "line" },
      { label: "95% interval on the score", color: COL.ink, kind: "area" }], RESULTS_FOOT));
}
!results1 ? html`<div class="dash-note">${popNote}</div>
<p class="dash-copy">Over this window, this move order accounts for
<b>${pct(win.gap, 0)}</b> of the games that reach the position (W×B ${pct(win.WxB)} vs true reach ${pct(win.reach)}).</p>`
: !hasResults ? html`<div class="dash-note">${popNote}</div>`
: html`<div class="dash-note">${popNote} ${RESULTS_FOOT}</div>
<p class="dash-copy">Over this window: White <b>${pct(win.win_w)}</b> · Draw <b>${pct(win.win_d)}</b> · Black <b>${pct(win.win_b)}</b>
· average score <b>${sc(win.score)}</b> (${games(win.res_n)} games).</p>`

2 · Who moved it, White or Black?

viewof view2 = Inputs.radio(["Attribution", "Levels"], { label: "View", value: hashParams.get("view") === "levels" ? "Levels" : "Attribution" })
view2 === "Levels" ? D(`<p class="dash-sub"><b>W</b> = how often White makes White's moves on the line when offered the
chance (1st move included); <b>B</b> = the same for Black. W × B is the dashed line in panel 1.</p>
<div class="dash-legend"><span>${key(COL.white)} W: White steers here</span><span>${key(COL.black)} B: Black allows it</span>
<span>shaded: 95% interval</span></div>`)
: D(`<p class="dash-sub">Change since the start of the window (first 12 months, geometric mean). On a log scale the two
contributions add up exactly to the net change, with no interaction left over.</p>
<div class="dash-legend"><span>${key(COL.white, "box")} White's contribution</span><span>${key(COL.black, "box")} Black's contribution</span>
<span>${key(COL.ink)} Net change in W×B</span></div>`)
chart2 = (w, h, P) => {
  const L = plotLayout(P, h, w, { hDefault: 300, mlBanner: 68, mlK: view2 === "Levels" ? 4.9 : 3.7 });
  if (view2 === "Levels") {
    return Plot.plot({
      width: w, ...L,
      x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)], ...xTicks(P, w, L.marginLeft) },
      y: { type: scale1, label: "probability", tickFormat: tickPct, grid: true, ...(scale1 === "log" ? { ticks: thin(logTicks(rows.flatMap((d) => [d.W_lo, d.W_hi, d.B_lo, d.B_hi])), yMax(P, L)) } : { nice: true, ...(P && !P.legacy ? { ticks: yMax(P, L) } : {}) }) },
      marks: [
        Plot.areaY(rows, { x: "date", y1: "W_lo", y2: "W_hi", fill: COL.white, fillOpacity: 0.15, curve: "monotone-x" }),
        Plot.areaY(rows, { x: "date", y1: "B_lo", y2: "B_hi", fill: COL.black, fillOpacity: 0.15, curve: "monotone-x" }),
        Plot.lineY(rows, { x: "date", y: "W", stroke: COL.white, strokeWidth: 2, curve: "monotone-x" }),
        Plot.lineY(rows, { x: "date", y: "B", stroke: COL.black, strokeWidth: 2, curve: "monotone-x" }),
        Plot.ruleX(rows, Plot.pointerX({ x: "date", stroke: COL.muted, strokeWidth: 1 })),
        Plot.tip(rows, Plot.pointerX({ x: "date", y: "W",
          title: (d) => `${d.label}\nW (White)  ${pct(d.W)}  (${pct(d.W_lo)}–${pct(d.W_hi)})\nB (Black)  ${pct(d.B)}  (${pct(d.B_lo)}–${pct(d.B_hi)})` })),
      ],
    });
  }
  const long = rows.flatMap((d) => [
    { date: d.date, side: "W", v: d.c_W, label: d.label },   // not "White"/"Black": Plot would
    { date: d.date, side: "B", v: d.c_B, label: d.label },   // read those as CSS colours
  ]).filter((d) => Number.isFinite(d.v));
  const ext = d3.extent([...long.map((d) => d.v), ...rows.map((d) => d.c_total).filter(Number.isFinite),
    ...rows.map((d) => Math.max(0, d.c_W) + Math.max(0, d.c_B)).filter(Number.isFinite),
    ...rows.map((d) => Math.min(0, d.c_W) + Math.min(0, d.c_B)).filter(Number.isFinite), 0]);
  const cands = [-0.9, -0.75, -0.5, -0.33, -0.2, -0.1, 0, 0.1, 0.25, 0.5, 0.75, 1, 2, 4, 9];
  const slack = P && !P.legacy ? 0 : 0.05;   // on screen and in the banner a tick may sit just past the data; the phone exports keep ticks inside the plot
  const ticks = cands.map((p) => Math.log(1 + p)).filter((t) => t >= ext[0] - slack && t <= ext[1] + slack);
  const fmt = (t) => { const p = Math.exp(t) - 1; return Math.abs(p) < 1e-9 ? "0%" : `${p > 0 ? "+" : ""}${Math.round(100 * p)}%`; };
  return Plot.plot({
    width: w, ...L,
    x: { type: "utc", label: null, domain: [monthDate(ix, m0), d3.utcMonth.offset(monthDate(ix, m1), 1)], ...xTicks(P, w, L.marginLeft) },
    y: { label: "change vs window start (log scale)", ticks: thin(ticks, yMax(P, L)), tickFormat: fmt, grid: true },
    color: { domain: ["W", "B"], range: [COL.white, COL.black] },
    marks: [
      Plot.rectY(long, { x: "date", interval: "month", y: "v", fill: "side", order: ["W", "B"], insetLeft: 0.5, insetRight: 0.5, fillOpacity: 0.9 }),
      Plot.ruleY([0], { stroke: COL.ink, strokeOpacity: 0.4 }),
      Plot.lineY(rows, { x: (d) => d3.utcDay.offset(d.date, 14), y: "c_total", stroke: COL.ink, strokeWidth: 1.5 }),
      Plot.ruleX(rows, Plot.pointerX({ x: (d) => d3.utcDay.offset(d.date, 14), stroke: COL.muted, strokeWidth: 1 })),
      Plot.tip(rows, Plot.pointerX({ x: (d) => d3.utcDay.offset(d.date, 14), y: "c_total",
        title: (d) => `${d.label}\nNet    ${fmt(d.c_total)}\nWhite  ${fmt(d.c_W)}\nBlack  ${fmt(d.c_B)}\n(log contributions add: ${d.c_W?.toFixed(3)} + ${d.c_B?.toFixed(3)} = ${d.c_total?.toFixed(3)})` })),
    ],
  });
}
chartWithDownload(chart2(Math.max(240, Math.min(width, 1100))), () => view2 === "Levels"
  ? exportSpec("Who moved it: W and B", "who-moved-it-levels", (w, h, P) => chart2(w, h, P), [
      { label: "W: White steers here", color: COL.white, kind: "line" },
      { label: "B: Black allows it", color: COL.black, kind: "line" },
      { label: "shaded: 95% interval", color: COL.muted, kind: "area" }])
  : exportSpec("Who moved it: change since the window start", "who-moved-it-attribution", (w, h, P) => chart2(w, h, P), [
      { label: "White's contribution", color: COL.white, kind: "box" },
      { label: "Black's contribution", color: COL.black, kind: "box" },
      { label: "Net change in W×B (log scale; the parts add exactly)", color: COL.ink, kind: "line" }]))
{
  const fmt = (t) => !Number.isFinite(t) ? "—" : `${t >= 0 ? "+" : ""}${Math.round(100 * (Math.exp(t) - 1))}%`;
  const last = rows.filter((d) => Number.isFinite(d.c_total)).slice(-12);
  const g = (k) => last.length ? d3.mean(last, (d) => d[k]) : NaN;
  return html`<div class="dash-note">${popNote}</div>
  ${view2 === "Attribution" ? html`<p class="dash-copy">Last 12 months vs the first 12 of the window: net <b>${fmt(g("c_total"))}</b>
  = White <b>${fmt(g("c_W"))}</b> and Black <b>${fmt(g("c_B"))}</b> (log contributions, so they add exactly).</p>` : ""}`;
}

3 · How often would you see it, and how often would your opponent?

// Local to this panel. Default: the side that plays the line's last move,
// i.e. the side "choosing" the opening.
viewof persp = Inputs.select(["White", "Black", "Both sides"], { label: "Perspective",
  value: inTree === 0 ? "Both sides" : inTree % 2 ? "White" : "Black" })
{
  const P = persp;
  const tile = (k, v, s, color, cls = "") => `<div class="dash-tile${cls}"><div class="k">${color ? key(color) : ""}${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`;
  let tiles;
  if (P === "Both sides") {
    const r = win.spec_edge_white;   // B ÷ W: > 1 favours White, < 1 favours Black
    const fav = !Number.isFinite(r) ? "—" : r > 1 ? `favours White ${times2(r)}` : r < 1 ? `favours Black ${times2(win.spec_edge_black)}` : "even (1.00×)";
    tiles = [
      tile("A White specialist gets it", oneIn(win.E_white), `E<sub>White</sub> = B: Black's moves decide`, COL.white),
      tile("A Black specialist gets it", oneIn(win.E_black), `E<sub>Black</sub> = W: White's moves decide`, COL.black),
      tile("Specialist edge", fav, `E<sub>White</sub> ÷ E<sub>Black</sub> = B ÷ W = ${times2(r)}: how many times more often a White specialist reaches this than a Black one`, null, " dash-spec"),
    ];
  } else {
    const w = P === "White", O = w ? "Black" : "White";
    const Eyou = w ? win.E_white : win.E_black, Eopp = w ? win.E_black : win.E_white;
    const spec = w ? win.spec_edge_white : win.spec_edge_black;
    tiles = [
      tile(`You (always play ${P}'s moves)`, oneIn(Eyou), `E<sub>${P}</sub> = ${w ? "B" : "W"}: your opponent's moves decide`, w ? COL.white : COL.black),
      tile(`Your opponent (a ${O} specialist) gets it`, oneIn(Eopp), `E<sub>${O}</sub> = ${w ? "W" : "B"}: ${P}'s moves decide`, w ? COL.black : COL.white),
      tile("Specialist edge", times2(spec), `E<sub>you</sub> ÷ E<sub>opponent</sub> = ${w ? "B ÷ W" : "W ÷ B"}: how many times more often you reach this than an opponent who specialises in ${O}`, null, " dash-spec"),
    ];
  }
  return D(`<div class="dash-tiles">${tiles.join("")}</div>`);
}
series3 = persp === "Both sides"
  ? [{ k: "E_white", name: "White specialist", color: COL.white }, { k: "E_black", name: "Black specialist", color: COL.black }]
  : persp === "White"
    ? [{ k: "E_white", name: "You (White)", color: COL.white }, { k: "E_black", name: "Your opponent (Black specialist)", color: COL.black }]
    : [{ k: "E_black", name: "You (Black)", color: COL.black }, { k: "E_white", name: "Your opponent (White specialist)", color: COL.white }]
// the specialist edge in a tooltip line: E_you ÷ E_opponent, or its direction under "Both sides"
specLine3 = (d) => persp === "White" ? `Specialist edge  ${times2(d.spec_edge_white)}`
  : persp === "Black" ? `Specialist edge  ${times2(d.spec_edge_black)}`
  : !Number.isFinite(d.spec_edge_white) ? "Specialist edge  —"
  : d.spec_edge_white >= 1 ? `Favours White  ${times2(d.spec_edge_white)}` : `Favours Black  ${times2(d.spec_edge_black)}`
chart3 = (w, h, P) => {
  const L = plotLayout(P, h, w, { hDefault: 280, mlBanner: 76, mlK: 5.4 });
  const inv = (x) => Number.isFinite(x) && x > 0 ? 1 / x : NaN;
  return Plot.plot({
    width: w, ...L,
    x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)], ...xTicks(P, w, L.marginLeft) },
    y: { type: "log", label: "once every … games", grid: true, tickFormat: (d) => `1 in ${d3.format("~s")(d)}`,
         ticks: thin(logTicks(rows.flatMap((d) => series3.map((s) => inv(d[s.k])))), yMax(P, L)) },
    marks: [
      ...series3.map((s) => Plot.lineY(rows, { x: "date", y: (d) => inv(d[s.k]), stroke: s.color, strokeWidth: 2.25, curve: "monotone-x" })),
      Plot.ruleX(rows, Plot.pointerX({ x: "date", stroke: COL.muted, strokeWidth: 1 })),
      Plot.tip(rows, Plot.pointerX({ x: "date", y: (d) => inv(d[series3[0].k]),
        title: (d) => [d.label, ...series3.map((s) => `${s.name}  ${oneIn(d[s.k])}`), specLine3(d)].join("\n") })),
    ],
  });
}
{
  const legend3 = series3.map((s) => ({ label: s.name, color: s.color, kind: "line" }));
  const legend = legend3.map((l) => `<span>${key(l.color, l.kind === "dashed" ? "dashed" : "solid")} ${l.name ?? l.label}</span>`).join("");
  const panel = persp === "Both sides" ? "White specialist vs Black specialist" : `You (${persp}) vs a ${persp === "White" ? "Black" : "White"} specialist`;
  const box = chartWithDownload(chart3(Math.max(240, Math.min(width, 1100))), () => exportSpec(panel, "you-vs-opponent", (w, h, P) => chart3(w, h, P), legend3,
    "A specialist always plays one side's moves of the line, so meets it when the other side cooperates (White: B, Black: W). On the log scale the gap between the two lines is the specialist edge."));
  return html`${D(`<div class="dash-legend">${legend}</div>`)}${box}
  <p class="dash-sub">On the log scale, the gap between the two lines is the specialist edge: how many times more often one side reaches this than the other.</p><div class="dash-note">${popNote}</div>`;
}

4 · The move path

{
  if (!res.cnt) return D(`<p class="dash-note">No moves yet.</p>`);
  const c = win.c, T = win.T, n = win.n;
  const fin = c.filter(Number.isFinite);
  const minC = fin.length ? Math.min(...fin) : NaN;
  const body = res.cnt.edges.map((e, i) => {
    const side = i % 2 === 0 ? "White" : "Black";
    const col = i % 2 === 0 ? COL.white : COL.black;
    const thin = T[i] < 30 || n[i] < 1;
    const label = `${Math.floor(i / 2) + 1}.${i % 2 === 0 ? "" : ".."} ${esc(e.san)}`;
    const bar = Number.isFinite(c[i]) ? `<span class="dash-bar" style="width:${Math.max(1, 160 * c[i])}px;background:${col}"></span>` : "";
    return `<tr class="${c[i] === minC ? "rare" : ""}"><td>${i + 1}</td><td>${key(col)} ${side}</td><td class="dash-moves">${label}</td>
      <td class="num">${thin ? `<span class="dash-gap">${pct(c[i])}</span>` : pct(c[i])}</td><td>${bar}</td>
      <td class="num">${d3.format(",")(n[i])}</td><td class="num">${d3.format(",")(T[i])}</td></tr>`;
  }).join("");
  return D(`<p class="dash-sub">Each move's share among the games that reached the position before it, pooled over the window.
  W multiplies the blue rows and B the orange ones. The rarest move is highlighted.</p>
  <table class="dash-path"><thead><tr><th>Ply</th><th>Side</th><th>Move</th><th class="num">c<sub>i</sub></th><th></th>
  <th class="num">Played</th><th class="num">Reached</th></tr></thead><tbody>${body}</tbody></table>
  <p class="dash-copy">Window: W = <b>${pct(win.W)}</b>, B = <b>${pct(win.B)}</b>, W×B = <b>${pct(win.WxB)}</b>,
  true reach = <b>${pct(win.reach)}</b> of ${d3.format(",")(win.N)} games.</p>
  <div class="dash-note">${popNote}</div>`);
}
Monthly numbers (table view)
Inputs.table(rows.map((d) => ({ month: d.label, W: d.W, B: d.B, "W×B": d.WxB, reach: d.reach, gap: d.gap,
  "specialist edge (White = B÷W)": d.spec_edge_white })), {
  format: { W: (x) => pct(x), B: (x) => pct(x), "W×B": (x) => pct(x), reach: (x) => pct(x), gap: (x) => pct(x, 0),
            "specialist edge (White = B÷W)": times2 }, rows: 12 })

Data: the Lichess open database, rated standard games 2013-01 to 2026-07, through a per-month opening-tree extract (counts summed over ply, the Lichess-explorer convention). Bots, arena/swiss games and abandoned or cheat-terminated games are excluded. Rating bands are by the two players’ average rating. Opening names: lichess-org/chess-openings. The data cover the positions on the named lines and every move between two of them. Panel 1’s win percentages are the results of the games that reach a named opening’s final position by any move order, counted from White’s side. Board: chess.js (BSD-2-Clause). Chess pieces by Colin M.L. Burnett (Cburnett), via Wikimedia Commons, used under the BSD licence (licence and sources).