Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
No edit summary
Line 78: Line 78:
}
}
end
end
return people[name]
return people[name]
end
end
Line 100: Line 101:
local page = trim(row.Page)
local page = trim(row.Page)
local displayName = trim(row.DisplayName)
local displayName = trim(row.DisplayName)
if isRealValue(page) then
if isRealValue(page) then
people[page] = {
people[page] = {
Line 249: Line 251:
end
end


local function getMarriageYear(union)
local function getUnionMeta(people, root, partner)
local union = findUnionBetween(people, root, partner)
if not union then return nil end
if not union then return nil end
local raw = union.marriageDate or union.engagementDate or union.startDate
 
if not isRealValue(raw) then return nil end
local label = union.unionType or union.status
return tostring(raw):match('^(%d%d%d%d)') or tostring(raw)
local year = union.marriageDate or union.startDate or union.engagementDate
 
local out = {}
if isRealValue(label) then
table.insert(out, label)
end
if isRealValue(year) then
local y = tostring(year):match('^(%d%d%d%d)') or tostring(year)
table.insert(out, y)
end
 
if #out == 0 then return nil end
return table.concat(out, ' • ')
end
end


Line 320: Line 335:
end
end


local function getRootSiblingSequence(people, root)
local function splitAroundCenter(items)
local siblings = getSiblings(people, root)
local left, right = {}, {}
local seq, inserted = {}, false
local n = #items
local midpoint = math.floor(#siblings / 2) + 1
local leftCount = math.floor(n / 2)


for i, sib in ipairs(siblings) do
for i, v in ipairs(items) do
if i == midpoint then
if i <= leftCount then
table.insert(seq, root)
table.insert(left, v)
inserted = true
else
table.insert(right, v)
end
end
table.insert(seq, sib)
end
end


if not inserted then
return left, right
table.insert(seq, root)
end
 
return seq
end
end


Line 372: Line 383:
end
end


-- partner-only groups, so spouses show even without children
-- partner-only groups so spouse still shows with no children
for _, partner in ipairs(person.partners or {}) do
for _, partner in ipairs(person.partners or {}) do
if isRealValue(partner) then
if isRealValue(partner) then
Line 454: Line 465:
end
end


local function renderCouple(people, leftName, rightName, marriageYear, leftClass, rightClass)
local function renderCouple(people, leftName, rightName)
if not isRealValue(leftName) and not isRealValue(rightName) then
return nil
end
 
if isRealValue(leftName) and isRealValue(rightName) then
if isRealValue(leftName) and isRealValue(rightName) then
local wrap = html.create('div')
local wrap = html.create('div')
wrap:addClass('kbft-couple')
wrap:addClass('kbft-couple')
 
wrap:node(renderCard(people, leftName))
wrap:node(renderCard(people, leftName, nil, leftClass))
 
local marriage = wrap:tag('div')
local marriage = wrap:tag('div')
marriage:addClass('kbft-marriage')
marriage:addClass('kbft-marriage')
 
marriage:tag('div'):addClass('kbft-marriage-line')
if isRealValue(marriageYear) then
wrap:node(renderCard(people, rightName))
marriage:tag('div')
:addClass('kbft-marriage-year')
:wikitext(marriageYear)
end
 
marriage:tag('div')
:addClass('kbft-marriage-line')
 
wrap:node(renderCard(people, rightName, nil, rightClass))
return wrap
return wrap
end
end


if isRealValue(leftName) then return renderSingleCard(people, leftName, leftClass) end
if isRealValue(leftName) then return renderSingleCard(people, leftName) end
if isRealValue(rightName) then return renderSingleCard(people, rightName, rightClass) end
return renderSingleCard(people, rightName)
return nil
end
end


local function renderGenerationRow(units)
local function renderGenerationRow(units, className)
local row = html.create('div')
local row = html.create('div')
row:addClass('kbft-row')
row:addClass(className or 'kbft-row')


for _, unit in ipairs(units) do
for _, unit in ipairs(units) do
Line 501: Line 504:
local units = {}
local units = {}
for _, pair in ipairs(couples) do
for _, pair in ipairs(couples) do
table.insert(units, renderCouple(people, pair[1], pair[2], nil))
table.insert(units, renderCouple(people, pair[1], pair[2]))
end
end


gen:node(renderGenerationRow(units))
gen:node(renderGenerationRow(units, 'kbft-row'))
return gen
return gen
end
end
Line 532: Line 535:
end
end


local function renderFocalGeneration(people, root)
local function renderFocalGeneration(people, root, groups)
local sequence = getRootSiblingSequence(people, root)
local siblings = getSiblings(people, root)
 
local partners = {}
local seen = {}
for _, group in ipairs(groups or {}) do
if isRealValue(group.partner) and not seen[group.partner] then
seen[group.partner] = true
table.insert(partners, group.partner)
end
end
sortNames(people, partners)
 
local leftSibs, rightSibs = splitAroundCenter(siblings)
local leftPartners, rightPartners = splitAroundCenter(partners)


local gen = html.create('div')
local gen = html.create('div')
gen:addClass('kbft-generation')
gen:addClass('kbft-generation')


local groupWrap = gen:tag('div')
local wrap = gen:tag('div')
groupWrap:addClass('kbft-siblings')
wrap:addClass('kbft-siblings')


groupWrap:tag('div')
wrap:tag('div')
:addClass('kbft-sibling-spine')
:addClass('kbft-sibling-spine')


local row = groupWrap:tag('div')
local row = wrap:tag('div')
row:addClass('kbft-sibling-row')
row:addClass('kbft-union-row')


for _, name in ipairs(sequence) do
local function addCol(kind, name, meta)
local unit = row:tag('div')
local col = row:tag('div')
unit:addClass('kbft-sibling-unit')
col:addClass('kbft-union-col')
col:attribute('data-kind', kind)


unit:tag('div')
local connector = col:tag('div')
:addClass('kbft-sibling-up')
connector:addClass('kbft-sibling-up')


if name == root then
if kind == 'root' then
unit:node(renderSingleCard(people, name, 'kbft-focus-card'))
col:node(renderSingleCard(people, name, 'kbft-focus-card'))
else
else
unit:node(renderSingleCard(people, name))
col:node(renderSingleCard(people, name))
end
 
if isRealValue(meta) then
col:tag('div')
:addClass('kbft-union-meta')
:wikitext(meta)
end
end
end
end


return gen
for _, sib in ipairs(leftSibs) do
end
addCol('sibling', sib, nil)
end
 
for _, partner in ipairs(leftPartners) do
addCol('partner', partner, getUnionMeta(people, root, partner))
end
 
addCol('root', root, nil)


local function renderBranchChildren(people, children)
for _, partner in ipairs(rightPartners) do
local childWrap = html.create('div')
addCol('partner', partner, getUnionMeta(people, root, partner))
childWrap:addClass('kbft-children')
end


for _, child in ipairs(children) do
for _, sib in ipairs(rightSibs) do
childWrap:node(
addCol('sibling', sib, nil)
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
end


return childWrap
return gen
end
end


local function renderFamilyGroupsGeneration(people, root)
local function renderDescendantGeneration(people, root, groups)
local groups = getFamilyGroupsForRoot(people, root)
if #groups == 0 then return nil end
if #groups == 0 then return nil end


local gen = html.create('div')
local partners = {}
gen:addClass('kbft-generation')
local partnerGroups = {}
local soloGroup = nil


-- =========================
-- ROW 1: ROOT + PARTNERS
-- =========================
local topRow = gen:tag('div')
topRow:addClass('kbft-row')
-- root FIRST
topRow:node(renderSingleCard(people, root, 'kbft-focus-card'))
-- partners
for _, group in ipairs(groups) do
for _, group in ipairs(groups) do
if isRealValue(group.partner) then
if isRealValue(group.partner) then
topRow:node(renderSingleCard(people, group.partner))
table.insert(partners, group.partner)
partnerGroups[group.partner] = group
else
soloGroup = group
end
end
end
end
sortNames(people, partners)


-- =========================
local gen = html.create('div')
-- CONNECTOR DOWN
gen:addClass('kbft-generation')
-- =========================
gen:addClass('kbft-desc-generation')


gen:tag('div')
local row = gen:tag('div')
:addClass('kbft-connector')
row:addClass('kbft-branch-columns')


-- =========================
local function addBranchCol(kind, partnerName, group)
-- ROW 2: CHILDREN GROUPED PER PARTNER
local col = row:tag('div')
-- =========================
col:addClass('kbft-branch-column')
col:attribute('data-kind', kind)


local bottomRow = gen:tag('div')
-- top spacer / hidden card to keep columns aligned to focal row
bottomRow:addClass('kbft-row')
local top = col:tag('div')
top:addClass('kbft-branch-top')


for _, group in ipairs(groups) do
if kind == 'solo' then
local unit = bottomRow:tag('div')
top:tag('div'):addClass('kbft-branch-hidden-card')
unit:addClass('kbft-sibling-unit')
elseif kind == 'partner' then
top:tag('div'):addClass('kbft-branch-hidden-card')
end


-- vertical drop line
if group and #group.children > 0 then
unit:tag('div')
col:tag('div')
:addClass('kbft-child-down')
:addClass('kbft-child-down')


local childrenWrap = unit:tag('div')
local childWrap = col:tag('div')
childrenWrap:addClass('kbft-children')
childWrap:addClass('kbft-children')


if #group.children > 0 then
for _, child in ipairs(group.children) do
for _, child in ipairs(group.children) do
childrenWrap:node(
childWrap:node(
renderCard(
renderCard(
people,
people,
Line 640: Line 660:
)
)
end
end
else
-- no children: spacer so alignment stays intact
childrenWrap:tag('div')
:addClass('kbft-branch-spacer')
end
end
end
-- left side: solo first if it exists
if soloGroup then
addBranchCol('solo', nil, soloGroup)
end
-- then partner branches
for _, partner in ipairs(partners) do
addBranchCol('partner', partner, partnerGroups[partner])
end
end


Line 675: Line 701:
table.insert(units, renderSingleCard(people, name))
table.insert(units, renderSingleCard(people, name))
end
end
gen:node(renderGenerationRow(units))
gen:node(renderGenerationRow(units, 'kbft-row'))


return tostring(node)
return tostring(node)
Line 710: Line 736:
table.insert(units, renderSingleCard(people, name))
table.insert(units, renderSingleCard(people, name))
end
end
gen:node(renderGenerationRow(units))
gen:node(renderGenerationRow(units, 'kbft-row'))
end
end


Line 725: Line 751:
return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(root) .. '".'
return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(root) .. '".'
end
end
local groups = getFamilyGroupsForRoot(people, root)


local node = html.create('div')
local node = html.create('div')
Line 745: Line 773:
end
end


node:node(renderFocalGeneration(people, root))
node:node(renderFocalGeneration(people, root, groups))


local familyGen = renderFamilyGroupsGeneration(people, root)
local descGen = renderDescendantGeneration(people, root, groups)
if familyGen then
if descGen then
node:tag('div'):addClass('kbft-connector')
node:tag('div'):addClass('kbft-connector')
node:node(familyGen)
node:node(descGen)
end
end



Revision as of 13:58, 30 March 2026

Documentation for this module may be created at Module:FamilyTree/doc

local p = {}

local cargo = mw.ext.cargo
local html = mw.html

-- =========================================
-- Helpers
-- =========================================

local function trim(s)
	if s == nil then return nil end
	s = tostring(s)
	s = mw.text.trim(s)
	if s == '' then return nil end
	return s
end

local function isRealValue(v)
	v = trim(v)
	if not v then return false end
	local lowered = mw.ustring.lower(v)
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'
end

local function addUnique(list, value)
	if not isRealValue(value) then return end
	for _, existing in ipairs(list) do
		if existing == value then return end
	end
	table.insert(list, value)
end

local function uniq(list)
	local out, seen = {}, {}
	for _, v in ipairs(list or {}) do
		if isRealValue(v) and not seen[v] then
			seen[v] = true
			table.insert(out, v)
		end
	end
	return out
end

local function getArg(frame, key)
	local v = frame.args[key]
	if isRealValue(v) then return trim(v) end
	local parent = frame:getParent()
	if parent then
		v = parent.args[key]
		if isRealValue(v) then return trim(v) end
	end
	return nil
end

local function getRoot(frame)
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text
end

local function makeLink(name, displayName)
	if not isRealValue(name) then return '' end
	displayName = trim(displayName) or name
	return string.format('[[%s|%s]]', name, displayName)
end

local function ensurePerson(people, name)
	name = trim(name)
	if not isRealValue(name) then return nil end

	if not people[name] then
		people[name] = {
			name = name,
			displayName = name,
			parents = {},
			children = {},
			partners = {},
			unions = {},
			childLinks = {}
		}
	end

	return people[name]
end

local function sortNames(people, names)
	table.sort(names, function(a, b)
		local ad = (people[a] and people[a].displayName) or a
		local bd = (people[b] and people[b].displayName) or b
		return mw.ustring.lower(ad) < mw.ustring.lower(bd)
	end)
end

-- =========================================
-- Data loading
-- =========================================

local function loadCharacters()
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })
	local people = {}

	for _, row in ipairs(results) do
		local page = trim(row.Page)
		local displayName = trim(row.DisplayName)

		if isRealValue(page) then
			people[page] = {
				name = page,
				displayName = displayName or page,
				parents = {},
				children = {},
				partners = {},
				unions = {},
				childLinks = {}
			}
		end
	end

	return people
end

local function loadParentChild(people)
	local results = cargo.query(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{ limit = 5000 }
	)

	for _, row in ipairs(results) do
		local child = trim(row.Child)
		local p1 = trim(row.Parent1)
		local p2 = trim(row.Parent2)
		local unionID = trim(row.UnionID)
		local relationshipType = trim(row.RelationshipType)
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999

		if isRealValue(child) then
			ensurePerson(people, child)

			if isRealValue(p1) then
				ensurePerson(people, p1)
				addUnique(people[child].parents, p1)
				addUnique(people[p1].children, child)

				table.insert(people[p1].childLinks, {
					child = child,
					otherParent = p2,
					unionID = unionID,
					relationshipType = relationshipType,
					birthOrder = birthOrder
				})
			end

			if isRealValue(p2) then
				ensurePerson(people, p2)
				addUnique(people[child].parents, p2)
				addUnique(people[p2].children, child)

				table.insert(people[p2].childLinks, {
					child = child,
					otherParent = p1,
					unionID = unionID,
					relationshipType = relationshipType,
					birthOrder = birthOrder
				})
			end
		end
	end
end

local function loadUnions(people)
	local results = cargo.query(
		'Unions',
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',
		{ limit = 5000 }
	)

	for _, row in ipairs(results) do
		local p1 = trim(row.Partner1)
		local p2 = trim(row.Partner2)

		if isRealValue(p1) then ensurePerson(people, p1) end
		if isRealValue(p2) then ensurePerson(people, p2) end

		if isRealValue(p1) and isRealValue(p2) then
			addUnique(people[p1].partners, p2)
			addUnique(people[p2].partners, p1)

			table.insert(people[p1].unions, {
				unionID = trim(row.UnionID),
				partner = p2,
				unionType = trim(row.UnionType),
				status = trim(row.Status),
				startDate = trim(row.StartDate),
				endDate = trim(row.EndDate),
				marriageDate = trim(row.MarriageDate),
				divorceDate = trim(row.DivorceDate),
				engagementDate = trim(row.EngagementDate)
			})

			table.insert(people[p2].unions, {
				unionID = trim(row.UnionID),
				partner = p1,
				unionType = trim(row.UnionType),
				status = trim(row.Status),
				startDate = trim(row.StartDate),
				endDate = trim(row.EndDate),
				marriageDate = trim(row.MarriageDate),
				divorceDate = trim(row.DivorceDate),
				engagementDate = trim(row.EngagementDate)
			})
		end
	end
end

local function finalizePeople(people)
	for _, person in pairs(people) do
		person.parents = uniq(person.parents)
		person.children = uniq(person.children)
		person.partners = uniq(person.partners)
	end
end

local function loadData()
	local people = loadCharacters()
	loadParentChild(people)
	loadUnions(people)
	finalizePeople(people)
	return people
end

-- =========================================
-- Relationship helpers
-- =========================================

local function relationshipBadge(relType)
	if not isRealValue(relType) then return nil end
	local t = mw.ustring.lower(relType)
	if t:find('adopt') then return 'adopted' end
	if t:find('step') then return 'step' end
	if t:find('bio') then return nil end
	return relType
end

local function findUnionBetween(people, name1, name2)
	if not isRealValue(name1) or not isRealValue(name2) then return nil end
	local person = people[name1]
	if not person or not person.unions then return nil end
	for _, union in ipairs(person.unions) do
		if union.partner == name2 then return union end
	end
	return nil
end

local function getUnionMeta(people, root, partner)
	local union = findUnionBetween(people, root, partner)
	if not union then return nil end

	local label = union.unionType or union.status
	local year = union.marriageDate or union.startDate or union.engagementDate

	local out = {}
	if isRealValue(label) then
		table.insert(out, label)
	end
	if isRealValue(year) then
		local y = tostring(year):match('^(%d%d%d%d)') or tostring(year)
		table.insert(out, y)
	end

	if #out == 0 then return nil end
	return table.concat(out, ' • ')
end

local function getParents(people, root)
	local person = people[root]
	if not person then return {} end
	local parents = uniq(person.parents)
	sortNames(people, parents)
	return parents
end

local function getGrandparents(people, root)
	local out = {}
	local parents = getParents(people, root)

	for _, parentName in ipairs(parents) do
		local parent = people[parentName]
		if parent then
			for _, gp in ipairs(parent.parents) do
				addUnique(out, gp)
			end
		end
	end

	out = uniq(out)
	sortNames(people, out)
	return out
end

local function getSiblings(people, root)
	local out, seen = {}, {}
	local person = people[root]
	if not person then return out end

	for _, parentName in ipairs(person.parents) do
		local parent = people[parentName]
		if parent then
			for _, childName in ipairs(parent.children) do
				if childName ~= root and not seen[childName] then
					seen[childName] = true
					table.insert(out, childName)
				end
			end
		end
	end

	out = uniq(out)
	sortNames(people, out)
	return out
end

local function getConnectedPeople(people, root)
	local out = {}
	local person = people[root]
	if not person then return out end

	for _, v in ipairs(person.parents) do addUnique(out, v) end
	for _, v in ipairs(person.children) do addUnique(out, v) end
	for _, v in ipairs(person.partners) do addUnique(out, v) end
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end

	out = uniq(out)
	sortNames(people, out)
	return out
end

local function splitAroundCenter(items)
	local left, right = {}, {}
	local n = #items
	local leftCount = math.floor(n / 2)

	for i, v in ipairs(items) do
		if i <= leftCount then
			table.insert(left, v)
		else
			table.insert(right, v)
		end
	end

	return left, right
end

local function getFamilyGroupsForRoot(people, root)
	local person = people[root]
	if not person then return {} end

	local groups = {}

	-- child-based groups
	for _, link in ipairs(person.childLinks or {}) do
		local key
		if isRealValue(link.unionID) then
			key = 'union::' .. link.unionID
		elseif isRealValue(link.otherParent) then
			key = 'partner::' .. link.otherParent
		else
			key = 'single::' .. root
		end

		if not groups[key] then
			groups[key] = {
				unionID = link.unionID,
				partner = link.otherParent,
				children = {}
			}
		end

		table.insert(groups[key].children, {
			name = link.child,
			relationshipType = link.relationshipType,
			birthOrder = tonumber(link.birthOrder) or 999
		})
	end

	-- partner-only groups so spouse still shows with no children
	for _, partner in ipairs(person.partners or {}) do
		if isRealValue(partner) then
			local found = false
			for _, group in pairs(groups) do
				if group.partner == partner then
					found = true
					break
				end
			end

			if not found then
				local union = findUnionBetween(people, root, partner)
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)

				groups[key] = {
					unionID = union and union.unionID or nil,
					partner = partner,
					children = {}
				}
			end
		end
	end

	local out = {}
	for _, group in pairs(groups) do
		table.sort(group.children, function(a, b)
			return (a.birthOrder or 999) < (b.birthOrder or 999)
		end)
		table.insert(out, group)
	end

	table.sort(out, function(a, b)
		local aSingle = not isRealValue(a.partner)
		local bSingle = not isRealValue(b.partner)

		if aSingle ~= bSingle then
			return aSingle
		end

		local ap = a.partner or ''
		local bp = b.partner or ''
		local ad = (people[ap] and people[ap].displayName) or ap
		local bd = (people[bp] and people[bp].displayName) or bp
		return mw.ustring.lower(ad) < mw.ustring.lower(bd)
	end)

	return out
end

-- =========================================
-- Rendering helpers
-- =========================================

local function renderCard(people, name, badgeText, extraClass)
	if not isRealValue(name) then return nil end
	local person = people[name] or { name = name, displayName = name }

	local card = html.create('div')
	card:addClass('kbft-card')
	if isRealValue(extraClass) then
		card:addClass(extraClass)
	end

	card:wikitext(makeLink(person.name, person.displayName))

	if isRealValue(badgeText) then
		card:tag('div')
			:addClass('kbft-years')
			:wikitext(badgeText)
	end

	return card
end

local function renderSingleCard(people, name, extraClass)
	local wrap = html.create('div')
	wrap:addClass('kbft-single')
	wrap:node(renderCard(people, name, nil, extraClass))
	return wrap
end

local function renderCouple(people, leftName, rightName)
	if not isRealValue(leftName) and not isRealValue(rightName) then
		return nil
	end

	if isRealValue(leftName) and isRealValue(rightName) then
		local wrap = html.create('div')
		wrap:addClass('kbft-couple')
		wrap:node(renderCard(people, leftName))
		local marriage = wrap:tag('div')
		marriage:addClass('kbft-marriage')
		marriage:tag('div'):addClass('kbft-marriage-line')
		wrap:node(renderCard(people, rightName))
		return wrap
	end

	if isRealValue(leftName) then return renderSingleCard(people, leftName) end
	return renderSingleCard(people, rightName)
end

local function renderGenerationRow(units, className)
	local row = html.create('div')
	row:addClass(className or 'kbft-row')

	for _, unit in ipairs(units) do
		if unit then row:node(unit) end
	end

	return row
end

local function renderUpperCoupleGeneration(people, couples)
	if #couples == 0 then return nil end

	local gen = html.create('div')
	gen:addClass('kbft-generation')

	local units = {}
	for _, pair in ipairs(couples) do
		table.insert(units, renderCouple(people, pair[1], pair[2]))
	end

	gen:node(renderGenerationRow(units, 'kbft-row'))
	return gen
end

local function buildGrandparentCouples(people, root)
	local parents = getParents(people, root)
	local couples = {}

	for _, parentName in ipairs(parents) do
		local parent = people[parentName]
		if parent then
			local gp = uniq(parent.parents)
			sortNames(people, gp)
			if #gp > 0 then
				table.insert(couples, { gp[1], gp[2] })
			end
		end
	end

	return couples
end

local function buildParentCouples(people, root)
	local parents = getParents(people, root)
	if #parents == 0 then return {} end
	return { { parents[1], parents[2] } }
end

local function renderFocalGeneration(people, root, groups)
	local siblings = getSiblings(people, root)

	local partners = {}
	local seen = {}
	for _, group in ipairs(groups or {}) do
		if isRealValue(group.partner) and not seen[group.partner] then
			seen[group.partner] = true
			table.insert(partners, group.partner)
		end
	end
	sortNames(people, partners)

	local leftSibs, rightSibs = splitAroundCenter(siblings)
	local leftPartners, rightPartners = splitAroundCenter(partners)

	local gen = html.create('div')
	gen:addClass('kbft-generation')

	local wrap = gen:tag('div')
	wrap:addClass('kbft-siblings')

	wrap:tag('div')
		:addClass('kbft-sibling-spine')

	local row = wrap:tag('div')
	row:addClass('kbft-union-row')

	local function addCol(kind, name, meta)
		local col = row:tag('div')
		col:addClass('kbft-union-col')
		col:attribute('data-kind', kind)

		local connector = col:tag('div')
		connector:addClass('kbft-sibling-up')

		if kind == 'root' then
			col:node(renderSingleCard(people, name, 'kbft-focus-card'))
		else
			col:node(renderSingleCard(people, name))
		end

		if isRealValue(meta) then
			col:tag('div')
				:addClass('kbft-union-meta')
				:wikitext(meta)
		end
	end

	for _, sib in ipairs(leftSibs) do
		addCol('sibling', sib, nil)
	end

	for _, partner in ipairs(leftPartners) do
		addCol('partner', partner, getUnionMeta(people, root, partner))
	end

	addCol('root', root, nil)

	for _, partner in ipairs(rightPartners) do
		addCol('partner', partner, getUnionMeta(people, root, partner))
	end

	for _, sib in ipairs(rightSibs) do
		addCol('sibling', sib, nil)
	end

	return gen
end

local function renderDescendantGeneration(people, root, groups)
	if #groups == 0 then return nil end

	local partners = {}
	local partnerGroups = {}
	local soloGroup = nil

	for _, group in ipairs(groups) do
		if isRealValue(group.partner) then
			table.insert(partners, group.partner)
			partnerGroups[group.partner] = group
		else
			soloGroup = group
		end
	end
	sortNames(people, partners)

	local gen = html.create('div')
	gen:addClass('kbft-generation')
	gen:addClass('kbft-desc-generation')

	local row = gen:tag('div')
	row:addClass('kbft-branch-columns')

	local function addBranchCol(kind, partnerName, group)
		local col = row:tag('div')
		col:addClass('kbft-branch-column')
		col:attribute('data-kind', kind)

		-- top spacer / hidden card to keep columns aligned to focal row
		local top = col:tag('div')
		top:addClass('kbft-branch-top')

		if kind == 'solo' then
			top:tag('div'):addClass('kbft-branch-hidden-card')
		elseif kind == 'partner' then
			top:tag('div'):addClass('kbft-branch-hidden-card')
		end

		if group and #group.children > 0 then
			col:tag('div')
				:addClass('kbft-child-down')

			local childWrap = col:tag('div')
			childWrap:addClass('kbft-children')

			for _, child in ipairs(group.children) do
				childWrap:node(
					renderCard(
						people,
						child.name,
						relationshipBadge(child.relationshipType)
					)
				)
			end
		end
	end

	-- left side: solo first if it exists
	if soloGroup then
		addBranchCol('solo', nil, soloGroup)
	end

	-- then partner branches
	for _, partner in ipairs(partners) do
		addBranchCol('partner', partner, partnerGroups[partner])
	end

	return gen
end

-- =========================================
-- Public renderers
-- =========================================

local function renderConnectedForRoot(people, root)
	local person = people[root]
	if not person then
		return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(root) .. '".'
	end

	local connected = getConnectedPeople(people, root)
	local node = html.create('div')
	node:addClass('kbft-tree')

	node:tag('div')
		:addClass('kbft-title')
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))

	local gen = node:tag('div')
	gen:addClass('kbft-generation')

	local units = {}
	for _, name in ipairs(connected) do
		table.insert(units, renderSingleCard(people, name))
	end
	gen:node(renderGenerationRow(units, 'kbft-row'))

	return tostring(node)
end

local function renderProfileForRoot(people, root)
	local person = people[root]
	if not person then
		return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(root) .. '".'
	end

	local node = html.create('div')
	node:addClass('kbft-tree')

	node:tag('div')
		:addClass('kbft-title')
		:wikitext(makeLink(person.name, person.displayName))

	local function addSection(label, names)
		names = uniq(names)
		if #names == 0 then return end
		sortNames(people, names)

		node:tag('div')
			:addClass('kbft-title')
			:css('margin-top', '22px')
			:wikitext(label)

		local gen = node:tag('div')
		gen:addClass('kbft-generation')

		local units = {}
		for _, name in ipairs(names) do
			table.insert(units, renderSingleCard(people, name))
		end
		gen:node(renderGenerationRow(units, 'kbft-row'))
	end

	addSection('Parents', person.parents)
	addSection('Partners', person.partners)
	addSection('Children', person.children)

	return tostring(node)
end

local function renderTreeForRoot(people, root)
	local person = people[root]
	if not person then
		return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(root) .. '".'
	end

	local groups = getFamilyGroupsForRoot(people, root)

	local node = html.create('div')
	node:addClass('kbft-tree')

	node:tag('div')
		:addClass('kbft-title')
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))

	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))
	if gpGen then
		node:node(gpGen)
		node:tag('div'):addClass('kbft-connector')
	end

	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))
	if parentGen then
		node:node(parentGen)
		node:tag('div'):addClass('kbft-connector')
	end

	node:node(renderFocalGeneration(people, root, groups))

	local descGen = renderDescendantGeneration(people, root, groups)
	if descGen then
		node:tag('div'):addClass('kbft-connector')
		node:node(descGen)
	end

	return tostring(node)
end

-- =========================================
-- Public functions
-- =========================================

function p.tree(frame)
	local root = getRoot(frame)
	local people = loadData()
	return renderTreeForRoot(people, root)
end

function p.profile(frame)
	local root = getRoot(frame)
	local people = loadData()
	return renderProfileForRoot(people, root)
end

function p.connected(frame)
	local root = getRoot(frame)
	local people = loadData()
	return renderConnectedForRoot(people, root)
end

return p