Jump to content

Module:Build bracket/Render

Permanently protected module
From Wikipedia, the free encyclopedia
local Render = {}

-- ================================
-- 1) MODULE STATE & BIND INJECTS
-- ================================
-- Upvalues bound in buildTable(...)
local state, config, Helpers, StateChecks

-- Local stdlib aliases
local t_insert = table.insert

-- Helper/function locals (set during bind)
local isempty, notempty, cellBorder, unboldParenthetical
local showSeeds, teamLegs, roundIsEmpty, defaultHeaderText, isBlankEntry

-- =========================
-- 2) CORE <td> CONSTRUCTOR
-- =========================
local function Cell(tbl, j, i, opts)
    opts = opts or {}
    local cell = tbl:tag("td")

    -- classes/attributes
    if opts.classes then
        for _, c in ipairs(opts.classes) do
            cell:addClass(c)
        end
    end
    if opts.colspan and opts.colspan ~= 1 then
        cell:attr("colspan", opts.colspan)
    end
    if opts.rowspan and opts.rowspan ~= 1 then
        cell:attr("rowspan", opts.rowspan)
    end

    -- styling
    if opts.borderWidth then
        cell:css("border-width", cellBorder(opts.borderWidth))
    end
    if opts.weight == "bold" then
        cell:css("font-weight", "bold")
    end
    if opts.bg then
        cell:css("background", opts.bg)
    end
    if opts.color then
        cell:css("color", opts.color)
    end

    -- alignment helpers
    if opts.align == "center" then
        cell:addClass("brk-center")
    elseif opts.align == "right" then
        cell:css("text-align", "right")
    end

    if opts.text then
        cell:wikitext(opts.text)
    end
    return cell
end

-- =====================================
-- 3) ENTRY SIZING (computed per-round)
-- =====================================
local entryColspan = nil
local function getEntryColspan(j)
    return entryColspan and entryColspan[j] or 1
end

-- ==========================
-- 4) TEAM / SCORE CELLS
-- ==========================
local function teamCell(tbl, k, j, i, l, colspan)
    local classes = {"brk-td", "brk-b", (k == "seed") and "brk-bgD" or "brk-bgL"}
    if k == "seed" or k == "score" then
        classes[#classes + 1] = "brk-center"
    end

    -- strict bolding
    local weightFlag
    if k == "team" then
        if state.entries[j][i].weight == "bold" then
            weightFlag = "bold"
        end
    elseif k == "score" and l ~= nil then
        local sc = state.entries[j][i].score
        if sc and sc.weight and sc.weight[l] == "bold" then
            weightFlag = "bold"
        end
    end

    local legs = teamLegs(j, i)
    local opts = {
        classes = classes,
        colspan = colspan,
        rowspan = 2,
        borderWidth = {0, 0, 1, 1},
        weight = weightFlag -- 'bold' or nil
    }

    -- borders
    if k == "team" and legs == 0 then
        opts.borderWidth[2] = 1
    end
    if state.entries[j][i].position == "top" then
        opts.borderWidth[1] = 1
    end
    if l == legs or l == "agg" or k == "seed" then
        opts.borderWidth[2] = 1
    end

    -- text
    local function tostr(x)
        return (x == nil) and "" or tostring(x)
    end
    if l == nil then
        opts.text = unboldParenthetical(tostr(state.entries[j][i][k]))
    else
        local v = state.entries[j][i][k] and state.entries[j][i][k][l]
        opts.text = tostr(v)
    end

    -- ensure seeds inherit team bold without affecting score logic
    if k == "seed" and state.entries[j][i] and state.entries[j][i].weight == "bold" then
        opts.weight = opts.weight or "bold"
    end
    return Cell(tbl, j, i, opts)
end

-- ======================================
-- 5) NIL/BLANK ENTRY HANDLING PER CELL
-- ======================================
local function handleEmptyOrNilEntry(tbl, j, i, R)
    local entry_colspan = getEntryColspan(j)
    local col = state.entries[j] or {}

    -- nil entry: optionally emit spanning blank to keep grid intact
    if col[i] == nil then
        if col[i - 1] ~= nil or i == 1 then
            local rowspan, row = 0, i
            repeat
                rowspan = rowspan + 1
                row = row + 1
            until col[row] ~= nil or row > R
            Cell(tbl, j, i, {rowspan = rowspan, colspan = entry_colspan})
            return true
        else
            return true -- intentionally omitted cell
        end
    end

    if col[i]["ctype"] == "blank" then
        return true
    end
    return false
