UniversalBoardDrawer.js

A userscript library for seamlessly adding chess move arrows to game boards on popular platforms like Chess.com and Lichess.org

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/470417/1562165/UniversalBoardDrawerjs.js

  1. /* UniversalBoardDrawer.js
  2. - Version: 1.3.6
  3. - Author: Haka
  4. - Description: A userscript library for seamlessly adding chess move arrows to game boards on popular platforms like Chess.com and Lichess.org
  5. - GitHub: https://github.com/Hakorr/UniversalBoardDrawer
  6. */
  7.  
  8. class UniversalBoardDrawer {
  9. constructor(boardElem, config) {
  10. this.boardElem = boardElem;
  11.  
  12. this.window = config?.window || window;
  13. this.document = this.window?.document;
  14. this.parentElem = config?.parentElem || this.document.body;
  15.  
  16. this.boardDimensions = {
  17. 'width': config?.boardDimensions?.[0] || 8,
  18. 'height': config?.boardDimensions?.[1] || 8
  19. };
  20.  
  21. this.adjustSizeByDimensions = config?.adjustSizeByDimensions || false;
  22. this.adjustSizeConfig = config?.adjustSizeConfig;
  23. this.orientation = config?.orientation || 'w';
  24. this.zIndex = config?.zIndex || 1000; // container z-index
  25. this.usePrepend = config?.prepend || false;
  26. this.debugMode = config?.debugMode || false;
  27. this.ignoreBodyRectLeft = config?.ignoreBodyRectLeft || false;
  28.  
  29. this.boardContainerElem = null;
  30. this.singleSquareSize = null;
  31. this.lastInputPositionStr = null;
  32. this.lastInputPosition = null;
  33.  
  34. this.addedShapes = [];
  35. this.squareSvgCoordinates = [];
  36. this.observers = [];
  37. this.customActivityListeners = [];
  38.  
  39. this.defaultFillColor = 'mediumseagreen';
  40. this.defaultOpacity = 0.8;
  41.  
  42. this.updateInterval = 100;
  43.  
  44. this.isInputDown = false;
  45. this.terminated = false;
  46.  
  47. if(!this.document) {
  48. if(this.debugMode) console.error(`Inputted document element doesn't exist!`);
  49.  
  50. return;
  51. }
  52.  
  53. if(!this.boardElem) {
  54. if(this.debugMode) console.error(`Inputted board element doesn't exist!`);
  55.  
  56. return;
  57. }
  58.  
  59. if(typeof this.boardDimensions != 'object') {
  60. if(this.debugMode) console.error(`Invalid board dimensions value, please use array! (e.g. [8, 8])`);
  61.  
  62. return;
  63. }
  64.  
  65. this.createOverlaySVG();
  66.  
  67. const handleMouseMove = e => {
  68. if (this.terminated) {
  69. this.document.removeEventListener('mousemove', handleMouseMove);
  70. return;
  71. }
  72. this.handleMouseEvent.bind(this)(e);
  73. };
  74.  
  75. const handleTouchStart = e => {
  76. if (this.terminated) {
  77. this.document.removeEventListener('touchstart', handleTouchStart);
  78. return;
  79. }
  80. this.handleMouseEvent.bind(this)(e);
  81. };
  82.  
  83. const handleMouseDown = () => {
  84. if (this.terminated) {
  85. this.document.removeEventListener('mousedown', handleMouseDown);
  86. return;
  87. }
  88. this.isInputDown = true;
  89. };
  90.  
  91. const handleMouseUp = () => {
  92. if (this.terminated) {
  93. this.document.removeEventListener('mouseup', handleMouseUp);
  94. return;
  95. }
  96.  
  97. this.isInputDown = false;
  98. };
  99.  
  100. this.document.addEventListener('mousemove', handleMouseMove);
  101. this.document.addEventListener('touchstart', handleTouchStart);
  102. this.document.addEventListener('mousedown', handleMouseDown);
  103. this.document.addEventListener('mouseup', handleMouseUp);
  104. }
  105.  
  106. setOrientation(orientation) {
  107. this.orientation = orientation;
  108.  
  109. this.updateDimensions();
  110. }
  111.  
  112. setBoardDimensions(dimensionArr) {
  113. const [width, height] = dimensionArr || [8, 8];
  114.  
  115. this.boardDimensions = { width, height };
  116.  
  117. this.updateDimensions();
  118. }
  119.  
  120. setAdjustSizeByDimensions(boolean) {
  121. this.adjustSizeByDimensions = boolean;
  122.  
  123. this.updateDimensions();
  124. }
  125.  
  126. createArrowBetweenPositions(from, to, config) {
  127. const fromCoordinateObj = this.squareSvgCoordinates.find(x => this.coordinateToFen(x.coordinates) == from);
  128. const toCoordinateObj = this.squareSvgCoordinates.find(x => this.coordinateToFen(x.coordinates) == to);
  129.  
  130. if(!fromCoordinateObj || !toCoordinateObj) {
  131. if(this.debugMode) console.error('Coordinates', from, to, 'do not exist. Possibly out of bounds?');
  132.  
  133. return;
  134. }
  135.  
  136. const [fromX, fromY] = fromCoordinateObj?.positions;
  137. const [toX, toY] = toCoordinateObj?.positions;
  138.  
  139. const distance = Math.sqrt(Math.pow(fromX - toX, 2) + Math.pow(fromY - toY, 2));
  140. const angle = Math.atan2(fromY - toY, fromX - toX);
  141.  
  142. const scale = this.singleSquareSize / 100;
  143.  
  144. const lineWidth = (config?.lineWidth || 15) * scale;
  145. const arrowheadWidth = (config?.arrowheadWidth || 55) * scale;
  146. const arrowheadHeight = (config?.arrowheadHeight || 45) * scale;
  147. const startOffset = (config?.startOffset || 20) * scale;
  148.  
  149. const existingArrowElem = config?.existingElem;
  150.  
  151. const arrowElem = typeof existingArrowElem === 'object'
  152. ? existingArrowElem
  153. : this.document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
  154.  
  155. arrowElem.setAttribute('transform', `rotate(${angle * (180 / Math.PI) - 90} ${fromX} ${fromY})`);
  156.  
  157. const arrowPoints = [
  158. { x: fromX - lineWidth / 2, y: fromY - startOffset },
  159. { x: fromX - lineWidth / 2, y: fromY - distance + arrowheadHeight },
  160. { x: fromX - arrowheadWidth / 2, y: fromY - distance + arrowheadHeight },
  161. { x: fromX, y: fromY - distance },
  162. { x: fromX + arrowheadWidth / 2, y: fromY - distance + arrowheadHeight },
  163. { x: fromX + lineWidth / 2, y: fromY - distance + arrowheadHeight },
  164. { x: fromX + lineWidth / 2, y: fromY - startOffset }
  165. ];
  166.  
  167. const pointsString = arrowPoints.map(point => `${point.x},${point.y}`).join(' ');
  168. arrowElem.setAttribute('points', pointsString);
  169. arrowElem.style.fill = this.defaultFillColor;
  170. arrowElem.style.opacity = this.defaultOpacity;
  171.  
  172. const style = config?.style;
  173.  
  174. if(style) arrowElem.setAttribute('style', style);
  175.  
  176. return arrowElem;
  177. }
  178.  
  179. createTextOnSquare(square, config) {
  180. const squareCoordinateObj = this.squareSvgCoordinates.find(x => this.coordinateToFen(x.coordinates) == square);
  181.  
  182. if(!squareCoordinateObj) {
  183. if(this.debugMode) console.error('Coordinate', square, 'does not exist. Possibly out of bounds?');
  184.  
  185. return;
  186. }
  187.  
  188. const defaultFontSize = 20;
  189. const sizeMultiplier = config?.size || 1;
  190. const scale = this.singleSquareSize / 100;
  191. const fontSize = defaultFontSize * sizeMultiplier * scale;
  192.  
  193. const textElem = this.document.createElementNS('http://www.w3.org/2000/svg', 'text');
  194. textElem.textContent = config?.text || '💧';
  195.  
  196. textElem.style.fontSize = `${fontSize}px`;
  197.  
  198. const style = config?.style;
  199.  
  200. if(style) {
  201. const existingStyle = textElem.getAttribute('style') || '';
  202.  
  203. textElem.setAttribute('style', `${existingStyle} ${style}`);
  204. }
  205.  
  206. return textElem;
  207. }
  208.  
  209. createRectOnSquare(square, config) {
  210. const squareCoordinateObj = this.squareSvgCoordinates.find(
  211. x => this.coordinateToFen(x.coordinates) == square
  212. );
  213.  
  214. if (!squareCoordinateObj) {
  215. if (this.debugMode) console.error('Coordinate', square, 'does not exist. Possibly out of bounds?');
  216. return;
  217. }
  218.  
  219. const [squareCenterX, squareCenterY] = squareCoordinateObj?.positions;
  220.  
  221. const rectElem = this.document.createElementNS('http://www.w3.org/2000/svg', 'rect');
  222.  
  223. rectElem.setAttribute('x', squareCenterX - this.singleSquareSize / 2);
  224. rectElem.setAttribute('y', squareCenterY - this.singleSquareSize / 2);
  225. rectElem.setAttribute('width', this.singleSquareSize);
  226. rectElem.setAttribute('height', this.singleSquareSize);
  227.  
  228. // Default styles
  229. rectElem.setAttribute('fill', 'black');
  230. rectElem.setAttribute('opacity', '0.5');
  231.  
  232. // Apply custom styles if provided
  233. const style = config?.style;
  234. if (style) {
  235. const existingStyle = rectElem.getAttribute('style') || '';
  236. rectElem.setAttribute('style', `${existingStyle} ${style}`);
  237. }
  238.  
  239. return rectElem;
  240. }
  241.  
  242. createDotOnSVG(x, y) {
  243. const dot = this.document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  244. dot.setAttribute('cx', x);
  245. dot.setAttribute('cy', y);
  246. dot.setAttribute('r', '1');
  247. dot.setAttribute('fill', 'black');
  248.  
  249. this.addedShapes.push({ type: 'debugDot', 'element': dot });
  250.  
  251. this.boardContainerElem.appendChild(dot);
  252. }
  253.  
  254. removeAllExistingShapes() {
  255. this.addedShapes
  256. .forEach(shapeObj => {
  257. shapeObj.element?.remove();
  258. });
  259. }
  260.  
  261. removeAllDebugDots() {
  262. this.addedShapes
  263. .filter(shapeObj => shapeObj.type == 'debugDot')
  264. .forEach(debugDotObj => {
  265. debugDotObj.element?.remove();
  266. });
  267. }
  268.  
  269. setTextPosition(square, originalElement, config, newElement = originalElement) {
  270. const squareCoordinateObj = this.squareSvgCoordinates.find(x => this.coordinateToFen(x.coordinates) == square);
  271.  
  272. const textBounds = originalElement.getBBox();
  273.  
  274. const squareCenterPos = squareCoordinateObj.positions;
  275. let [correctionX, correctionY] = config?.position || [0, 0];
  276.  
  277. correctionX = this.singleSquareSize * correctionX / 2;
  278. correctionY = this.singleSquareSize * -correctionY / 2;
  279.  
  280. const x = (squareCenterPos[0] - (textBounds?.width / 2) + correctionX);
  281. const y = (squareCenterPos[1] + (textBounds?.height / 4) + correctionY);
  282.  
  283. newElement.setAttribute('x', x);
  284. newElement.setAttribute('y', y);
  285. }
  286.  
  287. updateShapes() {
  288. if(this.debugMode) {
  289. this.removeAllDebugDots();
  290.  
  291. this.squareSvgCoordinates.forEach(x => this.createDotOnSVG(...x.positions));
  292. }
  293.  
  294. this.addedShapes
  295. .filter(shapeObj => shapeObj.type != 'debugDot')
  296. .forEach(shapeObj => {
  297. switch(shapeObj.type) {
  298. case 'text':
  299. // shapeObj.positions is a string, just one square fen
  300. const newTextElement = this.createTextOnSquare(shapeObj.positions, shapeObj.config);
  301. const originalTextElement = shapeObj.element;
  302.  
  303. this.setTextPosition(shapeObj.positions, originalTextElement, shapeObj.config, newTextElement);
  304.  
  305. this.transferAttributes(newTextElement, shapeObj.element);
  306.  
  307. break;
  308.  
  309. case 'rectangle':
  310. // shapeObj.positions is a string, just one square fen
  311. const newRectElem = this.createRectOnSquare(shapeObj.positions, shapeObj.config);
  312.  
  313. this.transferAttributes(newRectElem, shapeObj.element);
  314.  
  315. break;
  316.  
  317. default:
  318. const newArrowElem = this.createArrowBetweenPositions(...shapeObj.positions, shapeObj.config);
  319.  
  320. this.transferAttributes(newArrowElem, shapeObj.element);
  321.  
  322. break;
  323. }
  324. });
  325. }
  326.  
  327. createShape(type, positions, config) {
  328. let square;
  329.  
  330. if(this.terminated) {
  331. if(this.debugMode) console.warn('Failed to create shape! Tried to create shape after termination!');
  332.  
  333. return false;
  334. }
  335.  
  336. if(!this.boardContainerElem) {
  337. if(this.debugMode) console.warn(`Failed to create shape! Board SVG doesn't exist yet! (createOverlaySVG() failed?)`);
  338.  
  339. return false;
  340. }
  341.  
  342. if(typeof positions === 'string') {
  343. square = positions;
  344. }
  345.  
  346. switch(type) {
  347. case 'text':
  348. const textElement = this.createTextOnSquare(square, config);
  349.  
  350. if(textElement) {
  351. this.addedShapes.push({ type, positions, config, 'element': textElement });
  352.  
  353. if(this.usePrepend) {
  354. this.boardContainerElem.prepend(textElement);
  355. } else {
  356. this.boardContainerElem.appendChild(textElement);
  357. }
  358.  
  359. this.setTextPosition(square, textElement, config);
  360.  
  361. return textElement;
  362. }
  363.  
  364. break;
  365.  
  366. case 'rectangle':
  367. const rectElement = this.createRectOnSquare(square, config);
  368.  
  369. if(rectElement) {
  370. this.addedShapes.push({ type, positions, config, 'element': rectElement });
  371.  
  372. if(this.usePrepend) {
  373. this.boardContainerElem.prepend(rectElement);
  374. } else {
  375. this.boardContainerElem.appendChild(rectElement);
  376. }
  377.  
  378. return rectElement;
  379. }
  380.  
  381. break;
  382.  
  383. default:
  384. const arrowElement = this.createArrowBetweenPositions(...positions, config);
  385.  
  386. if(arrowElement) {
  387. this.addedShapes.push({ 'type': 'arrow', positions, config, 'element': arrowElement });
  388.  
  389. if(this.usePrepend) {
  390. this.boardContainerElem.prepend(arrowElement);
  391. } else {
  392. this.boardContainerElem.appendChild(arrowElement);
  393. }
  394.  
  395. return arrowElement;
  396. }
  397.  
  398. break;
  399. }
  400.  
  401. return null;
  402. }
  403.  
  404. coordinateToFen(coordinates) {
  405. let [x, y] = coordinates;
  406.  
  407. x = this.orientation == 'w' ? x : this.boardDimensions.width - x + 1;
  408. y = this.orientation == 'b' ? y : this.boardDimensions.height - y + 1;
  409.  
  410. const getCharacter = num => String.fromCharCode(96 + num);
  411.  
  412. const file = getCharacter(x);
  413. const rank = y;
  414.  
  415. return file + rank;
  416. }
  417.  
  418. updateCoords() {
  419. this.squareSvgCoordinates = []; // reset coordinate array
  420.  
  421. // calculate every square center point coordinates relative to the svg
  422. for(let y = 0; this.boardDimensions.height > y; y++) {
  423. for(let x = 0; this.boardDimensions.width > x; x++) {
  424. this.squareSvgCoordinates.push({
  425. coordinates: [x + 1, y + 1],
  426. positions: [this.squareWidth / 2 + (this.squareWidth * x),
  427. this.squareHeight / 2 + (this.squareHeight * y)]
  428. });
  429. }
  430. }
  431. }
  432.  
  433. transferAttributes(fromElem, toElem) {
  434. if(fromElem && fromElem?.attributes && toElem) {
  435. [...fromElem.attributes].forEach(attr =>
  436. toElem.setAttribute(attr.name, attr.value));
  437. }
  438. }
  439.  
  440. updateDimensions() {
  441. const boardRect = this.boardElem.getBoundingClientRect(),
  442. bodyRect = this.document.body.getBoundingClientRect(); // https://stackoverflow.com/a/62106310
  443.  
  444. let boardWidth = boardRect.width,
  445. boardHeight = boardRect.height;
  446.  
  447. let boardPositionTop = boardRect.top - bodyRect.top,
  448. boardPositionLeft = boardRect.left - (this.ignoreBodyRectLeft ? 0 : bodyRect.left);
  449.  
  450. if(this.adjustSizeByDimensions) {
  451.  
  452. if(this.boardDimensions.width > this.boardDimensions.height) {
  453. const multiplier = this.boardDimensions.height / this.boardDimensions.width,
  454. newHeight = boardWidth * multiplier;
  455.  
  456. if(boardHeight !== newHeight) {
  457. if(!this.adjustSizeConfig?.noTopAdjustment)
  458. boardPositionTop += (boardHeight - newHeight) / 2;
  459.  
  460. boardHeight = newHeight;
  461. }
  462. }
  463. else {
  464. const multiplier = this.boardDimensions.width / this.boardDimensions.height,
  465. newWidth = boardWidth * multiplier;
  466.  
  467. if(boardWidth !== newWidth) {
  468. if(!this.adjustSizeConfig?.noLeftAdjustment)
  469. boardPositionLeft += (boardWidth - newWidth) / 2;
  470.  
  471. boardWidth = newWidth;
  472. }
  473. }
  474.  
  475. }
  476.  
  477. this.boardContainerElem.style.width = boardWidth + 'px';
  478. this.boardContainerElem.style.height = boardHeight + 'px';
  479. this.boardContainerElem.style.left = boardPositionLeft + 'px';
  480. this.boardContainerElem.style.top = boardPositionTop + 'px';
  481.  
  482. const squareWidth = boardWidth / this.boardDimensions.width;
  483. const squareHeight = boardHeight / this.boardDimensions.height;
  484.  
  485. this.singleSquareSize = squareWidth;
  486. this.squareWidth = squareWidth;
  487. this.squareHeight = squareHeight;
  488.  
  489. this.updateCoords();
  490. this.updateShapes();
  491. }
  492.  
  493. createOverlaySVG() {
  494. const svg = this.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  495. svg.style.position = 'absolute';
  496. svg.style.pointerEvents = 'none';
  497. svg.style['z-index'] = this.zIndex;
  498.  
  499. this.boardContainerElem = svg;
  500.  
  501. this.updateDimensions();
  502.  
  503. this.parentElem.appendChild(this.boardContainerElem);
  504.  
  505. const rObs = new ResizeObserver(this.updateDimensions.bind(this));
  506. rObs.observe(this.boardElem);
  507. rObs.observe(this.document.body);
  508.  
  509. this.observers.push(rObs);
  510.  
  511. let oldBoardRect = JSON.stringify(this.boardElem.getBoundingClientRect());
  512.  
  513. const additionalCheckLoop = setInterval(() => {
  514. if(this.terminated) {
  515. clearInterval(additionalCheckLoop);
  516.  
  517. return;
  518. }
  519.  
  520. const boardRect = JSON.stringify(this.boardElem.getBoundingClientRect());
  521.  
  522. if(boardRect !== oldBoardRect) {
  523. oldBoardRect = boardRect;
  524.  
  525. this.updateDimensions();
  526. }
  527. }, this.updateInterval);
  528. }
  529.  
  530. getCoordinatesFromInputPosition(e) {
  531. const boardRect = this.boardElem.getBoundingClientRect();
  532.  
  533. const { clientX, clientY } = e.touches ? e.touches[0] : e;
  534. const isOutOfBounds = clientX < boardRect.left || clientX > boardRect.right || clientY < boardRect.top || clientY > boardRect.bottom;
  535.  
  536. const relativeX = clientX - boardRect.left;
  537. const relativeY = clientY - boardRect.top;
  538.  
  539. return isOutOfBounds
  540. ? [null, null]
  541. : [Math.floor(relativeX / this.squareWidth) + 1, Math.floor(relativeY / this.squareHeight) + 1];
  542. }
  543.  
  544. handleMouseEvent(e) {
  545. if(this.isInputDown) return;
  546.  
  547. const position = this.getCoordinatesFromInputPosition(e),
  548. positionStr = position?.toString();
  549.  
  550. if(positionStr != this.lastInputPositionStr) {
  551. const enteredSquareListeners = this.customActivityListeners.filter(obj => obj.square == this.coordinateToFen(position));
  552.  
  553. enteredSquareListeners.forEach(obj => obj.cb('enter'));
  554.  
  555. if(this.lastInputPosition && this.lastInputPosition[0] != null) {
  556. const leftSquareListeners = this.customActivityListeners.filter(obj => obj.square == this.coordinateToFen(this.lastInputPosition));
  557.  
  558. leftSquareListeners.forEach(obj => obj.cb('leave'));
  559. }
  560.  
  561. this.lastInputPositionStr = positionStr;
  562. this.lastInputPosition = position;
  563. }
  564. }
  565.  
  566. addSquareListener(square, cb) {
  567. this.customActivityListeners.push({ square, cb });
  568.  
  569. return { remove: () => {
  570. this.customActivityListeners = this.customActivityListeners.filter(obj => obj.square != square && obj.cb != cb);
  571. }};
  572. }
  573.  
  574. terminate() {
  575. this.terminated = true;
  576.  
  577. this.observers.forEach(observer => observer.disconnect());
  578.  
  579. this.boardContainerElem.remove();
  580. }
  581.  
  582. async demo() {
  583. const { width: boardWidth, height: boardHeight } = this.boardDimensions;
  584. const totalSteps = boardWidth + boardHeight;
  585. const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
  586.  
  587. let createdElements = [];
  588.  
  589. const createArrow = (x, y, opacity) => {
  590. const arrowStart = this.coordinateToFen([x - 1, y - 1]);
  591. const square = this.coordinateToFen([x, y]);
  592. return BoardDrawer.createShape('arrow', [arrowStart, square], {
  593. lineWidth: 10 + opacity * 25,
  594. arrowheadWidth: 30 + opacity * 45,
  595. arrowheadHeight: 30 + opacity * 15,
  596. startOffset: 25,
  597. style: `fill: pink; opacity: ${opacity};`
  598. });
  599. };
  600.  
  601. const createText = (square, opacity, size, x, y) => {
  602. return [
  603. BoardDrawer.createShape('text', square, {
  604. size,
  605. text: '♥️',
  606. style: `opacity: ${opacity};`,
  607. position: [0, 0]
  608. }),
  609. BoardDrawer.createShape('text', square, {
  610. size: size / 2,
  611. text: `(${x},${y})`,
  612. style: `opacity: ${opacity / 2};`,
  613. position: [0, 0.8]
  614. })
  615. ];
  616. };
  617.  
  618. for (let sum = 0; sum <= totalSteps; sum++) {
  619. const step = sum / totalSteps;
  620. const opacity = Math.min(step.toFixed(2), 1);
  621. const size = Math.max((5 - step * 4).toFixed(2), 1);
  622.  
  623. for (let x = 0; x <= sum; x++) {
  624. const y = sum - x;
  625. if (x > boardWidth || y > boardHeight) continue;
  626.  
  627. const square = this.coordinateToFen([x, y]);
  628. const rectStyle = `fill: white; opacity: ${opacity}; stroke-width: ${opacity * 2}; stroke: rgb(204,51,102,${opacity});`;
  629.  
  630. createdElements.push(BoardDrawer.createShape('rectangle', square, { style: rectStyle }));
  631.  
  632. if (x > 0 && y > 0) {
  633. createdElements.push(createArrow(x, y, opacity));
  634. }
  635.  
  636. createdElements.push(...createText(square, opacity, size, x, y));
  637.  
  638. await delay(50);
  639. }
  640. }
  641.  
  642. await delay(1000);
  643.  
  644. createdElements = createdElements
  645. .filter(x => x)
  646. .reverse();
  647.  
  648. for(let element of createdElements) {
  649. element?.remove();
  650. await delay(5);
  651. }
  652.  
  653. this.removeAllExistingShapes();
  654. }
  655. }