Module:Build bracket/Render
Appearance
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