end

-- ============================
-- 6) ENTRY INSERTORS (ctype)
-- ============================
-- 6.1 Header
local function insertHeader(tbl, j, i, entry)
    local byesJ = state.byes[j]
    local hideJ = state.hide[j]
    local entry_colspan = getEntryColspan(j)

    if (byesJ and byesJ[entry.headerindex] and roundIsEmpty(j, i)) or (hideJ and hideJ[entry.headerindex]) then
        return Cell(tbl, j, i, {rowspan = 2, colspan = entry_colspan})
    end

    if isempty(entry.header) then
        entry.header = defaultHeaderText(j, entry.headerindex)
    end

    local classes = {"brk-td", "brk-b", "brk-center"}
    local useCustomShade = entry.shade_is_rd and not isempty(entry.shade)
    if not useCustomShade then
        t_insert(classes, "brk-bgD")
    end

    local cellOpts = {
        rowspan = 2,
        colspan = entry_colspan,
        text = entry.header,
        classes = classes,
        borderWidth = {1, 1, 1, 1}
    }
    if useCustomShade then
        cellOpts.bg = entry.shade
    end
    return Cell(tbl, j, i, cellOpts)
end

-- 6.2 Team (+seed/+scores/+agg)
local function insertTeam(tbl, j, i, entry)
    local byesJ = state.byes[j]
    local hideJ = state.hide[j]
    local entry_colspan = getEntryColspan(j)
    local maxlegs = state.maxlegs[j] or 1
    local legs = teamLegs(j, i)
    local team_colspan = maxlegs - legs + 1

    -- bye/hidden → reserve footprint
    if ((byesJ and byesJ[entry.headerindex]) and isBlankEntry(j, i)) or (hideJ and hideJ[entry.headerindex]) then
        return Cell(tbl, j, i, {rowspan = 2, colspan = entry_colspan})
    end

    if config.aggregate and legs == 1 and maxlegs > 1 then
        team_colspan = team_colspan + 1
    end
    if maxlegs == 0 then
        team_colspan = team_colspan + 1
    end

    -- seed
    if config.seeds then
        if showSeeds(j, i) == true then
            teamCell(tbl, "seed", j, i)
        else
            team_colspan = team_colspan + 1
        end
    end

    -- team name
    teamCell(tbl, "team", j, i, nil, team_colspan)

    -- scores
    for l = 1, legs do
        teamCell(tbl, "score", j, i, l)
    end

    -- aggregate
    if config.aggregate and legs > 1 then
        teamCell(tbl, "score", j, i, "agg")
    end
end

-- 6.3 Text
local function insertText(tbl, j, i, entry)
    Cell(tbl, j, i, {rowspan = 2, colspan = getEntryColspan(j), text = entry.text})
end

-- 6.4 Group (spans columns)
local function insertGroup(tbl, j, i, entry)
    local span = state.entries[j][i].colspan or 1
    local colspan = 0

    -- sum entry widths per column
    for m = j, j + span - 1 do
        colspan = colspan + state.maxlegs[m] + 2
        if not config.seeds then
            colspan = colspan - 1
        end
        if (config.aggregate and state.maxlegs[m] > 1) or state.maxlegs[m] == 0 then
            colspan = colspan + 1
        end
    end

    -- add path columns between rounds
    for m = j, j + span - 2 do
        colspan = colspan + (state.hascross[m] and 3 or 2)
    end

    return Cell(tbl, j, i, {rowspan = 2, colspan = colspan, classes = {"brk-center"}, text = entry.group or ""})
end

-- 6.5 Line
local function insertLine(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)

    local borderWidth = {0, 0, 0, 0}
    if entry.borderWidth then
        borderWidth = entry.borderWidth
    else
        -- derive from left path column
        local wantTop = entry.border == "top" or entry.border == "both"
        local wantBottom = (entry.border == nil) or entry.border == "bottom" or entry.border == "both"

        if wantBottom and state.pathCell[j - 1] and state.pathCell[j - 1][i + 1] then
            borderWidth[3] = 2 * (state.pathCell[j - 1][i + 1][3][1][3] or 0)
        end
        if wantTop and state.pathCell[j - 1] and state.pathCell[j - 1][i] then
            borderWidth[1] = 2 * (state.pathCell[j - 1][i][3][1][1] or 0)
        end
    end

    local cell = Cell(tbl, j, i, {rowspan = 2, colspan = entry_colspan, text = entry.text, borderWidth = borderWidth})
    cell:addClass("brk-line")
    if entry.color then
        cell:css("border-color", entry.color)
    end
    return cell
end

local INSERTORS = {
    header = insertHeader,
    team = insertTeam,
    text = insertText,
    group = insertGroup,
    line = insertLine
}

local function insertEntry(tbl, j, i, R)
    if handleEmptyOrNilEntry(tbl, j, i, R) then
        return
    end
    local entry = state.entries[j][i]
    if not entry then
        return
    end
    local fn = INSERTORS[entry.ctype]
    if fn then
        return fn(tbl, j, i, entry)
    end
    return Cell(tbl, j, i, {rowspan = 2, colspan = getEntryColspan(j)})
end

-- ===================================
-- 7) PATH CELL EMITTERS (between RDs)
-- ===================================
-- Always emit the correct number of <td>s:
-- - 2 lanes when no cross (k=1,3)
-- - 3 lanes when cross (k=1,2,3). The center lane (k=2) is skipped only if no cross.
local function generatePathCell(tbl, j, i, k, bg, rowspan)
    if not state.hascross[j] and k == 2 then
        return
    end -- keep table aligned

    local colData = state.pathCell[j][i][k]
    local borders = (colData and colData[1]) or {0, 0, 0, 0}
    local color = (colData and colData.color) or "transparent"

    local cell = tbl:tag("td")
    if rowspan and rowspan ~= 1 then
        cell:attr("rowspan", rowspan)
    end

    if k == 2 and state.hascross[j] and notempty(bg) then
        cell:css("background", bg):css("transform", "translate(-1px)")
    end

    if borders[1] ~= 0 or borders[2] ~= 0 or borders[3] ~= 0 or borders[4] ~= 0 then
        cell:css("border", "solid " .. color):css(
            "border-width",
            (2 * borders[1]) ..
                "px " .. (2 * borders[2]) .. "px " .. (2 * borders[3]) .. "px " .. (2 * borders[4]) .. "px"
        )
    end
    return cell
end

local function insertPath(tbl, j, i, R)
    if state.skipPath[j][i] then
        return
    end

    local colspan, rowspan = 2, 1
    local bg = ""
    local cross = {"", ""}

    local Pj = state.pathCell[j]
    local Xj = state.crossCell[j]
    local SPj = state.skipPath[j]

    -- vertical merge: extend rowspan down while borders repeat identically
    if i < R then
        local function sameBorders(a)
            if a > R - 1 or SPj[a] then
                return false
            end
            local pi, pa = Pj[i], Pj[a]
            for k = 1, 3 do
                local bi, ba = pi[k][1], pa[k][1]
                if bi[1] ~= ba[1] or bi[2] ~= ba[2] or bi[3] ~= ba[3] or bi[4] ~= ba[4] then
                    return false
                end
            end
            return true
        end
        if sameBorders(i) then
            local row = i
            repeat
                if row ~= i and sameBorders(row) then
                    SPj[row] = true
                end
                rowspan = rowspan + 1
                row = row + 1
            until row > R or not sameBorders(row)
            rowspan = rowspan - 1
        end
    end

    -- avoid double-emitting cross rows (previous row already spans)
    if
        i > 1 and Xj[i - 1] and
            ((Xj[i - 1].left and Xj[i - 1].left[1] == 1) or (Xj[i - 1].right and Xj[i - 1].right[1] == 1))
     then
        return
    end

    -- cross visuals
    if state.hascross[j] then
        colspan = 3
        if Xj[i].left[1] == 1 or Xj[i].right[1] == 1 then
            rowspan = 2
            if Xj[i].left[1] == 1 then
                cross[1] =
                    "linear-gradient(to top right, transparent calc(50% - 1px)," ..
                    Xj[i].left[2] ..
                        " calc(50% - 1px)," .. Xj[i].left[2] .. " calc(50% + 1px), transparent calc(50% + 1px))"
            end
            if Xj[i].right[1] == 1 then
                cross[2] =
                    "linear-gradient(to bottom right, transparent calc(50% - 1px)," ..
                    Xj[i].right[2] ..
                        " calc(50% - 1px)," .. Xj[i].right[2] .. " calc(50% + 1px), transparent calc(50% + 1px))"
            end
        end
        if notempty(cross[1]) and notempty(cross[2]) then
            cross[1] = cross[1] .. ","
        end
        bg = cross[1] .. cross[2]
    end

    -- emit L | (CENTER) | R cells
    for k = 1, 3 do
        generatePathCell(tbl, j, i, k, bg, rowspan)
    end
end

-- =========================================
-- 8) INVISIBLE SCAFFOLDING (LEGACY COMPAT)
-- =========================================
local function emitRowHeight(tr, rowHeightPx) -- left height cell per data row
    tr:tag("td"):css("height", rowHeightPx)
end

-- widths row: fixes widths for seed/team/score(+agg) and path columns
local function emitWidthsRow(tbl, MINC, C, seedW, teamW, scoreW, aggW, pathW, leftPad, rightPad, crossW, crossPad)
    tbl:tag("tr"):css("visibility", "collapse") -- spacer

    local tr = tbl:tag("tr")
    tr:tag("td"):css("width", "1px") -- tiny leading gutter

    for j = MINC, C do
        if config.seeds then
            tr:tag("td"):css("width", seedW)
        end
        tr:tag("td"):css("width", teamW)

        local maxlegs = (state.maxlegs and state.maxlegs[j]) or 1
        if maxlegs <= 0 then
            tr:tag("td"):css("width", scoreW) -- legacy extra column when maxlegs == 0
        else
            for _ = 1, maxlegs do
                tr:tag("td"):css("width", scoreW)
            end
        end
        if config.aggregate and maxlegs > 1 then
            tr:tag("td"):css("width", aggW)
        end

        if j < C then
            if state.hascross and state.hascross[j] then
                tr:tag("td"):css("width", pathW):css("padding-left", leftPad) -- L
                tr:tag("td"):css("width", crossW):css("padding-left", crossPad) -- CENTER
                tr:tag("td"):css("width", pathW):css("padding-right", rightPad) -- R
            else
                tr:tag("td"):css("width", pathW):css("padding-left", leftPad) -- L
                tr:tag("td"):css("width", pathW):css("padding-right", rightPad) -- R
            end
        end
    end
end

