local p = {}
local state = {}
local config = {}
--========================
-- Localized globals for performance
--========================
local str_byte, str_char, str_find, str_format, str_gmatch, str_gsub, str_match =
string.byte, string.char, string.find, string.format, string.gmatch, string.gsub, string.match
local t_insert, t_sort, t_concat = table.insert, table.sort, table.concat
local m_max, m_min, m_ceil = math.max, math.min, math.ceil
local tonumber, tostring = tonumber, tostring
local pairs, ipairs, type = pairs, ipairs, type
local mw_html_create = mw.html.create
--========================
-- Color configuration
--========================
local COLORS = {
-- Cell backgrounds
cell_bg_light = 'var(--background-color-neutral-subtle,#f8f9fa)',
cell_bg_dark = 'var(--background-color-neutral,#eaecf0)',
text_color = 'var(--color-base,#202122)',
path_line_color = 'gray',
cell_border = 'var(--border-color-base,#a2a9b1)',
}
--========================
-- Core Helpers
--========================
--[[
Helper Index:
1. Basic checks: isempty, notempty, yes, no
2. Arg fetch: bargs
3. String: toChar, split, unboldParenthetical
4. Style: cellBorder, getWidth
]]
-- Basic truthy/empty checks
local function isempty(s) return s==nil or s=='' end
local function notempty(s) return s~=nil and s~='' end
local function yes(val) return val == 'y' or val == 'yes' end
local function no(val) return val == 'n' or val == 'no' end
-- Argument fetcher
local function bargs(s) -- reads pargs, fargs
return pargs[s] or fargs[s]
end
-- String helpers
local function toChar(num) -- pure
return str_char(str_byte("a")+num-1)
end
local function unboldParenthetical(text) -- pure, heavy string ops
if isempty(text) then return text end
local STYLE_NORMAL = '<span style="font-weight:normal">%s</span>'
local PLACEHOLDER_PREFIX = '__WIKILINK__'
-- Step 1: Extract and temporarily replace wikilinks
local counter = 0
local placeholders = {}
text = text:gsub('%[%[(.-)%]%]', function(link)
counter = counter + 1
local key = PLACEHOLDER_PREFIX .. counter .. '__'
placeholders[key] = link
return key
end)
-- Step 2: Wrap parenthetical (...) and bracketed [...] text in normal-weight span
text = text:gsub("(%b())", function(match)
return STYLE_NORMAL:format(match)
end)
text = text:gsub("(%b[])", function(match)
return STYLE_NORMAL:format(match)
end)
-- Step 3: Restore wikilinks
for key, link in pairs(placeholders) do
text = text:gsub(key, '[[' .. link .. ']]')
end
return text
end
local function split(str,delim,tonum) -- pure
local result = {};
local a = "[^"..t_concat(delim).."]+"
for w in str:gmatch(a) do
if tonum==true then
t_insert(result, tonumber(w));
else
t_insert(result, w);
end
end
return result;
end
-- Style & dimension helpers
local function cellBorder(b) -- pure
return b[1]..'px '..b[2]..'px '..b[3]..'px '..b[4]..'px'
end
local function getWidth(ctype, default) -- reads pargs, fargs
local result = bargs(ctype..'-width')
if isempty(result) then return default end
if tonumber(result)~=nil then return result..'px' end
return result
end
local function parseArgs(frame, config)
fargs, pargs = frame.args, frame:getParent().args
config.r = tonumber(fargs.rows) or ''
config.c = tonumber(fargs.rounds) or 1
local maxc = tonumber(pargs.maxrounds) or tonumber(pargs.maxround) or ''
config.minc = tonumber(pargs.minround) or 1
if notempty(maxc) then config.c = maxc end
config.autocol = yes(fargs.autocol)
config.colspacing = tonumber(fargs['col-spacing']) or 5
config.height = bargs('height') or 0
local bw = (bargs('boldwinner') or ''):lower()
config.boldwinner = bw -- keep for backward compat
config.boldwinner_mode = 'off'
config.boldwinner_aggonly = false
local function yesish(s) return s=='y' or s=='yes' or s=='true' or s=='1' end
if bw == 'low' then
config.boldwinner_mode = 'low'
elseif bw == 'high' or yesish(bw) then
config.boldwinner_mode = 'high'
elseif bw == 'aggregate' or bw == 'agg' or bw == 'aggregate-high' or bw == 'agg-high' then
config.boldwinner_mode = 'high'
config.boldwinner_aggonly = true
elseif bw == 'aggregate-low' or bw == 'agg-low' then
config.boldwinner_mode = 'low'
config.boldwinner_aggonly = true
end
-- optional explicit switch that works with any bw mode
if yes(bargs('boldwinner-aggregate-only')) then
config.boldwinner_aggonly = true
end
config.forceseeds = yes(bargs('seeds'))
config.seeds = not no(bargs('seeds'))
do
local aval = (bargs('aggregate') or ''):lower()
if aval == 'sets' or aval == 'legs' then
config.aggregate_mode = 'sets'
elseif aval == 'score' then
config.aggregate_mode = 'score'
elseif aval == 'y' or aval == 'yes' or aval == 'true' or aval == '1' then
config.aggregate_mode = 'manual'
else
config.aggregate_mode = 'off'
end
config.aggregate = (config.aggregate_mode ~= 'off')
end
config.autolegs = yes(bargs('autolegs'))
config.paramstyle = (bargs('paramstyle') == 'numbered') and 'numbered' or 'indexed'
config.nowrap = not no(pargs.nowrap)
end
--========================
-- Bracket State Checks
--========================
local function isBlankEntry(col,row) -- reads entries
local colEntries = state.entries[col]
if not colEntries then return true end
local e = colEntries[row]
if not e then return true end
return isempty(e.team) and isempty(e.text)
end
local function showSeeds(j, i) -- reads entries, teamsPerMatch, forceseeds
local row = state.entries[j]
if not row then return false end
local e = row[i]
if not e or e.ctype ~= 'team' then return false end
-- Force show, or if this team already has a seed
if config.forceseeds or notempty(e.seed) then
return true
end
local group = e.group
local tpm = state.teamsPerMatch[j] or 2
local step = 2 -- layout uses every 2 rows for teams in a match
local function neighborHasSeed(idx)
local n = row[idx]
return n and n.ctype == 'team' and n.group == group and notempty(n.seed)
end
for k = 1, tpm - 1 do
local plus = i + step * k
local minus = i - step * k
if plus <= config.r and neighborHasSeed(plus) then return true end
if minus >= 1 and neighborHasSeed(minus) then return true end
end
return false
end
local function isRoundHidden(j, i, headerindex) -- mutates state.hide
local col = state.entries[j]
if not col then return end
local e = col[i]
if not e then return end
local hidx = headerindex or e.headerindex
if not state.hide[j] then state.hide[j] = {} end
-- If there is a parent header, this header should be shown
if notempty(e.pheader) then
state.hide[j][hidx] = false
return
end
-- Scan forward until next header (or end of round).
local row = i + 1
while row <= config.r do
local r = col[row]
if r and r.ctype == 'header' then
break
end
if not isBlankEntry(j, row) then
state.hide[j][hidx] = false
break
end
row = row + 1
end
end
local function teamLegs(j, i) -- reads entries, rlegs, autolegs
local col = state.entries[j]
if not col then return state.rlegs[j] or 1 end
local e = col[i]
if not e or e.ctype ~= 'team' then
return state.rlegs[j] or 1
end
-- start with round default
local legs = state.rlegs[j] or 1
-- named override (if present)
if notempty(e.legs) then
legs = tonumber(e.legs) or legs
end
-- helper: treat nil/'' and values containing 'nbsp' as blank
local function isScoreBlank(v)
if isempty(v) then return true end
return type(v) == 'string' and v:find('nbsp', 1, true) ~= nil
end
-- autolegs: count contiguous non-blank leg entries starting at 1
if config.autolegs and e.score then
local l = 1
while not isScoreBlank(e.score[l]) do
l = l + 1
end
local inferred = l - 1
-- if nothing filled yet, keep prior legs; otherwise use inferred
if inferred > 0 then
legs = inferred
end
end
return legs
end
-- Determine whether the "round" after header at (j,i) is empty (used for bye detection)
local function roundIsEmpty(j, i) -- reads entries, isBlankEntry
local col = state.entries[j]
if not col then return true end
local row = i + 1
while row <= config.r do
local e = col[row]
if e and e.ctype == 'header' then
break
end
if not isBlankEntry(j, row) then
return false
end
row = row + 1
end
return true
end
-- Default header text when none is provided
local function defaultHeaderText(j, headerindex)
-- Non-primary headers
if headerindex ~= 1 then
return 'Lower round ' .. tostring(j)
end
-- Distance from the final column
local c = tonumber(config.c) or j
local rem = c - j
if rem == 0 then
return '決賽'
elseif rem == 1 then
return '-{zh-hans:半决赛; zh-hant:準決賽;}-'
elseif rem == 2 then
return '-{zh-hans:四分之一决赛; zh-hant:半準決賽;}-'
else
return '第' .. tostring(j) '-{zh-hans:轮; zh-hant:輪; zh-hk:圈;}-'
end
end
local function noPaths(j, i)
-- how many path columns to check
local cols = state.hascross[j] and 3 or 2
-- path cells: any nonzero entry in [k][1][n] means there's a path
local pcj = state.pathCell[j]
local pci = pcj and pcj[i]
if pci then
for k = 1, cols do
local ktab = pci[k]
local dir1 = ktab and ktab[1]
if dir1 then
for n = 1, 4 do
local v = dir1[n]
if v and v ~= 0 then
return false
end
end
end
end
end
-- cross cells: left/right flag of 1 means there's a cross path
if state.hascross[j] then
local ccj = state.crossCell[j]
local cci = ccj and ccj[i]
if cci then
local left = cci.left
local right = cci.right
if (left and left[1] == 1) or (right and right[1] == 1) then
return false
end
end
end
return true
end
--========================
-- Rendering (HTML / Table Building)
--========================
local function Cell(tbl, j, i, opts)
opts = opts or {}
local cell = tbl:tag('td')
-- classes first
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
if notempty(opts.border) then cell:css('border', opts.border) end
if notempty(opts.borderWidth) then cell:css('border-width', cellBorder(opts.borderWidth)) end
-- per-cell bg override (for RD*-shade)
if notempty(opts.bg) then cell:css('background-color', opts.bg) end
if notempty(opts.align) then cell:css('text-align', opts.align) end
if opts.padding and opts.padding ~= '' then cell:css('padding', opts.padding) end
if opts.weight == 'bold' then cell:css('font-weight', 'bold') end
-- only set color when a caller asks for it
if notempty(opts.color) then cell:css('color', opts.color) end
if notempty(opts.text) then cell:wikitext(opts.text) end
return cell
end
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
local opts = {
classes = classes, -- <-- gives border-style & color
colspan = colspan,
rowspan = 2,
borderWidth = {0, 0, 1, 1}, -- same logic as before, you still mutate this
-- bg only when overriding via RD*-shade; otherwise classes handle background
text = (l and tostring(state.entries[j][i][k][l])) or
unboldParenthetical(state.entries[j][i][k]),
weight = ((l == nil and state.entries[j][i].weight == 'bold')
or state.entries[j][i].score.weight[l] == 'bold') and 'bold' or nil,
}
-- Team cell specifics
if k == 'team' and teamLegs(j, i) == 0 then
opts.borderWidth[2] = 1
end
if state.entries[j][i].position == 'top' then
opts.borderWidth[1] = 1
end
if l == teamLegs(j, i) or l == 'agg' or k == 'seed' then
opts.borderWidth[2] = 1
end
-- Bold winner (unchanged logic)
if (l == nil and state.entries[j][i].weight == 'bold')
or state.entries[j][i].score.weight[l] == 'bold' then
opts.weight = 'bold'
end
-- Text content
if l == nil then
opts.text = unboldParenthetical(state.entries[j][i][k])
else
opts.text = tostring(state.entries[j][i][k][l])
end
return Cell(tbl, j, i, opts)
end
-- Compute standard colspan for a single "entry" cell in column j
local function getEntryColspan(j) -- reads maxlegs, seeds, aggregate
local colspan = state.maxlegs[j] + 2
if not config.seeds then
colspan = colspan - 1
end
if (config.aggregate and state.maxlegs[j] > 1) or state.maxlegs[j] == 0 then
colspan = colspan + 1
end
return colspan
end
-- Handle cases where the entry is nil (absent) or explicitly a blank placeholder.
-- Returns true if this function produced the appropriate cell (so caller should return)
local function handleEmptyOrNilEntry(tbl, j, i)
local entry_colspan = getEntryColspan(j)
-- nil entry: produce a spanning blank cell if appropriate
if state.entries[j][i] == nil then
-- If previous entry exists or this is the first row, create a rowspan covering following blank rows
if state.entries[j][i - 1] ~= nil or i == 1 then
local rowspan = 0
local row = i
repeat
rowspan = rowspan + 1
row = row + 1
until state.entries[j][row] ~= nil or row > config.r
-- produce an empty cell with the computed rowspan/colspan
local opts = {
rowspan = rowspan,
colspan = entry_colspan,
text = nil
}
Cell(tbl, j, i, opts)
return true
else
-- do nothing (cell intentionally omitted)
return true
end
end
-- explicit 'blank' ctype: do nothing (no visible content)
if state.entries[j][i]['ctype'] == 'blank' then
return true
end
return false
end
-- Insert a header cell
local function insertHeader(tbl, j, i, entry)
local entry_colspan = getEntryColspan(j)
local function emptyCell()
return Cell(tbl, j, i, { rowspan = 2, colspan = entry_colspan })
end
if state.byes[j][entry.headerindex] and roundIsEmpty(j, i) then return emptyCell() end
if state.hide[j][entry.headerindex] then return emptyCell() 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
table.insert(classes, 'brk-bgD') -- default bg via class
end
return Cell(tbl, j, i, {
rowspan = 2,
colspan = entry_colspan,
text = entry.header,
classes = classes,
borderWidth = {1,1,1,1},
-- only for custom RD*-shade:
bg = useCustomShade and entry.shade or nil,
color = useCustomShade and '#202122' or nil,
})
end
-- Insert a team cell (seed, team, and scores)
local function insertTeam(tbl, j, i, entry)
local entry_colspan = getEntryColspan(j)
-- If this team belongs to a 'bye' header and is blank, render empty cell
if (state.byes[j][entry.headerindex] and isBlankEntry(j, i)) or state.hide[j][entry.headerindex] then
return Cell(tbl, j, i, {
rowspan = 2,
colspan = entry_colspan
})
end
local legs = teamLegs(j, i)
local team_colspan = state.maxlegs[j] - legs + 1
if config.aggregate and legs == 1 and state.maxlegs[j] > 1 then
team_colspan = team_colspan + 1
end
if state.maxlegs[j] == 0 then
team_colspan = team_colspan + 1
end
-- Seed column (if enabled). Either render the seed cell or fold it into the team colspan.
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 cell (may span multiple score columns)
teamCell(tbl, 'team', j, i, nil, team_colspan)
-- Score cells (one per leg)
for l = 1, legs do
teamCell(tbl, 'score', j, i, l)
end
-- Aggregate score column (if configured)
if config.aggregate and legs > 1 then
teamCell(tbl, 'score', j, i, 'agg')
end
end
-- Insert a text cell
local function insertText(tbl, j, i, entry)
local entry_colspan = getEntryColspan(j)
Cell(tbl, j, i, {
rowspan = 2,
colspan = entry_colspan,
text = entry.text,
})
end
-- Insert a group cell (spans several columns)
local function insertGroup(tbl, j, i, entry)
local span = state.entries[j][i].colspan
local colspan = 0
-- sum entry widths per column in the span
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 each adjacent pair
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,
text = entry.group,
align = 'center'
})
end
-- Insert a line cell (visual path markers)
local function insertLine(tbl, j, i, entry)
local entry_colspan = getEntryColspan(j)
-- Border mask: {top, right, bottom, left}
local borderWidth = {0, 0, 0, 0}
-- If a caller precomputed a mask, use it
if entry.borderWidth then
borderWidth = entry.borderWidth
else
-- Otherwise derive from pathCell + the caller's intent:
-- entry.border = 'top' | 'bottom' | 'both' (default: 'bottom')
local wantTop = entry.border == 'top' or entry.border == 'both'
local wantBottom = (entry.border == nil) or entry.border == 'bottom' or entry.border == 'both'
-- bottom border uses (j-1, i+1)
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
-- top border uses (j-1, i)
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')
return cell
end
local INSERTORS = {
header = insertHeader,
team = insertTeam,
text = insertText,
group = insertGroup,
line = insertLine,
}
local function insertEntry(tbl, j, i)
if handleEmptyOrNilEntry(tbl, j, i) 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
-- Draw a single path cell in the bracket table
local function generatePathCell(tbl, j, i, k, bg, rowspan)
local colData = state.pathCell[j][i][k]
local color = colData.color
-- Skip center cell if there is no cross path
if not state.hascross[j] and k == 2 then
return
end
local cell = tbl:tag('td')
-- Rowspan if this cell spans multiple rows
if rowspan ~= 1 then
cell:attr('rowspan', rowspan)
end
-- Background shading for middle column (cross path visuals)
if notempty(bg) and k == 2 then
cell:css('background', bg)
:css('transform', 'translate(-1px)')
end
-- Draw border if any segment has nonzero width
local borders = colData[1]
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
-- Insert all path cells for a given position in the bracket
local function insertPath(tbl, j, i)
if state.skipPath[j][i] then
return
end
local colspan, rowspan = 2, 1
local bg = ''
local cross = { '', '' }
-- Detect vertical merging (rowspan) for repeated paths
if i < config.r then
local function repeatedPath(a)
if a > config.r - 1 or state.skipPath[j][a] then
return false
end
for k = 1, 3 do
for n = 1, 4 do
if state.pathCell[j][i][k][1][n] ~= state.pathCell[j][a][k][1][n] then
return false
end
end
end
return true
end
if repeatedPath(i) then
local row = i
repeat
if row ~= i and repeatedPath(row) then
state.skipPath[j][row] = true
end
rowspan = rowspan + 1
row = row + 1
until row > config.r or not repeatedPath(row)
rowspan = rowspan - 1
end
end
-- Skip if the previous row has cross path connections
if i > 1 and (state.crossCell[j][i - 1].left[1] == 1 or state.crossCell[j][i - 1].right[1] == 1) then
return
end
-- Handle cross paths
if state.hascross[j] then
colspan = 3
if state.crossCell[j][i].left[1] == 1 or state.crossCell[j][i].right[1] == 1 then
rowspan = 2
if state.crossCell[j][i].left[1] == 1 then
cross[1] = 'linear-gradient(to top right, transparent calc(50% - 1px),'
.. state.crossCell[j][i].left[2] .. ' calc(50% - 1px),'
.. state.crossCell[j][i].left[2] .. ' calc(50% + 1px), transparent calc(50% + 1px))'
end
if state.crossCell[j][i].right[1] == 1 then
cross[2] = 'linear-gradient(to bottom right, transparent calc(50% - 1px),'
.. state.crossCell[j][i].right[2] .. ' calc(50% - 1px),'
.. state.crossCell[j][i].right[2] .. ' calc(50% + 1px), transparent calc(50% + 1px))'
end
end
-- Combine left/right gradient layers if both exist
if notempty(cross[1]) and notempty(cross[2]) then
cross[1] = cross[1] .. ','
end
bg = cross[1] .. cross[2]
end
-- Generate three cells (left, middle, right) for this row
for k = 1, 3 do
generatePathCell(tbl, j, i, k, bg, rowspan)
end
end
-- ===================
-- Cluster 4 – Data Population & Parameters
-- ===================
local function paramNames(cname, j, i, l)
-- Helpers
local function getArg(key)
return bargs(key) or ''
end
local function getPArg(key)
return pargs[key] or ''
end
local function tryBoth(base, name, idx, suffix)
suffix = suffix or ''
local val = getArg(base .. '-' .. name .. idx .. suffix)
if isempty(val) then
val = getArg(base .. '-' .. name .. str_format('%02d', idx) .. suffix)
end
return val
end
-- Round names
local rname = {
{ 'RD' .. j, getArg('RD' .. j .. '-altname') or 'RD' .. j },
{ 'RD' .. j .. toChar(state.entries[j][i].headerindex),
getArg('RD' .. j .. toChar(state.entries[j][i].headerindex) .. '-altname')
or 'RD' .. j .. toChar(state.entries[j][i].headerindex) }
}
-- Base name and indices
local name = { cname, getArg(cname .. '-altname') or cname }
local index = { state.entries[j][i].index, state.entries[j][i].altindex }
local result = {}
if cname == 'header' then
if state.entries[j][i].headerindex == 1 then
for _, base in ipairs({ rname[1], rname[2] }) do
for k = 2, 1, -1 do
result[#result+1] = getArg(base[k])
end
end
else
for k = 2, 1, -1 do
result[#result+1] = getArg(rname[2][k])
end
end
elseif cname == 'pheader' then
if state.entries[j][i].headerindex == 1 then
for _, base in ipairs({ rname[1], rname[2] }) do
for k = 2, 1, -1 do
result[#result+1] = getPArg(base[k])
end
end
else
for k = 2, 1, -1 do
result[#result+1] = getPArg(rname[2][k])
end
end
elseif cname == 'score' then
local bases = { rname[2][2], rname[2][1], rname[1][2], rname[1][1] }
local idxs = { index[2], index[2], index[1], index[1] }
for n = 1, 4 do
if l == 1 then
result[#result+1] = tryBoth(bases[n], name[1], idxs[n])
end
result[#result+1] = tryBoth(bases[n], name[1], idxs[n], '-' .. l)
end
elseif cname == 'shade' then
for k = 2, 1, -1 do
local base = (state.entries[j][i].headerindex == 1) and rname[1][k] or rname[2][k]
result[#result+1] = getArg(base .. '-' .. name[1])
end
result[#result+1] = getArg('RD-shade')
result[#result+1] = COLORS.cell_bg_dark
elseif cname == 'text' then
local bases = { rname[2][2], rname[2][1], rname[1][2], rname[1][1] }
local idxs = { index[2], index[2], index[1], index[1] }
local names = { name[2], name[1] }
for ni = 1, 2 do
for n = 1, 4 do
result[#result+1] = tryBoth(bases[n], names[ni], idxs[n])
end
end
else -- default case
local bases = { rname[2][2], rname[2][1], rname[1][2], rname[1][1] }
local idxs = { index[2], index[2], index[1], index[1] }
for n = 1, 4 do
result[#result+1] = tryBoth(bases[n], name[1], idxs[n])
end
end
-- Return first non-empty
for _, val in ipairs(result) do
if notempty(val) then
return val
end
end
return ''
end
local function indexedParams(j)
local row = state.entries[j]
if not row then return end
local function nextArg()
local v = bargs(tostring(masterindex)) or ''
masterindex = masterindex + 1
return v
end
for i = 1, config.r do
local e = row[i]
if e then
local ct = e.ctype
if ct == 'team' then
local legs = state.rlegs[j]
if config.forceseeds then
e.seed = nextArg()
end
e.team = nextArg()
e.legs = paramNames('legs', j, i)
e.score = { weight = {} }
e.weight = 'normal'
if notempty(e.legs) then
legs = tonumber(e.legs) or legs
end
for l = 1, legs do
e.score[l] = nextArg()
e.score.weight[l] = 'normal'
end
if config.aggregate and legs > 1 then
e.score.agg = nextArg()
e.score.weight.agg = 'normal'
end
elseif ct == 'header' then
e.header = paramNames('header', j, i)
e.pheader = paramNames('pheader', j, i)
e.shade = paramNames('shade', j, i)
elseif ct == 'text' then
e.text = nextArg()
elseif ct == 'group' then
e.group = nextArg()
elseif ct == 'line' and e.hastext == true then
e.text = nextArg()
end
end
end
end
local function assignTeamParams(j, i)
local legs = state.rlegs[j]
state.entries[j][i].seed = paramNames('seed', j, i)
state.entries[j][i].team = paramNames('team', j, i)
state.entries[j][i].legs = paramNames('legs', j, i)
state.entries[j][i].score = { weight = {} }
state.entries[j][i].weight = 'normal'
if notempty(state.entries[j][i].legs) then
legs = tonumber(state.entries[j][i].legs)
end
if config.autolegs then
local l = 1
repeat
state.entries[j][i].score[l] = paramNames('score', j, i, l)
state.entries[j][i].score.weight[l] = 'normal'
l = l + 1
until isempty(paramNames('score', j, i, l))
legs = l - 1
else
for l = 1, legs do
state.entries[j][i].score[l] = paramNames('score', j, i, l)
state.entries[j][i].score.weight[l] = 'normal'
end
end
if config.aggregate and legs > 1 then
state.entries[j][i].score.agg = paramNames('score', j, i, 'agg')
state.entries[j][i].score.weight.agg = 'normal'
end
end
local function assignHeaderParams(j, i)
state.entries[j][i].header = paramNames('header', j, i)
state.entries[j][i].pheader = paramNames('pheader', j, i)
state.entries[j][i].shade = paramNames('shade', j, i)
-- Mark if the shade came from any RD*-shade param
local shadeParamsToCheck = {
'RD'..j..'-shade',
'RD'..j..toChar(state.entries[j][i].headerindex)..'-shade',
'RD-shade'
}
state.entries[j][i].shade_is_rd = false
for _, pname in ipairs(shadeParamsToCheck) do
if notempty(bargs(pname)) and state.entries[j][i].shade == bargs(pname) then
state.entries[j][i].shade_is_rd = true
break
end
end
end
local function assignTextParams(j, i)
state.entries[j][i].text = paramNames('text', j, i)
end
local function assignGroupParams(j, i)
state.entries[j][i].group = paramNames('group', j, i)
end
local function assignLineTextParams(j, i)
state.entries[j][i].text = paramNames('text', j, i)
end
local function assignParams()
masterindex = 1
local maxcol = 1
local byerows = 1
local hiderows = 1
local function updateMaxCol(j, i)
if config.autocol and not isBlankEntry(j, i) then
maxcol = m_max(maxcol, j)
end
end
local function updateByerows(j, i)
if state.entries[j][i] and not state.hide[j][state.entries[j][i].headerindex] then
if not state.byes[j][state.entries[j][i].headerindex] or
(state.byes[j][state.entries[j][i].headerindex] and not isBlankEntry(j, i)) then
byerows = m_max(byerows, i)
end
end
end
for j = config.minc, config.c do
-- Set legs for this column
state.rlegs[j] = tonumber(bargs('RD'..j..'-legs')) or tonumber(bargs('legs')) or 1
if notempty(bargs('RD'..j..'-legs')) or bargs('legs') then
config.autolegs = false
end
if config.paramstyle == 'numbered' then
indexedParams(j)
else
for i = 1, config.r do
if state.entries[j][i] ~= nil then
local ctype = state.entries[j][i].ctype
if ctype == 'team' then
assignTeamParams(j, i)
elseif ctype == 'header' then
assignHeaderParams(j, i)
elseif ctype == 'text' then
assignTextParams(j, i)
elseif ctype == 'group' then
assignGroupParams(j, i)
elseif ctype == 'line' and state.entries[j][i].hastext == true then
assignLineTextParams(j, i)
end
end
updateMaxCol(j, i)
end
end
-- Round hiding check
for i = 1, config.r do
if state.entries[j][i] and state.entries[j][i].ctype == 'header' then
isRoundHidden(j, i)
end
updateByerows(j, i)
end
end
-- Adjust row count if some rounds are byes or hidden
for j = config.minc, config.c do
for k = 1, state.headerindex[j] do
if state.byes[j][k] or state.hide[j][k] then
config.r = byerows + 1
end
end
end
-- Adjust column count automatically
if config.autocol then
config.c = maxcol
end
end
local function getHide(j,headerindex)
state.hide[j] = {}
for k=1,state.headerindex[j] do
if bargs('RD'..j..toChar(k)..'-hide')=='yes' or bargs('RD'..j..toChar(k)..'-hide')=='y' then
state.hide[j][k]=true
end
end
end
local function getByes(j,headerindex)
state.byes[j] = {}
for k=1,state.headerindex[j] do
if bargs('byes')=='yes' or bargs('byes')=='y' then
state.byes[j][k]=true
elseif tonumber(bargs('byes')) then
if j<=tonumber(bargs('byes')) then
state.byes[j][k]=true
end
else
state.byes[j][k]=false
end
if bargs('RD'..j..'-byes')=='yes' or bargs('RD'..j..'-byes')=='y' then
state.byes[j][k]=true
elseif bargs('RD'..j..'-byes')=='no' or bargs('RD'..j..'-byes')=='n' then
state.byes[j][k]=false
end
if bargs('RD'..j..toChar(k)..'-byes')=='yes' or bargs('RD'..j..toChar(k)..'-byes')=='y' then
state.byes[j][k]=true
elseif bargs('RD'..j..'-byes')=='no' or bargs('RD'..j..'-byes')=='n' then
state.byes[j][k]=false
end
end
end
local function getAltIndices()
for j = config.minc, config.c do
state.headerindex[j] = 0
-- per-round counters
local teamindex = 1
local textindex = 1
local groupindex = 1
local row = state.entries[j]
-- if the very first cell is nil, bump headerindex once
if row and row[1] == nil then
state.headerindex[j] = state.headerindex[j] + 1
end
-- walk rows in the round
for i = 1, config.r do
local e = row and row[i] or nil
if e then
local ct = e.ctype -- dot access instead of ['ctype']
if ct == 'header' then
e.altindex = state.headerindex[j]
teamindex = 1
textindex = 1
state.headerindex[j] = state.headerindex[j] + 1
elseif ct == 'team' then
e.altindex = teamindex
teamindex = teamindex + 1
elseif ct == 'text' or (ct == 'line' and e.hastext == true) then
-- treat 'line' with text like a text entry (matches your original)
e.altindex = textindex
textindex = textindex + 1
elseif ct == 'group' then
e.altindex = groupindex
groupindex = groupindex + 1
end
e.headerindex = state.headerindex[j]
end
end
getByes(j, state.headerindex[j])
getHide(j, state.headerindex[j])
end
end
local function matchGroups() -- mutates state.matchgroup and entry.group
for j = config.minc, config.c do
state.matchgroup[j] = {}
local mgj = state.matchgroup[j]
local tpm = tonumber(state.teamsPerMatch[j]) or 2
if tpm < 1 then tpm = 2 end
local col = state.entries[j]
if col then
for i = 1, config.r do
local e = col[i]
if e and e.ctype == 'team' then
local idx = tonumber(e.index) or tonumber(e.altindex) or i
local g = m_ceil(idx / tpm)
mgj[i] = g
e.group = g
end
end
end
end
end
local function computeAggregate()
if config.aggregate_mode == 'off' or config.aggregate_mode == 'manual' then return end
local modeLow = (config.boldwinner_mode == 'low')
local function numlead(s)
if not s or s == '' then return nil end
return tonumber((s):match('^%d+')) -- leading integer only
end
-- Build groups: round j -> groupId -> {team indices}
local function buildGroupsForRound(j)
local groups = {}
local mg = state.matchgroup[j] or {}
for i = 1, config.r do
local e = state.entries[j][i]
if e and e.ctype == 'team' then
local gid = mg[i]
if gid ~= nil then
local t = groups[gid]; if not t then t = {}; groups[gid] = t end
t[#t+1] = i
end
end
end
return groups
end
-- Pre-parse leg numbers for each team (per round)
local function preparseLegs(j)
local legNums = {} -- i -> { [l] = number|nil }
for i = 1, config.r do
local e = state.entries[j][i]
if e and e.ctype == 'team' then
local L = teamLegs(j, i)
if config.aggregate and L > 1 and (e.score and e.score.agg ~= nil) then
legNums[i] = {}
for l = 1, L do
legNums[i][l] = numlead(e.score[l])
end
end
end
end
return legNums
end
for j = config.minc, config.c do
local groups = buildGroupsForRound(j)
local legNums = preparseLegs(j)
if config.aggregate_mode == 'score' then
-- Sum per-leg scores
for _, members in pairs(groups) do
for _, i in ipairs(members) do
local e = state.entries[j][i]
if e and e.ctype == 'team' and config.aggregate and teamLegs(j, i) > 1 then
if isempty(e.score.agg) then
local sum = 0
local nums = legNums[i]
if nums then
for _, v in ipairs(nums) do if v then sum = sum + v end end
e.score.agg = tostring(sum)
end
end
end
end
end
else -- 'sets'/'legs' → count leg wins using high/low rule; ties = no win; missing numeric → skip the leg
for _, members in pairs(groups) do
-- wins per team index in this group
local wins = {}
-- Common legs we can actually compare across this group
local commonLegs = math.huge
for _, i in ipairs(members) do
local nums = legNums[i]
local L = (nums and #nums) or 0
if L == 0 then commonLegs = 0; break end
if L < commonLegs then commonLegs = L end
end
for l = 1, commonLegs do
-- all teams must have a numeric value for this leg
local allNumeric = true
for _, i in ipairs(members) do
if not (legNums[i] and legNums[i][l] ~= nil) then
allNumeric = false; break
end
end
if allNumeric then
-- find best (high or low). ties => no win
local best, bestIndex, tie = nil, nil, false
for _, i in ipairs(members) do
local v = legNums[i][l]
if best == nil then
best, bestIndex, tie = v, i, false
else
if (modeLow and v < best) or (not modeLow and v > best) then
best, bestIndex, tie = v, i, false
elseif v == best then
tie = true
end
end
end
if not tie and bestIndex then
wins[bestIndex] = (wins[bestIndex] or 0) + 1
end
end
end
-- Write aggregates if still empty
for _, i in ipairs(members) do
local e = state.entries[j][i]
if e and e.ctype == 'team' and config.aggregate and teamLegs(j, i) > 1 then
if isempty(e.score.agg) then
e.score.agg = tostring(wins[i] or 0)
end
end
end
end
end
end
end
local function boldWinner()
local modeLow = (config.boldwinner_mode == 'low')
local aggOnly = config.boldwinner_aggonly
local function isWin(mine, theirs)
if modeLow then return mine < theirs else return mine > theirs end
end
local function isAggWin(mine, theirs, l)
if l == 'agg' and config.aggregate_mode == 'sets' then
-- Sets/legs won: larger count wins regardless of low/high scoring sport
return mine > theirs
else
return isWin(mine, theirs)
end
end
local function boldScore(j, i, l)
local e = state.entries[j][i]
if not e or e.ctype ~= 'team' then return 'normal' end
local raw = e.score[l] or ''
local mine = tonumber((raw):match('^%d+'))
if not mine then return 'normal' end
local comps = {}
for oppIndex, groupId in pairs(state.matchgroup[j]) do
if groupId == state.matchgroup[j][i] and oppIndex ~= i then
local theirraw = state.entries[j][oppIndex].score[l] or ''
local theirs = tonumber((theirraw):match('^%d+'))
if not theirs then return 'normal' end
table.insert(comps, theirs)
end
end
for _, v in ipairs(comps) do
if not isAggWin(mine, v, l) then return 'normal' end
end
if l ~= 'agg' then
e.wins = (e.wins or 0) + 1
else
e.aggwins = 1
end
return 'bold'
end
local function boldTeam(j, i, useAggregate)
local e = state.entries[j][i]
local winsKey = useAggregate and 'aggwins' or 'wins'
local legs = teamLegs(j, i)
if not useAggregate then
if (e[winsKey] or 0) > legs / 2 then return 'bold' end
local checkFn = config.autolegs and notempty or function(val) return not isempty(val) end
for l = 1, legs do
local sv = e.score[l]
if not checkFn(sv) or str_find(sv or '', "nbsp") then
return 'normal'
end
end
end
for oppIndex, groupId in pairs(state.matchgroup[j]) do
if groupId == state.matchgroup[j][i] and oppIndex ~= i then
if (e[winsKey] or 0) <= tonumber(state.entries[j][oppIndex][winsKey] or 0) then
return 'normal'
end
end
end
return 'bold'
end
-- reset counters
for j = config.minc, config.c do
for i = 1, config.r do
if state.entries[j][i] and state.entries[j][i].ctype == 'team' then
state.entries[j][i].wins = 0
state.entries[j][i].aggwins = 0
end
end
-- per-score bolding
for i = 1, config.r do
if state.entries[j][i] and state.entries[j][i].ctype == 'team' then
local legs = teamLegs(j, i)
-- legs (skip entirely if agg-only)
if not aggOnly then
for l = 1, legs do
state.entries[j][i].score.weight[l] = boldScore(j, i, l)
end
end
-- aggregate column (if present)
if config.aggregate and legs > 1 then
state.entries[j][i].score.weight.agg = boldScore(j, i, 'agg')
end
end
end
-- whole-team bolding (skip if agg-only so only agg cell bolds)
if not aggOnly then
for i = 1, config.r do
if state.entries[j][i] and state.entries[j][i].ctype == 'team' then
local useAggregate = config.aggregate and teamLegs(j, i) > 1
state.entries[j][i].weight = boldTeam(j, i, useAggregate)
end
end
end
end
end
local function updateMaxLegs()
for j = config.minc, config.c do
state.maxlegs[j] = state.rlegs[j]
for i = 1, config.r do
if notempty(state.entries[j][i]) then
if notempty(state.entries[j][i].legs) then
state.maxlegs[j] = m_max(state.rlegs[j], state.entries[j][i].legs)
end
if config.autolegs then
local l = 1
repeat l = l + 1
until isempty(state.entries[j][i].score) or isempty(state.entries[j][i].score[l])
state.maxlegs[j] = m_max(state.maxlegs[j], l - 1)
end
end
end
end
end
-- ========================
-- Path Parsing & Drawing
-- ========================
local function parsePaths(j)
local result = {}
local str = fargs['col'..j..'-col'..(j+1)..'-paths'] or ''
for val in str:gsub("%s+","")
:gsub(",",", ")
:gsub("%S+","\0%0\0")
:gsub("%b()", function(s) return s:gsub("%z","") end)
:gmatch("%z(.-)%z") do
local array = split(val:gsub("%s+",""):gsub("%)",""):gsub("%(",""),{"-"})
for k,_ in pairs(array) do
array[k] = split(array[k],{","})
end
if notempty(array[2]) then
array[3] = array[3] or {} -- init once
for m=1,#array[2] do
array[2][m] = split(array[2][m],{":"})
array[3][m] = array[2][m][2]
array[2][m] = array[2][m][1]
end
for n=1,#array[1] do
for m=1,#array[2] do
t_insert(result, { tonumber(array[1][n]), tonumber(array[2][m]), color = array[3][m] })
end
end
end
end
return result
end
local function isPathHidden(j, start, stop)
-- normalize "show-bye-paths"
local function truthy(s)
s = (s or ''):lower()
return s == 'y' or s == 'yes' or s == '1' or s == 'true'
end
local showBye = truthy(bargs('show-bye-paths'))
-- check one side (source or destination)
local function sideHidden(col, headerRow, neighborRow)
local colEntries = state.entries[col]
local e = colEntries and colEntries[headerRow]
if not e then return false end
local hidx = e.headerindex
local byes = state.byes[col] and state.byes[col][hidx]
local hid = state.hide[col] and state.hide[col][hidx]
-- hide if it's a bye round with both adjacent slots blank (unless show-bye-paths)
if byes and isBlankEntry(col, headerRow) and isBlankEntry(col, neighborRow) then
if not showBye then return true end
end
-- hide if that header is explicitly hidden
if hid then return true end
return false
end
-- source side (round j)
if sideHidden(j, start - 1, start + 1) then return true end
-- destination side (round j+1)
if sideHidden(j + 1, stop - 1, stop + 1) then return true end
-- explicit RDj-RDj+1 path suppression (only affects primary headerindex==1)
local rdflag = (bargs('RD' .. j .. '-RD' .. (j + 1) .. '-path') or ''):lower()
if rdflag == 'n' or rdflag == 'no' or rdflag == '0' then
local srcHdr = state.entries[j] and state.entries[j][start - 1]
if srcHdr and srcHdr.headerindex == 1 then
return true
end
end
return false
end
-- ===============
-- Path helpers
-- ===============
-- ensure nested tables exist and return the [1] border array and the cell table
local function ensurePathSlot(j, row, colIndex)
state.pathCell[j] = state.pathCell[j] or {}
state.pathCell[j][row] = state.pathCell[j][row] or {}
state.pathCell[j][row][colIndex] = state.pathCell[j][row][colIndex] or {}
local cell = state.pathCell[j][row][colIndex]
cell[1] = cell[1] or { [1]=0, [2]=0, [3]=0, [4]=0 }
return cell[1], cell
end
-- Helper: write a segment to a path cell
local function setPathCell(j, row, colIndex, borderIndex, value, color)
local borders, cell = ensurePathSlot(j, row, colIndex)
if borderIndex >= 1 and borderIndex <= 4 then
borders[borderIndex] = value
end
cell.color = color
end
-- Safe read of a border; returns 0 if absent
local function getBorder(j, row, k, edge)
state.pathCell[j] = state.pathCell[j] or {}
state.pathCell[j][row] = state.pathCell[j][row] or {}
state.pathCell[j][row][k] = state.pathCell[j][row][k] or { {0,0,0,0}, color = COLORS.path_line_color }
local borders = state.pathCell[j][row][k][1]
return borders[edge] or 0
end
-- Safe fetch of a cell color (falls back to default)
local function getCellColor(j, row, k)
local col = state.pathCell[j]
local cell = col and col[row] and col[row][k]
return (cell and cell.color) or COLORS.path_line_color
end
local function setMultipleCells(j, row, colIndexes, borderIndex, value, color)
for _, colIndex in ipairs(colIndexes) do
setPathCell(j, row, colIndex, borderIndex, value, color)
end
end
-- ===== Path drawing =====
-- Handle straight path (start == stop)
local function handleStraightPath(j, start, color, straightpaths)
t_insert(straightpaths, { start, color })
end
-- Handle downward paths (start < stop)
local function handleDownwardPath(j, start, stop, cross, color)
if stop > config.r then return end
setPathCell(j, start + 1, 1, 1, 1, color)
if cross == 0 then
if state.hascross[j] then
setPathCell(j, start + 1, 2, 1, 1, color)
for i = start + 1, stop do
setPathCell(j, i, 2, 2, 1, color)
end
else
for i = start + 1, stop do
setPathCell(j, i, 1, 2, 1, color)
end
end
else
state.crossCell[j][cross].left = { 1, color }
for i = start + 1, cross - 1 do
setPathCell(j, i, 1, 2, 1, color)
end
for i = cross + 2, stop do
setPathCell(j, i, 2, 2, 1, color)
end
end
setPathCell(j, stop, 3, 3, 1, color)
end
-- Handle upward paths (start > stop)
local function handleUpwardPath(j, start, stop, cross, color)
if start > config.r then return end
setPathCell(j, stop + 1, 3, 1, 1, color)
if cross == 0 then
if state.hascross[j] then
for i = stop + 1, start do
setPathCell(j, i, 2, 2, 1, color)
end
setPathCell(j, start, 2, 3, 1, color)
else
for i = stop + 1, start do
setPathCell(j, i, 1, 2, 1, color)
end
end
else
state.crossCell[j][cross].right = { 1, color }
for i = stop + 1, cross - 1 do
setPathCell(j, i, 2, 2, 1, color)
end
for i = cross + 2, start do
setPathCell(j, i, 1, 2, 1, color)
end
end
setPathCell(j, start, 1, 3, 1, color)
end
-- ===== Thickness adjustments =====
-- Thicken start==stop paths
local function thickenStraightPaths(j, straightpaths)
for _, sp in ipairs(straightpaths) do
local i, color = sp[1], sp[2]
if i > config.r then break end
if state.pathCell[j][i][1][1][3] == 0 then
-- Set all three columns' bottom border to 1
setMultipleCells(j, i, {1, 2, 3}, 3, 1, color)
-- Set next row's top border to 0.5 if empty
if state.pathCell[j][i+1][1][1][1] == 0 then
setMultipleCells(j, i+1, {1, 2, 3}, 1, 0.5, color)
end
elseif state.pathCell[j][i+1][1][1][1] == 0 then
-- Set next row's top border to 1
setMultipleCells(j, i+1, {1, 3}, 1, 1, color)
if state.hascross[j] then
setMultipleCells(j, i+1, {2}, 1, 1, color)
end
end
end
end
-- Adjust path thickness for outpaths (thicken/thin transitions)
local function adjustOutpaths(j, outpaths)
for _, op in ipairs(outpaths) do
local i = op[1]
-- skip if this is the last row (i+1 would be out of range)
if i < config.r then
local top1, top2 = state.pathCell[j][i+1][1], state.pathCell[j][i+1][2]
local bottom1, bottom2 = state.pathCell[j][i][1], state.pathCell[j][i][2]
-- Thinning: strong bottom to empty top
if bottom1[1][3] == 1 and top1[1][1] == 0 then
top1[1][1] = 0.5 * bottom1[1][3]
top2[1][1] = 0.5 * bottom2[1][3]
top1.color = bottom1.color
top2.color = bottom2.color
-- Thickening: empty bottom to strong top
elseif bottom1[1][3] == 0 and top1[1][1] == 1 then
bottom1[1][3] = top1[1][1]
bottom2[1][3] = top2[1][1]
top1[1][1] = 0.5 * bottom1[1][3]
top2[1][1] = 0.5 * bottom2[1][3]
bottom1.color = top1.color
bottom2.color = top2.color
end
end
end
end
-- Thin double-in paths
local function thinDoubleInPaths(j, inpaths)
for _, ip in ipairs(inpaths) do
local i = ip[1]
-- skip if this is the last row (i+1 would be out of range)
if i < config.r then
local curr3 = state.pathCell[j][i][3]
local next3 = state.pathCell[j][i+1][3]
if curr3[1][3] == 1 and next3[1][1] == 1 and curr3.color == next3.color then
next3[1][1] = 0.5 * curr3[1][3]
end
end
end
end
-- ===== Cross-column connections =====
-- Connect straight paths between adjacent columns
local function connectStraightPaths()
for j = config.minc, config.c - 1 do
for i = 1, config.r - 1 do
local straightpath = false
-- Check if the top entry in next column is blank or a bye
local topEntry = state.entries[j+1] and state.entries[j+1][i-1]
local isTopBlankOrBye =
(topEntry == nil or (state.byes[j+1][topEntry and topEntry.headerindex] and isBlankEntry(j+1, i-1)))
if isTopBlankOrBye then
-- Make sure the big containers exist
state.pathCell[j] = state.pathCell[j] or {}
state.pathCell[j+1] = state.pathCell[j+1] or {}
state.entries[j+1] = state.entries[j+1] or {}
local currEnt = state.entries[j]
local nextEnt = state.entries[j+1]
if state.pathCell[j] and state.pathCell[j+1] and currEnt and nextEnt then
-- Read with guards
local curr_i3_b = getBorder(j, i, 3, 3) -- current col, row i, right cell, bottom edge
local next_i1_b = getBorder(j+1, i, 1, 3) -- next col, row i, left cell, bottom edge
local curr_ip1_t= getBorder(j, i+1, 3, 1) -- current col, row i+1, right, top edge
local next_ip1_t= getBorder(j+1, i+1, 1, 1) -- next col, row i+1, left, top edge
local cond1 = (curr_i3_b ~= 0 and next_i1_b ~= 0)
local cond2 = (curr_ip1_t ~= 0 and next_ip1_t ~= 0)
if cond1 or cond2 then
-- Detect "perfectly straight" (mirror left/right equal)
local next_i3_b = getBorder(j+1, i, 3, 3)
local next_ip1_1= getBorder(j+1, i+1, 3, 1)
if next_i1_b == next_i3_b and next_ip1_t == next_ip1_1 then
straightpath = true
end
-- Colors to propagate
local color_i = getCellColor(j, i, 3)
local color_ip1 = getCellColor(j, i+1, 3)
-- Copy left path data from current col to next col (write via safe setter)
setPathCell(j+1, i, 1, 3, curr_i3_b, color_i)
setPathCell(j+1, i+1, 1, 1, curr_ip1_t, color_ip1)
setPathCell(j+1, i, 2, 3, curr_i3_b, color_i)
setPathCell(j+1, i+1, 2, 1, curr_ip1_t, color_ip1)
-- Update entries to represent connecting lines
nextEnt[i-1] = { ctype = 'line', border = 'bottom' }
nextEnt[i] = { ctype = 'blank' }
if nextEnt[i+1] ~= nil then
nextEnt[i+1].ctype = 'line'
nextEnt[i+1].border = 'top'
else
nextEnt[i+1] = { ctype = 'line', border = 'top' }
end
nextEnt[i+2] = { ctype = 'blank' }
-- If perfectly straight path, mirror left values to right
if straightpath then
setPathCell(j+1, i, 3, 3, getBorder(j+1, i, 1, 3), getCellColor(j+1, i, 1))
setPathCell(j+1, i+1, 3, 1, getBorder(j+1, i+1, 1, 1), getCellColor(j+1, i+1, 1))
end
end
end
end
end
end
end
local function getPaths()
local paths = {}
-- Step 1: Determine which columns have cross paths
for j = config.minc, config.c - 1 do
state.hascross[j] = notempty(fargs['col' .. j .. '-col' .. (j + 1) .. '-cross'])
end
-- Step 2: Process each column
for j = config.minc, config.c - 1 do
paths[j] = parsePaths(j)
-- Initialize per-column path data
state.pathCell[j], state.crossCell[j], state.skipPath[j] = {}, {}, {}
for i = 1, config.r do
state.pathCell[j][i] = {}
for k = 1, 3 do
state.pathCell[j][i][k] = { {0, 0, 0, 0}, color = COLORS.path_line_color }
end
state.crossCell[j][i] = {
left = {0, COLORS.path_line_color},
right = {0, COLORS.path_line_color}
}
state.skipPath[j][i] = false
end
-- Prepare cross location list
local crossloc = split((fargs['col' .. j .. '-col' .. (j + 1) .. '-cross'] or '')
:gsub("%s+", ""), {","}, true)
local hasCrossLoc = notempty(crossloc[1])
if state.shift[j] ~= 0 and hasCrossLoc then
for n = 1, #crossloc do
crossloc[n] = crossloc[n] + state.shift[j]
end
end
-- Temporary holders for later thickening/thinning
local straightpaths, outpaths, inpaths = {}, {}, {}
-- Step 3: Process each path in the current column
for _, path in ipairs(paths[j]) do
local startRow = 2 * (path[1] + state.shift[j]) + (state.teamsPerMatch[j] - 2)
local stopRow = 2 * (path[2] + state.shift[j + 1]) + (state.teamsPerMatch[j + 1] - 2)
-- Build cross rows
local crossRows = {0}
if hasCrossLoc then
for n = 1, #crossloc do
crossRows[n] = 2 * crossloc[n] + (state.teamsPerMatch[j] - 2)
end
end
-- Find applicable cross row
local cross = 0
for _, cr in ipairs(crossRows) do
if (startRow < stopRow and cr < stopRow and cr > startRow)
or (startRow > stopRow and cr > stopRow and cr < startRow) then
cross = cr
end
end
local color = path.color or COLORS.path_line_color
t_insert(outpaths, { startRow, color })
t_insert(inpaths, { stopRow, color })
if not isPathHidden(j, startRow, stopRow) then
if startRow == stopRow then
handleStraightPath(j, startRow, color, straightpaths)
elseif startRow < stopRow then
handleDownwardPath(j, startRow, stopRow, cross, color)
else
handleUpwardPath(j, startRow, stopRow, cross, color)
end
end
end
-- Step 4: Apply thickness adjustments
thickenStraightPaths(j, straightpaths)
adjustOutpaths(j, outpaths)
thinDoubleInPaths(j, inpaths)
end
-- Ensure a blank path/cross/skip column for the terminal round (j = config.c)
state.pathCell[config.c] = state.pathCell[config.c] or {}
state.crossCell[config.c] = state.crossCell[config.c] or {}
state.skipPath[config.c] = state.skipPath[config.c] or {}
for i = 1, config.r do
state.pathCell[config.c][i] = state.pathCell[config.c][i] or {}
for k = 1, 3 do
state.pathCell[config.c][i][k] =
state.pathCell[config.c][i][k] or { {0, 0, 0, 0}, color = COLORS.path_line_color }
end
state.crossCell[config.c][i] = state.crossCell[config.c][i] or {
left = {0, COLORS.path_line_color},
right = {0, COLORS.path_line_color}
}
if state.skipPath[config.c][i] == nil then
state.skipPath[config.c][i] = false
end
end
-- Step 5: Connect straight paths across columns
connectStraightPaths()
end
local function getGroups()
-- Helper: true if cell (or the next cell) is empty or blank text
local function isBlankOrBlankText(j, i)
if state.entries[j][i] == nil then
if state.entries[j][i + 1] == nil then
return true
elseif state.entries[j][i + 1].ctype == 'text' and isBlankEntry(j, i + 1) then
return true
end
elseif state.entries[j][i].ctype == 'text' and isBlankEntry(j, i) then
return true
end
return false
end
for j = config.minc, config.c - 1 do
-- Only handle standard 2-team matches (same as original)
if state.teamsPerMatch[j] == 2 then
local groupIndex = 0
for i = 1, config.r - 1 do
if state.pathCell[j][i][3][1][3] == 1 or state.pathCell[j][i + 1][3][1][1] == 1 then
groupIndex = groupIndex + 1
if isBlankOrBlankText(j, i) then
local k = config.minc - 1
repeat
-- guard state.entries[j-k] existence before indexing
if state.entries[j - k] and state.entries[j - k][i + 1]
and state.entries[j - k][i + 1].ctype == 'text'
and isBlankEntry(j - k, i + 1) then
state.entries[j - k][i + 2] = nil
end
-- create blank placeholders as in original logic
if state.entries[j - k] == nil then state.entries[j - k] = {} end
state.entries[j - k][i] = { ctype = 'blank' }
state.entries[j - k][i+1] = { ctype = 'blank' }
if k > 0 and noPaths(j - k, i) then
state.skipPath[j - k][i] = true
state.skipPath[j - k][i+1] = true
end
k = k + 1
until k > j - 1 or not isBlankOrBlankText(j - k, i) or not noPaths(j - k, i)
k = k - 1
if state.entries[j - k] == nil then state.entries[j - k] = {} end
state.entries[j - k][i] = {
ctype = 'group',
index = groupIndex,
colspan = k + 1
}
state.entries[j - k][i + 1] = { ctype = 'blank' }
state.entries[j - k][i].group = bargs('RD' .. j .. '-group' .. groupIndex)
end
end
end
end
end
end
local function getCells()
local DEFAULT_TPM = 2
local maxrow = 1
local colentry = {}
local hasNoHeaders = true
-- Phase 1: Determine header presence and teamsPerMatch
for j = config.minc, config.c do
if notempty(fargs["col" .. j .. "-headers"]) then
hasNoHeaders = false
end
state.teamsPerMatch[j] =
tonumber(fargs["RD" .. j .. "-teams-per-match"]) or
tonumber(fargs["col" .. j .. "-teams-per-match"]) or
tonumber(fargs["teams-per-match"]) or
DEFAULT_TPM
state.maxtpm = m_max(state.maxtpm, state.teamsPerMatch[j])
end
-- Phase 2: Build colentry for each column
for j = config.minc, config.c do
state.entries[j] = {}
state.shift[j] = tonumber(bargs("RD" .. j .. "-shift")) or tonumber(bargs("shift")) or 0
colentry[j] = {
split((fargs["col" .. j .. "-headers"] or ""):gsub("%s+", ""), {","}, true),
split((fargs["col" .. j .. "-matches"] or ""):gsub("%s+", ""), {","}, true),
split((fargs["col" .. j .. "-lines"] or ""):gsub("%s+", ""), {","}, true),
split((fargs["col" .. j .. "-text"] or ""):gsub("%s+", ""), {","}, true),
}
if hasNoHeaders and fargs["noheaders"] ~= "y" and fargs["noheaders"] ~= "yes" then
t_insert(colentry[j][1], 1)
end
end
-- Ctype mapping for colentry positions
local CTYPE_MAP = { "header", "team", "line", "text", "group" }
-- Helper functions for each ctype
local function populateTeam(j, rowIndex, n)
if state.entries[j][rowIndex - 1] == nil and state.entries[j][rowIndex - 2] == nil then
state.entries[j][rowIndex - 2] = { ctype = "text", index = n }
state.entries[j][rowIndex - 1] = { ctype = "blank" }
end
state.entries[j][rowIndex] = { ctype = "team", index = state.teamsPerMatch[j] * n - (state.teamsPerMatch[j] - 1), position = "top" }
state.entries[j][rowIndex + 1] = { ctype = "blank" }
for m = 2, state.teamsPerMatch[j] do
local idx = state.teamsPerMatch[j] * n - (state.teamsPerMatch[j] - m)
state.entries[j][rowIndex + 2 * (m - 1)] = { ctype = "team", index = idx }
state.entries[j][rowIndex + 2 * (m - 1) + 1] = { ctype = "blank" }
end
end
local function populateText(j, rowIndex, index)
state.entries[j][rowIndex] = { ctype = "text", index = index }
state.entries[j][rowIndex + 1] = { ctype = "blank" }
end
local function populateLine(j, rowIndex)
-- first segment draws its bottom edge
state.entries[j][rowIndex] = { ctype = "line", border = "bottom" }
state.entries[j][rowIndex + 1] = { ctype = "blank" }
-- second segment draws its top edge
state.entries[j][rowIndex + 2] = { ctype = "line", border = "top" }
state.entries[j][rowIndex + 3] = { ctype = "blank" }
end
local function populateGroup(j, rowIndex, n)
state.entries[j][rowIndex] = { ctype = "group", index = n }
state.entries[j][rowIndex + 1] = { ctype = "blank" }
end
local function populateDefault(j, rowIndex, n)
state.entries[j][rowIndex] = { ctype = "header", index = n, position = "top" }
state.entries[j][rowIndex + 1] = { ctype = "blank" }
end
-- Phase 3: Populate entries for each column
for j = config.minc, config.c do
local textindex = 0
for k, positions in ipairs(colentry[j]) do
t_sort(positions)
local ctype = CTYPE_MAP[k]
for n = 1, #positions do
if state.shift[j] ~= 0 and positions[n] > 1 then
positions[n] = positions[n] + state.shift[j]
end
local rowIndex = 2 * positions[n] - 1
maxrow = m_max(rowIndex + 2 * state.teamsPerMatch[j] - 1, maxrow)
if ctype == "team" then
populateTeam(j, rowIndex, n)
textindex = n
elseif ctype == "text" then
populateText(j, rowIndex, textindex + n)
elseif ctype == "line" then
populateLine(j, rowIndex)
elseif ctype == "group" then
populateGroup(j, rowIndex, n)
else
populateDefault(j, rowIndex, n)
end
end
end
end
if isempty(config.r) then
config.r = maxrow
end
end
--=== Build HTML output ===--
local function buildTable()
local frame = mw.getCurrentFrame()
local div = mw_html_create('div'):css('overflow', 'auto')
-- Load TemplateStyles from the Template namespace
div:wikitext(frame:extensionTag('templatestyles', '', {
src = 'Module:Build bracket/styles.css'
}))
if config.height ~= 0 then div:css('height', config.height) end
local tbl = mw_html_create('table'):addClass('brk')
if config.nowrap then tbl:addClass('brk-nw') end
-- Invisible header row for column widths
tbl:tag('tr'):css('visibility', 'collapse')
tbl:tag('td'):css('width', '1px')
for j = config.minc, config.c do
if config.seeds then tbl:tag('td'):css('width', getWidth('seed', '25px')) end
tbl:tag('td'):css('width', getWidth('team', '150px'))
local scoreWidth = getWidth('score', '25px')
if state.maxlegs[j] == 0 then
tbl:tag('td'):css('width', scoreWidth)
else
for l = 1, state.maxlegs[j] do
tbl:tag('td'):css('width', scoreWidth)
end
end
if config.aggregate and state.maxlegs[j] > 1 then
tbl:tag('td'):css('width', getWidth('agg', scoreWidth))
end
if j ~= config.c then
if state.hascross[j] then
tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-left', '4px')
tbl:tag('td'):css('padding-left', '5px'):css('width', '5px')
tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-right', '2px')
else
tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-left', '4px')
tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-right', '2px')
end
end
end
-- Table rows
for i = 1, config.r do
local row = tbl:tag('tr')
row:tag('td'):css('height', '11px')
for j = config.minc, config.c do
insertEntry(row, j, i)
if j ~= config.c then insertPath(row, j, i) end
end
end
div:wikitext(tostring(tbl))
return tostring(div)
end
--========================
-- Deprecations (simple)
--========================
-- Only add categories in mainspace and when nocat is not set.
local function trackingAllowed()
if yes(bargs('nocat')) then return false end
local title = mw.title.getCurrentTitle()
return title and title.namespace == 0 -- articles only
end
-- Scan current args for deprecated usage based on config.deprecations
local function checkDeprecations()
state.deprecated_hits = {}
local rules = config.deprecations or {}
if next(rules) == nil then return end
-- 1) Specific param with specific deprecated values, e.g. paramstyle = indexed
if rules.paramstyle then
local raw = bargs('paramstyle')
if raw and rules.paramstyle[(raw or ''):lower()] then
t_insert(state.deprecated_hits, { name='paramstyle', value=raw })
end
end
-- 2) Any param that ends with "-byes" and is set to a deprecated "yes"/"y"
if rules['*-byes'] then
for name, value in pairs(fargs or {}) do
if type(name) == 'string' and name:sub(-5) == '-byes' then
local v = (tostring(value or '')):lower()
if rules['*-byes'][v] then
t_insert(state.deprecated_hits, { name = name, value = value })
end
end
end
end
end
-- Emit unique categories (in preview is OK; we only limit to mainspace and nocat)
local function emitDeprecationCats()
if not trackingAllowed() then return '' end
if not state.deprecated_hits or #state.deprecated_hits == 0 then return '' end
local seen, out = {}, {}
local cat = config.deprecation_category or 'Bracket pages using deprecated parameters'
-- single bucket (simple)
if not seen[cat] then
out[#out+1] = '[[Category:' .. cat .. ']]'
seen[cat] = true
end
return table.concat(out)
end
--=== Main function ===--
function p.main(frame)
frame:extensionTag('templatestyles', '', { src = 'Module:Build bracket/styles.css' })
state = {
entries = {},
pathCell = {},
crossCell = {},
skipPath = {},
shift = {},
hascross = {},
teamsPerMatch = {},
rlegs = {},
maxlegs = {},
byes = {},
hide = {},
matchgroup = {},
headerindex = {},
masterindex = 1,
maxtpm = 1,
}
config = {
autolegs = false,
nowrap = true,
autocol = false,
seeds = true,
forceseeds = false,
boldwinner = false,
boldwinner_mode = 'off', -- 'off' | 'high' | 'low'
boldwinner_aggonly = false, -- true => only bold the 'agg' column
aggregate = false, -- keeps layout conditionals working
aggregate_mode = 'off', -- 'off' | 'manual' | 'sets' | 'score'
paramstyle = "indexed",
colspacing = 5,
height = 0,
minc = 1,
c = 1, -- number of rounds
r = nil, -- rows (set later)
deprecation_category = 'Pages using Build Bracket with deprecated parameters',
deprecations = {
paramstyle = { numbered = true },
-- example: any ...-byes set to yes/y
---- ['*-byes'] = { yes = true, y = true },
},
}
parseArgs(frame, config)
checkDeprecations()
-- === Data processing phases ===
getCells(state, config)
getAltIndices(state, config)
assignParams(state, config)
matchGroups(state, config)
computeAggregate()
if config.boldwinner_mode ~= 'off' then
boldWinner(state, config)
end
getPaths(state, config)
if config.minc == 1 then
getGroups(state, config)
end
updateMaxLegs(state, config)
-- === Output ===
local out = buildTable(state, config)
return tostring(out) .. emitDeprecationCats()
end
return p