From d47d4fc8cc011e298f12c49a9606df19284938c9 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Thu, 24 Jul 2025 19:45:13 +0200 Subject: [PATCH] Beta version of explain page --- .../org/jeudego/pairgoth/api/ApiTools.kt | 5 - .../jeudego/pairgoth/api/ExplainHandler.kt | 68 +++++++++ .../org/jeudego/pairgoth/model/Pairing.kt | 4 +- .../org/jeudego/pairgoth/model/Tournament.kt | 16 +- .../pairgoth/pairing/BasePairingHelper.kt | 6 +- .../jeudego/pairgoth/pairing/HistoryHelper.kt | 5 +- .../org/jeudego/pairgoth/server/ApiServlet.kt | 2 + view-webapp/src/main/sass/explain.scss | 141 ++++++++++++++++++ view-webapp/src/main/sass/main.scss | 2 +- view-webapp/src/main/sass/tour.scss | 4 +- .../src/main/webapp/explain-pairing.html | 0 view-webapp/src/main/webapp/explain.html | 114 ++++++++++++++ .../src/main/webapp/result-sheets.html | 1 + .../src/main/webapp/tour-pairing.inc.html | 5 +- 14 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ExplainHandler.kt create mode 100644 view-webapp/src/main/sass/explain.scss delete mode 100644 view-webapp/src/main/webapp/explain-pairing.html create mode 100644 view-webapp/src/main/webapp/explain.html diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt index 51fdd06..9e8c25d 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt @@ -18,11 +18,6 @@ import kotlin.math.min fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = false): List { - fun Pairable.mmBase(): Double { - if (pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon") - return min(max(rank, pairing.mmFloor), pairing.mmBar) + MacMahonSolver.mmsZero + mmsCorrection - } - if (frozen != null) { return ArrayList(frozen!!.map { it -> it as Json.Object }) } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ExplainHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ExplainHandler.kt new file mode 100644 index 0000000..a851105 --- /dev/null +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ExplainHandler.kt @@ -0,0 +1,68 @@ +package org.jeudego.pairgoth.api + +import com.republicate.kson.Json +import com.republicate.kson.toJsonArray +import com.republicate.kson.toJsonObject +import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.model.toJson +import org.jeudego.pairgoth.pairing.solver.CollectingListener +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +object ExplainHandler: PairgothApiHandler { + + override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? { + val tournament = getTournament(request) + val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") + if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played") + val paired = tournament.games(round).values.flatMap { + listOf(it.black, it.white) + }.filter { + it != 0 + }.map { + tournament.pairables[it] ?: throw Error("Unknown pairable ID: $it") + } + val games = tournament.games(round).map { it.value }.toList() + // build the scores map by redoing the whole pairing + tournament.unpair(round) + val history = tournament.historyHelper(round) + val weightsCollector = CollectingListener() + tournament.pair(round, paired, false, weightsCollector) + val weights = weightsCollector.out + // Since weights are generally in two groups towards the min and the max, + // compute the max of the low group ("low") and the min of the high group ("high") + // to improve coloring. + // Total weights axis: + // ----[min]xxxx[low]----[middle]----[high]xxxx[max]----> + val min = weights.values.minOfOrNull { it.values.sum() } ?: 0.0 + val max = weights.values.maxOfOrNull { it.values.sum() } ?: 0.0 + val middle = (max - min) / 2.0 + val low = weights.values.map { it.values.sum() }.filter { it < middle }.maxOrNull() ?: middle + val high = weights.values.map { it.values.sum() }.filter { it > middle }.minOrNull() ?: middle + val ret = Json.Object( + "paired" to paired.sortedByDescending { 1000 * (history.scores[it.id] ?: 0.0) + (history.sos[it.id] ?: 0.0) }.map { + it.toMutableJson().apply { + put("score", history.scores[it.id]) + put("wins", history.wins[it.id]) + put("sos", history.sos[it.id]) + put("dudd", history.drawnUpDown[it.id]) + } + }.toJsonArray(), + // "games" to games.map { it.toJson() }.toJsonArray(), + "games" to games.associateBy { "${it.white}-${it.black}" }.mapValues { it.value.toJson() }.toJsonObject(), + "weights" to weights.entries.map { (key, value) -> + Pair( + "${key.first}-${key.second}", + value.also { + it.put("total", it.values.sum()) + } + ) + }.toJsonObject(), + "min" to min, + "low" to low, + "high" to high, + "max" to max + ) + return ret + } +} diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt index f887c42..968b3d2 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt @@ -133,8 +133,8 @@ sealed class Pairing( val pairingParams: PairingParams, val placementParams: PlacementParams) { companion object {} - abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List): Solver - fun pair(tournament: Tournament<*>, round: Int, pairables: List, legacyMode: Boolean = false, listener: PairingListener? = null): List { + internal abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List): Solver + internal fun pair(tournament: Tournament<*>, round: Int, pairables: List, legacyMode: Boolean = false, listener: PairingListener? = null): List { return solver(tournament, round, pairables) .also { solver -> solver.legacyMode = legacyMode diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt index efee97e..a8fdaef 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt @@ -100,14 +100,17 @@ sealed class Tournament ( fun lastRound() = max(1, games.size) + /** + * Recompute DUDD for a specific game + */ fun recomputeDUDD(round: Int, gameID: ID) { // Instantiate solver with game history - val solver = pairing.solver(this, round, pairables.values.toList()) + val solver = pairing.solver(this, round, emptyList()) // Recomputes DUDD and hd val game = games(round)[gameID]!! - val white = solver.pairables.find { p-> p.id == game.white }!! - val black = solver.pairables.find { p-> p.id == game.black }!! + val white = pairables[game.white]!! + val black = pairables[game.black]!! game.drawnUpDown = solver.dudd(black, white) game.handicap = solver.hd(white = white, black = black) } @@ -119,17 +122,16 @@ sealed class Tournament ( fun recomputeDUDD(round: Int) { if (pairables.isEmpty() || games(1).isEmpty()) return; // Instantiate solver with game history - val solver = pairing.solver(this, round, pairables.values.toList()) + val solver = pairing.solver(this, round, emptyList()) for (game in games(round).values) { if (game.black != 0 && game.white != 0) { - val white = solver.pairables.find { p-> p.id == game.white }!! - val black = solver.pairables.find { p-> p.id == game.black }!! + val white = pairables[game.white]!! + val black = pairables[game.black]!! game.drawnUpDown = solver.dudd(black, white) } } } - /** * Recompute DUDD for all rounds */ diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/BasePairingHelper.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/BasePairingHelper.kt index 3b829fa..f0f97cd 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/BasePairingHelper.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/BasePairingHelper.kt @@ -78,17 +78,17 @@ abstract class BasePairingHelper( protected fun Pairable.played(other: Pairable) = history.playedTogether(this, other) // color balance (nw - nb) - protected val Pairable.colorBalance: Int get() = history.colorBalance(this) ?: 0 + protected val Pairable.colorBalance: Int get() = history.colorBalance[id] ?: 0 protected val Pairable.group: Int get() = _groups[id]!! - protected val Pairable.drawnUpDown: Pair get() = history.drawnUpDown(this) ?: Pair(0, 0) + protected val Pairable.drawnUpDown: Pair get() = history.drawnUpDown[id] ?: Pair(0, 0) protected val Pairable.nbBye: Int get() = history.nbPlayedWithBye(this) ?: 0 val Pairable.score: Double get() = history.scores[id] ?: 0.0 val Pairable.scoreX: Double get() = history.scoresX[id] ?: 0.0 - val Pairable.nbW: Double get() = history.nbW(this) ?: 0.0 + val Pairable.nbW: Double get() = history.wins[id] ?: 0.0 val Pairable.sos: Double get() = history.sos[id] ?: 0.0 val Pairable.sosm1: Double get() = history.sosm1[id] ?: 0.0 val Pairable.sosm2: Double get() = history.sosm2[id] ?: 0.0 diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/HistoryHelper.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/HistoryHelper.kt index 2cbb595..1531bf0 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/HistoryHelper.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/HistoryHelper.kt @@ -33,9 +33,6 @@ open class HistoryHelper( open fun playedTogether(p1: Pairable, p2: Pairable) = paired.contains(Pair(p1.id, p2.id)) open fun colorBalance(p: Pairable) = colorBalance[p.id] open fun nbPlayedWithBye(p: Pairable) = nbPlayedWithBye[p.id] - open fun nbW(p: Pairable) = wins[p.id] - - fun drawnUpDown(p: Pairable) = drawnUpDown[p.id] protected val paired: Set> by lazy { (history.flatten().map { game -> @@ -47,7 +44,7 @@ open class HistoryHelper( // Returns the number of games played as white minus the number of games played as black // Only count games without handicap - private val colorBalance: Map by lazy { + val colorBalance: Map by lazy { history.flatten().filter { game -> game.handicap == 0 }.filter { game -> diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt index 1bd1a0c..00d5232 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt @@ -5,6 +5,7 @@ import com.github.benmanes.caffeine.cache.Caffeine import com.republicate.kson.Json import org.apache.commons.io.input.BOMInputStream import org.jeudego.pairgoth.api.ApiHandler +import org.jeudego.pairgoth.api.ExplainHandler import org.jeudego.pairgoth.api.PairingHandler import org.jeudego.pairgoth.api.PlayerHandler import org.jeudego.pairgoth.api.ResultsHandler @@ -99,6 +100,7 @@ class ApiServlet: HttpServlet() { if ("token" == selector) TokenHandler else when (subEntity) { null -> TournamentHandler + "explain" -> ExplainHandler "part" -> PlayerHandler "pair" -> PairingHandler "res" -> ResultsHandler diff --git a/view-webapp/src/main/sass/explain.scss b/view-webapp/src/main/sass/explain.scss new file mode 100644 index 0000000..0c91fc4 --- /dev/null +++ b/view-webapp/src/main/sass/explain.scss @@ -0,0 +1,141 @@ +@layer pairgoth { + /* explain section */ + + #pairing-table-wrapper { + padding: 8em 2em 1em 2em; + } + #pairing-table { + border-collapse: collapse; + thead { + //border-collapse: collapse; + tr { + &:first-child { + min-height: 8rem; + th:first-child { + background: linear-gradient(to right top, #ffffff 0%,#ffffff 49.9%,#000000 50%,#000000 51%,#ffffff 51.1%,#ffffff 100%); + position: relative; + min-width: 8rem; + label.top-right { + position: absolute; + top: 0; + right: 0; + } + label.bottom-left { + position: absolute; + bottom: 0; + left: 0; + } + } + th:not(:first-child) { + z-index: 5; + width: 55px; + height: 140px; + white-space: nowrap; + padding-bottom: 10px; + position: relative; + > div { + transform: translate(35px, 51px) rotate(315deg); + width: 30px; + &:hover { + cursor: pointer; + > span { + background-color: rgba(0, 0, 255, 0.2) !important; + } + } + > span { + border-bottom: 1px solid gray; + padding: 5px 10px; + } + } + > pre { + display: none; + font-size: smaller; + line-height: 1em; + text-align: left; + position: absolute; + pointer-events: none; + top: 100%; + left: 100%; + background-color: rgba(255, 255, 255, 0.7); + } + &:hover { + cursor: pointer; + > pre { + display: block; + z-index: 10; + } + } + } + } + } + } + tbody { + tr { + th { + text-align: left; + padding-right: 1em; + position: relative; + > pre { + display: none; + font-size: smaller; + line-height: 1em; + text-align: left; + position: absolute; + pointer-events: none; + top: 100%; + left: 100%; + background-color: rgba(255, 255, 255, 0.7); + } + &:hover { + cursor: pointer; + background-color: rgba(0, 0, 255, 0.2) !important; + pre { + display: block; + z-index: 10; + } + } + } + td { + height: 55px; + border: solid 1px gray; + position: relative; + &.game::after { + position: absolute; + content: "X"; + color: blue; + text-align: center; + top: 50%; + transform: translateY(-50%); + bottom: 0; + left: 0; + right: 0; + } + .weights { + display: none; + font-size: smaller; + line-height: 1em; + text-align: left; + position: absolute; + pointer-events: none; + top: 100%; + left: 100%; + background-color: rgba(255, 255, 255, 0.7); + } + &:hover { + background-color: rgba(0, 0, 255, 0.7) !important; + cursor: pointer; + .weights { + display: block; + z-index: 10; + } + } + } + } + } + } + + #captures { + padding: 1em; + } +} + diff --git a/view-webapp/src/main/sass/main.scss b/view-webapp/src/main/sass/main.scss index fa9baea..9f61a22 100644 --- a/view-webapp/src/main/sass/main.scss +++ b/view-webapp/src/main/sass/main.scss @@ -570,7 +570,7 @@ } /* TODO - plenty of those elements could just use the .noprint class */ - #header, #logo, #lang, .steps, #filter-box, #reglist-mode, #footer, #unpairables, #pairing-buttons, button, #standings-params, #logout, .pairing-stats, .result-sheets, .toggle, #overview, .tables-exclusion, .button, .noprint { + #header, #logo, #lang, .steps, #filter-box, #reglist-mode, #footer, #unpairables, #pairing-buttons, button, #standings-params, #logout, .pairing-stats, .pairing-post-actions, .toggle, #overview, .tables-exclusion, .button, .noprint { display: none !important; } diff --git a/view-webapp/src/main/sass/tour.scss b/view-webapp/src/main/sass/tour.scss index 031c53c..e0f3ec0 100644 --- a/view-webapp/src/main/sass/tour.scss +++ b/view-webapp/src/main/sass/tour.scss @@ -418,8 +418,10 @@ padding: 0.2em 0.8em; } - .result-sheets { + .pairing-post-actions { margin-top: 0.2em; + display: flex; + justify-content: space-around; } .bottom-pairing-actions { diff --git a/view-webapp/src/main/webapp/explain-pairing.html b/view-webapp/src/main/webapp/explain-pairing.html deleted file mode 100644 index e69de29..0000000 diff --git a/view-webapp/src/main/webapp/explain.html b/view-webapp/src/main/webapp/explain.html new file mode 100644 index 0000000..b188ca9 --- /dev/null +++ b/view-webapp/src/main/webapp/explain.html @@ -0,0 +1,114 @@ +#macro(rank $rank)#if( $rank<0 )#set( $k = -$rank )${k}k#else#set( $d=$rank+1 )${d}d#end#end +#if (!$tour) +
+

Invalid tournament id

+
+#stop +#end +#set($round = $math.toInteger($!params.round)) +#if(!$round) + #set($round = 1) +#else + #set($round = $math.min($math.max($round, 1), $tour.rounds)) +#end +#if($tour.type == 'INDIVIDUAL' || $tour.type.startsWith('TEAM')) + #set($parts = $api.get("tour/${params.id}/part")) +#else + #set($parts = $api.get("tour/${params.id}/team")) +#end +#set($pmap = $utils.toMap($parts)) +#set($roundPairing = $api.get("tour/${params.id}/pair/$round")) +#if($roundPairing.error) + + #stop +#end +#set($explain = $api.get("tour/${params.id}/explain/$round")) +
+ + + + +#foreach($white in $explain.paired) + +#end + + + +#foreach($black in $explain.paired) + + + #foreach($white in $explain.paired) + #if($white.id != $black.id) + #set($key = "$white.id-$black.id") + #set($weights = $explain.weights[$key]) + #set($toMax = $explain.max - $weights.total) + #set($toMin = $weights.total - $explain.min) + #if ($toMax > $toMin) + ## total is close to min + #set($percent = ($weights.total - $explain.min) / ($explain.low - $explain.min) * 40) + #else + ## total is close to max + #set($percent = 60 + ($explain.max - $weights.total) / ($explain.max - $explain.high) * 40) + #end + + #set($game = $explain.games[$key]) + + #else + + #end + #end + +#end + +
+ + + +
+ + $white.name $white.firstname + +
+
$white.toPrettyString()
+
+
+ + $black.name $black.firstname + +
+
$black.toPrettyString()
+
+
+
$weights.toPrettyString()
+
+
+
+
+ \ No newline at end of file diff --git a/view-webapp/src/main/webapp/result-sheets.html b/view-webapp/src/main/webapp/result-sheets.html index c8aeb4a..966c119 100644 --- a/view-webapp/src/main/webapp/result-sheets.html +++ b/view-webapp/src/main/webapp/result-sheets.html @@ -3,6 +3,7 @@

Invalid tournament id

+#stop #end #set($round = $math.toInteger($!params.round)) #if(!$round) diff --git a/view-webapp/src/main/webapp/tour-pairing.inc.html b/view-webapp/src/main/webapp/tour-pairing.inc.html index 4fb2aac..51a3dd8 100644 --- a/view-webapp/src/main/webapp/tour-pairing.inc.html +++ b/view-webapp/src/main/webapp/tour-pairing.inc.html @@ -83,7 +83,10 @@ #end #if(!$tour.type.startsWith('TEAM')) - +
+ result sheets + explain pairing +
#end