@lichess-org/pgn-viewer 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +127 -0
  3. package/demo/demo.js +241 -0
  4. package/demo/frame.html +11 -0
  5. package/demo/full-screen.html +19 -0
  6. package/demo/index.html +30 -0
  7. package/demo/lichess-pgn-viewer.css +734 -0
  8. package/demo/lichess-pgn-viewer.demo.css +17 -0
  9. package/demo/lichess-pgn-viewer.js +5916 -0
  10. package/demo/one.html +25 -0
  11. package/demo/one.js +32 -0
  12. package/dist/config.d.ts +30 -0
  13. package/dist/config.js +54 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/events.d.ts +4 -0
  16. package/dist/events.js +42 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/game.d.ts +20 -0
  19. package/dist/game.js +45 -0
  20. package/dist/game.js.map +1 -0
  21. package/dist/interfaces.d.ts +95 -0
  22. package/dist/interfaces.js +2 -0
  23. package/dist/interfaces.js.map +1 -0
  24. package/dist/lichess-pgn-viewer.css +1 -0
  25. package/dist/lichess-pgn-viewer.min.js +4 -0
  26. package/dist/main.d.ts +3 -0
  27. package/dist/main.js +18 -0
  28. package/dist/main.js.map +1 -0
  29. package/dist/path.d.ts +16 -0
  30. package/dist/path.js +18 -0
  31. package/dist/path.js.map +1 -0
  32. package/dist/pgn.d.ts +4 -0
  33. package/dist/pgn.js +128 -0
  34. package/dist/pgn.js.map +1 -0
  35. package/dist/pgnViewer.d.ts +34 -0
  36. package/dist/pgnViewer.js +90 -0
  37. package/dist/pgnViewer.js.map +1 -0
  38. package/dist/translation.d.ts +2 -0
  39. package/dist/translation.js +14 -0
  40. package/dist/translation.js.map +1 -0
  41. package/dist/tsconfig.tsbuildinfo +1 -0
  42. package/dist/view/glyph.d.ts +1 -0
  43. package/dist/view/glyph.js +104 -0
  44. package/dist/view/glyph.js.map +1 -0
  45. package/dist/view/main.d.ts +5 -0
  46. package/dist/view/main.js +78 -0
  47. package/dist/view/main.js.map +1 -0
  48. package/dist/view/menu.d.ts +3 -0
  49. package/dist/view/menu.js +61 -0
  50. package/dist/view/menu.js.map +1 -0
  51. package/dist/view/player.d.ts +3 -0
  52. package/dist/view/player.js +32 -0
  53. package/dist/view/player.js.map +1 -0
  54. package/dist/view/side.d.ts +3 -0
  55. package/dist/view/side.js +102 -0
  56. package/dist/view/side.js.map +1 -0
  57. package/dist/view/util.d.ts +4 -0
  58. package/dist/view/util.js +23 -0
  59. package/dist/view/util.js.map +1 -0
  60. package/package.json +73 -0
  61. package/scss/_chessground.base.scss +164 -0
  62. package/scss/_chessground.cburnett.css +37 -0
  63. package/scss/_chessground.transp.css +57 -0
  64. package/scss/_controls.scss +30 -0
  65. package/scss/_fbt.scss +32 -0
  66. package/scss/_font-embed.scss +30 -0
  67. package/scss/_font.scss +33 -0
  68. package/scss/_layout.scss +147 -0
  69. package/scss/_lichess-pgn-viewer.lib.scss +78 -0
  70. package/scss/_pane.scss +31 -0
  71. package/scss/_player.scss +39 -0
  72. package/scss/_scrollbar.scss +16 -0
  73. package/scss/_side.scss +155 -0
  74. package/scss/_util.scss +7 -0
  75. package/scss/lichess-pgn-viewer.scss +4 -0
  76. package/src/config.ts +53 -0
  77. package/src/events.ts +42 -0
  78. package/src/game.ts +61 -0
  79. package/src/interfaces.ts +108 -0
  80. package/src/main.ts +24 -0
  81. package/src/path.ts +28 -0
  82. package/src/pgn.ts +141 -0
  83. package/src/pgnViewer.ts +114 -0
  84. package/src/translation.ts +17 -0
  85. package/src/view/glyph.ts +113 -0
  86. package/src/view/main.ts +99 -0
  87. package/src/view/menu.ts +92 -0
  88. package/src/view/player.ts +41 -0
  89. package/src/view/side.ts +123 -0
  90. package/src/view/util.ts +40 -0
