Модуль:Кубковая сетка

Материал из Наро-Фоминский Футбол
Перейти к навигации Перейти к поиску

Для документации этого модуля может быть создана страница Модуль:Кубковая сетка/doc

--[==[
================================================================================
 Модуль:Кубковая сетка
 ------------------------------------------------------------------------------
 Турнирная сетка кубкового (олимпийского) турнира + сводная таблица матчей.
 Вызывается из «Шаблон:Кубковая сетка» так:  {{#invoke:Кубковая сетка|main}}
 (все параметры берутся из вызова шаблона). Внешних зависимостей нет:
 ни Модуль:Arguments, ни другие модули не требуются.

 СХЕМА ПАРАМЕТРОВ (плоская, имена латиницей, регистр важен)
   sKmJ_<поле>   поле матча J этапа K           s1m3_team1 = BRA
   sK_<поле>     поле этапа K                   s1_name = 1/8 финала
   third_<поле>  поле матча за 3-е место        third_score1 = 2
   team_<КОД>    реестр: название команды       team_FRA = [[…|Франция]]
   flag_<КОД>    реестр: флаг команды           flag_FRA = Flag of France.svg
   остальное     глобальные параметры           title, stages, summary, …

 Любое поле матча, даже неизвестное модулю, сохраняется в m.f[<поле>].
 Поэтому новые поля (ссылки на отчёты, статусы, маркеры хозяев и т. п.)
 добавляются без изменения разбора: достаточно начать их где-то использовать.

 ГЕОМЕТРИЯ СЕТКИ (CSS Grid)
   N этапов, R = 2^(N-1) вертикальных «слотов» (= матчей первого этапа).
   Строка 1 — заголовки этапов. Матч J этапа K занимает
       строки   2 + (J-1)·2^(K-1)  …  span 2^(K-1)
       колонку  2K-1
   и поэтому сам оказывается по центру между своими «предками» — матчами
   2J-1 и 2J этапа K-1. Колонки 2K отданы соединительным линиям.
   Никаких фиксированных координат: число этапов и матчей — любое (до
   CFG.maxStages), вся геометрия выводится из этих двух формул.

 СТРУКТУРА ФАЙЛА
   1. CFG      — настройки, тексты, словари (обычно правят только его)
   2. U        — утилиты: да/нет, даты, числа, флаги
   3. Разбор   — плоские параметры → этапы → матчи → поля
   4. Модель   — команды, счёт, победитель, статус, автозаполнение
   5. Сетка    — вывод CSS Grid
   6. Таблица  — сводка матчей (только заполненные)
   7. Точки входа
================================================================================
]==]

local p = {}

--------------------------------------------------------------------------------
-- 1. КОНФИГУРАЦИЯ
--------------------------------------------------------------------------------
local CFG = {}

CFG.maxStages     = 7            -- 7 этапов = начиная с 1/64 финала
CFG.flagSize      = '23x15px'    -- размер флага по умолчанию (параметр flagsize)
CFG.fileNamespace = 'File'       -- каноническое имя работает в вики на любом языке
CFG.dataPrefix    = 'Модуль:Кубковая сетка/данные/'  -- для параметра data=
CFG.errorCategory = nil          -- напр. 'Страницы с ошибками в шаблоне Кубковая сетка'

CFG.i18n = {
	final      = 'Финал',
	semifinal  = 'Полуфиналы',
	quarter    = 'Четвертьфиналы',
	fraction   = '1/%d финала',   -- для этапов раньше четвертьфинала
	penSuffix  = 'пен.',          -- приписка к победителю в серии пенальти
	aetSuffix  = 'доп. вр.',      -- приписка к победителю в доп. время
	shootout   = 'Пенальти',
	attendance = 'Зрителей: ',
	referee    = 'Судья: ',
	report     = 'отчёт',
	noScore    = '–:–',
	scoreSep   = ':',
	captionSep = ' — ',           -- «30 июня — Сочи»
	tbd        = '',              -- текст вместо неизвестной команды
	warnPrefix = 'Кубковая сетка: ',
}

CFG.months = { 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
	'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' }

-- Статусы матча. Ключ → CSS-класс cb-status-<ключ>; label выводится в сводке.
CFG.statuses = {
	scheduled = { label = 'запланирован' },
	live      = { label = 'идёт' },
	finished  = {},
	postponed = { label = 'перенесён' },
	cancelled = { label = 'отменён' },
}
CFG.statusAliases = {
	scheduled = 'scheduled', planned = 'scheduled',
	['запланирован'] = 'scheduled',
	live = 'live', ['идёт'] = 'live', ['идет'] = 'live',
	finished = 'finished', played = 'finished',
	['завершён'] = 'finished', ['завершен'] = 'finished', ['сыгран'] = 'finished',
	postponed = 'postponed', ['перенесён'] = 'postponed', ['перенесен'] = 'postponed',
	cancelled = 'cancelled', canceled = 'cancelled',
	['отменён'] = 'cancelled', ['отменен'] = 'cancelled',
}

-- Синонимы имён полей (матча и этапа). Позволяют локализовать параметры,
-- не трогая логику: s1m1_команда1 == s1m1_team1.
CFG.fieldAliases = {
	['команда1'] = 'team1', ['команда2'] = 'team2',
	['флаг1'] = 'flag1', ['флаг2'] = 'flag2',
	['счёт1'] = 'score1', ['счёт2'] = 'score2', ['счет1'] = 'score1', ['счет2'] = 'score2',
	['пен1'] = 'pen1', ['пен2'] = 'pen2', pens1 = 'pen1', pens2 = 'pen2',
	['дата'] = 'date', ['время'] = 'time', ['город'] = 'city', ['стадион'] = 'stadium',
	venue = 'stadium', ['зрители'] = 'attendance', ['судья'] = 'referee',
	['отчёт'] = 'report', ['статус'] = 'status', ['победитель'] = 'winner',
	et = 'aet', ['доп'] = 'aet', ['название'] = 'name',
}

-- Дополнительные (вне дерева) матчи: выводятся в колонке финала.
--   key        — префикс параметров (third_team1 …) и имя выключателя (third = нет)
--   title      — заголовок по умолчанию (переопределяется параметром <key>_title)
--   autofill   — 'losers': участники = проигравшие полуфиналов
--   beforeFinal — место в сводной таблице (перед финалом или после)
-- Чтобы добавить, например, матч за 5-е место, достаточно ещё одной строки:
--   { key = 'fifth', title = 'Матч за пятое место' },
CFG.extras = {
	{ key = 'third', title = 'Матч за третье место', autofill = 'losers', beforeFinal = true },
}

-- Известные глобальные параметры (остальные попадут в предупреждения при debug=да).
CFG.globals = {
	title = true, stages = true, summary = true, summary_title = true,
	summary_stage = true, summary_sort = true, bracket = true, autofill = true, hide_empty = true,
	flagtemplate = true, flagsize = true, theme = true, class = true,
	styles = true, data = true, debug = true,
}

-- Служебные поля матча, которые не считаются «данными» (для решения,
-- показывать ли доп. матч и считать ли матч заполненным).
CFG.metaFields = { title = true, position = true, class = true, hide = true, summary = true }

--------------------------------------------------------------------------------
-- 2. УТИЛИТЫ
--------------------------------------------------------------------------------
local U = {}

--- Обрезает пробелы; пустую строку превращает в nil.
function U.clean(v)
	if v == nil then return nil end
	v = mw.text.trim(tostring(v))
	if v == '' then return nil end
	return v
end

local YES = { yes = true, y = true, ['true'] = true, ['1'] = true, on = true,
	['да'] = true, ['д'] = true, ['вкл'] = true }
local NO = { no = true, n = true, ['false'] = true, ['0'] = true, off = true,
	['нет'] = true, ['н'] = true, ['выкл'] = true }

--- Разбор «да/нет». Возвращает default, если значение пустое или непонятное.
function U.yesno(v, default)
	v = U.clean(v)
	if not v then return default end
	v = mw.ustring.lower(v)
	if YES[v] then return true end
	if NO[v] then return false end
	return default
end

function U.pow2(n)
	local r = 1
	for _ = 1, n do r = r * 2 end
	return r
end

--- Наименьшее k, при котором 2^k >= n.
function U.ceilLog2(n)
	local k, v = 0, 1
	while v < n do v = v * 2; k = k + 1 end
	return k
end

--- Оставляет в строке только безопасные для class="" символы.
function U.safeClass(s)
	if not s then return nil end
	s = s:gsub('[^%w%-_ ]', '')
	if s == '' then return nil end
	return s
end

--- Дата: 'ГГГГ-ММ-ДД' → '30 июня' (или '30 июня 2018'); любой другой текст — как есть.
function U.formatDate(v, withYear)
	if not v then return nil end
	local y, mo, d = v:match('^(%d%d%d%d)%-(%d%d?)%-(%d%d?)$')
	local month = y and CFG.months[tonumber(mo)]
	if month then
		local s = tonumber(d) .. ' ' .. month
		if withYear then s = s .. ' ' .. y end
		return s
	end
	return v
end

--- Число зрителей: '42873' → '42 873' по правилам языка вики; текст — как есть.
function U.formatNumber(v)
	if not v then return nil end
	local digits = v:gsub('&nbsp;', ''):gsub('\194\160', ''):gsub('%s', '')
	if digits:match('^%d+$') then
		return mw.getContentLanguage():formatNum(tonumber(digits))
	end
	return v
end

--- Ссылка на отчёт: URL → внешняя ссылка «отчёт»; викитекст — как есть.
function U.reportLink(v)
	if v:match('^https?://') or v:match('^//') then
		return '[' .. v .. ' ' .. CFG.i18n.report .. ']'
	end
	return v
end

local IMAGE_EXT = { svg = true, png = true, jpg = true, jpeg = true, gif = true, webp = true }

--- Создаёт функцию отрисовки флага с учётом реестра и настроек вызова.
-- Значение флага может быть:
--   • кодом из реестра (flag_FRA = …)           → берётся значение из реестра;
--   • именем файла (Flag of France.svg)          → [[File:…|23x15px|border|link=]];
--   • готовым викитекстом/результатом шаблона    → выводится как есть;
--   • коротким кодом при заданном flagtemplate   → {{<flagtemplate>|код}}.
function U.makeFlagRenderer(ctx)
	local cache = {}
	return function (v)
		v = U.clean(v)
		if not v then return nil end
		if cache[v] ~= nil then return cache[v] or nil end
		local src = ctx.registry.flag[v] or v
		local out
		if src:find('[%[{<\127]') then
			out = src
		else
			local ext = src:match('%.(%w+)$')
			if ext and IMAGE_EXT[ext:lower()] then
				out = string.format('[[%s:%s|%s|border|link=]]', CFG.fileNamespace, src, ctx.flagSize)
			elseif ctx.flagTemplate then
				out = ctx.frame:expandTemplate{ title = ctx.flagTemplate, args = { src } }
			else
				out = src -- например, эмодзи-флаг
			end
		end
		cache[v] = out or false
		return out
	end
end

--------------------------------------------------------------------------------
-- 3. РАЗБОР ПАРАМЕТРОВ
--------------------------------------------------------------------------------
local function newMatch(key)
	return { key = key, f = {} }
end

local function newStage()
	return { f = {}, matches = {}, maxIndex = 0 }
end

--- Превращает плоский список параметров в иерархию.
-- Возвращает таблицу d:
--   d.g            глобальные параметры
--   d.stages[K]    { f = поля этапа, matches = { [J] = матч }, maxIndex }
--   d.extras[key]  доп. матчи (third …)
--   d.registry     { team = {КОД = название}, flag = {КОД = флаг} }
--   d.warnings     список сообщений об ошибках данных
local function parse(args)
	local d = {
		g = {}, stages = {}, extras = {}, warnings = {}, unknown = {},
		registry = { team = {}, flag = {} },
	}
	local extraKeys = {}
	for _, spec in ipairs(CFG.extras) do
		extraKeys[spec.key] = true
		d.extras[spec.key] = newMatch(spec.key)
	end

	local function field(name) return CFG.fieldAliases[name] or name end

	for name, raw in pairs(args) do
		local value = U.clean(raw)
		if value and type(name) == 'string' then
			local k, j, fname = name:match('^s(%d+)m(%d+)_(.+)$')
			if k then
				k, j = tonumber(k), tonumber(j)
				if k < 1 or j < 1 or k > CFG.maxStages then
					table.insert(d.warnings, 'параметр «' .. name .. '» вне допустимого диапазона')
				else
					d.stages[k] = d.stages[k] or newStage()
					local s = d.stages[k]
					s.matches[j] = s.matches[j] or newMatch('s' .. k .. 'm' .. j)
					s.matches[j].f[field(fname)] = value
					if j > s.maxIndex then s.maxIndex = j end
				end
			else
				local sk, sfield = name:match('^s(%d+)_(.+)$')
				local prefix, rest = name:match('^([%w]+)_(.+)$')
				if sk then
					sk = tonumber(sk)
					if sk >= 1 and sk <= CFG.maxStages then
						d.stages[sk] = d.stages[sk] or newStage()
						d.stages[sk].f[field(sfield)] = value
					end
				elseif CFG.globals[name] or extraKeys[name] then
					d.g[name] = value
				elseif prefix == 'team' then
					d.registry.team[rest] = value
				elseif prefix == 'flag' then
					d.registry.flag[rest] = value
				elseif prefix and extraKeys[prefix] then
					d.extras[prefix].f[field(rest)] = value
				else
					d.g[name] = value
					table.insert(d.unknown, name)
				end
			end
		end
	end
	return d
end

--- Сколько этапов рисовать. Явный stages= важнее; иначе — минимум,
-- при котором все заданные матчи помещаются в дерево:
-- для этапа K с J матчами нужно N >= K + ceil(log2 J).
local function computeStages(d)
	local need = 1
	for k, s in pairs(d.stages) do
		local n = k + U.ceilLog2(math.max(s.maxIndex, 1))
		if n > need then need = n end
	end
	local explicit = tonumber(d.g.stages)
	if explicit then
		explicit = math.floor(explicit)
		if explicit < 1 or explicit > CFG.maxStages then
			table.insert(d.warnings, 'stages должно быть от 1 до ' .. CFG.maxStages)
			explicit = nil
		end
	end
	local N = explicit or need
	if N > CFG.maxStages then N = CFG.maxStages end
	return N
end

--------------------------------------------------------------------------------
-- 4. МОДЕЛЬ МАТЧА
--------------------------------------------------------------------------------

--- Победитель: явный winner=1|2, иначе по счёту, при равенстве — по пенальти.
-- Нечисловой счёт (например «+:−») требует явного winner.
local function computeWinner(m)
	local w = tonumber(m.f.winner)
	if w == 1 or w == 2 then return w end
	local a, b = tonumber(m.side[1].score), tonumber(m.side[2].score)
	if not (a and b) then return nil end
	if a ~= b then return a > b and 1 or 2 end
	local pa, pb = tonumber(m.side[1].pen), tonumber(m.side[2].pen)
	if pa and pb and pa ~= pb then return pa > pb and 1 or 2 end
	return nil
end

local function computeStatus(m)
	local v = m.f.status
	if v then
		local key = CFG.statusAliases[mw.ustring.lower(v)]
		return key or U.safeClass(mw.ustring.lower(v)) or 'custom'
	end
	if m.side[1].score or m.side[2].score then return 'finished' end
	return 'scheduled'
end

--- Есть ли у матча собственные (введённые, а не служебные) данные.
local function hasOwnData(m)
	for k in pairs(m.f) do
		if not CFG.metaFields[k] then return true end
	end
	return false
end

--- Нормализует «сырые» поля матча в удобную для отрисовки структуру m.side[1..2].
local function normalize(m, d)
	if m.normalized then return m end
	local f = m.f
	m.side = {}
	for i = 1, 2 do
		local raw = f['team' .. i]
		local side = { score = f['score' .. i], pen = f['pen' .. i] }
		if raw then
			side.explicit = true
			if d.registry.team[raw] then
				side.name, side.code = d.registry.team[raw], raw
			elseif d.registry.flag[raw] then
				side.name, side.code = raw, raw   -- код есть только в реестре флагов
			else
				side.name = raw
			end
		end
		if f['flag' .. i] then
			side.flag = d.flag(f['flag' .. i])
			side.flagExplicit = true
		elseif side.code and (d.registry.flag[side.code] or d.flagTemplate) then
			side.flag = d.flag(side.code)
		end
		m.side[i] = side
	end
	m.aet     = U.yesno(f.aet, false)
	m.hasPens = (m.side[1].pen or m.side[2].pen) ~= nil
	m.home    = tonumber(f.home)
	m.winner  = computeWinner(m)
	m.status  = computeStatus(m)
	m.hasData = hasOwnData(m)
	m.normalized = true
	return m
end

local function slots(d, k)
	return U.pow2(d.N - k)
end

--- Возвращает (при необходимости создаёт) нормализованный матч J этапа K.
local function getMatch(d, k, j)
	d.stages[k] = d.stages[k] or newStage()
	local s = d.stages[k]
	s.matches[j] = s.matches[j] or newMatch('s' .. k .. 'm' .. j)
	return normalize(s.matches[j], d)
end

--- Копирует команду из матча-источника в свободную сторону.
local function fillSide(target, source)
	target.name, target.code, target.auto = source.name, source.code, true
	if not target.flagExplicit then target.flag = source.flag end
end

--- Прямой проход по этапам: нормализация всех слотов дерева и
-- автозаполнение: победитель матча 2J-1 этапа K-1 → команда 1 матча J этапа K,
-- победитель матча 2J → команда 2. Явно заданная команда не перезаписывается.
local function buildTree(d)
	for k = 1, d.N do
		for j = 1, slots(d, k) do
			local m = getMatch(d, k, j)
			if k > 1 and d.autofill then
				for i = 1, 2 do
					local src = getMatch(d, k - 1, 2 * (j - 1) + i)
					if not m.side[i].explicit and src.winner and src.side[src.winner].name then
						fillSide(m.side[i], src.side[src.winner])
					end
				end
			end
		end
	end
	-- матчи, не попавшие в дерево
	for k, s in pairs(d.stages) do
		for j, m in pairs(s.matches) do
			if (k > d.N or j > U.pow2(math.max(d.N - k, 0))) and hasOwnData(m) then
				table.insert(d.warnings, 'матч ' .. m.key .. ' не помещается в сетку из ' .. d.N ..
					' этапов (увеличьте stages= или проверьте номер)')
			end
		end
	end
	-- дополнительные матчи
	for _, spec in ipairs(CFG.extras) do
		local m = normalize(d.extras[spec.key], d)
		local switch = U.yesno(d.g[spec.key], nil)
		if switch == nil then m.shown = m.hasData else m.shown = switch end
		m.title = m.f.title or spec.title
		if m.shown and d.autofill and spec.autofill == 'losers' and d.N >= 2 then
			for i = 1, 2 do
				local src = getMatch(d, d.N - 1, i)
				if not m.side[i].explicit and src.winner then
					local loser = src.side[3 - src.winner]
					if loser.name then fillSide(m.side[i], loser) end
				end
			end
		end
	end
	-- скрытие (после автозаполнения, чтобы не прятать заполненные матчи)
	local function isEmpty(m)
		local a, b = m.side[1], m.side[2]
		return not (a.name or b.name or a.score or b.score or m.f.date or m.f.caption)
	end
	for k = 1, d.N do
		for j = 1, slots(d, k) do
			local m = getMatch(d, k, j)
			m.hidden = U.yesno(m.f.hide, false) or (d.hideEmpty and isEmpty(m))
		end
	end
end

--------------------------------------------------------------------------------
-- 5. СЕТКА
--------------------------------------------------------------------------------

--- Название этапа: sK_name или значение по умолчанию по расстоянию до финала.
local function stageName(d, k)
	local s = d.stages[k]
	if s and s.f.name then return s.f.name end
	local dist = d.N - k
	if dist == 0 then return CFG.i18n.final end
	if dist == 1 then return CFG.i18n.semifinal end
	if dist == 2 then return CFG.i18n.quarter end
	return string.format(CFG.i18n.fraction, U.pow2(dist))
end

--- Подпись над матчем в сетке: «30 июня — Сочи». caption= заменяет её целиком.
local function bracketCaption(m)
	if m.f.caption then return m.f.caption end
	local date = U.formatDate(m.f.date, false)
	local place = m.f.place or m.f.city or m.f.stadium
	if date and place then return date .. CFG.i18n.captionSep .. place end
	return date or place or '&nbsp;' -- пустая подпись всё равно занимает место
end

local function winnerSuffix(m)
	if m.hasPens then return CFG.i18n.penSuffix end
	if m.aet then return CFG.i18n.aetSuffix end
	return nil
end

--- Один матч сетки: подпись + две строки «флаг | команда | счёт».
-- Структура (caption + box + отступ) одинакова у всех матчей: на этом
-- держится выравнивание соединительных линий (см. styles.css, «Геометрия»).
local function renderMatchItem(m, extraClass)
	local item = mw.html.create('div')
		:addClass('cb-item')
		:addClass('cb-status-' .. m.status)
		:addClass(U.safeClass(m.f.class))
		:addClass(extraClass)
	if m.hidden then item:addClass('cb-item--hidden') end
	if m.f.report then item:addClass('cb-item--has-report') end

	item:tag('div'):addClass('cb-caption'):tag('span'):wikitext(bracketCaption(m))

	local box = item:tag('div'):addClass('cb-match')
	local suffix = winnerSuffix(m)
	for i = 1, 2 do
		local s = m.side[i]
		local row = box:tag('div'):addClass('cb-team')
		if m.winner == i then
			row:addClass('cb-team--winner')
		elseif m.winner then
			row:addClass('cb-team--loser')
		end
		if m.home == i then row:addClass('cb-team--home') end
		if not s.name then row:addClass('cb-team--tbd') end
		if s.auto then row:addClass('cb-team--auto') end

		local cell = row:tag('div'):addClass('cb-name')
		if s.flag then cell:tag('span'):addClass('cb-flag'):wikitext(s.flag) end
		local label = cell:tag('span'):addClass('cb-label'):wikitext(s.name or CFG.i18n.tbd)
		if suffix and m.winner == i then
			label:tag('span'):addClass('cb-suffix'):wikitext(' (' .. suffix .. ')')
		end

		local score = s.score or ''
		if s.score and s.pen then score = score .. '&nbsp;(' .. s.pen .. ')' end
		row:tag('div'):addClass('cb-score'):wikitext(score)
	end
	return item
end

--- Соединитель от матча J этапа K к его «потомку» в этапе K+1.
-- Верхний предок (J нечётный) рисует линию вниз, нижний — вверх;
-- горизонтальный «вход» в потомка рисует верхний предок (или нижний,
-- если верхний скрыт — например, при свободном проходе).
local function renderConnector(grid, area, col, isTop, drawIn)
	local conn = grid:tag('div')
		:addClass('cb-conn')
		:addClass(isTop and 'cb-conn--top' or 'cb-conn--bottom')
		:css('grid-row', area)
		:css('grid-column', tostring(col))
	conn:tag('div'):addClass('cb-conn-out')
	if drawIn then conn:tag('div'):addClass('cb-conn-in') end
end

--- Размещение доп. матчей в колонке финала.
-- 'inside' — в нижней половине колонки финала (как на исходном изображении);
-- 'below'  — отдельной строкой под сеткой.
-- inside занимает нижнюю половину слотов (строки R/2+2 … R+1) и потому
-- центрируется на уровне нижнего полуфинала. Это возможно только при N >= 4:
-- в половине колонки должно быть не меньше 4 строк-слотов, иначе блок
-- наедет на финал или растянет строки (а растянутая строка сдвинула бы
-- соединительные линии). При N <= 3 блок всегда выводится под сеткой.
local function extrasArea(d, visible)
	local pos
	for _, e in ipairs(visible) do pos = pos or e.m.f.position end
	local R = U.pow2(d.N - 1)
	local inside = d.N >= 4 and pos ~= 'below'
	if inside then
		return (R / 2 + 2) .. ' / span ' .. (R / 2)
	end
	return (R + 2) .. ' / span 1'
end

local function renderBracket(d)
	local N = d.N
	local wrap = mw.html.create('div'):addClass('cb-scroll')
	local grid = wrap:tag('div'):addClass('cb-grid')

	for k = 1, N do
		local col = 2 * k - 1
		local sf = d.stages[k] and d.stages[k].f or {}
		grid:tag('div')
			:addClass('cb-head')
			:addClass(U.safeClass(sf.class))
			:css('grid-row', '1')
			:css('grid-column', tostring(col))
			:wikitext(stageName(d, k))

		local span = U.pow2(k - 1)
		for j = 1, slots(d, k) do
			local m = getMatch(d, k, j)
			local area = (2 + (j - 1) * span) .. ' / span ' .. span
			grid:node(renderMatchItem(m, U.safeClass(sf.class))
				:css('grid-row', area)
				:css('grid-column', tostring(col)))

			if k < N and not m.hidden then
				local child = getMatch(d, k + 1, math.ceil(j / 2))
				if not child.hidden then
					local isTop = (j % 2 == 1)
					local sibling = getMatch(d, k, isTop and j + 1 or j - 1)
					renderConnector(grid, area, col + 1, isTop, isTop or sibling.hidden)
				end
			end
		end
	end

	-- доп. матчи (за 3-е место и т. п.)
	local visible = {}
	for _, spec in ipairs(CFG.extras) do
		local m = d.extras[spec.key]
		if m.shown then table.insert(visible, { spec = spec, m = m }) end
	end
	if #visible > 0 then
		local box = grid:tag('div')
			:addClass('cb-extras')
			:css('grid-row', extrasArea(d, visible))
			:css('grid-column', tostring(2 * N - 1))
		for _, e in ipairs(visible) do
			box:tag('div')
				:addClass('cb-extra')
				:addClass('cb-extra--' .. e.spec.key)
				:tag('div'):addClass('cb-head'):addClass('cb-extra-head'):wikitext(e.m.title):done()
				:node(renderMatchItem(e.m))
		end
	end
	return wrap
end

--------------------------------------------------------------------------------
-- 6. СВОДНАЯ ТАБЛИЦА
--------------------------------------------------------------------------------

-- Строки информационной колонки (место, зрители, судья, примечание).
-- Каждая функция получает нормализованный матч и возвращает викитекст или nil.
-- Новая строка (например, «Трансляция: …») = ещё одна функция в списке.
CFG.summaryInfo = {
	function (m)
		local parts = {}
		if m.f.stadium then table.insert(parts, m.f.stadium) end
		if m.f.city then table.insert(parts, m.f.city) end
		if #parts > 0 then return table.concat(parts, ', ') end
	end,
	function (m)
		if m.f.attendance then return CFG.i18n.attendance .. U.formatNumber(m.f.attendance) end
	end,
	function (m, d)
		if m.f.referee then
			local flag = d.flag(m.f.referee_flag)
			return CFG.i18n.referee ..
				(flag and ('<span class="cb-flag">' .. flag .. '</span>&nbsp;') or '') .. m.f.referee
		end
	end,
	function (m)
		if m.f.note then return "''" .. m.f.note .. "''" end
	end,
}

--- Попадает ли матч в сводку: summary=да/нет у матча решает сразу;
-- иначе нужны обе команды и хотя бы что-то из счёта/даты/места.
local function inSummary(m)
	local ov = U.yesno(m.f.summary, nil)
	if ov ~= nil then return ov end
	local a, b, f = m.side[1], m.side[2], m.f
	if not (a.name and b.name) then return false end
	return (a.score or b.score or f.date or f.time or f.stadium or f.city) ~= nil
end

local function scoreLine(m)
	local a, b = m.side[1].score, m.side[2].score
	if not (a or b) then return CFG.i18n.noScore end
	return (a or '?') .. CFG.i18n.scoreSep .. (b or '?')
end

local function renderSummaryTeam(tr, m, i, d)
	local s = m.side[i]
	local td = tr:tag('td'):addClass('cb-s-team'):addClass('cb-s-team' .. i)
	if m.winner == i then td:addClass('cb-s-winner') end
	local name = td:tag('div'):addClass('cb-s-name')
	local flag = s.flag and ('<span class="cb-flag">' .. s.flag .. '</span>') or nil
	-- команда 1: «Франция [флаг]», команда 2: «[флаг] Аргентина» — как на исходном образце
	if i == 1 then
		name:wikitext(s.name)
		if flag then name:wikitext('&nbsp;' .. flag) end
	else
		if flag then name:wikitext(flag .. '&nbsp;') end
		name:wikitext(s.name)
	end
	if m.f['goals' .. i] then
		td:tag('div'):addClass('cb-s-goals'):wikitext(m.f['goals' .. i])
	end
end

local function renderSummaryMatch(tbl, m, d)
	local f = m.f
	local shootoutRow = m.hasPens and (f.shootout1 or f.shootout2)

	local tr = tbl:tag('tr')
		:addClass('cb-s-row')
		:addClass('cb-status-' .. m.status)
		:addClass(U.safeClass(f.class))
	if shootoutRow then tr:addClass('cb-s-row--with-shootout') end

	-- 1. дата и время
	local when = tr:tag('td'):addClass('cb-s-when')
	when:tag('div'):addClass('cb-s-date'):wikitext(U.formatDate(f.date, true) or '')
	if f.time then when:tag('div'):addClass('cb-s-time'):wikitext(f.time) end

	-- 2. команда 1
	renderSummaryTeam(tr, m, 1, d)

	-- 3. результат
	local res = tr:tag('td'):addClass('cb-s-result')
	local sc = res:tag('div'):addClass('cb-s-score'):wikitext(scoreLine(m))
	if m.aet then
		sc:tag('span'):addClass('cb-s-aet'):wikitext(' (' .. CFG.i18n.aetSuffix .. ')')
	end
	if m.hasPens and not shootoutRow then
		res:tag('div'):addClass('cb-s-pens'):wikitext(CFG.i18n.penSuffix .. ' ' ..
			(m.side[1].pen or '?') .. CFG.i18n.scoreSep .. (m.side[2].pen or '?'))
	end
	if f.report then
		res:tag('div'):addClass('cb-s-report'):wikitext('(' .. U.reportLink(f.report) .. ')')
	end
	local st = CFG.statuses[m.status]
	if st and st.label then
		res:tag('div'):addClass('cb-s-status'):wikitext(st.label)
	end

	-- 4. команда 2
	renderSummaryTeam(tr, m, 2, d)

	-- 5. место, зрители, судья…
	local info = tr:tag('td'):addClass('cb-s-info')
	for _, fn in ipairs(CFG.summaryInfo) do
		local line = fn(m, d)
		if line then info:tag('div'):wikitext(line) end
	end

	-- серия пенальти со списками бьющих — отдельной строкой
	if shootoutRow then
		local tr2 = tbl:tag('tr'):addClass('cb-s-shootout')
		tr2:tag('td')
		tr2:tag('td'):addClass('cb-s-team1'):wikitext(f.shootout1 or '')
		local c = tr2:tag('td'):addClass('cb-s-result')
		c:tag('div'):addClass('cb-s-shootout-title'):wikitext(CFG.i18n.shootout)
		c:tag('div'):wikitext((m.side[1].pen or '?') .. CFG.i18n.scoreSep .. (m.side[2].pen or '?'))
		tr2:tag('td'):addClass('cb-s-team2'):wikitext(f.shootout2 or '')
		tr2:tag('td')
	end
end

--- Список разделов сводки в хронологическом порядке:
-- этапы 1…N-1, доп. матчи с beforeFinal, финал, остальные доп. матчи.
local function summarySections(d)
	local list = {}
	local function addExtras(before)
		for _, spec in ipairs(CFG.extras) do
			local m = d.extras[spec.key]
			if m.shown and (spec.beforeFinal or false) == before then
				table.insert(list, { key = spec.key, title = m.title, matches = { m } })
			end
		end
	end
	for k = 1, d.N do
		if k == d.N then addExtras(true) end
		local sf = d.stages[k] and d.stages[k].f or {}
		if U.yesno(sf.summary, true) then
			local ms = {}
			for j = 1, slots(d, k) do table.insert(ms, getMatch(d, k, j)) end
			table.insert(list, { key = tostring(k), title = stageName(d, k), matches = ms })
		end
	end
	addExtras(false)
	return list
end

--- Ключ хронологической сортировки: '2018-06-30 17:00' (дата только в ISO).
local function chronoKey(m)
	local date = m.f.date and m.f.date:match('^%d%d%d%d%-%d%d?%-%d%d?$')
	if not date then return nil end
	local y, mo, dd = date:match('^(%d+)%-(%d+)%-(%d+)$')
	local h, mi = (m.f.time or ''):match('^(%d%d?):(%d%d)')
	return string.format('%s-%02d-%02d %02d:%s', y, tonumber(mo), tonumber(dd),
		tonumber(h or 99), mi or '99')
end

--- Сортирует строки раздела по дате и времени, если у всех матчей дата
-- задана в ISO; иначе оставляет порядок сетки (по номерам матчей).
local function sortRows(rows)
	local keys = {}
	for i, m in ipairs(rows) do
		keys[m] = chronoKey(m)
		if not keys[m] then return rows end
		m.order = i
	end
	table.sort(rows, function (a, b)
		if keys[a] ~= keys[b] then return keys[a] < keys[b] end
		return a.order < b.order  -- устойчивость: при равенстве — порядок сетки
	end)
	return rows
end

local function renderSummary(d)
	local sortByDate = U.yesno(d.g.summary_sort, true)
	-- summary_stage = 1,2,third — выводить только перечисленные разделы
	local only
	if d.g.summary_stage then
		only = {}
		for _, v in ipairs(mw.text.split(d.g.summary_stage, ',')) do
			only[mw.text.trim(v)] = true
		end
	end

	local tbl = mw.html.create('table'):addClass('cb-summary')
	if d.g.summary_title then tbl:tag('caption'):wikitext(d.g.summary_title) end
	local any = false
	for _, sec in ipairs(summarySections(d)) do
		if not only or only[sec.key] then
			local rows = {}
			for _, m in ipairs(sec.matches) do
				if inSummary(m) then table.insert(rows, m) end
			end
			if sortByDate then rows = sortRows(rows) end
			if #rows > 0 then
				any = true
				tbl:tag('tr'):addClass('cb-s-stage')
					:tag('th'):attr('colspan', '5'):wikitext(sec.title)
				for _, m in ipairs(rows) do renderSummaryMatch(tbl, m, d) end
			end
		end
	end
	if not any then return nil end
	local wrap = mw.html.create('div'):addClass('cb-summary-wrap')
	wrap:node(tbl)
	return wrap
end

--------------------------------------------------------------------------------
-- 7. ТОЧКИ ВХОДА
--------------------------------------------------------------------------------

--- Собирает модель из плоских аргументов (используется и в тестах, и другими модулями).
function p._build(args, frame)
	local d = parse(args)
	d.N          = computeStages(d)
	d.autofill   = U.yesno(d.g.autofill, true)
	d.hideEmpty  = U.yesno(d.g.hide_empty, false)
	d.flagTemplate = d.g.flagtemplate
	d.flag = U.makeFlagRenderer{
		registry = d.registry, frame = frame,
		flagTemplate = d.g.flagtemplate, flagSize = d.g.flagsize or CFG.flagSize,
	}
	buildTree(d)
	return d
end

--- Отрисовывает модель в HTML (строкой).
function p._render(d)
	local root = mw.html.create('div'):addClass('cb')
		:addClass(d.g.theme and U.safeClass('cb--theme-' .. d.g.theme) or nil)
		:addClass(U.safeClass(d.g.class))
		:attr('data-stages', tostring(d.N))

	if d.g.title then root:tag('div'):addClass('cb-title'):wikitext(d.g.title) end
	if U.yesno(d.g.bracket, true) then root:node(renderBracket(d)) end
	if U.yesno(d.g.summary, true) then
		local s = renderSummary(d)
		if s then root:node(s) end
	end

	if U.yesno(d.g.debug, false) and #d.unknown > 0 then
		table.sort(d.unknown)
		table.insert(d.warnings, 'неизвестные параметры: ' .. table.concat(d.unknown, ', '))
	end
	local tail = ''
	if #d.warnings > 0 then
		local w = root:tag('div'):addClass('cb-warnings')
		for _, msg in ipairs(d.warnings) do
			w:tag('div'):addClass('error'):wikitext(CFG.i18n.warnPrefix .. msg)
		end
		if CFG.errorCategory then tail = '[[Category:' .. CFG.errorCategory .. ']]' end
	end
	return tostring(root) .. tail
end

--- Точка входа для {{#invoke:Кубковая сетка|main}}.
-- Приоритет источников: данные из модуля-хранилища (data=) < параметры
-- #invoke < параметры шаблона. Так одну и ту же сетку можно хранить
-- в Модуль:Кубковая сетка/данные/<имя> и выводить в разных местах статьи.
function p.main(frame)
	local parent = frame:getParent()
	local pargs = parent and parent.args or {}
	local args = {}

	local dataName = U.clean(pargs.data) or U.clean(frame.args.data)
	local warn
	if dataName then
		local ok, data = pcall(mw.loadData, CFG.dataPrefix .. dataName)
		if ok and type(data) == 'table' then
			for k, v in pairs(data) do args[k] = v end
		else
			warn = 'не удалось загрузить данные «' .. CFG.dataPrefix .. dataName .. '»'
		end
	end
	for k, v in pairs(frame.args) do if k ~= 'styles' then args[k] = v end end
	for k, v in pairs(pargs) do args[k] = v end

	local d = p._build(args, frame)
	if warn then table.insert(d.warnings, warn) end
	local out = p._render(d)

	-- при прямом #invoke можно подключить стили без шаблона: |styles=Шаблон:…/styles.css
	local styles = U.clean(frame.args.styles)
	if styles then
		out = frame:extensionTag('templatestyles', '', { src = styles }) .. out
	end
	return out
end

return p