diff --git a/javascript/commons/ExportImage.js b/javascript/commons/ExportImage.js index f6f6e19a6df..bfcf35805bf 100644 --- a/javascript/commons/ExportImage.js +++ b/javascript/commons/ExportImage.js @@ -83,7 +83,12 @@ const EXPORT_IMAGE_CONFIG = { typeName: 'Participants' }, { selector: '.standings-ffa', targetSelector: 'tbody', typeName: 'BR/FFA Standings Table' }, - { selector: '.standings-swiss', targetSelector: 'tbody', typeName: 'Swiss Standings Table' }, + { + selector: '.table2.standings-swiss', + targetSelector: 'table.table2__table', + titleSelector: '.table2__title', + typeName: 'Swiss Standings Table' + }, { selector: '.table2#MvpTable', targetSelector: '.table2__container', diff --git a/lua/wikis/commons/Widget/Basic/Label.lua b/lua/wikis/commons/Widget/Basic/Label.lua index 9b34a1682d8..ea8d72a072f 100644 --- a/lua/wikis/commons/Widget/Basic/Label.lua +++ b/lua/wikis/commons/Widget/Basic/Label.lua @@ -13,6 +13,7 @@ local Widget = Lua.import('Module:Widget') local HtmlWidgets = Lua.import('Module:Widget/Html/All') ---@class GenericLabelProps +---@field attributes table? ---@field css table? ---@field children Renderable|Renderable[] ---@field labelScheme string? @@ -31,10 +32,13 @@ function GenericLabel:render() props.css = props.css or {} props.css['--label-scale'] = props.labelScale end + if props.labelType then + props.attributes = props.attributes or {} + props.attributes['data-label-type'] = props.labelType + end + return HtmlWidgets.Div{ - attributes = props.labelType and { - ['data-label-type'] = props.labelType - } or nil, + attributes = props.attributes, classes = { 'generic-label', props.labelScheme and ('label--' .. props.labelScheme) or nil, diff --git a/lua/wikis/commons/Widget/Standings/MatchOverview.lua b/lua/wikis/commons/Widget/Standings/MatchOverview.lua index 6997976ebc4..9f0520cd120 100644 --- a/lua/wikis/commons/Widget/Standings/MatchOverview.lua +++ b/lua/wikis/commons/Widget/Standings/MatchOverview.lua @@ -9,19 +9,26 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') local Class = Lua.import('Module:Class') +local FnUtil = Lua.import('Module:FnUtil') local Widget = Lua.import('Module:Widget') local HtmlWidgets = Lua.import('Module:Widget/Html/All') +local Label = Lua.import('Module:Widget/Basic/Label') +local WidgetUtil = Lua.import('Module:Widget/Util') local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') +---@class MatchOverviewWidgetProps +---@field match MatchGroupUtilMatch +---@field showOpponent integer + ---@class MatchOverviewWidget: Widget ----@operator call(table): MatchOverviewWidget +---@operator call(MatchOverviewWidgetProps): MatchOverviewWidget +---@field props MatchOverviewWidgetProps local MatchOverviewWidget = Class.new(Widget) ---@return Widget? function MatchOverviewWidget:render() - ---@type MatchGroupUtilMatch local match = self.props.match local opponentIndexToShow = tonumber(self.props.showOpponent) if not match or not opponentIndexToShow or #match.opponents ~= 2 then @@ -40,30 +47,73 @@ function MatchOverviewWidget:render() return HtmlWidgets.Div{ css = { - ['display'] = 'flex', + display = 'flex', ['justify-content'] = 'space-between', ['flex-direction'] = 'column', ['align-items'] = 'center', + gap = '0.25rem', }, - children = { - HtmlWidgets.Span{ - children = OpponentDisplay.BlockOpponent{ - opponent = opponentToShow, - overflow = 'ellipsis', - teamStyle = 'icon', - } - }, - HtmlWidgets.Span{ - css = { - ['font-size'] = '0.8em', - }, - children = { - OpponentDisplay.InlineScore(leftOpponent), - ' - ', - OpponentDisplay.InlineScore(opponentToShow), - }, - }, + children = WidgetUtil.collect( + self:_createResultDisplay( + OpponentDisplay.InlineScore(leftOpponent), + OpponentDisplay.InlineScore(opponentToShow) + ), + OpponentDisplay.InlineOpponent{ + opponent = opponentToShow, + overflow = 'ellipsis', + teamStyle = 'icon', + } + ), + } +end + +---@private +---@param self MatchOverviewWidget +---@return string +MatchOverviewWidget._getMatchResultType = FnUtil.memoize(function (self) + local match = self.props.match + local opponentIndexToShow = tonumber(self.props.showOpponent) + + if match.phase == 'ongoing' then + return 'default' + elseif match.winner == opponentIndexToShow then + return 'loss' + elseif match.winner == 0 then + return 'draw' + end + return 'win' +end) + +---@private +---@param leftScore string +---@param rightScore string +---@return Widget[] +function MatchOverviewWidget:_createScoreContainer(leftScore, rightScore) + local resultType = self:_getMatchResultType() + return { + HtmlWidgets.Span{ + css = resultType == 'win' and {['font-weight'] = 'bold'} or nil, + children = leftScore }, + HtmlWidgets.Span{children = ':'}, + HtmlWidgets.Span{ + css = resultType == 'loss' and {['font-weight'] = 'bold'} or nil, + children = rightScore + } + } +end + +---@private +---@return Widget? +function MatchOverviewWidget:_createResultDisplay(leftScore, rightScore) + if self.props.match.phase == 'upcoming' then + return + end + local resultType = self:_getMatchResultType() + return Label{ + labelScheme = 'standings-result', + labelType = 'result-' .. resultType, + children = self:_createScoreContainer(leftScore, rightScore) } end diff --git a/lua/wikis/commons/Widget/Standings/Swiss.lua b/lua/wikis/commons/Widget/Standings/Swiss.lua index 583610bcc9a..30d753d9a1c 100644 --- a/lua/wikis/commons/Widget/Standings/Swiss.lua +++ b/lua/wikis/commons/Widget/Standings/Swiss.lua @@ -13,15 +13,19 @@ local Logic = Lua.import('Module:Logic') local WidgetUtil = Lua.import('Module:Widget/Util') local Widget = Lua.import('Module:Widget') -local HtmlWidgets = Lua.import('Module:Widget/Html/All') -local DataTable = Lua.import('Module:Widget/Basic/DataTable') +local Label = Lua.import('Module:Widget/Basic/Label') local MatchOverview = Lua.import('Module:Widget/Standings/MatchOverview') +local TableWidgets = Lua.import('Module:Widget/Table2/All') local Opponent = Lua.import('Module:Opponent/Custom') local OpponentDisplay = Lua.import('Module:OpponentDisplay/Custom') +---@class StandingsSwissWidgetProps +---@field standings StandingsModel + ---@class StandingsSwissWidget: Widget ----@operator call(table): StandingsSwissWidget +---@operator call(StandingsSwissWidgetProps): StandingsSwissWidget +---@field props StandingsSwissWidgetProps local StandingsSwissWidget = Class.new(Widget) ---@return Widget? @@ -30,116 +34,128 @@ function StandingsSwissWidget:render() return end - ---@type StandingsModel local standings = self.props.standings local lastRound = standings.rounds[#standings.rounds] - return DataTable{ - wrapperClasses = {'standings-swiss'}, - classes = {'wikitable-bordered', 'wikitable-striped'}, + return TableWidgets.Table{ + classes = {'standings-swiss'}, + title = Logic.nilIfEmpty(standings.title), + columns = self:_buildColumnDefinitions(), children = WidgetUtil.collect( - -- Outer header - Logic.isNotEmpty(standings.title) and HtmlWidgets.Tr{children = HtmlWidgets.Th{ - attributes = { - colspan = 100, - }, - children = { - HtmlWidgets.Div{ - css = {['position'] = 'relative'}, - children = { - HtmlWidgets.Span{ - children = standings.title - }, - }, - }, - }, - }} or nil, -- Column Header - HtmlWidgets.Tr{children = WidgetUtil.collect( - HtmlWidgets.Th{children = '#'}, - HtmlWidgets.Th{children = 'Participant'}, - Array.map(standings.tiebreakers, function(tiebreaker) - if not tiebreaker.title then - return - end - return HtmlWidgets.Th{children = tiebreaker.title} - end), - Array.map(standings.rounds, function(round) - return HtmlWidgets.Th{children = round.title} - end) - )}, + self:_headerRow(), -- Rows - Array.map(lastRound.opponents, function(slot) - local positionBackground = slot.positionStatus and ('bg-' .. slot.positionStatus) or nil - local teamBackground - if slot.definitiveStatus then - teamBackground = 'bg-' .. slot.definitiveStatus + TableWidgets.TableBody{children = Array.map(lastRound.opponents, function(slot) + return self:_createRow(slot) + end)} + ) + } +end + +---@private +---@return table[] +function StandingsSwissWidget:_buildColumnDefinitions() + local standings = self.props.standings + return WidgetUtil.collect( + {align = 'left'}, + {align = 'left'}, + Array.map(standings.tiebreakers, function(tiebreaker) + if not tiebreaker.title then + return + end + return {align = 'center'} + end), + Array.map(standings.rounds, function(round) + return {align = 'center'} + end) + ) +end + +---@private +---@return Widget +function StandingsSwissWidget:_headerRow() + local standings = self.props.standings + + ---@param text string? + ---@return Widget + local makeHeaderCell = function(text) + return TableWidgets.CellHeader{children = text} + end + + return TableWidgets.TableHeader{children = { + TableWidgets.Row{children = WidgetUtil.collect( + makeHeaderCell('#'), + makeHeaderCell('Participant'), + Array.map(standings.tiebreakers, function(tiebreaker) + if not tiebreaker.title then + return end - return HtmlWidgets.Tr{ - children = WidgetUtil.collect( - HtmlWidgets.Td{ - children = {slot.placement, '.'}, - css = {['font-weight'] = 'bold'}, - classes = {positionBackground}, - }, - HtmlWidgets.Td{ - classes = {teamBackground}, - children = OpponentDisplay.BlockOpponent{ - opponent = slot.opponent, - overflow = 'ellipsis', - teamStyle = 'hybrid', - showPlayerTeam = true, - } - }, - Array.map(standings.tiebreakers, function(tiebreaker, tiebreakerIndex) - if not tiebreaker.title then - return - end - return HtmlWidgets.Td{ - classes = {teamBackground}, - css = {['font-weight'] = tiebreakerIndex == 1 and 'bold' or nil, ['text-align'] = 'center'}, - children = slot.tiebreakerValues[tiebreaker.id] and slot.tiebreakerValues[tiebreaker.id].display or '' - } - end), - Array.map(standings.rounds, function(columnRound) - local entry = Array.find(columnRound.opponents, function(columnSlot) - return Opponent.same(columnSlot.opponent, slot.opponent) - end) - if not entry then - return HtmlWidgets.Td{} - end - local match = entry.match - if not match then - return HtmlWidgets.Td{} - end - - local opposingOpponentIndex = Array.indexOf(match.opponents, function(opponent) - return not Opponent.same(entry.opponent, opponent) - end) - if not entry.match.opponents[opposingOpponentIndex] then - return HtmlWidgets.Td{} - end - - local bgClassSuffix - if match.finished then - local winner = match.winner - bgClassSuffix = winner == opposingOpponentIndex and 'down' or winner == 0 and 'draw' or 'up' - end - - return HtmlWidgets.Td{ - classes = { - bgClassSuffix and ('bg-' .. bgClassSuffix) or nil, - }, - children = MatchOverview{ - match = match, - showOpponent = opposingOpponentIndex, - }, - } - end) - ), + return makeHeaderCell(tiebreaker.title) + end), + Array.map(standings.rounds, function(round) + return makeHeaderCell(round.title) + end) + )} + }} +end + +---@private +---@param slot StandingsEntryModel +---@return Widget +function StandingsSwissWidget:_createRow(slot) + local standings = self.props.standings + return TableWidgets.Row{ + attributes = {['data-position-status'] = slot.positionStatus}, + children = WidgetUtil.collect( + TableWidgets.Cell{ + children = Label{ + children = slot.placement, + attributes = {['data-placement-type'] = slot.definitiveStatus}, + labelScheme = 'placement', + }, + }, + TableWidgets.Cell{ + children = OpponentDisplay.BlockOpponent{ + opponent = slot.opponent, + overflow = 'ellipsis', + teamStyle = 'hybrid', + showPlayerTeam = true, + } + }, + Array.map(standings.tiebreakers, function(tiebreaker, tiebreakerIndex) + if not tiebreaker.title then + return + end + return TableWidgets.Cell{ + css = {['font-weight'] = tiebreakerIndex == 1 and 'bold' or nil}, + children = slot.tiebreakerValues[tiebreaker.id] and slot.tiebreakerValues[tiebreaker.id].display or '' } + end), + Array.map(standings.rounds, function(columnRound) + local entry = Array.find(columnRound.opponents, function(columnSlot) + return Opponent.same(columnSlot.opponent, slot.opponent) + end) + if not entry then + return TableWidgets.Cell{} + end + local match = entry.match + if not match then + return TableWidgets.Cell{} + end + + local opposingOpponentIndex = Array.indexOf(match.opponents, function(opponent) + return not Opponent.same(entry.opponent, opponent) + end) + if not entry.match.opponents[opposingOpponentIndex] then + return TableWidgets.Cell{} + end + + return TableWidgets.Cell{children = MatchOverview{ + match = match, + showOpponent = opposingOpponentIndex, + }} end) - ) + ), } end diff --git a/stylelint.config.mjs b/stylelint.config.mjs index 9e18b4a72fd..4a07eecaaae 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -12,12 +12,13 @@ export default { "scss/at-rule-no-unknown": true, "color-hex-length": "long", "unit-disallowed-list": null, - "declaration-property-unit-disallowed-list": {}, + "declaration-property-unit-disallowed-list": [], "no-descending-specificity": null, "selector-max-id": null, "no-duplicate-selectors": null, "function-url-no-scheme-relative": null, "declaration-no-important": null, + "function-disallowed-list": [], "function-no-unknown": null, "scss/function-no-unknown": true, "@stylistic/string-quotes": "double" diff --git a/stylesheets/commons/Label.scss b/stylesheets/commons/Label.scss index d2f0ca97369..cf49825c8e3 100644 --- a/stylesheets/commons/Label.scss +++ b/stylesheets/commons/Label.scss @@ -9,7 +9,7 @@ padding: calc( var( --label-scale ) * 0.125rem ) calc( var( --label-scale ) * 0.5rem ); display: inline-flex; align-items: center; - justify-content: center; + place-content: center center; gap: calc( var( --label-scale ) * 0.25rem ); align-self: center; white-space: nowrap; @@ -75,31 +75,127 @@ } } - &[ data-label-type^="result" ]:empty { + &[ data-label-type^="result" ] { height: calc( var( --label-scale ) * 1.25rem ); + gap: unset; + + &:empty { + width: calc( var( --label-scale ) * 1.25rem ); + font-weight: bold; + padding: unset; + + &[ data-label-type="result-win" ]::before { + content: "W"; + } + + &[ data-label-type="result-draw" ]::before { + content: "D"; + } + + &[ data-label-type="result-loss" ]::before { + content: "L"; + } + + &[ data-label-type="result-default" ]::before { + content: "-"; + } + } + } + + &[ data-label-type^="veto" ] { + text-transform: uppercase; + font-weight: bold; + } + + &.label--placement { width: calc( var( --label-scale ) * 1.25rem ); font-weight: bold; - padding: unset; + color: var( --placement-text-color ); + background-color: var( --placement-solid-color ); + + --placement-text-color: var( --clr-secondary-100, #ffffff ); + + .theme--dark & { + --placement-text-color: var( --clr-secondary-9, #181818 ); + } + + &:not( [ data-placement-type ] ) { + --placement-solid-color: var( --clr-on-surface-light-primary-4 ); + --placement-text-color: var( --clr-secondary-25 ); + + .theme--dark & { + --placement-solid-color: var( --clr-on-surface-dark-primary-8 ); + --placement-text-color: var( --clr-secondary-90 ); + } + } + + &[ data-label-type="placement-minimum" ] { + color: var( --placement-solid-color ); + background-color: rgb( from var( --placement-solid-color ) r g b / 0.08 ); + box-shadow: 0 0 0 calc( var( --label-scale ) * 0.0125rem ) var( --placement-solid-color ) inset; + } + + &[ data-placement-type="byeup" ] { + --placement-solid-color: #8046a3; + + .theme--dark & { + --placement-solid-color: #cc9fe7; + } + } + + &[ data-placement-type="seedup" ] { + --placement-solid-color: #006bd4; - &[ data-label-type="result-win" ]::before { - content: "W"; + .theme--dark & { + --placement-solid-color: #a9caeb; + } } - &[ data-label-type="result-draw" ]::before { - content: "D"; + &[ data-placement-type="up" ] { + --placement-solid-color: var( --clr-semantic-positive-30 ); + + .theme--dark & { + --placement-solid-color: #65a765; + } } - &[ data-label-type="result-loss" ]::before { - content: "L"; + &[ data-placement-type="stayup" ] { + --placement-solid-color: #094e09; + + .theme--dark & { + --placement-solid-color: #b6f8b6; + } } - &[ data-label-type="result-default" ]::before { - content: "-"; + &[ data-placement-type="stay" ] { + --placement-solid-color: #966f00; + + .theme--dark & { + --placement-solid-color: #e5c976; + } + } + + &[ data-placement-type="staydown" ] { + --placement-solid-color: #d4400f; + + .theme--dark & { + --placement-solid-color: #f2a288; + } + } + + &[ data-placement-type="down" ] { + --placement-solid-color: var( --clr-semantic-negative-40 ); + + .theme--dark & { + --placement-solid-color: #fc6868; + } } } - &[ data-label-type^="veto" ] { - text-transform: uppercase; - font-weight: bold; + &.label--standings-result { + display: grid; + grid-template-columns: 1fr auto 1fr; + justify-items: center; + padding: 0.25rem; } } diff --git a/stylesheets/commons/Standings.scss b/stylesheets/commons/Standings.scss index 1703250eaa0..d07ca80c248 100644 --- a/stylesheets/commons/Standings.scss +++ b/stylesheets/commons/Standings.scss @@ -1,5 +1,4 @@ -div.standings-ffa, -div.standings-swiss { +div.standings-ffa { & > table > * > tr > th { padding: 12px; } @@ -18,3 +17,75 @@ div.standings-swiss { align-items: center; } } + +.standings-swiss { + tr[ data-position-status ] { + position: relative; + + &::after { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0.25rem; + background-color: var( --placement-row-color ); + } + + &[ data-position-status="byeup" ] { + --placement-row-color: #8046a3; + + .theme--dark & { + --placement-row-color: #cc9fe7; + } + } + + &[ data-position-status="seedup" ] { + --placement-row-color: #006bd4; + + .theme--dark & { + --placement-row-color: #a9caeb; + } + } + + &[ data-position-status="up" ] { + --placement-row-color: var( --clr-semantic-positive-30 ); + + .theme--dark & { + --placement-row-color: #65a765; + } + } + + &[ data-position-status="stayup" ] { + --placement-row-color: #094e09; + + .theme--dark & { + --placement-row-color: #b6f8b6; + } + } + + &[ data-position-status="stay" ] { + --placement-row-color: #966f00; + + .theme--dark & { + --placement-row-color: #e5c976; + } + } + + &[ data-position-status="staydown" ] { + --placement-row-color: #d4400f; + + .theme--dark & { + --placement-row-color: #f2a288; + } + } + + &[ data-position-status="down" ] { + --placement-row-color: var( --clr-semantic-negative-40 ); + + .theme--dark & { + --placement-row-color: #fc6868; + } + } + } +}