commit 5361fca930a87d24162ae6bb7589e599d4533bf0
parent accf8cd49bda8837ab3c5fb7b55da3caf6864c97
Author: n0tr1v <n0tr1v@protonmail.com>
Date: Wed, 14 Jun 2023 22:47:25 -0700
improve game stats viewing ux
Diffstat:
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)