dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

commit 5361fca930a87d24162ae6bb7589e599d4533bf0
parent accf8cd49bda8837ab3c5fb7b55da3caf6864c97
Author: n0tr1v <n0tr1v@protonmail.com>
Date:   Wed, 14 Jun 2023 22:47:25 -0700

improve game stats viewing ux

Diffstat:
Mpkg/web/handlers/chess.go | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpkg/web/handlers/interceptors/chess.go | 56++++----------------------------------------------------
Mpkg/web/middlewares/middlewares.go | 1+
Mpkg/web/web.go | 1+
4 files changed, 171 insertions(+), 73 deletions(-)

diff --git a/pkg/web/handlers/chess.go b/pkg/web/handlers/chess.go @@ -243,6 +243,132 @@ func ChessGameAnalyseHandler(c echo.Context) error { return c.String(http.StatusOK, "done") } +func ChessGameStatsHandler(c echo.Context) error { + key := c.Param("key") + g, err := interceptors.ChessInstance.GetGame(key) + if err != nil { + return c.NoContent(http.StatusOK) + } + htmlTmpl := cssReset + ` +<style> +.graph { + border: 0px solid #000; + background-color: #666; + box-sizing: border-box; +} +.graph tr { height: 240px; } +.graph td { + height: inherit; + border-right: 0px solid #555; +} +.graph td:hover { + background-color: #5c5c5c; +} +.graph .column-wrapper-wrapper { + height: 100%; + width: {{ .ColumnWidth }}px; + position: relative; +} +.graph .column-wrapper { + height: 50%; + width: {{ .ColumnWidth }}px; + position: relative; +} +.graph .column { + position: absolute; + width: {{ .ColumnWidth }}px; + box-sizing: border-box; + border-right: 1px solid #555; +} +</style> +<table class="graph"> + <tr> + {{ range $idx, $el := .Stats.Scores }} + <td title="{{ $idx | fmtMove }} {{ $el.Move }} | Advantage: {{ if not $el.Mate }}{{ $el.CP | cp }}{{ else }}#{{ $el.Mate }}{{ end }}"> + {{ $el.BestMove | commentHTML }} + <a class="column-wrapper-wrapper" href="/chess/{{ $.Key }}/stats?m={{ $idx | plus }}" style="display: block;{{ if eq $.MoveIdx ($idx | plus) }} background-color: rgba(255, 255, 0, 0.2);{{ end }}"> + <div class="column-wrapper"> + {{ if ge .CP 0 }} + <div class="column" style="height: {{ $el | renderCP "white" }}px; background-color: #eee; bottom: 0;"></div> + {{ end }} + </div> + <div class="column-wrapper"> + {{ if le .CP 0 }} + <div class="column" style="height: {{ $el | renderCP "black" }}px; background-color: #111;"></div> + {{ end }} + </div> + </div> + </td> + {{ end }} + </tr> +</table>` + + moveIdx, _ := strconv.Atoi(c.QueryParam("m")) + interceptors.ChessPubSub.Pub(key, interceptors.ChessMove{MoveIdx: moveIdx}) + const graphWidth = 800 + var columnWidth = 1 + var stats *interceptors.AnalyseResult + _ = json.Unmarshal(g.DbChessGame.Stats, &stats) + if stats != nil { + if len(stats.Scores) > 0 { + columnWidth = utils.MaxInt(graphWidth/len(stats.Scores), 1) + } + } + + data := map[string]any{ + "Key": key, + "IsAnalysed": g.DbChessGame.AccuracyWhite != 0 && g.DbChessGame.AccuracyBlack != 0, + "WhiteAccuracy": g.DbChessGame.AccuracyWhite, + "BlackAccuracy": g.DbChessGame.AccuracyBlack, + "Stats": stats, + "ColumnWidth": columnWidth, + "MoveIdx": moveIdx, + } + + fns := template.FuncMap{ + "commentHTML": func(s string) template.HTML { + return template.HTML(fmt.Sprintf("<!-- %s -->", s)) + }, + "attr": func(s string) template.HTMLAttr { + return template.HTMLAttr(s) + }, + "plus": func(v int) int { return v + 1 }, + "pct": func(v float64) string { + return fmt.Sprintf("%.1f%%", v) + }, + "abs": func(v int) int { return int(math.Abs(float64(v))) }, + "cp": func(v int) string { + return fmt.Sprintf("%.2f", float64(v)/100) + }, + "fmtMove": func(idx int) string { + idx += 2 + if idx%2 == 0 { + return fmt.Sprintf("%d.", idx/2) + } + return fmt.Sprintf("%d...", idx/2) + }, + "renderCP": func(color string, v interceptors.Score) int { + const maxH = 120 // Max graph height + const maxV = 1200 // Max cp value. Anything bigger should take 100% of graph height space. + absV := int(math.Abs(float64(v.CP))) + absV = utils.MinInt(absV, maxV) + absV = absV * maxH / maxV + if v.CP == 0 && v.Mate != 0 { + if (color == "white" && v.Mate < 0) || (color == "black" && v.Mate > 0) { + absV = maxH + } + } + return absV + }, + } + + var buf1 bytes.Buffer + if err := utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data); err != nil { + logrus.Error(err) + } + return c.HTML(http.StatusOK, buf1.String()) +} + func ChessGameFormHandler(c echo.Context) error { key := c.Param("key") csrf, _ := c.Get("csrf").(string) @@ -356,6 +482,16 @@ func ChessGameFormHandler(c echo.Context) error { return c.HTML(http.StatusOK, buf.String()) } +func squareCoord(sq chess.Square, isFlipped bool) (int, int) { + x, y := int(sq.File()), int(sq.Rank()) + if isFlipped { + x = 7 - x + } else { + y = 7 - y + } + return x, y +} + func ChessGameHandler(c echo.Context) error { debugChess := true @@ -404,17 +540,8 @@ func ChessGameHandler(c echo.Context) error { _, _ = c.Response().Write([]byte(s)) } - // If you are not a spectator, and it's your turn to play, we just render the form directly. - if game.Outcome() != chess.NoOutcome { - c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8) - c.Response().WriteHeader(http.StatusOK) - send(cssReset) - send(`<style>html, body { background-color: #222; }</style>`) - m, _ := strconv.Atoi(c.QueryParam("m")) - card := g.DrawPlayerCard(m, key, isFlipped, false, authUser.ChessSoundsEnabled, authUser.IsAdmin) - send(card) - return nil - } + // Keep track of "if the game was over" when we loaded the page + gameLoadedOver := game.Outcome() != chess.NoOutcome quit := hutils.CloseSignalChan(c) @@ -485,7 +612,8 @@ Loop: default: } - if game.Outcome() != chess.NoOutcome { + // If we loaded the page and game was ongoing, we will stop the infinite loading page and display pgn + if game.Outcome() != chess.NoOutcome && !gameLoadedOver { send(`<style>#outcome:after { content: "` + game.Outcome().String() + `" }</style>`) send(`<style>.gameover { display: none !important; }</style>`) send(`<textarea readonly style="position: absolute; width: 200px; left: calc(50% - 100px); bottom: 20px">` + game.String() + `</textarea>`) @@ -500,6 +628,29 @@ Loop: continue } + // If game was over when we loaded the page + if game.Outcome() != chess.NoOutcome && gameLoadedOver { + if payload.MoveIdx != 0 { + var styles StylesBuilder + styles.Append(`.img { display: none !important; }`) + pos := game.Positions()[payload.MoveIdx] + moves := game.Moves()[:payload.MoveIdx] + piecesCache := interceptors.InitPiecesCache(moves) + for i := 0; i < 64; i++ { + sq := chess.Square(i) + if pos.Board().Piece(sq) != chess.NoPiece { + sqID := piecesCache[sq] + x1, y1 := squareCoord(sq, isFlipped) + styles.Appendf(`#%s { display: block !important; left: calc(%d*12.5%%) !important; top: calc(%d*12.5%%) !important; }`, + sqID, x1, y1) + } + } + send(styles.Build()) + c.Response().Flush() + } + continue + } + if authUser.ChessSoundsEnabled { if game.Method() != chess.Resignation { isCapture := payload.Move.HasTag(chess.Capture) || payload.Move.HasTag(chess.EnPassant) @@ -512,15 +663,8 @@ Loop: const animationMs = 400 animate := func(s1, s2 chess.Square, id string) { - x1, y1 := int(s1.File()), int(s1.Rank()) - x2, y2 := int(s2.File()), int(s2.Rank()) - if isFlipped { - x1 = 7 - x1 - x2 = 7 - x2 - } else { - y1 = 7 - y1 - y2 = 7 - y2 - } + x1, y1 := squareCoord(s1, isFlipped) + x2, y2 := squareCoord(s2, isFlipped) animationIdx++ animationName := fmt.Sprintf("move_anim_%d", animationIdx) keyframes := "@keyframes %s {" + diff --git a/pkg/web/handlers/interceptors/chess.go b/pkg/web/handlers/interceptors/chess.go @@ -35,6 +35,7 @@ type ChessMove struct { EnPassant string CheckIDStr string Move chess.Move + MoveIdx int } var ChessPubSub = pubsub.NewPubSub[ChessMove]() @@ -79,7 +80,7 @@ func newChessGame(gameKey string, player1, player2 database.User, dbChessGame *d g.lastUpdated = time.Now() g.Player1 = newChessPlayer(player1) g.Player2 = newChessPlayer(player2) - g.piecesCache = initPiecesCache(g.Game.Moves()) + g.piecesCache = InitPiecesCache(g.Game.Moves()) return g } @@ -338,35 +339,6 @@ func (g *ChessGame) drawPlayerCard(moveIdx int, key string, isBlack, isSpectator #black-advantage .score:after { content: "{{ .BlackScore }}"; } #outcome:after { content: "{{ .Outcome }}"; } .score { font-size: 11px; } - -.graph { - border: 3px solid #000; - background-color: #666; -} -.graph tr { height: 240px; } -.graph td { - height: inherit; - border-right: 0px solid #555; -} -.graph td:hover { - background-color: #5c5c5c; -} -.graph .column-wrapper-wrapper { - height: 100%; - width: {{ .ColumnWidth }}px; - position: relative; -} -.graph .column-wrapper { - height: 50%; - width: {{ .ColumnWidth }}px; - position: relative; -} -.graph .column { - position: absolute; - width: {{ .ColumnWidth }}px; - box-sizing: border-box; - border-right: 1px solid #555; -} </style> <table style="width: 100%; height: 100%;"> <tr> @@ -435,31 +407,11 @@ func (g *ChessGame) drawPlayerCard(moveIdx int, key string, isBlack, isSpectator </table> {{ if .Stats }} + <iframe src="/chess/{{ .Key }}/stats" style="width: 800px; height: 240px; margin: 10px 0; border: 3px solid black;"></iframe> {{ if .IsAnalysed }} <div style="color: #eee;">White accuracy: <span id="white-accuracy">{{ .WhiteAccuracy | pct }}</span></div> <div style="color: #eee;">Black accuracy: <span id="black-accuracy">{{ .BlackAccuracy | pct }}</span></div> {{ end }} - <table class="graph"> - <tr> - {{ range $idx, $el := .Stats.Scores }} - <td title="{{ $idx | fmtMove }} {{ $el.Move }} | Advantage: {{ if not $el.Mate }}{{ $el.CP | cp }}{{ else }}#{{ $el.Mate }}{{ end }}"> - {{ $el.BestMove | commentHTML }} - <a class="column-wrapper-wrapper" href="/chess/{{ $.Key }}?m={{ $idx | plus }}" style="display: block;{{ if eq $.MoveIdx ($idx | plus) }} background-color: rgba(255, 255, 0, 0.2);{{ end }}"> - <div class="column-wrapper"> - {{ if ge .CP 0 }} - <div class="column" style="height: {{ $el | renderCP "white" }}px; background-color: #eee; bottom: 0;"></div> - {{ end }} - </div> - <div class="column-wrapper"> - {{ if le .CP 0 }} - <div class="column" style="height: {{ $el | renderCP "black" }}px; background-color: #111;"></div> - {{ end }} - </div> - </div> - </td> - {{ end }} - </tr> - </table> {{ end }} </td> </tr> @@ -776,7 +728,7 @@ const ( BlackQueenSideRookID = "piece_a8" ) -func initPiecesCache(moves []*chess.Move) map[chess.Square]string { +func InitPiecesCache(moves []*chess.Move) map[chess.Square]string { piecesCache := make(map[chess.Square]string) game := chess.NewGame() pos := game.Position() diff --git a/pkg/web/middlewares/middlewares.go b/pkg/web/middlewares/middlewares.go @@ -273,6 +273,7 @@ func IsAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { // Prevent clickjacking by setting the header on every logged in page if !strings.Contains(c.Path(), "/chess/:key/form") && + !strings.Contains(c.Path(), "/chess/:key/stats") && !strings.Contains(c.Path(), "/api/v1/chat/messages") && !strings.Contains(c.Path(), "/api/v1/chat/messages/:roomName/stream") && !strings.Contains(c.Path(), "/api/v1/chat/top-bar") && diff --git a/pkg/web/web.go b/pkg/web/web.go @@ -105,6 +105,7 @@ func getMainServer(db *database.DkfDB, i18nBundle *i18n.Bundle, renderer *tmp.Te authGroup.GET("/chess/:key/analyse", handlers.ChessGameAnalyseHandler) authGroup.GET("/chess/:key/form", handlers.ChessGameFormHandler) authGroup.POST("/chess/:key/form", handlers.ChessGameFormHandler) + authGroup.GET("/chess/:key/stats", handlers.ChessGameStatsHandler) authGroup.GET("/settings/chat", handlers.SettingsChatHandler) authGroup.POST("/settings/chat", handlers.SettingsChatHandler, middlewares.AuthRateLimitMiddleware(2*time.Second, 1)) authGroup.GET("/settings/chat/pm", handlers.SettingsChatPMHandler)