Skip to content

Instantly share code, notes, and snippets.

@zamfofex
Last active July 31, 2023 20:22
Show Gist options
  • Save zamfofex/6f157c522b1ea1cab39b6818a8b8a628 to your computer and use it in GitHub Desktop.
Save zamfofex/6f157c522b1ea1cab39b6818a8b8a628 to your computer and use it in GitHub Desktop.
simple PGN viewer
*
!/.gitignore
!/readme.md
!/index.html
!/main.js

simple PGN viewer

This is a tool that allows people to view PGNs on a web page. You can view a demo online!

git clone https://gist.github.com/zamfofex/6f157c522b1ea1cab39b6818a8b8a628 pgn-viewer
cd pgn-viewer
git clone https://github.com/zamfofex/dummyette
python3 -m http.server
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title> simple PGN viewer </title>
<style>
@import "main.css";
*
{
box-sizing: border-box;
}
body
{
margin: 2em;
font-family:
"DejaVu Sans",
"DejaVu LGC Sans",
"Verdana",
"Bitstream Vera Sans",
"Geneva",
sans-serif;
}
#widget
{
display: grid;
justify-content: center;
grid-gap: 2em;
}
form
{
border: 1px solid #333;
padding: 1em;
border-radius: 1em;
margin: 2em auto;
text-align: center;
max-width: max-content;
}
h1, h2
{
text-align: center;
margin: 2em 0 1em;
font-weight: normal;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
p
{
margin: 0;
}
body > p
{
text-align: center;
}
textarea
{
width: 32em;
max-width: 100%;
resize: vertical;
min-height: 16em;
}
input[type="url"]
{
width: 16em;
}
label
{
display: inline-block;
}
.dummyette-widget
{
width: 32em;
max-width: 75vw;
}
</style>
<script type="module">
import {toElement} from "./main.js"
import {toGame, toGames} from "./dummyette/notation/from-pgn.js"
let fromLichess = async (url, literate) =>
{
url = new URL(url, "https://lichess.org")
if (url.origin !== "https://lichess.org") return
let match = url.pathname.match(/^\/[a-zA-Z0-9]{8}/)
if (!match) return
let id = match[0].slice(1)
let url2 = `https://lichess.org/game/export/${id}`
if (literate) url2 += "?literate=1"
let response = await fetch(url2)
let pgn = await response.text()
let game = toGame(pgn)
if (!game) return
widget.textContent = ""
widget.append(toElement(game))
}
let widget = document.querySelector("#widget")
let lichessInput = document.querySelector("#url")
let pasteInput = document.querySelector("#text")
let lichessForm = document.querySelector("#lichess")
let pasteForm = document.querySelector("#paste")
let annotations = document.querySelector("#annotations")
lichessForm.addEventListener("submit", event =>
{
event.preventDefault()
fromLichess(lichessInput.value || lichessInput.placeholder, annotations.checked)
})
pasteForm.addEventListener("submit", event =>
{
event.preventDefault()
let games = toGames(pasteInput.value)
if (!games || !games.every(Boolean)) return
widget.textContent = ""
for (let game of games)
widget.append(toElement(game))
})
lichessForm.hidden = false
pasteForm.hidden = false
fromLichess("ErSfVbRk", true)
</script>
<h1> simple PGN viewer </h1>
<div id="widget"></div>
<h2> load PGN </h2>
<form id="lichess" hidden>
<p> <label>from Lichess: <input id="url" type="url" placeholder="https://lichess.org/JS50I4UL"></label> <button>load</button> <label><input id="annotations" type="checkbox" checked> annotations?</label> </p>
</form>
<form id="paste" hidden>
<p> or paste/type your PGN below: </p>
<p> <textarea id="text" placeholder='[Event "Casual game"]&#x0A;[White "Someone"]&#x0A;[Black "Anonymous"]&#x0A;[WhiteTitle "GM"]&#x0A;[BlackTitle "IM"]&#x0A;...&#x0A;&#x0A;1. e4 e5 2. Ke2 ...'></textarea> </p>
<p> <button>load</button> </p>
</form>
<h2> source and license </h2>
<p> AGPL (v3 or later), <a href="https://gist.github.com/zamfofex/6f157c522b1ea1cab39b6818a8b8a628">source on GitHub</a> </p>
import {toSAN} from "./dummyette/notation.js"
import {standardBoard, types} from "./dummyette/chess.js"
let format = n =>
{
let mate
if (n.startsWith("#"))
{
mate = true
n = n.slice(1)
}
n = Number(n)
let sign = "+"
if (n < 0)
{
n *= -1
sign = "\u2212"
}
if (mate) return "#" + sign + n
if (n === 0) return sign + "0.0"
if (n < 10) return sign + n.toFixed(2)
return sign + n.toFixed(1)
}
export let toElement = (game, {document = globalThis.document, prefix = "dummyette"} = {}) =>
{
prefix = String(prefix)
if (prefix) prefix += "-"
let widget = document.createElement("div")
widget.classList.add(prefix + "widget")
widget.tabIndex = -1
let whitePlayer = document.createElement("div")
let blackPlayer = document.createElement("div")
whitePlayer.classList.add(prefix + "player", prefix + "white-player")
blackPlayer.classList.add(prefix + "player", prefix + "black-player")
widget.append(whitePlayer, blackPlayer)
if (game.info.WhiteTitle)
{
let title = document.createElement("span")
title.classList.add(prefix + "title")
title.append(game.info.WhiteTitle)
whitePlayer.append(title)
}
if (game.info.BlackTitle)
{
let title = document.createElement("span")
title.classList.add(prefix + "title")
title.append(game.info.BlackTitle)
blackPlayer.append(title)
}
let white = document.createElement("span")
white.classList.add(prefix + "name")
white.append(game.info.White ?? "???")
whitePlayer.append(white)
let black = document.createElement("span")
black.classList.add(prefix + "name")
black.append(game.info.Black ?? "???")
blackPlayer.append(black)
if (game.info.WhiteElo)
{
let rating = document.createElement("span")
rating.classList.add(prefix + "rating")
rating.append(game.info.WhiteElo)
whitePlayer.append(rating)
}
if (game.info.BlackElo)
{
let rating = document.createElement("span")
rating.classList.add(prefix + "rating")
rating.append(game.info.BlackElo)
blackPlayer.append(rating)
}
let controls = document.createElement("div")
controls.classList.add(prefix + "controls")
widget.append(controls)
let gotoStart = document.createElement("button")
gotoStart.append("\u21C7")
let gotoEnd = document.createElement("button")
gotoEnd.append("\u21C9")
let previous = document.createElement("button")
previous.append("\u2190")
let next = document.createElement("button")
next.append("\u2192")
let flip = document.createElement("button")
flip.append("\u27F3")
controls.append(gotoStart, previous, flip, next, gotoEnd)
gotoStart.addEventListener("click", () => { update(-3) })
gotoEnd.addEventListener("click", () => { update(3) })
previous.addEventListener("click", () => update(-1))
next.addEventListener("click", () => update(1))
flip.addEventListener("click", () => { flipped = !flipped ; update() })
let board = document.createElement("div")
board.classList.add(prefix + "board")
widget.append(board)
let marksElement = document.createElement("div")
marksElement.classList.add(prefix + "marks")
board.append(marksElement)
let moveList = document.createElement("div")
moveList.classList.add(prefix + "moves")
widget.append(moveList)
let pieces = []
let chessBoard = game.deltas[0]?.before ?? standardBoard
for (let x = 0 ; x < 8 ; x++)
for (let y = 0 ; y < 8 ; y++)
{
let piece = chessBoard.at(x, y)
if (!piece) continue
let element = document.createElement("div")
element.classList.add(prefix + "piece", prefix + piece.color, prefix + piece.color + "-" + piece.type)
pieces.push({x, y, element, type: piece.type})
marksElement.before(element)
}
let stops = []
let nodes = [{children: [], pieces}]
let handleVariations = (deltas, parentElement, pieces0, parent) =>
{
let moveElement = document.createElement("div")
moveElement.classList.add(prefix + "move")
for (let {move, comments, before, variations, annotation} of deltas)
{
parentElement.append(moveElement)
let j = nodes.length
let pieces = pieces0.slice()
let children = []
let node = {parent, children, pieces}
nodes.push(node)
nodes[parent].children.push(j)
let i = pieces.findIndex(({x, y, captured}) => !captured && x === move.from.x && y === move.from.y)
let moved = [pieces[i].element]
node.moved = moved
if (move.captured)
{
let i = pieces.findIndex(({x, y, captured}) => !captured && x === move.captured.position.x && y === move.captured.position.y)
moved.push(pieces[i].element)
pieces[i] = {...pieces[i], captured: true}
}
pieces[i] = {...pieces[i], x: move.to.x, y: move.to.y}
if (move.promotion) pieces[i].type = move.promotion.type
if (move.rook)
{
let i = pieces.findIndex(({x, y, captured}) => !captured && x === move.rook.from.x && y === move.rook.from.y)
moved.push(pieces[i].element)
pieces[i] = {...pieces[i], x: move.rook.to.x, y: move.rook.to.y}
}
for (let comment of comments)
{
let time = comment.match(/\[%clk ([0-9:]+)\]/)
if (time)
{
node.time = time[1].slice(time[1].match(/^[0:]*/)[0].length).padStart(4, "0:00")
break
}
}
if(!node.time) node.time = nodes[parent].time
let scoreElement = ""
for (let comment of comments)
{
let scoreText = comment.match(/\[%eval (#?[-+]?[0-9\.]+)\]/)
if (!scoreText) continue
let score = format(scoreText[1])
scoreElement = document.createElement("span")
scoreElement.classList.add(prefix + "score")
scoreElement.append(score)
break
}
let annotationText = ""
if (annotation === 1) annotationText = "!"
if (annotation === 2) annotationText = "?"
if (annotation === 3) annotationText = "!!"
if (annotation === 4) annotationText = "??"
if (annotation === 5) annotationText = "!?"
if (annotation === 6) annotationText = "?!"
let button = document.createElement("button")
button.classList.add(prefix + before.turn + "-move")
button.append(toSAN(move) + annotationText, " ", scoreElement)
moveElement.append(button)
node.button = button
button.addEventListener("click", () =>
{
current = j
update()
})
for (let comment of comments)
{
if (comment[0] !== "{") continue
if (comment.startsWith("{ [%")) continue
let commentElement = document.createElement("span")
commentElement.classList.add(prefix + "comment")
commentElement.append(comment.slice(1, -1))
moveElement.append(commentElement)
}
for (let deltas of variations)
{
let moveList = document.createElement("div")
moveList.classList.add(prefix + "variation")
moveElement.append(moveList)
stops.push(nodes.length)
handleVariations(deltas, moveList, pieces0, parent)
}
parent = j
pieces0 = pieces
if (before.turn === "black")
{
moveElement = document.createElement("div")
moveElement.classList.add(prefix + "move")
}
}
}
handleVariations(game.deltas, moveList, pieces, 0)
let whiteTime
let blackTime
if (nodes.some(({time}) => time))
{
whiteTime = document.createElement("div")
blackTime = document.createElement("div")
whiteTime.classList.add(prefix + "time", prefix + "white-time")
blackTime.classList.add(prefix + "time", prefix + "black-time")
widget.append(whiteTime, blackTime)
}
let getPosition = event =>
{
let rect = board.getBoundingClientRect()
let x = Math.floor((event.x - rect.x - board.clientLeft) * 8 / board.scrollWidth)
let y = Math.floor((event.y - rect.y - board.clientTop) * 8 / board.scrollHeight)
if (x < 0) x = 0
if (y < 0) y = 0
if (x > 7) x = 7
if (y > 7) y = 7
return {x, y}
}
let marks = new Set()
board.addEventListener("contextmenu", event => event.preventDefault())
let currentMark
board.addEventListener("pointerdown", event =>
{
if (event.button !== 2)
{
for (let mark of marks)
mark.element.remove()
marks.clear()
return
}
let {x, y} = getPosition(event)
let element = document.createElement("div")
element.classList.add(prefix + "mark", prefix + "circle")
board.append(element)
element.style.setProperty("--" + prefix + "x0", x)
element.style.setProperty("--" + prefix + "y0", y)
element.style.setProperty("--" + prefix + "x1", x)
element.style.setProperty("--" + prefix + "y1", y)
currentMark = {element, x0: x, y0: y, x1: x, y1: y}
marks.add(currentMark)
marksElement.append(element)
})
board.addEventListener("pointermove", event =>
{
if (!currentMark) return
let {x, y} = getPosition(event)
currentMark.x1 = x
currentMark.y1 = y
let {x0, y0, element} = currentMark
let circle = x0 === x && y0 === y
element.classList.toggle(prefix + "circle", circle)
element.classList.toggle(prefix + "arrow", !circle)
element.style.setProperty("--" + prefix + "x1", x)
element.style.setProperty("--" + prefix + "y1", y)
element.style.setProperty("--" + prefix + "length", Math.sqrt((x - x0) ** 2 + (y - y0) ** 2))
element.style.setProperty("--" + prefix + "angle", Math.atan2(y - y0, x - x0) + "rad")
})
document.addEventListener("pointerup", event =>
{
if (!currentMark) return
let matching
for (let mark of marks)
{
if (mark !== currentMark)
if (mark.x0 == currentMark.x0)
if (mark.y0 == currentMark.y0)
if (mark.x1 == currentMark.x1)
if (mark.y1 == currentMark.y1)
{
matching = mark
break
}
}
if (matching)
{
marks.delete(matching)
marks.delete(currentMark)
matching.element.remove()
currentMark.element.remove()
}
currentMark = undefined
})
widget.addEventListener("keydown", event =>
{
switch (event.code)
{
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "ArrowDown":
event.preventDefault()
}
})
widget.addEventListener("keyup", event =>
{
switch (event.code)
{
case "ArrowLeft":
update(-1)
break
case "ArrowRight":
update(1)
break
case "ArrowUp":
if (event.shiftKey)
update(-2)
else
update(-3)
break
case "ArrowDown":
if (event.shiftKey)
update(2)
else
update(3)
break
}
})
let stops2 = stops.slice().reverse()
let findNextVariation = index => stops.find(i => i > index) ?? lastIndex
let findPreviousVariation = index => stops2.find(i => i < index) ?? 0
let findLast = index =>
{
let children = nodes[index].children
if (children.length === 0) return index
let i = 0
return findLast(children[i])
}
let lastIndex = findLast(0)
let update = n =>
{
let moved
if (n === -1)
{
moved = nodes[current].moved
current = nodes[current]?.parent ?? current
}
if (n === 1)
{
current = nodes[current]?.children?.[0] ?? current
moved = nodes[current].moved
}
if (n === -2) current = findPreviousVariation(current)
if (n === 2) current = findNextVariation(current)
if (n === -3) current = 0
if (n === 3) current = lastIndex
if (moved)
{
for (let {element} of nodes[current].pieces)
{
if (moved.includes(element)) continue
board.prepend(element)
}
}
let first = current === 0
previous.disabled = first
gotoStart.disabled = first
let last = current === lastIndex
next.disabled = last
gotoEnd.disabled = last
let whiteToPlay = current % 2 == 1
widget.classList.toggle(prefix + "white-to-play", !whiteToPlay)
widget.classList.toggle(prefix + "black-to-play", whiteToPlay)
widget.classList.toggle(prefix + "from-white", !flipped)
widget.classList.toggle(prefix + "from-black", flipped)
for (let {x, y, element, captured, type} of nodes[current].pieces)
{
if (flipped) x = 7 - x
else y = 7 - y
element.style.setProperty("--" + prefix + "x", x)
element.style.setProperty("--" + prefix + "y", y)
element.classList.toggle(prefix + "captured", Boolean(captured))
for (let type of types) element.classList.toggle(prefix + type, false)
element.classList.toggle(prefix + type, true)
}
if (whiteTime)
{
let parent = nodes[current].parent
let grandparent = nodes[parent]?.parent
let first = nodes[nodes[0].children[0]]
let second = first && nodes[first.children[0]]
let initialWhiteTime = first?.time
let initialBlackTime = second?.time
if (whiteToPlay)
{
whiteTime.textContent = nodes[parent]?.time ?? initialWhiteTime
blackTime.textContent = nodes[grandparent]?.time ?? initialBlackTime
}
else
{
blackTime.textContent = nodes[parent]?.time ?? initialBlackTime
whiteTime.textContent = nodes[grandparent]?.time ?? initialWhiteTime
}
}
for (let {button} of nodes.slice(1))
button.classList.remove(prefix + "current")
if (current > 0)
{
nodes[current].button.classList.add(prefix + "current")
let rect0 = moveList.getBoundingClientRect()
let rect1 = nodes[current].button.getBoundingClientRect()
let top = rect1.top - rect0.top
let bottom = rect0.bottom - rect1.bottom
if (top < 0) moveList.scrollBy(0, top)
if (bottom < 0) moveList.scrollBy(0, -bottom)
}
else
{
moveList.scrollTop = 0
}
}
let flipped = false
let current = 0
update()
return widget
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment