@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.
- package/LICENSE +674 -0
- package/README.md +127 -0
- package/demo/demo.js +241 -0
- package/demo/frame.html +11 -0
- package/demo/full-screen.html +19 -0
- package/demo/index.html +30 -0
- package/demo/lichess-pgn-viewer.css +734 -0
- package/demo/lichess-pgn-viewer.demo.css +17 -0
- package/demo/lichess-pgn-viewer.js +5916 -0
- package/demo/one.html +25 -0
- package/demo/one.js +32 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/events.d.ts +4 -0
- package/dist/events.js +42 -0
- package/dist/events.js.map +1 -0
- package/dist/game.d.ts +20 -0
- package/dist/game.js +45 -0
- package/dist/game.js.map +1 -0
- package/dist/interfaces.d.ts +95 -0
- package/dist/interfaces.js +2 -0
- package/dist/interfaces.js.map +1 -0
- package/dist/lichess-pgn-viewer.css +1 -0
- package/dist/lichess-pgn-viewer.min.js +4 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -0
- package/dist/path.d.ts +16 -0
- package/dist/path.js +18 -0
- package/dist/path.js.map +1 -0
- package/dist/pgn.d.ts +4 -0
- package/dist/pgn.js +128 -0
- package/dist/pgn.js.map +1 -0
- package/dist/pgnViewer.d.ts +34 -0
- package/dist/pgnViewer.js +90 -0
- package/dist/pgnViewer.js.map +1 -0
- package/dist/translation.d.ts +2 -0
- package/dist/translation.js +14 -0
- package/dist/translation.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/view/glyph.d.ts +1 -0
- package/dist/view/glyph.js +104 -0
- package/dist/view/glyph.js.map +1 -0
- package/dist/view/main.d.ts +5 -0
- package/dist/view/main.js +78 -0
- package/dist/view/main.js.map +1 -0
- package/dist/view/menu.d.ts +3 -0
- package/dist/view/menu.js +61 -0
- package/dist/view/menu.js.map +1 -0
- package/dist/view/player.d.ts +3 -0
- package/dist/view/player.js +32 -0
- package/dist/view/player.js.map +1 -0
- package/dist/view/side.d.ts +3 -0
- package/dist/view/side.js +102 -0
- package/dist/view/side.js.map +1 -0
- package/dist/view/util.d.ts +4 -0
- package/dist/view/util.js +23 -0
- package/dist/view/util.js.map +1 -0
- package/package.json +73 -0
- package/scss/_chessground.base.scss +164 -0
- package/scss/_chessground.cburnett.css +37 -0
- package/scss/_chessground.transp.css +57 -0
- package/scss/_controls.scss +30 -0
- package/scss/_fbt.scss +32 -0
- package/scss/_font-embed.scss +30 -0
- package/scss/_font.scss +33 -0
- package/scss/_layout.scss +147 -0
- package/scss/_lichess-pgn-viewer.lib.scss +78 -0
- package/scss/_pane.scss +31 -0
- package/scss/_player.scss +39 -0
- package/scss/_scrollbar.scss +16 -0
- package/scss/_side.scss +155 -0
- package/scss/_util.scss +7 -0
- package/scss/lichess-pgn-viewer.scss +4 -0
- package/src/config.ts +53 -0
- package/src/events.ts +42 -0
- package/src/game.ts +61 -0
- package/src/interfaces.ts +108 -0
- package/src/main.ts +24 -0
- package/src/path.ts +28 -0
- package/src/pgn.ts +141 -0
- package/src/pgnViewer.ts +114 -0
- package/src/translation.ts +17 -0
- package/src/view/glyph.ts +113 -0
- package/src/view/main.ts +99 -0
- package/src/view/menu.ts +92 -0
- package/src/view/player.ts +41 -0
- package/src/view/side.ts +123 -0
- 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
|
+
}
|
package/src/pgnViewer.ts
ADDED
|
@@ -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
|
+
};
|
package/src/view/main.ts
ADDED
|
@@ -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
|
+
});
|
package/src/view/menu.ts
ADDED
|
@@ -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;
|