import Router from "@koa/router" ; import assert from "assert" ; import crypto from "crypto" ; import path from "path" ; import { Context } from "koa" ; import { Image , createCanvas , loadImage , registerFont } from "canvas" ; import { ASSETS_BASE } from "../util/consts" ; import { CacheService } from "../shared/cache" ; import { PrettyMove } from "../util/chess" ; import { words } from "../util/misc" ; export interface ImageUrls { boardImage : string ; boardImageWhite : string ; boardImageBlack : string ; moveImage : string ; moveImageWhite : string ; moveImageBlack : string ; } export interface DrawCtrl { draw ( slug ) : Promise < Buffer > ; imageUrls ( fen , move ? ) : ImageUrls ; } const { PI , atan2 , cos , hypot , sin } = Math ; const HALF_PI = PI / 2 ; const CC_LOWER_A = "a" . charCodeAt ( 0 ) ; const BACKGROUND_STYLE = "#fff" ; const LABEL_FILL_STYLE = "#444" ; const LIGHT_SQUARE_STYLE = "#ffce9e" ; const DARK_SQUARE_STYLE = "#d18b47" ; const LINE_FILL_STYLE = "#c00" ; const MARGIN_SIZE = 80 ; const SQUARE_SIZE = 140 ; const SQUARE_PADDING_SIZE = 8 ; const LINE_END_CUT = 45 ; const LINE_CURVE_MIN_DIST = 300 ; const LINE_CURVE_FACTOR = 0.3 ; const LINE_CURVE_WIDTH_BASE = 8 ; const LINE_CURVE_WIDTH_FACTOR = 0.02 ; const CAP_SIZE_BASE = 50 ; const CAP_SIZE_FACTOR = 0.005 ; const CAP_INDENT_FACTOR = 0.8 ; const CAP_WIDTH_ANGLE = 0.3 ; const BOARD_SIZE = SQUARE_SIZE * 8 ; const IMAGE_SIZE = BOARD_SIZE + MARGIN_SIZE * 2 ; const HALF_MARGIN_SIZE = MARGIN_SIZE / 2 ; const HALF_SQUARE_SIZE = SQUARE_SIZE / 2 ; const SQUARE_IMAGE_SIZE = SQUARE_SIZE - 2 * SQUARE_PADDING_SIZE ; const LEFT_MARGIN_CENTER = HALF_MARGIN_SIZE ; const RIGHT_MARGIN_CENTER = IMAGE_SIZE - HALF_MARGIN_SIZE ; const TOP_MARGIN_CENTER = HALF_MARGIN_SIZE ; const BOTTOM_MARGIN_CENTER = IMAGE_SIZE - HALF_MARGIN_SIZE ; const FONT = "48px DejaVuSans" ; const fontPath = path . resolve ( ASSETS_BASE , "DejaVuSans.ttf" ) ; registerFont ( fontPath , { family : "DejaVuSans" } ) ; export default async ( { cache , hmacSecret , origin , router , } ) : Promise < DrawCtrl > => { const pieceImages = await ( async ( ) => { const names = words ( `
      wb wk wn wp wq wr
      bb bk bn bp bq br
    ` ) ; const obj = { } ; for ( const name of names ) { const imagePath = path . resolve ( ASSETS_BASE , `img/${ name }.png` ) ; obj [ name ] = await loadImage ( imagePath ) ; } return obj ; } ) ( ) ; const decodeSlug = ( slug ) : { pieces : string [ ] ; side : string ; move ? : { from : string ; to : string ; } ; } => { const match = /-([a-h][1-8])([a-h][1-8])$/ . exec ( slug ) ; const move = match ? { from : match [ 1 ] , to : match [ 2 ] , } : undefined ; const chrs = Array . from ( match ? slug . slice ( 0 , - 5 ) : slug ) ; const side = chrs . pop ( ) ! ; const pieces = new Array ( 64 ) ; let idx = 0 ; for ( const chr of chrs ) { if ( /[rnbqkp]/ . test ( chr ) ) { pieces [ idx ++ ] = "b" + chr ; } else if ( /[RNBQKP]/ . test ( chr ) ) { pieces [ idx ++ ] = "w" + chr . toLowerCase ( ) ; } else if ( /[1-8]/ . test ( chr ) ) { let empties = parseInt ( chr , 10 ) ; while ( empties -- ) { pieces [ idx ++ ] = "" ; } } } assert . strictEqual ( idx , 64 ) ; return { pieces , side , move } ; } ; const draw = async ( slug ) => { const { pieces , side , move } = decodeSlug ( slug ) ; const canvas = createCanvas ( IMAGE_SIZE , IMAGE_SIZE ) ; const ctx = canvas . getContext ( "2d" ) ; ctx . font = FONT ; ctx . textAlign = "center" ; ctx . textBaseline = "middle" ; ctx . fillStyle = BACKGROUND_STYLE ; ctx . fillRect ( 0 , 0 , IMAGE_SIZE , IMAGE_SIZE ) ; ctx . fillStyle = LIGHT_SQUARE_STYLE ; ctx . fillRect ( MARGIN_SIZE , MARGIN_SIZE , BOARD_SIZE , BOARD_SIZE ) ; for ( let y = 0 ; y < 8 ; y ++ ) { for ( let x = 0 ; x < 8 ; x ++ ) { const pixelX = MARGIN_SIZE + x * SQUARE_SIZE ; const pixelY = MARGIN_SIZE + y * SQUARE_SIZE ; const realX = side === "w" ? x : 7 - x ; const realY = side === "b" ? y : 7 - y ; if ( ( ( realY % 2 ) + realX ) % 2 === 0 ) { ctx . fillStyle = DARK_SQUARE_STYLE ; ctx . fillRect ( pixelX , pixelY , SQUARE_SIZE , SQUARE_SIZE ) ; } const piece = pieces [ ( 7 - realY ) * 8 + realX ] ; if ( piece ) { const image = pieceImages [ piece ] ; ctx . drawImage ( image , pixelX + SQUARE_PADDING_SIZE , pixelY + SQUARE_PADDING_SIZE , SQUARE_IMAGE_SIZE , SQUARE_IMAGE_SIZE ) ; } } } ctx . fillStyle = LABEL_FILL_STYLE ; for ( let x = 0 ; x < 8 ; x ++ ) { const centerX = MARGIN_SIZE + x * SQUARE_SIZE + HALF_SQUARE_SIZE ; const realX = side === "w" ? x : 7 - x ; const char = String . fromCharCode ( CC_LOWER_A + realX ) ; ctx . fillText ( char , centerX , TOP_MARGIN_CENTER ) ; ctx . fillText ( char , centerX , BOTTOM_MARGIN_CENTER ) ; } for ( let y = 0 ; y < 8 ; y ++ ) { const centerY = MARGIN_SIZE + y * SQUARE_SIZE + HALF_SQUARE_SIZE ; const realY = side === "b" ? y : 7 - y ; const char = `${ realY + 1 }` ; ctx . fillText ( char , LEFT_MARGIN_CENTER , centerY ) ; ctx . fillText ( char , RIGHT_MARGIN_CENTER , centerY ) ; } if ( move ) { const { from , to } = move ; const fromRealX = from . charCodeAt ( 0 ) - CC_LOWER_A ; const fromRealY = parseInt ( from [ 1 ] , 10 ) - 1 ; const toRealX = to . charCodeAt ( 0 ) - CC_LOWER_A ; const toRealY = parseInt ( to [ 1 ] , 10 ) - 1 ; const fromX = side === "w" ? fromRealX : 7 - fromRealX ; const fromY = side === "b" ? fromRealY : 7 - fromRealY ; const toX = side === "w" ? toRealX : 7 - toRealX ; const toY = side === "b" ? toRealY : 7 - toRealY ; const fromCenterX = MARGIN_SIZE + fromX * SQUARE_SIZE + HALF_SQUARE_SIZE ; const fromCenterY = MARGIN_SIZE + fromY * SQUARE_SIZE + HALF_SQUARE_SIZE ; const toCenterX = MARGIN_SIZE + toX * SQUARE_SIZE + HALF_SQUARE_SIZE ; const toCenterY = MARGIN_SIZE + toY * SQUARE_SIZE + HALF_SQUARE_SIZE ; const distX = toCenterX - fromCenterX ; const distY = toCenterY - fromCenterY ; const midX = fromCenterX + distX / 2 ; const midY = fromCenterY + distY / 2 ; const dist = hypot ( distX , distY ) ; const curveWidth = LINE_CURVE_WIDTH_BASE + dist * LINE_CURVE_WIDTH_FACTOR ; const angle = atan2 ( toCenterX - fromCenterX , toCenterY - fromCenterY ) ; const anglePerp = angle + HALF_PI ; const [ sinPerp , cosPerp ] = [ sin ( anglePerp ) , cos ( anglePerp ) ] ; const curveFactor = dist >= LINE_CURVE_MIN_DIST ? LINE_CURVE_FACTOR : 0 ; const ctrlX = midX + sinPerp * dist * curveFactor ; const ctrlY = midY + cosPerp * dist * curveFactor ; const ctrlInnerX = ctrlX - sinPerp * curveWidth ; const ctrlInnerY = ctrlY - cosPerp * curveWidth ; const ctrlOuterX = ctrlX + sinPerp * curveWidth ; const ctrlOuterY = ctrlY + cosPerp * curveWidth ; const angleControl = atan2 ( toCenterX - ctrlX , toCenterY - ctrlY ) ; const endX = toCenterX - sin ( angleControl ) * LINE_END_CUT ; const endY = toCenterY - cos ( angleControl ) * LINE_END_CUT ; const longCapSize = CAP_SIZE_BASE + dist * CAP_SIZE_FACTOR ; const shortCapSize = longCapSize * CAP_INDENT_FACTOR ; ctx . fillStyle = LINE_FILL_STYLE ; ctx . beginPath ( ) ; ctx . moveTo ( endX , endY ) ; ctx . quadraticCurveTo ( ctrlOuterX , ctrlOuterY , fromCenterX , fromCenterY ) ; ctx . quadraticCurveTo ( ctrlInnerX , ctrlInnerY , endX , endY ) ; ctx . fill ( ) ; ctx . beginPath ( ) ; ctx . lineTo ( endX - sin ( angleControl + CAP_WIDTH_ANGLE ) * longCapSize , endY - cos ( angleControl + CAP_WIDTH_ANGLE ) * longCapSize ) ; ctx . lineTo ( endX - sin ( angleControl ) * shortCapSize , endY - cos ( angleControl ) * shortCapSize ) ; ctx . lineTo ( endX - sin ( angleControl - CAP_WIDTH_ANGLE ) * longCapSize , endY - cos ( angleControl - CAP_WIDTH_ANGLE ) * longCapSize ) ; ctx . lineTo ( endX , endY ) ; ctx . fill ( ) ; } return new Promise ( ( resolve , reject ) => { canvas . toBuffer ( ( err , png ) => { err ? reject ( err ) : resolve ( png ) ; } ) ; } ) ; } ; router . get ( "/images/:slug/:signature.png" , async ( ctx ) => { const { slug , signature } = ctx . params ; const expected = crypto . createHmac ( "sha256" , hmacSecret ) . update ( slug ) . digest ( "hex" ) ; ctx . assert ( signature === expected , 404 , "Image not found" ) ; let png = await cache . draw . get ( slug ) ; if ( ! png ) { png = await draw ( slug ) ; console . log ( `Created and cached drawing for board state '${ slug }'` ) ; cache . draw . set ( slug , png ) . catch ( ( err ) => { console . error ( err ) ; } ) ; } ctx . body = png ; ctx . type = "image/png" ; } ) ; const imageUrlFor = ( pieces , side , move ? ) : string => { let slug = `${ pieces }${ side }` ; if ( move ) { slug += `-${ move . from }${ move . to }` ; } const signature = crypto . createHmac ( "sha256" , hmacSecret ) . update ( slug ) . digest ( "hex" ) ; return `${ origin }/images/${ slug }/${ signature }.png` ; } ; const imageUrls = ( fen , move ) => { const [ pieces , currentSide ] = fen . replace ( /\//g , "" ) . split ( " " , 2 ) ; const urls = { boardImage : imageUrlFor ( pieces , currentSide ) , boardImageWhite : imageUrlFor ( pieces , "w" ) , boardImageBlack : imageUrlFor ( pieces , "b" ) , } ; return move ? { ... urls , moveImage : imageUrlFor ( pieces , currentSide , move ) , moveImageWhite : imageUrlFor ( pieces , "w" , move ) , moveImageBlack : imageUrlFor ( pieces , "b" , move ) , } : { ... urls , moveImage : urls . boardImage , moveImageWhite : urls . boardImageWhite , moveImageBlack : urls . boardImageBlack , } ; } ; return { draw , imageUrls } ; } ;