package/src/pgn.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { Color, makeUci, Position } from 'chessops';
2
+ import { scalachessCharPair } from 'chessops/compat';
3
+ import { makeFen } from 'chessops/fen';
4
+ import { parsePgn, parseComment, PgnNodeData, startingPosition, transform, Node } from 'chessops/pgn';
5
+ import { makeSanAndPlay, parseSan } from 'chessops/san';
6
+ import { Game } from './game';
7
+ import { MoveData, Initial, Players, Player, Comments, Metadata, Clocks, Lichess } from './interfaces';
8
+ import { Path } from './path';
9
+
10
+ class State {
11
+ constructor(
12
+ readonly pos: Position,
13
+ public path: Path,
14
+ public clocks: Clocks,
15
+ ) {}
16
+ clone = () => new State(this.pos.clone(), this.path, { ...this.clocks });
17
+ }
18
+
19
+ export const parseComments = (strings: string[]): Comments => {
20
+ const comments = strings.map(parseComment);
21
+ const reduceTimes = (times: Array<number | undefined>) =>
22
+ times.reduce<number | undefined>((last, time) => (typeof time == undefined ? last : time), undefined);
23
+ return {
24
+ texts: comments.map(c => c.text).filter(t => !!t),
25
+ shapes: comments.flatMap(c => c.shapes),
26
+ clock: reduceTimes(comments.map(c => c.clock)),
27
+ emt: reduceTimes(comments.map(c => c.emt)),
28
+ };
29
+ };
30
+
31
+ export const makeGame = (pgn: string, lichess: Lichess = false): Game => {
32
+ const game = parsePgn(pgn)[0] || parsePgn('*')[0];
33
+ const start = startingPosition(game.headers).unwrap();
34
+ const fen = makeFen(start.toSetup());
35
+ const comments = parseComments(game.comments || []);
36
+ const headers = new Map(Array.from(game.headers, ([key, value]) => [key.toLowerCase(), value]));
37
+ const metadata = makeMetadata(headers, lichess);
38
+ const initial: Initial = {
39
+ fen,
40
+ turn: start.turn,
41
+ check: start.isCheck(),
42
+ pos: start.clone(),
43
+ comments: comments.texts,
44
+ shapes: comments.shapes,
45
+ clocks: {
46
+ white: metadata.timeControl?.initial || comments.clock,
47
+ black: metadata.timeControl?.initial || comments.clock,
48
+ },
49
+ };
50
+ const moves = makeMoves(start, game.moves, metadata);
51
+ const players = makePlayers(headers, metadata);
52
+ return new Game(initial, moves, players, metadata);
53
+ };
54
+
55
+ const makeMoves = (start: Position, moves: Node<PgnNodeData>, metadata: Metadata) =>
56
+ transform<PgnNodeData, MoveData, State>(moves, new State(start, Path.root, {}), (state, node, _index) => {
57
+ const move = parseSan(state.pos, node.san);
58
+ if (!move) return undefined;
59
+ const moveId = scalachessCharPair(move);
60
+ const path = state.path.append(moveId);
61
+ const san = makeSanAndPlay(state.pos, move);
62
+ state.path = path;
63
+ const setup = state.pos.toSetup();
64
+ const comments = parseComments(node.comments || []);
65
+ const startingComments = parseComments(node.startingComments || []);
66
+ const shapes = [...comments.shapes, ...startingComments.shapes];
67
+ const ply = (setup.fullmoves - 1) * 2 + (state.pos.turn === 'white' ? 0 : 1);
68
+ let clocks = (state.clocks = makeClocks(state.clocks, state.pos.turn, comments.clock));
69
+ if (ply < 2 && metadata.timeControl)
70
+ clocks = {
71
+ white: metadata.timeControl.initial,
72
+ black: metadata.timeControl.initial,
73
+ ...clocks,
74
+ };
75
+ const moveNode: MoveData = {
76
+ path,
77
+ ply,
78
+ move,
79
+ san,
80
+ uci: makeUci(move),
81
+ fen: makeFen(state.pos.toSetup()),
82
+ turn: state.pos.turn,
83
+ check: state.pos.isCheck(),
84
+ comments: comments.texts,
85
+ startingComments: startingComments.texts,
86
+ nags: node.nags || [],
87
+ shapes,
88
+ clocks,
89
+ emt: comments.emt,
90
+ };
91
+ return moveNode;
92
+ });
93
+
94
+ const makeClocks = (prev: Clocks, turn: Color, clk?: number): Clocks =>
95
+ turn == 'white' ? { ...prev, black: clk } : { ...prev, white: clk };
96
+
97
+ type Headers = Map<string, string>;
98
+
99
+ function makePlayers(headers: Headers, metadata: Metadata): Players {
100
+ const get = (color: Color, field: string): string | undefined => {
101
+ const raw = headers.get(`${color}${field}`);
102
+ return raw == '?' || raw == '' ? undefined : raw;
103
+ };
104
+ const makePlayer = (color: Color): Player => {
105
+ const name = get(color, '');
106
+ return {
107
+ name,
108
+ title: get(color, 'title'),
109
+ rating: parseInt(get(color, 'elo') || '') || undefined,
110
+ isLichessUser: metadata.isLichess && !!name?.match(/^[a-z0-9][a-z0-9_-]{0,28}[a-z0-9]$/i),
111
+ };
112
+ };
113
+ return {
114
+ white: makePlayer('white'),
115
+ black: makePlayer('black'),
116
+ };
117
+ }
118
+
119
+ function makeMetadata(headers: Headers, lichess: Lichess): Metadata {
120
+ const site =
121
+ headers.get('chapterurl') || headers.get('gameurl') || headers.get('source') || headers.get('site');
122
+ const tcs = headers
123
+ .get('timecontrol')
124
+ ?.split('+')
125
+ .map(x => parseInt(x));
126
+ const timeControl =
127
+ tcs && tcs[0]
128
+ ? {
129
+ initial: tcs[0],
130
+ increment: tcs[1] || 0,
131
+ }
132
+ : undefined;
133
+ const orientation = headers.get('orientation');
134
+ return {
135
+ externalLink: site && site.match(/^https?:\/\//) ? site : undefined,
136
+ isLichess: !!(lichess && site?.startsWith(lichess)),
137
+ timeControl,
138
+ orientation: orientation === 'white' || orientation === 'black' ? orientation : undefined,
139
+ result: headers.get('result'),
140
+ };
141
+ }
@@ -0,0 +1,114 @@
1
+ import { Api as CgApi } from 'chessground/api';
2
+ import { makeSquare, opposite } from 'chessops';
3
+ import translator from './translation';
4
+ import { GoTo, InitialOrMove, Opts, Translate } from './interfaces';
5
+ import { Config as CgConfig } from 'chessground/config';
6
+ import { uciToMove } from 'chessground/util';
7
+ import { Path } from './path';
8
+ import { AnyNode, Game, isMoveData } from './game';
9
+ import { makeGame } from './pgn';
10
+
11
+ export default class PgnViewer {
12
+ game: Game;
13
+ path: Path;
14
+ translate: Translate;
15
+ ground?: CgApi;
16
+ div?: HTMLElement;
17
+ flipped = false;
18
+ pane = 'board';
19
+ autoScrollRequested = false;
20
+
21
+ constructor(
22
+ readonly opts: Opts,
23
+ readonly redraw: () => void,
24
+ ) {
25
+ this.game = makeGame(opts.pgn, opts.lichess);
26
+ opts.orientation = opts.orientation || this.game.metadata.orientation;
27
+ this.translate = translator(opts.translate);
28
+ this.path = this.game.pathAtMainlinePly(opts.initialPly);
29
+ }
30
+
31
+ curNode = (): AnyNode => this.game.nodeAt(this.path) || this.game.moves;
32
+ curData = (): InitialOrMove => this.game.dataAt(this.path) || this.game.initial;
33
+
34
+ goTo = (to: GoTo, focus = true) => {
35
+ const path =
36
+ to == 'first'
37
+ ? Path.root
38
+ : to == 'prev'
39
+ ? this.path.init()
40
+ : to == 'next'
41
+ ? this.game.nodeAt(this.path)?.children[0]?.data.path
42
+ : this.game.pathAtMainlinePly('last');
43
+ this.toPath(path || this.path, focus);
44
+ };
45
+
46
+ canGoTo = (to: GoTo) => (to == 'prev' || to == 'first' ? !this.path.empty() : !!this.curNode().children[0]);
47
+
48
+ toPath = (path: Path, focus = true) => {
49
+ this.path = path;
50
+ this.pane = 'board';
51
+ this.autoScrollRequested = true;
52
+ this.redrawGround();
53
+ this.redraw();
54
+ if (focus) this.focus();
55
+ };
56
+
57
+ focus = () => this.div?.focus();
58
+
59
+ toggleMenu = () => {
60
+ this.pane = this.pane == 'board' ? 'menu' : 'board';
61
+ this.redraw();
62
+ };
63
+ togglePgn = () => {
64
+ this.pane = this.pane == 'pgn' ? 'board' : 'pgn';
65
+ this.redraw();
66
+ };
67
+
68
+ orientation = () => {
69
+ const base = this.opts.orientation || 'white';
70
+ return this.flipped ? opposite(base) : base;
71
+ };
72
+
73
+ flip = () => {
74
+ this.flipped = !this.flipped;
75
+ this.pane = 'board';
76
+ this.redrawGround();
77
+ this.redraw();
78
+ };
79
+
80
+ cgState = (): CgConfig => {
81
+ const data = this.curData();
82
+ const lastMove = isMoveData(data) ? uciToMove(data.uci) : this.opts.chessground?.lastMove;
83
+ return {
84
+ fen: data.fen,
85
+ orientation: this.orientation(),
86
+ check: data.check,
87
+ lastMove,
88
+ turnColor: data.turn,
89
+ };
90
+ };
91
+
92
+ analysisUrl = () =>
93
+ (this.game.metadata.isLichess && this.game.metadata.externalLink) ||
94
+ `https://lichess.org/analysis/${this.curData().fen.replace(' ', '_')}?color=${this.orientation()}`;
95
+ practiceUrl = () => `${this.analysisUrl()}#practice`;
96
+
97
+ setGround = (cg: CgApi) => {
98
+ this.ground = cg;
99
+ this.redrawGround();
100
+ };
101
+
102
+ private redrawGround = () =>
103
+ this.withGround(g => {
104
+ g.set(this.cgState());
105
+ g.setShapes(
106
+ this.curData().shapes.map(s => ({
107
+ orig: makeSquare(s.from),
108
+ dest: makeSquare(s.to),
109
+ brush: s.color,
110
+ })),
111
+ );
112
+ });
113
+ private withGround = (f: (cg: CgApi) => void) => this.ground && f(this.ground);
114
+ }
@@ -0,0 +1,17 @@
1
+ import { Translate } from './interfaces';
2
+
3
+ export default function translate(translator?: Translate) {
4
+ return (key: string) => (translator && translator(key)) || defaultTranslator(key);
5
+ }
6
+
7
+ const defaultTranslator = (key: string) => defaultTranslations[key];
8
+
9
+ const defaultTranslations: { [key: string]: string } = {
10
+ flipTheBoard: 'Flip the board',
11
+ analysisBoard: 'Analysis board',
12
+ practiceWithComputer: 'Practice with computer',
13
+ getPgn: 'Get PGN',
14
+ download: 'Download',
15
+ viewOnLichess: 'View on Lichess',
16
+ viewOnSite: 'View on site',
17
+ };
@@ -0,0 +1,113 @@
1
+ import { h } from 'snabbdom';
2
+
3
+ export const renderNag = (nag: number) => {
4
+ const glyph = glyphs[nag];
5
+ return glyph ? h('nag', { attrs: { title: glyph.name } }, glyph.symbol) : undefined;
6
+ };
7
+
8
+ type Glyph = {
9
+ symbol: string;
10
+ name: string;
11
+ };
12
+ interface Glyphs {
13
+ [key: number]: Glyph;
14
+ }
15
+
16
+ const glyphs: Glyphs = {
17
+ 1: {
18
+ symbol: '!',
19
+ name: 'Good move',
20
+ },
21
+ 2: {
22
+ symbol: '?',
23
+ name: 'Mistake',
24
+ },
25
+ 3: {
26
+ symbol: '!!',
27
+ name: 'Brilliant move',
28
+ },
29
+ 4: {
30
+ symbol: '??',
31
+ name: 'Blunder',
32
+ },
33
+ 5: {
34
+ symbol: '!?',
35
+ name: 'Interesting move',
36
+ },
37
+ 6: {
38
+ symbol: '?!',
39
+ name: 'Dubious move',
40
+ },
41
+ 7: {
42
+ symbol: '□',
43
+ name: 'Only move',
44
+ },
45
+ 22: {
46
+ symbol: '⨀',
47
+ name: 'Zugzwang',
48
+ },
49
+ 10: {
50
+ symbol: '=',
51
+ name: 'Equal position',
52
+ },
53
+ 13: {
54
+ symbol: '∞',
55
+ name: 'Unclear position',
56
+ },
57
+ 14: {
58
+ symbol: '⩲',
59
+ name: 'White is slightly better',
60
+ },
61
+ 15: {
62
+ symbol: '⩱',
63
+ name: 'Black is slightly better',
64
+ },
65
+ 16: {
66
+ symbol: '±',
67
+ name: 'White is better',
68
+ },
69
+ 17: {
70
+ symbol: '∓',
71
+ name: 'Black is better',
72
+ },
73
+ 18: {
74
+ symbol: '+−',
75
+ name: 'White is winning',
76
+ },
77
+ 19: {
78
+ symbol: '-+',
79
+ name: 'Black is winning',
80
+ },
81
+ 146: {
82
+ symbol: 'N',
83
+ name: 'Novelty',
84
+ },
85
+ 32: {
86
+ symbol: '↑↑',
87
+ name: 'Development',
88
+ },
89
+ 36: {
90
+ symbol: '↑',
91
+ name: 'Initiative',
92
+ },
93
+ 40: {
94
+ symbol: '→',
95
+ name: 'Attack',
96
+ },
97
+ 132: {
98
+ symbol: '⇆',
99
+ name: 'Counterplay',
100
+ },
101
+ 138: {
102
+ symbol: '⊕',
103
+ name: 'Time trouble',
104
+ },
105
+ 44: {
106
+ symbol: '=∞',
107
+ name: 'With compensation',
108
+ },
109
+ 140: {
110
+ symbol: '∆',
111
+ name: 'With the idea',
112
+ },
113
+ };
@@ -0,0 +1,99 @@
1
+ import PgnViewer from '../pgnViewer';
2
+ import { Chessground } from 'chessground';
3
+ import { Config as CgConfig } from 'chessground/config';
4
+ import { h, VNode } from 'snabbdom';
5
+ import { onInsert } from './util';
6
+ import { onKeyDown, stepwiseScroll } from '../events';
7
+ import { renderMenu, renderControls } from './menu';
8
+ import { renderMoves } from './side';
9
+ import renderPlayer from './player';
10
+
11
+ export default function view(ctrl: PgnViewer) {
12
+ const opts = ctrl.opts,
13
+ staticClasses = `lpv.lpv--moves-${opts.showMoves}.lpv--controls-${opts.showControls}${
14
+ opts.classes ? '.' + opts.classes.replace(' ', '.') : ''
15
+ }`;
16
+ const showPlayers = opts.showPlayers == 'auto' ? ctrl.game.hasPlayerName() : opts.showPlayers;
17
+ return h(
18
+ `div.${staticClasses}`,
19
+ {
20
+ class: {
21
+ 'lpv--menu': ctrl.pane != 'board',
22
+ 'lpv--players': showPlayers,
23
+ },
24
+ attrs: {
25
+ tabindex: 0,
26
+ },
27
+ hook: onInsert(el => {
28
+ ctrl.setGround(Chessground(el.querySelector('.cg-wrap') as HTMLElement, makeConfig(ctrl, el)));
29
+ if (opts.keyboardToMove) el.addEventListener('keydown', onKeyDown(ctrl));
30
+ }),
31
+ },
32
+ [
33
+ showPlayers ? renderPlayer(ctrl, 'top') : undefined,
34
+ renderBoard(ctrl),
35
+ showPlayers ? renderPlayer(ctrl, 'bottom') : undefined,
36
+ opts.showControls ? renderControls(ctrl) : undefined,
37
+ opts.showMoves ? renderMoves(ctrl) : undefined,
38
+ ctrl.pane == 'menu' ? renderMenu(ctrl) : ctrl.pane == 'pgn' ? renderPgnPane(ctrl) : undefined,
39
+ ],
40
+ );
41
+ }
42
+
43
+ const renderBoard = (ctrl: PgnViewer): VNode =>
44
+ h(
45
+ 'div.lpv__board',
46
+ {
47
+ hook: onInsert(el => {
48
+ el.addEventListener('click', ctrl.focus);
49
+ if (ctrl.opts.scrollToMove && !('ontouchstart' in window))
50
+ el.addEventListener(
51
+ 'wheel',
52
+ stepwiseScroll((e: WheelEvent, scroll: boolean) => {
53
+ e.preventDefault();
54
+ if (e.deltaY > 0 && scroll) ctrl.goTo('next', false);
55
+ else if (e.deltaY < 0 && scroll) ctrl.goTo('prev', false);
56
+ }),
57
+ );
58
+ }),
59
+ },
60
+ h('div.cg-wrap'),
61
+ );
62
+
63
+ const renderPgnPane = (ctrl: PgnViewer): VNode => {
64
+ const blob = new Blob([ctrl.opts.pgn], { type: 'text/plain' });
65
+ return h('div.lpv__pgn.lpv__pane', [
66
+ h(
67
+ 'a.lpv__pgn__download.lpv__fbt',
68
+ {
69
+ attrs: {
70
+ href: window.URL.createObjectURL(blob),
71
+ download: ctrl.opts.menu.getPgn.fileName || `${ctrl.game.title()}.pgn`,
72
+ },
73
+ },
74
+ ctrl.translate('download'),
75
+ ),
76
+ h('textarea.lpv__pgn__text', ctrl.opts.pgn),
77
+ ]);
78
+ };
79
+
80
+ export const makeConfig = (ctrl: PgnViewer, rootEl: HTMLElement): CgConfig => ({
81
+ viewOnly: !ctrl.opts.drawArrows,
82
+ addDimensionsCssVarsTo: rootEl,
83
+ drawable: {
84
+ enabled: ctrl.opts.drawArrows,
85
+ visible: true,
86
+ },
87
+ disableContextMenu: ctrl.opts.drawArrows,
88
+ ...(ctrl.opts.chessground || {}),
89
+ movable: {
90
+ free: false,
91
+ },
92
+ draggable: {
93
+ enabled: false,
94
+ },
95
+ selectable: {
96
+ enabled: false,
97
+ },
98
+ ...ctrl.cgState(),
99
+ });
@@ -0,0 +1,92 @@
1
+ import { h } from 'snabbdom';
2
+ import PgnViewer from '../pgnViewer';
3
+ import { GoTo } from '../interfaces';
4
+ import { bind, bindMobileMousedown, onInsert } from './util';
5
+ import { eventRepeater } from '../events';
6
+
7
+ export const renderMenu = (ctrl: PgnViewer) =>
8
+ h('div.lpv__menu.lpv__pane', [
9
+ h(
10
+ 'button.lpv__menu__entry.lpv__menu__flip.lpv__fbt',
11
+ {
12
+ hook: bind('click', ctrl.flip),
13
+ },
14
+ ctrl.translate('flipTheBoard'),
15
+ ),
16
+ ctrl.opts.menu.analysisBoard?.enabled
17
+ ? h(
18
+ 'a.lpv__menu__entry.lpv__menu__analysis.lpv__fbt',
19
+ {
20
+ attrs: {
21
+ href: ctrl.analysisUrl(),
22
+ target: '_blank',
23
+ },
24
+ },
25
+ ctrl.translate('analysisBoard'),
26
+ )
27
+ : undefined,
28
+ ctrl.opts.menu.practiceWithComputer?.enabled
29
+ ? h(
30
+ 'a.lpv__menu__entry.lpv__menu__practice.lpv__fbt',
31
+ {
32
+ attrs: {
33
+ href: ctrl.practiceUrl(),
34
+ target: '_blank',
35
+ },
36
+ },
37
+ ctrl.translate('practiceWithComputer'),
38
+ )
39
+ : undefined,
40
+ ctrl.opts.menu.getPgn.enabled
41
+ ? h(
42
+ 'button.lpv__menu__entry.lpv__menu__pgn.lpv__fbt',
43
+ {
44
+ hook: bind('click', ctrl.togglePgn),
45
+ },
46
+ ctrl.translate('getPgn'),
47
+ )
48
+ : undefined,
49
+ renderExternalLink(ctrl),
50
+ ]);
51
+
52
+ const renderExternalLink = (ctrl: PgnViewer) => {
53
+ const link = ctrl.game.metadata.externalLink;
54
+ return (
55
+ link &&
56
+ h(
57
+ 'a.lpv__menu__entry.lpv__fbt',
58
+ {
59
+ attrs: {
60
+ href: link,
61
+ target: '_blank',
62
+ },
63
+ },
64
+ ctrl.translate(ctrl.game.metadata.isLichess ? 'viewOnLichess' : 'viewOnSite'),
65
+ )
66
+ );
67
+ };
68
+
69
+ export const renderControls = (ctrl: PgnViewer) =>
70
+ h('div.lpv__controls', [
71
+ ctrl.pane == 'board' ? undefined : dirButton(ctrl, 'first', 'step-backward'),
72
+ dirButton(ctrl, 'prev', 'left-open'),
73
+ h(
74
+ 'button.lpv__fbt.lpv__controls__menu.lpv__icon',
75
+ {
76
+ class: {
77
+ active: ctrl.pane != 'board',
78
+ 'lpv__icon-ellipsis-vert': ctrl.pane == 'board',
79
+ },
80
+ hook: bind('click', ctrl.toggleMenu),
81
+ },
82
+ ctrl.pane == 'board' ? undefined : 'X',
83
+ ),
84
+ dirButton(ctrl, 'next', 'right-open'),
85
+ ctrl.pane == 'board' ? undefined : dirButton(ctrl, 'last', 'step-forward'),
86
+ ]);
87
+
88
+ const dirButton = (ctrl: PgnViewer, to: GoTo, icon: string) =>
89
+ h(`button.lpv__controls__goto.lpv__controls__goto--${to}.lpv__fbt.lpv__icon.lpv__icon-${icon}`, {
90
+ class: { disabled: ctrl.pane == 'board' && !ctrl.canGoTo(to) },
91
+ hook: onInsert(el => bindMobileMousedown(el, e => eventRepeater(() => ctrl.goTo(to), e))),
92
+ });
@@ -0,0 +1,41 @@
1
+ import { Color, opposite } from 'chessops';
2
+ import { h, VNode } from 'snabbdom';
3
+ import PgnViewer from '../pgnViewer';
4
+
5
+ export default function renderPlayer(ctrl: PgnViewer, side: 'top' | 'bottom'): VNode {
6
+ const color = side == 'bottom' ? ctrl.orientation() : opposite(ctrl.orientation());
7
+ const player = ctrl.game.players[color];
8
+ const personEls = [
9
+ player.title ? h('span.lpv__player__title', player.title) : undefined,
10
+ h('span.lpv__player__name', player.name),
11
+ player.rating ? h('span.lpv__player__rating', ['(', player.rating, ')']) : undefined,
12
+ ];
13
+ return h(`div.lpv__player.lpv__player--${side}`, [
14
+ player.isLichessUser
15
+ ? h(
16
+ 'a.lpv__player__person.ulpt.user-link',
17
+ { attrs: { href: `${ctrl.opts.lichess}/@/${player.name}` } },
18
+ personEls,
19
+ )
20
+ : h('span.lpv__player__person', personEls),
21
+ ctrl.opts.showClocks ? renderClock(ctrl, color) : undefined,
22
+ ]);
23
+ }
24
+
25
+ const renderClock = (ctrl: PgnViewer, color: Color): VNode | undefined => {
26
+ const move = ctrl.curData();
27
+ const clock = move.clocks && move.clocks[color];
28
+ return typeof clock == undefined
29
+ ? undefined
30
+ : h('div.lpv__player__clock', { class: { active: color == move.turn } }, clockContent(clock));
31
+ };
32
+
33
+ const clockContent = (seconds: number | undefined): string[] => {
34
+ if (!seconds && seconds !== 0) return ['-'];
35
+ const date = new Date(seconds * 1000),
36
+ sep = ':',
37
+ baseStr = pad2(date.getUTCMinutes()) + sep + pad2(date.getUTCSeconds());
38
+ return seconds >= 3600 ? [Math.floor(seconds / 3600) + sep + baseStr] : [baseStr];
39
+ };
40
+
41
+ const pad2 = (num: number): string => (num < 10 ? '0' : '') + num;