Published
Edited
Feb 22, 2021
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
data = evaluationInCentipawns.map((d, i) => {
const toPawns = (centipawns) => centipawns / 100;
return {
ply: i + 1,
evaluation: toPawns(d)
};
})
Insert cell
// evaluation of the board positions starting with the first half-move ('d4': white pawn from d2 to d4)
evaluationInCentipawns = game.analysis.map(d => d.eval)
Insert cell
game = {
return FileAttachment("ivanchuk-wolff-1993.json").json(); // comment out to update
return fetchAnalysedGame(pgn); // download JSON for re-attachment
}
Insert cell
// visit https://lichess.org/[response.id] to request computer analysis manually for games new to lichess
fetchAnalysedGame = async (pgn) => {
const response = await importGameForAnalysis(pgn); // existing games are not re-imported
return exportAnalysedGame(response.id); // so game identifiers are persistent
}
Insert cell
// see: https://lichess.org/api#operation/gameImport
importGameForAnalysis = (pgn) => {
const demoProxyUrl = "https://cors-anywhere.herokuapp.com"; // enables cross-origin requests to anywhere
const apiUrl = "https://lichess.org/api/import";
const proxiedApiUrl = `${demoProxyUrl}/${apiUrl}`;
const createRequestBody = (pgn) => {
// serialized as 'application/x-www-form-urlencoded'
const body = new URLSearchParams();
body.set("pgn", pgn);
return body;
};
const options = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"
},
body: createRequestBody(pgn)
};
return fetch(proxiedApiUrl, options).then(response => response.json())
}
Insert cell
Insert cell
// see: https://lichess.org/api#operation/gamePgn
exportAnalysedGame = (gameId) => {
const url = `https://lichess.org/game/export/${gameId}`;
const options = {
method: "GET",
headers: {
"Accept": "application/json" // default: 'application/x-chess-pgn'
}
}
return fetch(url, options).then(response => response.json());
}
Insert cell
// hardcoded, values from: https://lichess.org/vWMnVibY
gamePhases = [
{label: "Opening", ply: 1, stroke: "#639b24"},
{label: "Middlegame", ply: 21, stroke: "#3093cc"},
{label: "Endgame", ply: 83, stroke: "#cc9730"}
]
Insert cell
Insert cell
createChart = () => {
const svg = createCanvas();
createPlane(svg)
.call(addBackground)
.call(addGamePhaseLines)
.call(addAdvantageAreas)
.call(addGameTimeline)
.call(addAdvantageTrend)
.call(addGamePhaseLabels);
return svg.node()
}
Insert cell
d3 = require("d3@6")
Insert cell
createCanvas = () =>
d3.create("svg")
.attr("class", "chart")
.attr("width", canvasWidth)
.attr("height", canvasHeight)
Insert cell
canvasWidth = {
const figureWidth = 640;
return Math.min(width, figureWidth);
}
Insert cell
canvasHeight = 240
Insert cell
createPlane = (parent) => {
const magicOffset = 0.5; // enables crisp single pixel width/height lines
return parent.append("g")
.attr("class", "plane")
.attr("transform", `translate(${magicOffset},${magicOffset})`);
}
Insert cell
Insert cell
Insert cell
addBackground = (parent) => {
const container = parent.append("g")
.attr("class", "background");
addBackgroundGradient(container);
addEvaluationArea(container);
}
Insert cell
addBackgroundGradient = (parent) => {
createGradient(parent)
.call(addStop, 0, "white", 0.5)
.call(addStop, 50, "#d8d6d4", 0.8)
.call(addStop, 100, "white", 0.5);
parent.append("rect")
.attr("width", canvasWidth)
.attr("height", canvasHeight)
.attr("fill", "url(#background-gradient)");
}
Insert cell
createGradient = (parent) =>
parent.append("defs")
.append("linearGradient")
.attr("id", "background-gradient")
.attr("x2", "0%")
.attr("y2", "100%")
Insert cell
addStop = (gradient, offset, color, opacity) =>
gradient.append("stop")
.attr("offset", `${offset}%`)
.attr("stop-color", color)
.attr("stop-opacity", opacity)
Insert cell
addEvaluationArea = (parent) =>
parent.append("path")
.attr("id", "evaluation-area")
.attr("fill", "white")
.attr("d", area(data))
Insert cell
area = d3.area()
.x(d => x(d.ply))
.y0(d => y(0))
.y1(d => y(d.evaluation))
Insert cell
x = d3.scaleLinear()
.domain([0, data.length])
.rangeRound([0, canvasWidth])
Insert cell
y = d3.scaleLinear()
.domain([yMax, -yMax])
.rangeRound([0, canvasHeight])
Insert cell
yMax = {
const max = d3.max(data, d => Math.abs(d.evaluation));
const forcedMateOffset = 2; // forced mate in N moves positions have no scores set ('#5' for 'mate in five moves')
const padding = 2;
return Math.ceil(max) + forcedMateOffset + padding;
}
Insert cell
md`### Game timeline
Is ${game.moves.split(" ").length} half-moves (aka *ply*) long, ending in a ${game.status}:`
Insert cell
Insert cell
addGameTimeline = (parent) =>
parent.append("line")
.attr("id", "game-timeline")
.attr("y1", y(0))
.attr("x2", canvasWidth)
.attr("y2", y(0))
.attr("stroke", "#808080") // #a0a0a0
Insert cell
Insert cell
Insert cell
addGamePhaseLines = (parent) => {
const container = parent.append("g")
.attr("class", "game-phase-lines");
gamePhases.forEach(phase => addVerticalLine(container, phase));
}
Insert cell
addVerticalLine = (parent, phase) => {
const startingPoint = {
x: x(phase.ply),
}
parent.append("line")
.attr("x1", startingPoint.x)
.attr("x2", startingPoint.x)
.attr("y2", canvasHeight)
.attr("stroke", phase.stroke);
}
Insert cell
addGamePhaseLabels = (parent) => {
const container = parent.append("g")
.attr("class", "game-phase-labels")
.attr("fill", "gray") // #a0a0a0
.attr("font-family", "sans-serif")
.attr("font-size", 11);
gamePhases.forEach(phase => addVerticalLabel(container, phase));
}
Insert cell
addVerticalLabel = (parent, phase) => {
const dx = x(phase.ply) + 5.5;
const dy = 3.5;
const magicRotationOffsetInDegrees = 0.1;
// looks better than a 90° rotation (in Chrome, for the sans-serif font given)
const rotationInDegrees = 90 - magicRotationOffsetInDegrees;
parent.append("text")
.attr("transform", `translate(${dx},${dy}) rotate(${rotationInDegrees})`)
.text(phase.label)
}
Insert cell
Insert cell
Insert cell
addAdvantageAreas = (parent) => {
const container = parent.append("g")
.attr("class", "advantage-areas");
container.append("defs")
.append("clipPath")
.attr("id", "clip")
.append("use")
.attr("href", "#evaluation-area");
container.append("g")
.attr("clip-path", "url(#clip)")
.attr("fill-opacity", 0.7)
.call(addWhiteAdvantageArea)
.call(addBlackAdvantageArea);
}
Insert cell
addWhiteAdvantageArea = (parent) =>
parent.append("rect")
.attr("id", "white-advantage-area")
.attr("width", canvasWidth)
.attr("height", y(0))
.attr("fill", "white")
Insert cell
addBlackAdvantageArea = (parent) =>
parent.append("rect")
.attr("id", "black-advantage-area")
.attr("y", y(0))
.attr("width", canvasWidth)
.attr("height", canvasHeight - y(0))
.attr("fill", "gray")
Insert cell
{
let literal = `### Advantage trend

`;
// black's advantage is represented as a negative value
const evaluations = data.map(d => d.evaluation);
const [black, white] = d3.extent(evaluations).map(d => Math.abs(d));
literal += `${Math.max(black, white)} pawns for the ${black > white ? "black" : "white"} at its maximum:`;
return md`${literal}`;
}
Insert cell
Insert cell
addAdvantageTrend = (parent) => {
const container = parent.append("g")
.attr("class", "advantage-trend");

container.append("path")
.attr("fill", "none")
.attr("stroke", "#d85406")
.attr("d", line(data));
}
Insert cell
line = d3.line()
.x(d => x(d.ply))
.y(d => y(d.evaluation))
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more