@reis/seki
Version:
Seki – A modern javascript based Go board renderer and player, that is simple to use, extensible, compact and intuitive.
611 lines (545 loc) • 17.7 kB
JavaScript
import {
SekiPlayer,
SekiGame,
SekiBoardStatic,
SekiMarkupFactory,
helpers,
playerModes,
playerActions,
boardLayerTypes,
stoneColors,
markupTypes,
keyValues
} from '../../src/index.js'
//Get helpers
const {
util: {toggleClass},
} = helpers
//Code source location (images not loading correctly otherwise)
const baseUrl = `https://sekiplayer.com`
//Regexes
const regexSgf = /\(;[A-Za-z0-9[\]]*FF\[[1-4]\](.*)\)/
const regexOgsUrl = /https:\/\/online-go\.com\/game\/([0-9]+)/
const {regexEventUrl} = helpers.parsing
//Generate captures string
const capturesString = (count) => `${count} capture${count === 1 ? '' : 's'}`
//Generate event string
const eventString = (...parts) => parts.filter(Boolean).join(', ')
//Extract event string and link
const eventStringAndLink = (...parts) => {
const str = eventString(...parts)
const match = str.match(regexEventUrl)
if (match) {
return [str.replace(regexEventUrl, ''), match[2]]
}
return [str, null]
}
//Show all move numbers for a static board
const showAllMoveNumbers = (game, board) => {
game.node
.getAllMoveNodes()
.forEach((node, i) => {
const {x, y} = node.move
const number = i + 1
board
.add(boardLayerTypes.MARKUP, x, y, SekiMarkupFactory
.create(markupTypes.MOVE_NUMBER, board, {number}))
})
}
//Append notice
const appendNotice = (element, text = 'Generated using') => {
const notice = document.createElement('div')
notice.classList.add('seki-notice')
notice.innerHTML = `${text} the <a href="https://sekigoplayer.com">Seki Go Player</a>`
element.appendChild(notice)
}
//Parse URL
const parseUrl = url => {
if (url) {
const match = url.match(regexOgsUrl)
if (match) {
return `https://online-go.com/api/v1/games/${match[1]}/sgf`
}
}
return url
}
//Parse content loaded from URL
const parseUrlContent = content => {
if (content) {
const match = content.match(regexSgf)
if (match) {
return match[0]
}
}
return content
}
/**
* Parse time
*/
const parseTime = (time = 0) => {
time = Math.floor(time)
if (time >= 24 * 3600) {
const days = String(Math.floor(time / (24 * 3600)))
return (days === '1') ? `1 day` : `${days} days`
}
else if (time >= 3600) {
const hours = String(Math.floor(time / 3600)).padStart(2, '0')
const minutes = String(Math.floor((time % 3600) / 60)).padStart(2, '0')
const seconds = String(time % 60).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
else if (time >= 60) {
const minutes = String(Math.floor(time / 60)).padStart(2, '0')
const seconds = String(time % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
else {
const seconds = String(time).padStart(2, '0')
return `00:${seconds}`
}
}
//Load game from data attributes
const loadGame = async(dataset) => {
if (dataset.game) {
const data = dataset.game
return SekiGame.fromData(data)
}
else if (dataset.gameUrl) {
const url = parseUrl(dataset.gameUrl)
const file = await fetch(url)
const raw = await file.text()
const data = parseUrlContent(raw)
return SekiGame.fromData(data)
}
return null
}
//Load game into player
const loadGameIntoPlayer = async(player, dataset) => {
const game = await loadGame(dataset)
if (game) {
player.loadGame(game)
player.setMode(playerModes.REPLAY)
}
else {
player.setMode(playerModes.EDIT)
}
}
//Export static seki board initialiser
export async function sekiBoardStatic(element, config = {}) {
//Extend given config with defaults
config = Object.assign({
theme: {
board: {
backgroundImage: 'images/wood-1.jpg',
},
},
}, config || {})
//Extend with element data config
if (element.dataset.config) {
const json = element.dataset.config.replace(/'/g, '"')
config = Object.assign(config, JSON.parse(json))
}
//Instantiate static board
const board = new SekiBoardStatic(config)
const game = await loadGame(element.dataset)
//Bootstrap board
board.bootstrap(element)
//Append notice
if (config.showNotice) {
appendNotice(element)
}
//Load game info from data attribute (always go to last position)
if (game) {
game.goToFirstPosition()
game.goToLastPosition()
const position = game.getPosition()
board.loadConfigFromGame(game)
board.updatePosition(position)
//Show all move numbers
if (config.showAllMoveNumbers) {
setTimeout(() => {
showAllMoveNumbers(game, board)
})
}
}
//Return board
return {board, game}
}
//Export dynamic seki board initialiser
export async function sekiBoardDynamic(element, config = {}) {
//Extend given config with defaults
config = Object.assign({
theme: {
board: {
backgroundImage: 'images/wood-1.jpg',
},
},
}, config || {})
//Extend with element data config
if (element.dataset.config) {
const json = element.dataset.config.replace(/'/g, '"')
config = Object.assign(config, JSON.parse(json))
}
//Instantiate player
const player = new SekiPlayer(config)
//Bootstrap player
player.bootstrap(element)
//Append notice
if (config.showNotice) {
appendNotice(element)
}
//Load game into player
await loadGameIntoPlayer(player, element.dataset)
//Return player
return {player}
}
//Export seki player initialiser
export async function sekiPlayer(element, config = {}) {
//Extend given config with defaults
config = Object.assign({
theme: {
board: {
backgroundColor: '',
backgroundImage: '',
},
},
availableModes: [
playerModes.REPLAY,
playerModes.EDIT,
],
keyBindings: [
{
key: keyValues.ARROW_LEFT,
action: playerActions.GO_TO_PREV_POSITION,
},
{
key: keyValues.ARROW_RIGHT,
action: playerActions.GO_TO_NEXT_POSITION,
},
{
key: keyValues.ARROW_UP,
action: playerActions.SELECT_PREV_VARIATION,
},
{
key: keyValues.ARROW_DOWN,
action: playerActions.SELECT_NEXT_VARIATION,
},
{
key: keyValues.ARROW_LEFT,
shiftKey: true,
action: playerActions.GO_BACK_NUM_POSITIONS,
},
{
key: keyValues.ARROW_RIGHT,
shiftKey: true,
action: playerActions.GO_FORWARD_NUM_POSITIONS,
},
{
key: keyValues.ARROW_LEFT,
metaKey: true,
action: playerActions.GO_TO_FIRST_POSITION,
},
{
key: keyValues.ARROW_RIGHT,
metaKey: true,
action: playerActions.GO_TO_LAST_POSITION,
},
{
key: keyValues.ARROW_LEFT,
altKey: true,
action: playerActions.GO_TO_PREV_FORK,
},
{
key: keyValues.ARROW_RIGHT,
altKey: true,
action: playerActions.GO_TO_NEXT_FORK,
},
{
key: keyValues.ARROW_LEFT,
altKey: true,
shiftKey: true,
action: playerActions.GO_TO_PREV_COMMENT,
},
{
key: keyValues.ARROW_RIGHT,
altKey: true,
shiftKey: true,
action: playerActions.GO_TO_NEXT_COMMENT,
},
{
key: keyValues.SPACE,
action: playerActions.TOGGLE_AUTO_PLAY,
},
{
key: 'C',
action: playerActions.TOGGLE_COORDINATES,
},
],
}, config || {})
//Extend with element data config
if (element.dataset.config) {
const json = element.dataset.config.replace(/'/g, '"')
config = Object.assign(config, JSON.parse(json))
}
//Create HTML structure for player
element.classList.add('seki-player-container')
element.innerHTML = `
<div class="seki-player-wrapper">
<div class="seki-player-board-and-controls">
<div class="seki-player-board"></div>
<div class="seki-controls">
<button class="seki-button seki-button-first" title="Go to first position">
<img src="${baseUrl}/images/backward-fast.svg" />
</button>
<button class="seki-button seki-button-back" title="Skip backward">
<img src="${baseUrl}/images/backward-skip.svg" />
</button>
<button class="seki-button seki-button-previous" title="Go back">
<img src="${baseUrl}/images/backward-step.svg" />
</button>
<button class="seki-button seki-button-play" title="Start auto play">
<img src="${baseUrl}/images/play.svg" />
</button>
<button class="seki-button seki-button-pause seki-hidden" title="Pause auto play">
<img src="${baseUrl}/images/pause.svg" />
</button>
<button class="seki-button seki-button-next" title="Go forward">
<img src="${baseUrl}/images/forward-step.svg" />
</button>
<button class="seki-button seki-button-forward" title="Skip forward">
<img src="${baseUrl}/images/forward-skip.svg" />
</button>
<button class="seki-button seki-button-last" title="Go to last position">
<img src="${baseUrl}/images/forward-fast.svg" />
</button>
</div>
</div>
<div class="seki-info-container">
<div class="seki-info-players">
<div class="seki-info-block seki-info-block-black">
<div class="seki-identity-and-time">
<div class="seki-identity">
<div class="seki-color seki-color-black"></div>
<div class="seki-name-and-rank">
<span class="seki-name seki-name-black">Black</span>
<span class="seki-rank seki-rank-black"></span>
</div>
</div>
<div class="seki-time seki-time-black"></div>
</div>
<div class="seki-score">
<span class="seki-captures-black">0 captures</span>
</div>
</div>
<div class="seki-info-block seki-info-block-white">
<div class="seki-identity-and-time">
<div class="seki-identity">
<div class="seki-color seki-color-white"></div>
<div class="seki-name-and-rank">
<span class="seki-name seki-name-white">White</span>
<span class="seki-rank seki-rank-white"></span>
</div>
</div>
<div class="seki-time seki-time-white"></div>
</div>
<div class="seki-score">
<span class="seki-captures-white">0 captures</span>
<span class="seki-komi"></span>
</div>
</div>
</div>
<div class="seki-info-game-details">
<div class="seki-info-block">
<div class="seki-info-group seki-info-group-name">
<label class="seki-label">game</label>
<div class="seki-game-name"></div>
</div>
<div class="seki-info-group seki-info-group-date">
<label class="seki-label">played on</label>
<div class="seki-game-date"></div>
</div>
<div class="seki-info-group seki-info-group-event">
<label class="seki-label">played at</label>
<div class="seki-event-without-link">
<span class="seki-event"></span>
</div>
<div class="seki-event-with-link seki-hidden">
<a class="seki-event seki-event-link seki-link" target="_blank" href="#"></a>
</div>
</div>
<div class="seki-info-group seki-info-group-result">
<label class="seki-label">result</label>
<a class="seki-link seki-result-toggle">Show result</a>
<div class="seki-game-result seki-hidden"></div>
</div>
</div>
</div>
<div class="seki-info-comments">
<div class="seki-info-block">
<textarea class="seki-comments" readonly></textarea>
</div>
</div>
</div>
</div>
`
//Append notice
appendNotice(element, `Powered by`)
//Helper to find elements by class name within this player instance
const findElements = (className) => {
return Array.from(element.getElementsByClassName(`seki-${className}`))
}
//Helper to set text content of an element by class name
const setText = (className, text) => {
findElements(className).forEach(el => el.textContent = text)
}
//Helper to set attribute of element by classname
const setAttr = (className, attr, value) => {
findElements(className).forEach(el => el.setAttribute(attr, value))
}
//Apply click handler
const onClick = (className, fn) => {
findElements(className).forEach(el => el.addEventListener('click', fn))
}
//Helper to toggle an element hidden
const toggleHidden = (className, val) => {
findElements(className).forEach(el => toggleClass(el, 'seki-hidden', val))
}
//Show result
const showResult = () => {
toggleHidden('result-toggle', true)
toggleHidden('game-result', false)
}
//Instantiate player and get board element
const player = new SekiPlayer(config)
const boardElement = element
.getElementsByClassName('seki-player-board')[0]
//Bootstrap player onto board element
player.bootstrap(boardElement)
//Path change handler
const onPathChange = () => {
//Get data
const {game} = player
const {black, white} = game.getCaptureCount()
const node = game.getCurrentNode()
const blackTimeLeft = game.getTimeLeft(stoneColors.BLACK)
const whiteTimeLeft = game.getTimeLeft(stoneColors.WHITE)
const blackPeriodsLeft = game.getPeriodsLeft(stoneColors.BLACK)
const whitePeriodsLeft = game.getPeriodsLeft(stoneColors.WHITE)
//Show result
if (node.isMainPath() && !node.hasChildren()) {
showResult()
}
//Set comments
if (node.hasComments()) {
const comments = node
.getComments()
.join('\n\n')
setText('comments', comments)
}
else {
setText('comments', '')
}
//Set captures
setText('captures-black', capturesString(black))
setText('captures-white', capturesString(white))
//Set time left
if (blackTimeLeft && blackPeriodsLeft) {
setText('time-black', `${blackPeriodsLeft} x ${blackTimeLeft}s`)
}
else if (blackTimeLeft) {
setText('time-black', parseTime(blackTimeLeft))
}
else {
setText('time-black', '')
}
if (whiteTimeLeft && whitePeriodsLeft) {
setText('time-white', `${whitePeriodsLeft} x ${whiteTimeLeft}s`)
}
else if (whiteTimeLeft) {
setText('time-white', parseTime(whiteTimeLeft))
}
else {
setText('time-white', '')
}
}
//Game load handler
const onGameLoad = () => {
//Get data
const {game} = player
const {black, white} = game.getPlayers()
const komi = game.getKomi()
const result = game.getGameResult()
const name = game.getGameName()
const date = game.getGameDate()
//Clear comments
setText('comments', '')
//Set player information
setText('name-black', black.name || 'Black')
setText('name-white', white.name || 'White')
setText('rank-black', black.rank || '')
setText('rank-white', white.rank || '')
setText('komi', komi ? `+${komi}` : '')
//Set game information
setText('game-result', result || '')
setText('game-name', name || '')
setText('game-date', date || '')
//Set event details
const [eventStr, eventLink] = eventStringAndLink(
game.getEventName(),
game.getEventLocation(),
game.getEventRound()
)
setText('event', eventStr || '')
setAttr('event-link', 'href', eventLink || '#')
toggleHidden('event-without-link', !!eventLink)
toggleHidden('event-with-link', !eventLink)
//Hide info groups with no content
toggleHidden('info-group-name', !name)
toggleHidden('info-group-date', !date)
toggleHidden('info-group-event', !eventStr)
toggleHidden('info-group-result', !result)
//Call path change handler as well to load comments etc.
onPathChange()
}
//On auto play toggle
const onAutoPlayToggle = event => {
const {isAutoPlaying} = event.detail
toggleHidden('button-play', isAutoPlaying)
toggleHidden('button-pause', !isAutoPlaying)
}
//Apply handlers
player.on('gameLoad', onGameLoad)
player.on('pathChange', onPathChange)
player.on('autoPlayToggle', onAutoPlayToggle)
//Bind click handlers for controls
onClick('button-first', () => player.goToFirstPosition())
onClick('button-back', () => player.goBackNumPositions())
onClick('button-previous', () => player.goToPreviousPosition())
onClick('button-play', () => player.toggleAutoPlay())
onClick('button-pause', () => player.toggleAutoPlay())
onClick('button-next', () => player.goToNextPosition())
onClick('button-forward', () => player.goForwardNumPositions())
onClick('button-last', () => player.goToLastPosition())
//Bind click handler for result toggle
onClick('result-toggle', () => showResult())
//Show result right away
if (config.showResult) {
showResult()
}
//Load game into player
await loadGameIntoPlayer(player, element.dataset)
//Return player
return {player}
}
//Auto bootstrap all seki boards and players
export function bootstrap() {
Array
.from(document.getElementsByClassName('seki-board-static'))
.forEach(el => sekiBoardStatic(el))
Array
.from(document.getElementsByClassName('seki-board-dynamic'))
.forEach(el => sekiBoardDynamic(el))
Array
.from(document.getElementsByClassName('seki-player'))
.forEach(el => sekiPlayer(el))
}