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 = "648eccd308b0"
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&view=attribution
// (#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
hashMonth = (k, dflt) => {
const m = /^(\d{4})-(\d{2})$/.exec(hashParams.get(k) ?? "");
const [y0, mm0] = ix.first_month.split("-").map(Number);
const i = m ? (+m[1] - y0) * 12 + (+m[2] - mm0) : dflt;
return Math.max(0, Math.min(M - 1, i));
}
initialMoves = hashList("moves") ?? (hashParams.has("opening") ? ix.byId.get(+hashParams.get("opening"))?.moves : null) ?? ITALIAN{
// 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;
}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"])
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))}`
times = (x) => !Number.isFinite(x) ? "—" : x >= 10 ? `${d3.format(",")(Math.round(x))}×` : `${x.toFixed(1)}×`
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 !== 0) p.set("from", monthLabel(ix, m0));
if (m1 !== M - 1) p.set("to", monthLabel(ix, m1));
if (smooth === 3) p.set("smooth", "3");
if (view2 === "Attribution") p.set("view", "attribution");
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) => ({
build, legend,
title: `${chartName.title} — ${panel}`,
subtitle: chartName.subtitle,
footer: popNote,
source: "Source: dnield.com/openings · Lichess open database",
filename: `opening_${chartName.slug}_${panelSlug}_${monthLabel(ix, m0)}_${monthLabel(ix, m1)}.png`,
})
exportFontStyle = (fontFamily) => fontFamily ? { ...font, fontFamily, fontSize: "13px" } : font
// 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;
}1 · How popular is it?
chart1 = (w, fontFamily) => Plot.plot({
width: w, height: 300, marginLeft: 68, marginTop: 30, style: exportFontStyle(fontFamily),
x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)] },
y: { type: scale1, label: "share of games", tickFormat: tickPct, grid: true, ...(scale1 === "log" ? { ticks: logTicks(rows.flatMap((d) => [d.reach_lo, d.reach_hi, d.WxB])) } : { nice: true }) },
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)}` })),
],
})chartWithDownload(chart1(Math.max(240, Math.min(width, 1100))), () => exportSpec("How popular is it?", "popularity",
(w) => chart1(w, EXPORT_FONT), [
{ 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" }]))2 · Who moved it, White or Black?
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, fontFamily) => {
if (view2 === "Levels") {
return Plot.plot({
width: w, height: 300, marginLeft: 68, marginTop: 30, style: exportFontStyle(fontFamily),
x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)] },
y: { type: scale1, label: "probability", tickFormat: tickPct, grid: true, ...(scale1 === "log" ? { ticks: logTicks(rows.flatMap((d) => [d.W_lo, d.W_hi, d.B_lo, d.B_hi])) } : { nice: true }) },
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 ticks = cands.map((p) => Math.log(1 + p)).filter((t) => t >= ext[0] - 0.05 && t <= ext[1] + 0.05);
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, height: 300, marginLeft: 68, marginTop: 30, style: exportFontStyle(fontFamily),
x: { type: "utc", label: null, domain: [monthDate(ix, m0), d3.utcMonth.offset(monthDate(ix, m1), 1)] },
y: { label: "change vs window start (log scale)", ticks, 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) => chart2(w, EXPORT_FONT), [
{ 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) => chart2(w, EXPORT_FONT), [
{ 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?
{
const P = persp;
const tile = (k, v, s, color) => `<div class="dash-tile"><div class="k">${color ? key(color) : ""}${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`;
const typical = tile("Typical opponent meets it", oneIn(win.WxB), `along this move order; ${oneIn(win.reach)} by any order`, COL.prod);
let tiles;
if (P === "Both sides") {
tiles = [
tile("A White specialist gets it", oneIn(win.E_white), `E<sub>White</sub> = B: Black's moves decide · edge ${times(win.edge_white)}`, COL.white),
tile("A Black specialist gets it", oneIn(win.E_black), `E<sub>Black</sub> = W: White's moves decide · edge ${times(win.edge_black)}`, COL.black),
typical,
];
} else {
const w = P === "White";
const Eyou = w ? win.E_white : win.E_black, edge = w ? win.edge_white : win.edge_black, edgeR = w ? win.edge_white_reach : win.edge_black_reach;
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),
typical,
tile("Your familiarity edge", times(edge), `E<sub>you</sub> ÷ (W×B) = 1 ÷ ${w ? "W" : "B"} · vs true reach ${times(edgeR)}`),
];
}
return D(`<div class="dash-tiles">${tiles.join("")}</div>`);
}chart3 = (w, fontFamily) => {
const inv = (x) => Number.isFinite(x) && x > 0 ? 1 / x : NaN;
return Plot.plot({
width: w, height: 280, marginLeft: 76, marginTop: 30, style: exportFontStyle(fontFamily),
x: { type: "utc", label: null, domain: [monthDate(ix, m0), monthDate(ix, m1)] },
y: { type: "log", label: "once every … games", grid: true, tickFormat: (d) => `1 in ${d3.format("~s")(d)}`,
ticks: logTicks(rows.flatMap((d) => [...series3.map((s) => inv(d[s.k])), inv(d.WxB), inv(d.reach)])) },
marks: [
...series3.map((s) => Plot.lineY(rows, { x: "date", y: (d) => inv(d[s.k]), stroke: s.color, strokeWidth: 2, curve: "monotone-x" })),
Plot.lineY(rows, { x: "date", y: (d) => inv(d.WxB), stroke: COL.prod, strokeWidth: 2, strokeDasharray: "5,4", curve: "monotone-x" }),
Plot.lineY(rows, { x: "date", y: (d) => inv(d.reach), stroke: COL.reach, strokeWidth: 1.5, 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.WxB),
title: (d) => [d.label, ...series3.map((s) => `${s.name} ${oneIn(d[s.k])}`), `Typical (W×B) ${oneIn(d.WxB)}`, `Typical (reach) ${oneIn(d.reach)}`].join("\n") })),
],
});
}{
const legend3 = [...series3.map((s) => ({ label: s.name, color: s.color, kind: "line" })),
{ label: "Typical opponent (W×B)", color: COL.prod, kind: "dashed" },
{ label: "Typical, any move order (reach)", color: COL.reach, 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 and Black specialists vs a typical opponent" : `You (${persp}) vs your opponent`;
const box = chartWithDownload(chart3(Math.max(240, Math.min(width, 1100))), () => exportSpec(panel, "you-vs-opponent", (w) => chart3(w, EXPORT_FONT), legend3));
return html`${D(`<div class="dash-legend">${legend}</div>`)}${box}<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,
"edge (White)": d.edge_white, "edge (Black)": d.edge_black })), {
format: { W: (x) => pct(x), B: (x) => pct(x), "W×B": (x) => pct(x), reach: (x) => pct(x), gap: (x) => pct(x, 0),
"edge (White)": times, "edge (Black)": times }, 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. 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).