-- ==============================
-- 9) PUBLIC: BUILD THE TABLE
-- ==============================
function Render.buildTable(frame, _state, _config, _Helpers, _StateChecks)
    -- Bind upvalues
    state, config, Helpers, StateChecks = _state, _config, _Helpers, _StateChecks

    -- Helpers
    isempty, notempty = Helpers.isempty, Helpers.notempty
    cellBorder = Helpers.cellBorder
    unboldParenthetical = Helpers.unboldParenthetical

    -- State checks
    showSeeds = StateChecks.showSeeds
    teamLegs = StateChecks.teamLegs
    roundIsEmpty = StateChecks.roundIsEmpty
    defaultHeaderText = StateChecks.defaultHeaderText
    isBlankEntry = StateChecks.isBlankEntry

    -- Hot locals
    local MINC, C = config.minc, config.c
    local R0 = tonumber(config.r) or 0
    local entries = state.entries
    local pathCell = state.pathCell
    local crossCell = state.crossCell
    local hide = state.hide or {}
    local byes = state.byes or {}
    local maxlegsArr = state.maxlegs or {}
    local hascross = state.hascross or {}

    -- Respect explicit |rows=|
    local userRowsArg = config._fargs and config._fargs.rows

    -- Is this entry visibly rendered
    local function entryIsVisible(j, i)
        local col = entries[j]
        local e = col and col[i]
        if not e then
            return false
        end

        local ct = e.ctype
        local hidx = e.headerindex
        local hid = (hide[j] and hide[j][hidx]) or false
        if hid then
            return false
        end

        if ct == "team" then
            local bye = (byes[j] and byes[j][hidx]) or false
            if bye and isBlankEntry(j, i) then
                return false
            end
            return true
        elseif ct == "header" then
            local bye = (byes[j] and byes[j][hidx]) or false
            if bye and roundIsEmpty(j, i) then
                return false
            end
            return true -- header shows (defaults applied) unless hidden or bye+empty
        elseif ct == "text" then
            return notempty(e.text)
        elseif ct == "group" then
            return notempty(e.group)
        elseif ct == "line" then
            -- Count visible borders via path scan, not the placeholder cell itself
            return notempty(e.text) -- only count if it actually prints text
        end
        return false
    end

    -- Backward scan for last visually-used row (entries or painted paths)
    local function computeBottomUsedRow(R)
        for i = R, 1, -1 do
            -- Any visible entry on this row?
            for j = MINC, C do
                if entryIsVisible(j, i) then
                    local e = entries[j][i]
                    -- teams occupy a row pair → include the following row if within bounds
                    if e.ctype == "team" then
                        return (i + 1 <= R) and (i + 1) or i
                    else
                        return i
                    end
                end
            end

            -- Any path/cross on this row?
            for j = MINC, C - 1 do
                local Pj = pathCell[j]
                local Xj = crossCell[j]

                -- Cross uses a row pair
                local cc = Xj and Xj[i]
                if cc and ((cc.left and cc.left[1] == 1) or (cc.right and cc.right[1] == 1)) then
                    return (i + 1 <= R) and (i + 1) or i
                end

                -- Any nonzero border on any lane?
                local row = Pj and Pj[i]
                if row then
                    for k = 1, 3 do
                        local cell = row[k]
                        local b = cell and cell[1]
                        if b and ((b[1] or 0) ~= 0 or (b[2] or 0) ~= 0 or (b[3] or 0) ~= 0 or (b[4] or 0) ~= 0) then
                            return i
                        end
                    end
                end
            end
        end
        return 1 -- nothing visible; keep at least one row
    end

    -- Effective number of data rows to emit
    local R_eff = (userRowsArg and userRowsArg ~= "") and R0 or computeBottomUsedRow(R0)

    -- Precompute entryColspan per round
    entryColspan = {}
    for j = MINC, C do
        local ml = maxlegsArr[j] or 1
        local col = ml + 2
        if not config.seeds then
            col = col - 1
        end
        if (config.aggregate and ml > 1) or ml == 0 then
            col = col + 1
        end
        entryColspan[j] = col
    end

    -- Table skeleton
    local tbl = mw.html.create("table"):addClass("brk")
    if config.nowrap then
        tbl:addClass("brk-nw")
    end

    -- Fixed internal row height (do NOT use config.height here)
    local rowHeightPx = "11px"

    -- Column widths (resolve once)
    local getWidth = Helpers.getWidth
    local seedW = (getWidth and getWidth("seed", "25px")) or "25px"
    local teamW = (getWidth and getWidth("team", "150px")) or "150px"
    local scoreW = (getWidth and getWidth("score", "25px")) or "25px"
    local aggRaw = (getWidth and getWidth("agg", nil)) or nil
    local aggW = Helpers.isempty(aggRaw) and scoreW or aggRaw
    local pathW = "2px"
    local crossW = (getWidth and getWidth("cross", "5px")) or "5px"
    local crossPad = (getWidth and getWidth("crosspad", "5px")) or "5px"

    -- between-round spacing split (e.g., 6 → 4px left, 2px right)
    local spacing = tonumber(config.colspacing) or 6
    local leftFrac = math.floor(spacing * 2 / 3)
    local leftPad, rightPad = (leftFrac .. "px"), ((spacing - leftFrac) .. "px")

    -- widths row
    emitWidthsRow(tbl, MINC, C, seedW, teamW, scoreW, aggW, pathW, leftPad, rightPad, crossW, crossPad)

    -- Data rows
    for i = 1, R_eff do
        local tr = tbl:tag("tr")
        emitRowHeight(tr, rowHeightPx) -- left height cell

        for j = MINC, C do
            insertEntry(tr, j, i, R_eff)
            if j < C then
                insertPath(tr, j, i, R_eff)
            end
        end
    end

    -- Wrap with a div that loads TemplateStyles and enables scroll overflow
    local fr = frame or mw.getCurrentFrame()
    local container = mw.html.create("div")

    -- Height now applies to container (numbers → px; units respected)
    local containerHeight = Helpers.toCssLength(config.height, nil)
    if containerHeight then
        container:css("max-height", containerHeight)
    end

    container:css("overflow-x", "auto"):css("overflow-y", "auto")
    container:wikitext(fr:extensionTag("templatestyles", "", {src = "Module:Build bracket/styles.css"}))
    container:node(tbl)

    return tostring(container)
end

-- ============
-- 10) EXPORTS
-- ============
return Render