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 'Final'
elseif rem == 1 then
return 'Semifinals'
elseif rem == 2 then
return 'Quarterfinals'
else
return 'Round ' .. tostring(j)
end
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
local function makePathCell()
return {
borders = { top = 0, right = 0, bottom = 0, left = 0 },
color = COLORS.path_line_color
}
end
-- Draw a single path cell in the bracket table
local function generatePathCell(tbl, j, i, k, bg, rowspan)
-- Skip center cell if there is no cross path
if not state.hascross[j] and k == 2 then return end
local cellData = ensurePathCell(j, i, k)
local td = tbl:tag('td')
if rowspan ~= 1 then td:attr('rowspan', rowspan) end
-- Background shading for middle (cross)
if notempty(bg) and k == 2 then
td:css('background', bg):css('transform', 'translate(-1px)')
end
local b = cellData.borders
if b.top ~= 0 or b.right ~= 0 or b.bottom ~= 0 or b.left ~= 0 then
td:css('border', 'solid ' .. cellData.color)
:css('border-width',
(2*b.top) .. 'px ' ..
(2*b.right) .. 'px ' ..
(2*b.bottom) .. 'px ' ..
(2*b.left) .. 'px')
end
return td
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
-- ========================
-- Edge map (supports both names and 1..4 indices)
local EDGE_MAP = { top=1, right=2, bottom=3, left=4 }
local EDGE_REV = { [1]='top', [2]='right', [3]='bottom', [4]='left' }
local function makePathCell()
return {
borders = { top=0, right=0, bottom=0, left=0 },
color = COLORS.path_line_color
}
end
-- Ensure and return the PathCell at (j,row,k)
local function ensurePathCell(j, row, k)
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 makePathCell()
return state.pathCell[j][row][k]
end
-- Set one edge (edge can be 'top'/'right'/'bottom'/'left' or 1..4)
local function setBorder(j, row, k, edge, val, color)
local cell = ensurePathCell(j, row, k)
local e = EDGE_REV[edge] or edge -- allow numeric or named
cell.borders[e] = val
if color then cell.color = color end
end
-- Read one edge (returns 0 if missing)
local function getBorder(j, row, k, edge)
local cell = state.pathCell[j] and state.pathCell[j][row] and state.pathCell[j][row][k]
if not cell then return 0 end
local e = EDGE_REV[edge] or edge
return (cell.borders and cell.borders[e]) or 0
end
local function noPaths(j, i)
local cols = state.hascross[j] and 3 or 2
for k = 1, cols do
if getBorder(j, i, k, 'top') ~= 0
or getBorder(j, i, k, 'right') ~= 0
or getBorder(j, i, k, 'bottom') ~= 0
or getBorder(j, i, k, 'left') ~= 0 then
return false
end
end
if state.hascross[j] then
local cc = state.crossCell[j] and state.crossCell[j][i]
if cc and ((cc.left and cc.left[1] == 1) or (cc.right and cc.right[1] == 1)) then
return false
end
end
return true
end
local function getCellColor(j, row, k)
local cell = state.pathCell[j] and state.pathCell[j][row] and state.pathCell[j][row][k]
return (cell and cell.color) or COLORS.path_line_color
end
-- Convenience for writing many k’s at once
local function setMany(j, row, ks, edge, val, color)
for _, k in ipairs(ks) do setBorder(j, row, k, edge, val, color) end
end
-- Back‑compat adapters so old calls don’t crash while you migrate:
-- setPathCell(j,row,k,edgeIndex,val,color)
-- setMultipleCells(j,row,{k...},edgeIndex,val,color)
local function edgeIndexToName(n) return EDGE_REV[n] end
function setPathCell(j, row, k, edgeIndex, val, color)
setBorder(j, row, k, edgeIndexToName(edgeIndex), val, color)
end
function setMultipleCells(j, row, ks, edgeIndex, val, color)
setMany(j, row, ks, edgeIndexToName(edgeIndex), val, color)
end
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