Moduł:Build bracket/Paths
Wygląd
local Paths = {}
-- ==============================
-- 1) MODULE STATE & HOT HELPERS
-- ==============================
local _pathsCache = {} -- memo for parsePaths during one build
local t_insert = table.insert -- stdlib alias
local mmin, mmax = math.min, math.max
-- upvalues bound per call (set in bind)
local state, config, Helpers, StateChecks
local split, notempty, bargs, getFArg, isBlankEntry
local defaultColor
local showByeOn = true -- toggle for bye through-lines
local base = 0 -- left-round base offset
-- cache for per-round “RDx-RDy-path” OFF flags
local rdGateOff = {}
-- thickness constants: renderer maps 1.0 => 2px, 0.5 => 1px
local STRONG = 1.0
local FEATHER = 0.5
-- Small helpers
local function tpmOf(col) -- teams per match for a given team column
return (state.teamsPerMatch and state.teamsPerMatch[col]) or 2
end
local function entries(col) -- convenience accessor
return state.entries and state.entries[col]
end
local function clearPathsCache() -- reset parse cache each build
_pathsCache = {}
end
local function origJ(j) -- external round index from internal j
return j + base
end
-- ===================
-- 2) ROW/COLUMN MATH
-- ===================
local function rowOf(j, m)
local sj = (state.shift and state.shift[j]) or 0
local tpm = tpmOf(j)
-- map match index m to internal row for team slot center
return 2 * (m + sj) + (tpm - 2)
end
-- ============================
-- 3) BIND + ARG/CONFIG WIRING
-- ============================
local function bind(_state, _config, _Helpers, _StateChecks)
state, config, Helpers, StateChecks = _state, _config, _Helpers, _StateChecks
split, notempty, bargs, getFArg = _Helpers.split, _Helpers.notempty, _Helpers.bargs, _Helpers.getFArg
isBlankEntry = _StateChecks.isBlankEntry
defaultColor = (config.COLORS and config.COLORS.path_line_color) or "gray"
base = config.base or 0
-- cache bye toggle once
local v = (_Helpers.bargs and _Helpers.bargs("show-bye-paths")) or ""
showByeOn = not Helpers.no(v)
end
-- Precompute RD gate flags once per build (RDx-RDy-path = "no" means gate ON => block)
local function computeRoundGates()
rdGateOff = {}
for j = config.minc, config.c - 1 do
local rd = (bargs(("RD%d-RD%d-path"):format(origJ(j), origJ(j) + 1)) or ""):lower()
rdGateOff[j] = Helpers.no(rd)
end
end
-- ========================
-- 4) PATH PARSING + CACHE
-- ========================
local function parsePaths(j)
local cached = _pathsCache[j]
if cached then
return cached
end
local oj = origJ(j)
local str = (getFArg("col" .. oj .. "-col" .. (oj + 1) .. "-paths") or ""):gsub("%s+", "")
local result = {}
-- grammar: (a,b- c:d, e:f - color1,color2,...) etc.
for val in str:gsub(",", ", "):gsub("%S+", "\0%0\0"):gsub(
"%b()",
function(s)
return s:gsub("%z", "")
end
):gmatch("%z(.-)%z") do
-- strip parens once, split once
local array = split(val:gsub("[()]", ""), {"-"})
for k, _ in ipairs(array) do
array[k] = split(array[k], {","})
end
if notempty(array[2]) then
array[3] = array[3] or {}
for m = 1, #array[2] do
local pair = split(array[2][m], {":"})
array[2][m] = pair[1]
array[3][m] = pair[2]
end
for n = 1, #array[1] do
local a = tonumber(array[1][n])
for m = 1, #array[2] do
t_insert(result, {a, tonumber(array[2][m]), color = array[3][m]})
end
end
end
end
_pathsCache[j] = result
return result
end
-- ==============================
-- 5) STRUCTURAL READS & WINDOWS
-- ==============================
-- Through-line mask that already exists in a team column (from a previous step)
local function throughEdgesMaskAt(teamCol, sRow)
local e = entries(teamCol)
if not e then
return false, false
end
local hasBottom =
(sRow - 1 >= 1) and e[sRow - 1] and e[sRow - 1].ctype == "line" and
(e[sRow - 1].border == "bottom" or e[sRow - 1].border == "both")
local hasTop =
(sRow + 1 <= config.r) and e[sRow + 1] and e[sRow + 1].ctype == "line" and
(e[sRow + 1].border == "top" or e[sRow + 1].border == "both")
return hasBottom, hasTop
end
-- window = the block of rows that constitutes a match near sRow
local function matchWindow(col, sRow)
local tpm = tpmOf(col)
local half = 2 * (tpm - 1) -- distance to top/bottom team rows
local lo = mmax(1, sRow - half)
local hi = mmin(config.r, sRow + half)
return lo, hi, tpm
end
-- Single-pass scan: returns hasTeam, headerIndex (or nil), allTeamsBlank
local function scanWindow(col, sRow)
local ent = entries(col)
if not ent then
return false, nil, true
end
local lo, hi = matchWindow(col, sRow)
local hidx, hasTeam = nil, false
local allBlank = true
for r = lo, hi do
local e = ent[r]
if e then
if not hidx and e.headerindex then
hidx = e.headerindex
end
if e.ctype == "team" then
hasTeam = true
if allBlank and not isBlankEntry(col, r) then
allBlank = false
end
end
end
end
return hasTeam, hidx, allBlank
end
-- =================================
-- 6) SLOT DISPOSITION / VISIBILITY
-- =================================
local slotDisp -- j -> sRow -> 'visible' | 'through' | 'suppress'
local function computeSlotDispositions()
slotDisp = {}
local minc, c = config.minc, config.c
local R = mmax(1, (config.r or 1) - 1)
for j = minc, c do
local sdj = {}
slotDisp[j] = sdj
for sRow = 1, R do
local hasTeam, hidx, allBlank = scanWindow(j, sRow)
if not hasTeam then
sdj[sRow] = (j > minc and j < c) and "through" or "suppress"
else
local disp = "visible"
if hidx then
local hid = state.hide and state.hide[j] and state.hide[j][hidx]
local byes = state.byes and state.byes[j] and state.byes[j][hidx]
if hid then
disp = "suppress"
elseif byes and allBlank then
disp = (showByeOn and j > minc and j < c) and "through" or "suppress"
end
end
sdj[sRow] = disp
end
end
end
end
local function dispositionOf(j, sRow) -- on-demand fallback
local sdj = slotDisp and slotDisp[j]
if sdj and sdj[sRow] then
return sdj[sRow]
end
local _, hidx, allBlank = scanWindow(j, sRow)
if not hidx then
return "visible"
end
local hid = state.hide and state.hide[j] and state.hide[j][hidx]
local byes = state.byes and state.byes[j] and state.byes[j][hidx]
if hid then
return "suppress"
end
if byes and allBlank then
if not showByeOn then
return "suppress"
end
if j > config.minc and j < config.c then
return "through"
end
return "suppress"
end
return "visible"
end
local function slotIsVisiblyReal(col, sRow)
local hasTeam, hidx, allBlank = scanWindow(col, sRow)
if not hasTeam then
return false
end
if hidx then
local hid = state.hide and state.hide[col] and state.hide[col][hidx]
local byes = state.byes and state.byes[col] and state.byes[col][hidx]
if hid then
return false
end
if byes and (not showByeOn) and allBlank then
return false
end
end
return true
end
local function isThroughableTeamSlot(teamCol, sRow)
return dispositionOf(teamCol, sRow) == "through"
end
-- =======================================
-- 7) RENDER PRIMITIVES (low-level utils)
-- =======================================
-- === Tiny cell primitives (keep paintVertical as-is) ===
local function ensurePathCell(j, i, k)
state.pathCell[j] = state.pathCell[j] or {}
state.pathCell[j][i] = state.pathCell[j][i] or {}
local cell = state.pathCell[j][i][k]
if not cell then
cell = {{0, 0, 0, 0}, color = defaultColor}
state.pathCell[j][i][k] = cell
end
return cell
end
-- Ensure all arrays for column j exist and are sized to R rows
local function ensureColumnScaffolding(j)
local R = assert(config.r, "config.r must be set before scaffolding")
state.pathCell[j] = state.pathCell[j] or {}
state.crossCell[j] = state.crossCell[j] or {}
state.skipPath[j] = state.skipPath[j] or {}
for i = 1, R do
-- make sure the 3 path lanes exist
ensurePathCell(j, i, 1)
ensurePathCell(j, i, 2)
ensurePathCell(j, i, 3)
-- default cross info
state.crossCell[j][i] = state.crossCell[j][i] or {left = {0, defaultColor}, right = {0, defaultColor}}
-- skipPath defaults to boolean
if state.skipPath[j][i] == nil then
state.skipPath[j][i] = false
end
end
end
local function paintEdge(j, i, k, edge, val, color)
local cell = ensurePathCell(j, i, k)
local b = cell[1]
if val > (b[edge] or 0) then
b[edge] = val
end
if color then
cell.color = color
end
end
-- === One generic "pair painter" ===
-- kind: 'L' (left path col = k=1), 'C' (center = k=2), 'R' (right = k=3)
-- side: 'upper' | 'lower'
-- feather: true => opposite side FEATHER; false => opposite side omitted (no feather)
local K = {L = 1, C = 2, R = 3}
local TOP, BOT = 1, 3
local function paintPair(kind, side, feather, j, row, color)
local k = K[kind]
local R = config.r
local strongAt, weakAt = (side == "upper") and BOT or TOP, (side == "upper") and TOP or BOT
-- strong on owned side
paintEdge(j, row + (side == "lower" and 1 or 0), k, strongAt, STRONG, color)
-- optional feather on the other side (skip entirely when feather=false)
if feather and row + (side == "upper" and 1 or 0) <= R then
paintEdge(j, row + (side == "upper" and 1 or 0), k, weakAt, FEATHER, color)
end
end
-- Tiny wrappers (purely to keep call sites readable/short)
local function OUT(j, row, side, feather, color)
paintPair("L", side, feather, j, row, color)
end
local function JOIN(j, row, side, feather, color)
paintPair("C", side, feather, j, row, color)
end
local function IN(j, row, side, feather, color)
paintPair("R", side, feather, j, row, color)
end
-- vertical stroke (use left edge=2)
local function paintVertical(j, i1, i2, k, color)
if not i1 or not i2 then
return
end
local a, b = i1, i2
if a > b then
a, b = b, a
end
for r = a, b do
paintEdge(j, r, k, 2, STRONG, color)
end
end
-- ===============================
-- 8) CROSSING / COLORING HELPERS
-- ===============================
local function getCrossRowsFor(j)
local oj = origJ(j)
local raw = (getFArg("col" .. oj .. "-col" .. (oj + 1) .. "-cross") or ""):gsub("%s+", "")
if raw == "" then
return {}, false
end
local parts = split(raw, {","}, true)
local rows = {}
if notempty(parts[1]) then
for idx = 1, #parts do
local m = tonumber(parts[idx])
if m then
rows[#rows + 1] = rowOf(j, m)
end
end
end
return rows, (#rows > 0)
end
local function pickCrossBetween(crossRows, startRow, stopRow)
if #crossRows == 0 then
return 0
end
local lo, hi = startRow, stopRow
if lo > hi then
lo, hi = hi, lo
end
for _, cr in ipairs(crossRows) do
if cr > lo and cr < hi then
return cr
end
end
return 0
end
local function paintVerticalWithCross(j, sRow, eRow, dir, color, crossRows, hasCross, centerUseByStart, inboundDownAt)
local goingDown = (dir == "down")
if goingDown then
inboundDownAt[eRow] = true
end
local function markCU()
local cu = centerUseByStart[sRow] or {up = false, down = false}
if goingDown then
cu.down = true
else
cu.up = true
end
centerUseByStart[sRow] = cu
end
if not hasCross then
if goingDown then
paintVertical(j, sRow + 1, eRow, 1, color)
IN(j, eRow, "upper", false, color)
else
paintVertical(j, eRow + 1, sRow, 1, color)
IN(j, eRow, "lower", false, color)
end
markCU()
return
end
local cr = pickCrossBetween(crossRows, sRow, eRow)
if cr ~= 0 then
if goingDown then
paintVertical(j, sRow + 1, cr - 1, 1, color)
state.crossCell[j][cr].left = {1, color}
state.crossCell[j][cr].right = state.crossCell[j][cr].right or {0, color}
paintVertical(j, cr + 2, eRow, 2, color)
IN(j, eRow, "upper", false, color)
else
paintVertical(j, eRow + 1, cr - 1, 2, color)
state.crossCell[j][cr].right = {1, color}
state.crossCell[j][cr].left = state.crossCell[j][cr].left or {0, color}
paintVertical(j, cr + 2, sRow, 1, color)
IN(j, eRow, "lower", false, color)
end
else
if goingDown then
paintVertical(j, sRow + 1, eRow, 2, color)
IN(j, eRow, "upper", false, color)
else
paintVertical(j, eRow + 1, sRow, 2, color)
IN(j, eRow, "lower", false, color)
end
markCU()
end
end
-- Detect incoming style (from left path column) per row:
-- returns: map[row] = { side = "upper"|"lower", feather = true|false }
local function detectIncomingStyle(j)
local res = {}
local left = state.pathCell and state.pathCell[j - 1]
if not left then
return res
end
local R = config.r
for i = 1, R - 1 do
local cU = left[i] and left[i][3] and left[i][3][1] or {0, 0, 0, 0} -- row i, k=3
local cL = left[i + 1] and left[i + 1][3] and left[i + 1][3][1] or {0, 0, 0, 0}
-- row i+1, k=3
local upperStrong = (cU[3] or 0) == STRONG
local lowerStrong = (cL[1] or 0) == STRONG
local upperFeather = (cL[1] or 0) > 0 and (cL[1] ~= STRONG) -- L feather present?
local lowerFeather = (cU[3] or 0) > 0 and (cU[3] ~= STRONG) -- U feather present?
if upperStrong and not lowerStrong then
res[i] = {side = "upper", feather = upperFeather}
elseif lowerStrong and not upperStrong then
res[i] = {side = "lower", feather = lowerFeather}
end
end
return res
end
-- If no edge is painted, returns 0, nil
local function getEdge(col, row, k, edge)
local pc = state.pathCell and state.pathCell[col]
local cell = pc and pc[row] and pc[row][k]
if not cell then
return 0, nil
end
local w = (cell[1] and cell[1][edge]) or 0
return w, (w ~= 0) and cell.color or nil
end
local function finalizeThroughLineColors()
for teamCol = config.minc + 1, config.c do
local leftPathCol = teamCol - 1
local rightPathCol = teamCol
local Ent = state.entries[teamCol]
if Ent then
for sRow = 1, config.r - 1 do
local hasBottom, hasTop = throughEdgesMaskAt(teamCol, sRow)
if hasBottom and Ent[sRow - 1] and Ent[sRow - 1].ctype == "line" then
-- bottom edges at row sRow (left k=3, right k=1) use edge=3
local wL, cL = getEdge(leftPathCol, sRow, 3, 3)
local wR, cR = getEdge(rightPathCol, sRow, 1, 3)
Ent[sRow - 1].color = (wL ~= 0 and wR ~= 0 and cL == cR) and cL or nil
end
if hasTop and Ent[sRow + 1] and Ent[sRow + 1].ctype == "line" then
-- top edges at row sRow+1 use edge=1
local wL, cL = getEdge(leftPathCol, sRow + 1, 3, 1)
local wR, cR = getEdge(rightPathCol, sRow + 1, 1, 1)
Ent[sRow + 1].color = (wL ~= 0 and wR ~= 0 and cL == cR) and cL or nil
end
end
end
end
end
-- =========================
-- 9) PATH VISIBILITY GATES
-- =========================
local function hasVisibleOutgoingFrom(j, startRow)
if j >= config.c then
return false
end
for _, q in ipairs(parsePaths(j) or {}) do
local qStartRow = rowOf(j, q[1])
if qStartRow == startRow then
local qStopRow = rowOf(j + 1, q[2])
if dispositionOf(j + 1, qStopRow) ~= "suppress" then
return true
end
end
end
return false
end
local function roundGateBlocks(col, startRow)
if not rdGateOff[col] then
return false
end
local _, hidx = scanWindow(col, startRow)
return (hidx == 1)
end
local function hasNonSuppressedIncomingTo(j, startRow)
if j <= config.minc then
return false
end
for _, q in ipairs(parsePaths(j - 1) or {}) do
if rowOf(j, q[2]) == startRow then
local leftStart = rowOf(j - 1, q[1])
if dispositionOf(j - 1, leftStart) ~= "suppress" and not roundGateBlocks(j - 1, leftStart) then
return true
end
end
end
return false
end
local function isPathHidden(j, start, stop, leftDisp, rightDisp)
leftDisp = leftDisp or dispositionOf(j, start)
rightDisp = rightDisp or dispositionOf(j + 1, stop)
if leftDisp == "suppress" or rightDisp == "suppress" then
return true
end
do
local b, t = throughEdgesMaskAt(j, start)
if not (slotIsVisiblyReal(j, start) or b or t) then
return true
end
end
do
local b, t = throughEdgesMaskAt(j + 1, stop)
if not (slotIsVisiblyReal(j + 1, stop) or isThroughableTeamSlot(j + 1, stop) or b or t) then
return true
end
end
if leftDisp == "through" and not hasNonSuppressedIncomingTo(j, start) then
return true
end
if rightDisp == "through" and not hasVisibleOutgoingFrom(j + 1, stop) then
return true
end
if rdGateOff[j] then
local _, hidx = scanWindow(j, start)
if hidx == 1 then
return true
end
end
return false
end
-- ============================
-- 10) THROUGH-LINES INSERTION
-- ============================
local function insertThroughLinesForColumn(j)
if not showByeOn then
return
end
local Pj = state.pathCell[j]
if not Pj then
return
end
state.entries[j + 1] = state.entries[j + 1] or {}
local Ent, R = state.entries[j + 1], config.r
for i = 1, R - 1 do
if isThroughableTeamSlot(j + 1, i) then
local c_i3 = Pj[i] and Pj[i][3]
local c_ip1_3 = Pj[i + 1] and Pj[i + 1][3]
local b_i3 = c_i3 and c_i3[1] or nil
local b_ip1_3 = c_ip1_3 and c_ip1_3[1] or nil
local hasBottom = (b_i3 and (b_i3[3] or 0) ~= 0) or false
local hasTop = (b_ip1_3 and (b_ip1_3[1] or 0) ~= 0) or false
-- bounds guards
if hasBottom and (i - 1) >= 1 then
Ent[i - 1] = {ctype = "line", border = "bottom"}
Ent[i] = Ent[i] or {ctype = "blank"}
end
if hasTop and (i + 1) <= R then
Ent[i + 1] = {ctype = "line", border = "top"}
if (i + 2) <= R then
Ent[i + 2] = Ent[i + 2] or {ctype = "blank"}
end
end
end
end
end
-- Thin same-color double-in at the right path column (k=3):
-- If both U (row i, edge=3) and L (row i+1, edge=1) are STRONG and colors match,
-- make L a FEATHER => 2U/1L.
local function thinDoubleInSameColorForColumn(j)
local R = config.r
local pc = state.pathCell[j]
if not pc then
return
end
for i = 1, R - 1 do
local cellU = pc[i] and pc[i][3] -- row i, k=3 (U edge lives here at b[3])
local cellL = pc[i + 1] and pc[i + 1][3] -- row i+1, k=3 (L edge lives here at b[1])
if cellU and cellL and cellU[1] and cellL[1] then
local bU, bL = cellU[1], cellL[1]
local uStrong = (bU[3] or 0) == STRONG
local lStrong = (bL[1] or 0) == STRONG
-- Only thin when BOTH sides are strong and colors match
if uStrong and lStrong and cellU.color == cellL.color then
bL[1] = FEATHER -- 2U2L -> 2U1L
-- keep color as-is (already the same on both cells)
end
end
end
end
local function rememberRowStyle(rowStyleByColor, sRow, color, side, feather)
local map = rowStyleByColor[sRow]
if not map then
map = {}
rowStyleByColor[sRow] = map
end
map[color] = {side = side, feather = feather}
end
local function summarizeDirs(items)
local hasUp, hasDown, hasStraight = false, false, false
local upColor, downColor, straightColor
for _, it in ipairs(items) do
if it.dir == "up" then
hasUp, upColor = true, it.color
end
if it.dir == "down" then
hasDown, downColor = true, it.color
end
if it.dir == "straight" then
hasStraight, straightColor = true, it.color
end
end
return hasUp, hasDown, hasStraight, upColor, downColor, straightColor
end
-- ===========================
-- 11) MAIN PATH DRAWING LOOP
-- ===========================
local function drawPathsForColumn(j)
local R = config.r
ensureColumnScaffolding(j)
local crossRows, hasCross = getCrossRowsFor(j)
state.hascross[j] = hasCross
local paths = parsePaths(j) or {}
local outByStart, straightStubs, centerUseByStart, inboundDownAt = {}, {}, {}, {}
-- record the chosen OUT style per starting row and color for this column
local rowStyleByColor = {} -- [sRow] -> { [color] = { side="upper"|"lower", feather=bool } }
-- paint verticals + collect fork info
for _, p in ipairs(paths) do
local sIdx, eIdx = p[1], p[2]
local color = p.color or defaultColor
local sRow = rowOf(j, sIdx)
local eRow = rowOf(j + 1, eIdx)
-- skip if either endpoint suppressed; avoid cycles by using isPathHidden
local leftDisp = dispositionOf(j, sRow)
local rightDisp = dispositionOf(j + 1, eRow)
if not isPathHidden(j, sRow, eRow, leftDisp, rightDisp) then
local dir = (sRow < eRow) and "down" or ((sRow > eRow) and "up" or "straight")
if dir == "down" then
inboundDownAt[eRow] = true
end
local bucket = outByStart[sRow]
if not bucket then
bucket = {}
outByStart[sRow] = bucket
end
bucket[#bucket + 1] = {dir = dir, color = color}
if dir ~= "straight" then
paintVerticalWithCross(j, sRow, eRow, dir, color, crossRows, hasCross, centerUseByStart, inboundDownAt)
else
local list = straightStubs[sRow] or {}
straightStubs[sRow] = list
list[#list + 1] = {stopRow = eRow, color = color}
end
end
end
-- decide fork placements and paint edges/joins/stubs
local incomingStyle = detectIncomingStyle(j) -- row -> {side, feather}
for sRow, items in pairs(outByStart) do
local hasUp, hasDown, hasStraight, upColor, downColor, straightColor = summarizeDirs(items)
local straightPlacement = "upper" -- default
local wantTopLower, topColor = false, nil
local wantBottomUpper, bottomColor = false, nil
if hasUp then
wantBottomUpper = true
bottomColor = upColor
end
if hasDown then
wantTopLower = true
topColor = downColor
end
if hasStraight then
if hasUp then
straightPlacement = "lower"
else
local needsLower = false
local list = straightStubs[sRow]
if list then
for _, stub in ipairs(list) do
if inboundDownAt[stub.stopRow] then
needsLower = true
break
end
end
end
if needsLower then
straightPlacement = "lower"
end
end
if straightPlacement == "lower" then
wantTopLower = true
topColor = straightColor or topColor
else
wantBottomUpper = true
bottomColor = straightColor or bottomColor
end
end
-- obey forced incoming placement if a through-edge exists from the left
local appliedForced = nil
do
local leftHasBottom, leftHasTop = throughEdgesMaskAt(j, sRow)
if (leftHasBottom or leftHasTop) then
local info = incomingStyle[sRow] -- { side="upper"|"lower", feather=... } or nil
if info and (info.side == "upper" or info.side == "lower") then
appliedForced = info.side
-- make side intent explicit so later logic is consistent
wantBottomUpper, wantTopLower = (appliedForced == "upper"), (appliedForced == "lower")
if appliedForced == "upper" then
bottomColor = bottomColor or straightColor or upColor or downColor or defaultColor
straightPlacement = "upper"
else
topColor = topColor or straightColor or upColor or downColor or defaultColor
straightPlacement = "lower"
end
end
end
end
-- preserve prior through-edges if present
do
local prevBottom, prevTop = throughEdgesMaskAt(j, sRow)
if prevBottom or prevTop then
wantBottomUpper = prevBottom
wantTopLower = prevTop
if prevBottom ~= prevTop then
appliedForced = prevBottom and "upper" or "lower"
straightPlacement = appliedForced
end
end
end
-- Decide the style to use for a single outgoing path on this row.
local isSingleOut = (#items == 1)
-- default for single-out: always "upper" (2U/1L), unless forced by left through
local styleSingle
if isSingleOut then
styleSingle = appliedForced or "upper"
else
-- multi-out: keep existing side selection logic
if appliedForced then
styleSingle = appliedForced -- "upper" | "lower"
elseif wantBottomUpper and not wantTopLower then
styleSingle = "upper"
elseif wantTopLower and not wantBottomUpper then
styleSingle = "lower"
else
styleSingle = "upper"
end
end
-- If we have a through edge from the left, try to copy feather/no-feather from the incoming.
local lb, lt = throughEdgesMaskAt(j, sRow)
local throughHere = (dispositionOf(j, sRow) == "through") or lb or lt
local forcedInfo = throughHere and incomingStyle[sRow] or nil -- gate by through
local forcedFeather = forcedInfo and forcedInfo.feather -- true | false | nil
if isSingleOut then
local singleColor = (items[1] and items[1].color) or bottomColor or topColor or defaultColor
if forcedFeather == false then
-- through row and incoming was NO feather → out₂ NO feather
OUT(j, sRow, styleSingle, false, singleColor)
rememberRowStyle(rowStyleByColor, sRow, singleColor, styleSingle, false)
else
-- either not a through row, or incoming was feather/unknown → use feathered out (2U/1L)
OUT(j, sRow, styleSingle, true, singleColor)
rememberRowStyle(rowStyleByColor, sRow, singleColor, styleSingle, true)
end
else
-- Multi-out: split sides (no feather), and record
local upC = hasUp and (upColor or defaultColor) or nil
local downC = hasDown and (downColor or defaultColor) or nil
local straightC = hasStraight and (straightColor or defaultColor) or nil
local upperColors, lowerColors = {}, {}
if hasUp and hasDown and not hasStraight then
upperColors[upC] = true -- up → U
lowerColors[downC] = true -- down → L
elseif hasStraight and hasDown and not hasUp then
upperColors[straightC] = true -- straight → U
lowerColors[downC] = true -- down → L
elseif hasStraight and hasUp and not hasDown then
upperColors[upC] = true -- up → U
lowerColors[straightC] = true -- straight → L
else
if wantBottomUpper then
upperColors[(bottomColor or defaultColor)] = true
end
if wantTopLower then
lowerColors[(topColor or defaultColor)] = true
end
end
if next(upperColors) then
for colstr, _ in pairs(upperColors) do
paintEdge(j, sRow, 1, 3, STRONG, colstr) -- 2U, 0L
rememberRowStyle(rowStyleByColor, sRow, colstr, "upper", false)
end
end
if next(lowerColors) and (sRow + 1) <= R then
for colstr, _ in pairs(lowerColors) do
paintEdge(j, sRow + 1, 1, 1, STRONG, colstr) -- 0U, 2L
rememberRowStyle(rowStyleByColor, sRow, colstr, "lower", false)
end
end
end
local cu = centerUseByStart[sRow]
if state.hascross[j] then
-- Look up side+feather recorded for this row+color (falls back to a side, defaults feather=true)
local function styleObjFor(color, fallbackSide)
local map = rowStyleByColor[sRow]
local info = map and color and map[color]
if info then
return info.side, info.feather
end
return fallbackSide, true
end
-- Non-crossing UP hop uses center join: mimic out style for that color
if cu and cu.up then
local color = upColor or defaultColor
local side, feather = styleObjFor(color, appliedForced or "upper")
if feather == false then
JOIN(j, sRow, side, false, color)
else
JOIN(j, sRow, side, true, color)
end
end
-- Non-crossing DOWN hop uses center join: mimic out style for that color
if cu and cu.down then
local color = downColor or defaultColor
local side, feather = styleObjFor(color, appliedForced or "lower")
if feather == false then
JOIN(j, sRow, side, false, color)
else
JOIN(j, sRow, side, true, color)
end
end
-- Non-crossing STRAIGHT(s): there can be multiple colors; do one join per color
if hasStraight and straightStubs[sRow] then
local seen = {}
for _, stub in ipairs(straightStubs[sRow]) do
local color = stub.color or bottomColor or topColor or defaultColor
if not seen[color] then
seen[color] = true
local fallbackSide = (straightPlacement == "lower" and "lower" or "upper")
local side, feather = styleObjFor(color, fallbackSide)
if feather == false then
JOIN(j, sRow, side, false, color)
else
JOIN(j, sRow, side, true, color)
end
end
end
end
end
if hasStraight and straightStubs[sRow] then
for _, stub in ipairs(straightStubs[sRow]) do
local color = stub.color or bottomColor or topColor or defaultColor
local info = rowStyleByColor[sRow] and rowStyleByColor[sRow][color]
local side =
(info and info.side) or appliedForced or styleSingle or
(straightPlacement == "lower" and "lower" or "upper")
if info and info.feather == false then
-- origin was a shared split → IN should be no-feather (2U/0L or 0U/2L)
IN(j, stub.stopRow, side, false, color)
else
-- single-out or unknown → IN mimics feathered OUT (2U/1L or 1U/2L)
IN(j, stub.stopRow, side, true, color)
end
end
end
end
-- Post-pass: thin same-color double-in stubs on this column
thinDoubleInSameColorForColumn(j)
end
-- ===============
-- 12) PUBLIC API
-- ===============
function Paths.build(_state, _config, _Helpers, _StateChecks)
bind(_state, _config, _Helpers, _StateChecks)
clearPathsCache()
computeRoundGates()
computeSlotDispositions()
-- draw each path column, then immediately create through-lines for the team
-- column to its right so the next path column can "see" them
for j = config.minc, config.c - 1 do
drawPathsForColumn(j)
insertThroughLinesForColumn(j)
end
finalizeThroughLineColors()
-- ensure last column exists for renderer (fills arrays to full size)
ensureColumnScaffolding(config.c)
end
-- =======================
-- 13) POST-PASS GROUPING
-- =======================
local function isBlankOrBlankText(col, row)
local ent = entries(col)
if not ent then
return true
end
local top = ent[row]
local bot = ent[row + 1]
if top == nil then
if bot == nil then
return true
end
if bot.ctype == "text" and isBlankEntry(col, row + 1) then
return true
end
elseif top.ctype == "text" and isBlankEntry(col, row) then
return true
end
return false
end
local function applyGroups() -- cosmetic grouping labels spanning left
local R = config.r
for j = config.minc, config.c - 1 do
if (state.teamsPerMatch[j] or 2) == 2 then
local groupIndex = 0
for i = 1, R - 1 do
local hasBottom = (getEdge(j, i, 3, 3) == STRONG)
local hasTop = (getEdge(j, i + 1, 3, 1) == STRONG)
if hasBottom or hasTop then
groupIndex = groupIndex + 1
if isBlankOrBlankText(j, i) then
local k = 0
repeat
local col = j - k
if
state.entries[col] and state.entries[col][i + 1] and
state.entries[col][i + 1].ctype == "text" and
isBlankEntry(col, i + 1)
then
state.entries[col][i + 2] = nil
end
state.entries[col] = state.entries[col] or {}
state.entries[col][i] = {ctype = "blank"}
state.entries[col][i + 1] = {ctype = "blank"}
if k > 0 and StateChecks.noPaths(col, i) then
state.skipPath[col] = state.skipPath[col] or {}
state.skipPath[col][i] = true
state.skipPath[col][i + 1] = true
end
k = k + 1
until k > (j - 1) or not isBlankOrBlankText(j - k, i) or not StateChecks.noPaths(j - k, i)
k = k - 1
local leftCol = j - k
local oj = origJ(j)
state.entries[leftCol] = state.entries[leftCol] or {}
state.entries[leftCol][i] = {
ctype = "group",
index = groupIndex,
colspan = k + 1,
group = bargs("RD" .. oj .. "-group" .. groupIndex),
align = "center"
}
state.entries[leftCol][i + 1] = {ctype = "blank"}
end
end
end
end
end
end
function Paths.attachGroups(_state, _config, _Helpers, _StateChecks)
if _state then
bind(_state, _config, _Helpers, _StateChecks)
end
applyGroups()
end
return Paths