Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
Tag: Reverted
No edit summary
Tags: Manual revert Reverted
Line 6: Line 6:
local SLOT_WIDTH = 340
local SLOT_WIDTH = 340
local ANCHOR_CENTER = 90
local ANCHOR_CENTER = 90
local UNION_CENTER = 170
local CHILD_GAP = 24
local CHILD_GAP = 24
local GROUP_GAP = 28


-- forward declarations for rendering helpers used earlier in the file
-- forward declarations for rendering helpers used earlier in the file
Line 790: Line 788:
-- =========================================
-- =========================================


local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)
local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)
local links = getChildLinksOf(people, personName, includeNonBiological)
local links = getChildLinksOf(people, personName, includeNonBiological)
local seen = {}
if #links == 0 then
local out = {}
return nil
 
for _, link in ipairs(links) do
if isRealValue(link.otherParent) and not seen[link.otherParent] then
seen[link.otherParent] = true
table.insert(out, link.otherParent)
end
end
end


table.sort(out, function(a, b)
local partnerSeen = {}
local ua = findUnionBetween(people, personName, a)
local partnerList = {}
local ub = findUnionBetween(people, personName, b)
local hasSolo = false
 
local da = ua and sortKeyDate(ua) or '9999-99-99'
local db = ub and sortKeyDate(ub) or '9999-99-99'
 
if da ~= db then
return da < db
end
 
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)
 
return out
end
 
local function buildPartnerLayoutForFamilyTree(partners)
local partnerCenters = {}
local unionCenters = {}
local tempCenters = {}
 
local leftPartners, rightPartners = splitAroundCenter(partners)
 
local minCenter = 0
local maxCenter = 0
 
for i, name in ipairs(leftPartners) do
local idxFromRoot = #leftPartners - i + 1
local c = -(idxFromRoot * 180)
tempCenters[name] = c
if c < minCenter then minCenter = c end
if c > maxCenter then maxCenter = c end
end
 
for i, name in ipairs(rightPartners) do
local c = i * 180
tempCenters[name] = c
if c < minCenter then minCenter = c end
if c > maxCenter then maxCenter = c end
end
 
local shift = -minCenter + 70
local rootCenter = shift
local rowWidth = (maxCenter - minCenter) + 140
 
for name, c in pairs(tempCenters) do
local shifted = c + shift
partnerCenters[name] = shifted
unionCenters[name] = math.floor((rootCenter + shifted) / 2)
end
 
return {
rowWidth = math.max(rowWidth, 140),
rootCenter = rootCenter,
partnerCenters = partnerCenters,
unionCenters = unionCenters
}
end
 
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)
local links = getChildLinksOf(people, personName, includeNonBiological)
local groupsByKey = {}
local order = {}


for _, link in ipairs(links) do
for _, link in ipairs(links) do
local key
local anchorKind
local partnerName = nil
if isRealValue(link.otherParent) then
if isRealValue(link.otherParent) then
key = 'union::' .. link.otherParent
if not partnerSeen[link.otherParent] then
anchorKind = 'union'
partnerSeen[link.otherParent] = true
partnerName = link.otherParent
table.insert(partnerList, link.otherParent)
end
else
else
key = 'self::' .. personName
hasSolo = true
anchorKind = 'self'
end
 
if not groupsByKey[key] then
groupsByKey[key] = {
anchorKind = anchorKind,
partnerName = partnerName,
children = {},
links = {}
}
table.insert(order, key)
end
end
end


addUnique(groupsByKey[key].children, link.child)
if hasSolo then
table.insert(groupsByKey[key].links, link)
return nil
end
end


local out = {}
if #partnerList == 1 then
for _, key in ipairs(order) do
return partnerList[1]
table.insert(out, groupsByKey[key])
end
end


return out
return nil
end
end


local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)
local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)
local childNames = getChildrenOf(people, personName, includeNonBiological)
local layout = buildPartnerLayoutForFamilyTree(displayPartners)
local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)
local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)


local childGroups = {}
local childNodes = {}
local childRowWidth = 0


for _, rawGroup in ipairs(rawGroups) do
for i, childName in ipairs(childNames) do
local group = {
local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)
anchorKind = rawGroup.anchorKind,
table.insert(childNodes, childNode)
partnerName = rawGroup.partnerName,
childRowWidth = childRowWidth + childNode.width
nodes = {},
if i > 1 then
width = 0,
childRowWidth = childRowWidth + CHILD_GAP
label = nil
}
 
for i, childName in ipairs(rawGroup.children) do
local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)
table.insert(group.nodes, childNode)
group.width = group.width + childNode.width
if i > 1 then
group.width = group.width + CHILD_GAP
end
end
 
if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then
group.sourceCenter = layout.unionCenters[group.partnerName]
 
local union = findUnionBetween(people, personName, group.partnerName)
if union then
group.label = formatUnionMeta(
union.unionType,
union.status,
union.marriageDate or union.startDate or union.engagementDate
)
end
else
group.sourceCenter = layout.rootCenter
 
-- label self-only groups when they are adoptive/step/etc.
local relKinds = {}
local seenKinds = {}
 
for _, link in ipairs(rawGroup.links or {}) do
local badge = relationshipBadge(link.relationshipType)
if isRealValue(badge) and not seenKinds[badge] then
seenKinds[badge] = true
table.insert(relKinds, badge)
end
end
 
if #relKinds == 1 then
group.label = relKinds[1]
elseif #relKinds > 1 then
group.label = table.concat(relKinds, ' / ')
end
end
end
table.insert(childGroups, group)
end
end


table.sort(childGroups, function(a, b)
local nodeWidth = SLOT_WIDTH
if a.sourceCenter ~= b.sourceCenter then
if childRowWidth > nodeWidth then
return a.sourceCenter < b.sourceCenter
nodeWidth = childRowWidth
end
 
local an = (a.partnerName or '')
local bn = (b.partnerName or '')
return mw.ustring.lower(an) < mw.ustring.lower(bn)
end)
 
local groupsRowWidth = 0
for gi, group in ipairs(childGroups) do
groupsRowWidth = groupsRowWidth + group.width
if gi > 1 then
groupsRowWidth = groupsRowWidth + GROUP_GAP
end
end
end


local topWidth = layout.rowWidth
local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)
local nodeWidth = math.max(topWidth, groupsRowWidth, 140)
local selfAnchorAbs = selfLeft + ANCHOR_CENTER
 
local selfLeft = math.floor((nodeWidth - topWidth) / 2)
local rootCenterAbs = selfLeft + layout.rootCenter


local node = html.create('div')
local node = html.create('div')
node:addClass('kbft-ft-node')
node:addClass('kbft-ft-node')
node:css('position', 'relative')
node:css('display', 'inline-block')
node:css('vertical-align', 'top')
node:css('text-align', 'left')
node:css('width', tostring(nodeWidth) .. 'px')
node:css('width', tostring(nodeWidth) .. 'px')


local selfRow = node:tag('div')
local selfRow = node:tag('div')
selfRow:addClass('kbft-ft-selfrow')
selfRow:addClass('kbft-ft-selfrow')
selfRow:css('position', 'relative')
selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')
selfRow:css('width', tostring(topWidth) .. 'px')
selfRow:css('height', '72px')
selfRow:css('margin-left', tostring(selfLeft) .. 'px')
selfRow:css('margin-left', tostring(selfLeft) .. 'px')


for _, partnerName in ipairs(displayPartners) do
local selfSlot = selfRow:tag('div')
local partnerCenter = layout.partnerCenters[partnerName]
selfSlot:addClass('kbft-ft-selfslot')
local lineLeft = math.min(layout.rootCenter, partnerCenter)
local lineWidth = math.abs(layout.rootCenter - partnerCenter)


if lineWidth > 0 then
local anchorWrap = selfSlot:tag('div')
local seg = selfRow:tag('div')
anchorWrap:addClass('kbft-ft-anchor')
seg:addClass('kbft-ft-unionseg')
if focus then
seg:css('position', 'absolute')
anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))
seg:css('top', '28px')
else
seg:css('left', tostring(lineLeft) .. 'px')
anchorWrap:node(renderCard(people, personName))
seg:css('width', tostring(lineWidth) .. 'px')
seg:css('height', '2px')
seg:css('background', '#bca88e')
end
end
end


do
local unionLine = selfSlot:tag('div')
local slot = selfRow:tag('div')
unionLine:addClass('kbft-ft-unionline')
slot:addClass('kbft-ft-cardslot')
if not isRealValue(partnerName) then
slot:addClass('kbft-ft-rootslot')
unionLine:addClass('kbft-ft-hidden')
slot:css('position', 'absolute')
slot:css('top', '0')
slot:css('left', tostring(layout.rootCenter) .. 'px')
slot:css('transform', 'translateX(-50%)')
slot:css('z-index', '2')
 
if focus then
slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))
else
slot:node(renderCard(people, personName))
end
end
end


for _, partnerName in ipairs(displayPartners) do
local partnerWrap = selfSlot:tag('div')
local slot = selfRow:tag('div')
partnerWrap:addClass('kbft-ft-partner')
slot:addClass('kbft-ft-cardslot')
if isRealValue(partnerName) then
slot:addClass('kbft-ft-partnerslot')
partnerWrap:node(renderCard(people, partnerName))
slot:css('position', 'absolute')
else
slot:css('top', '0')
partnerWrap:addClass('kbft-ft-partner-empty')
slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')
slot:css('transform', 'translateX(-50%)')
slot:css('z-index', '2')
slot:node(renderCard(people, partnerName))
end
end


if #childGroups > 0 then
if #childNodes > 0 then
local baseBarTop = 28
local levelStep = 26
local labelGap = 14
 
for i, group in ipairs(childGroups) do
group.barTop = baseBarTop + ((i - 1) * levelStep)
end
 
local maxBarTop = childGroups[#childGroups].barTop
local branchHeight = maxBarTop + 14
 
local branch = node:tag('div')
local branch = node:tag('div')
branch:addClass('kbft-ft-branch')
branch:addClass('kbft-ft-branch')
branch:css('position', 'relative')
branch:css('width', tostring(nodeWidth) .. 'px')
branch:css('width', tostring(nodeWidth) .. 'px')
branch:css('height', tostring(branchHeight) .. 'px')
branch:css('margin-top', '4px')


local childRow = node:tag('div')
local childRow = node:tag('div')
childRow:addClass('kbft-ft-childrenrow')
childRow:addClass('kbft-ft-childrenrow')
childRow:css('display', 'flex')
childRow:css('width', tostring(childRowWidth) .. 'px')
childRow:css('align-items', 'flex-start')
childRow:css('margin-left', tostring(math.floor((nodeWidth - childRowWidth) / 2)) .. 'px')
childRow:css('justify-content', 'flex-start')
childRow:css('width', tostring(groupsRowWidth) .. 'px')
childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')
 
local groupAnchors = {}
local runningGroupX = 0
local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)
 
for gi, group in ipairs(childGroups) do
local groupWrap = childRow:tag('div')
groupWrap:addClass('kbft-ft-groupwrap')
groupWrap:css('position', 'relative')
groupWrap:css('display', 'inline-block')
groupWrap:css('vertical-align', 'top')
groupWrap:css('width', tostring(group.width) .. 'px')
if gi < #childGroups then
groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')
end
 
local childAnchorsAbs = {}
local runningChildX = 0
local childDropHeight = branchHeight - group.barTop
 
for ci, childNode in ipairs(group.nodes) do
local childWrap = groupWrap:tag('div')
childWrap:addClass('kbft-ft-childwrap')
childWrap:css('position', 'relative')
childWrap:css('display', 'inline-block')
childWrap:css('vertical-align', 'top')
childWrap:css('width', tostring(childNode.width) .. 'px')
if ci < #group.nodes then
childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')
end
 
local drop = childWrap:tag('div')
drop:addClass('kbft-ft-childdrop')
drop:css('position', 'absolute')
drop:css('top', tostring(-childDropHeight) .. 'px')
drop:css('left', tostring(childNode.anchorX) .. 'px')
drop:css('width', '2px')
drop:css('height', tostring(childDropHeight) .. 'px')
drop:css('margin-left', '-1px')
drop:css('background', '#bca88e')
drop:css('z-index', '1')
 
childWrap:wikitext(childNode.html)


table.insert(
local childAbsAnchors = {}
childAnchorsAbs,
local runningX = 0
groupsStart + runningGroupX + runningChildX + childNode.anchorX
)


runningChildX = runningChildX + childNode.width + (ci < #group.nodes and CHILD_GAP or 0)
for i, childNode in ipairs(childNodes) do
local childWrap = childRow:tag('div')
childWrap:addClass('kbft-ft-childwrap')
childWrap:css('width', tostring(childNode.width) .. 'px')
if i < #childNodes then
childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')
end
end


local sourceAnchorAbs = selfLeft + group.sourceCenter
local drop = childWrap:tag('div')
drop:addClass('kbft-ft-childdrop')
drop:css('left', tostring(childNode.anchorX) .. 'px')


table.insert(groupAnchors, {
childWrap:wikitext(childNode.html)
sourceAnchorAbs = sourceAnchorAbs,
childAnchorsAbs = childAnchorsAbs,
barTop = group.barTop,
label = group.label
})


runningGroupX = runningGroupX + group.width + (gi < #childGroups and GROUP_GAP or 0)
table.insert(childAbsAnchors, math.floor((nodeWidth - childRowWidth) / 2) + runningX + childNode.anchorX)
runningX = runningX + childNode.width + (i < #childNodes and CHILD_GAP or 0)
end
end


for _, info in ipairs(groupAnchors) do
local parentDrop = branch:tag('div')
local sourceAnchorAbs = info.sourceAnchorAbs
parentDrop:addClass('kbft-ft-parentdrop')
local childAbsAnchors = info.childAnchorsAbs
parentDrop:css('left', tostring(selfAnchorAbs) .. 'px')
local barTop = info.barTop
local label = info.label


local parentDrop = branch:tag('div')
local firstAnchor = childAbsAnchors[1]
parentDrop:addClass('kbft-ft-parentdrop')
local lastAnchor = childAbsAnchors[#childAbsAnchors]
parentDrop:css('position', 'absolute')
parentDrop:css('top', '0')
parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')
parentDrop:css('width', '2px')
parentDrop:css('height', tostring(barTop) .. 'px')
parentDrop:css('margin-left', '-1px')
parentDrop:css('background', '#bca88e')


local firstAnchor = childAbsAnchors[1]
if #childAbsAnchors == 1 then
local lastAnchor = childAbsAnchors[#childAbsAnchors]
local onlyAnchor = childAbsAnchors[1]
local lineLeft, lineWidth


if #childAbsAnchors == 1 then
if selfAnchorAbs ~= onlyAnchor then
local onlyAnchor = childAbsAnchors[1]
local lineLeft = math.min(selfAnchorAbs, onlyAnchor)
lineLeft = math.min(sourceAnchorAbs, onlyAnchor)
local lineWidth = math.abs(selfAnchorAbs - onlyAnchor)
lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)
if lineWidth > 0 then
else
local bar = branch:tag('div')
lineLeft = firstAnchor
bar:addClass('kbft-ft-childrenbar')
lineWidth = lastAnchor - firstAnchor
bar:css('left', tostring(lineLeft) .. 'px')
end
bar:css('width', tostring(lineWidth) .. 'px')
 
if lineWidth and lineWidth > 0 then
local bar = branch:tag('div')
bar:addClass('kbft-ft-childrenbar')
bar:css('position', 'absolute')
bar:css('top', tostring(barTop) .. 'px')
bar:css('left', tostring(lineLeft) .. 'px')
bar:css('width', tostring(lineWidth) .. 'px')
bar:css('height', '2px')
bar:css('background', '#bca88e')
end
 
if isRealValue(label) then
local labelLeft = math.min(sourceAnchorAbs, firstAnchor)
local labelRight = math.max(sourceAnchorAbs, lastAnchor)
local labelWidth = labelRight - labelLeft
 
if labelWidth < 90 then
labelWidth = 90
labelLeft = math.floor(((math.min(sourceAnchorAbs, firstAnchor) + math.max(sourceAnchorAbs, lastAnchor)) / 2) - (labelWidth / 2))
end
end
local labelNode = branch:tag('div')
labelNode:addClass('kbft-ft-grouplabel')
labelNode:css('position', 'absolute')
labelNode:css('top', tostring(barTop - labelGap - 12) .. 'px')
labelNode:css('left', tostring(labelLeft) .. 'px')
labelNode:css('width', tostring(labelWidth) .. 'px')
labelNode:css('text-align', 'center')
labelNode:css('font-size', '0.8em')
labelNode:css('color', '#5f4b36')
labelNode:wikitext(label)
end
end
else
local bar = branch:tag('div')
bar:addClass('kbft-ft-childrenbar')
bar:css('left', tostring(firstAnchor) .. 'px')
bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')
end
end
end
end
Line 1,208: Line 940:
html = tostring(node),
html = tostring(node),
width = nodeWidth,
width = nodeWidth,
anchorX = rootCenterAbs
anchorX = selfAnchorAbs
}
}
end
end

Revision as of 16:42, 15 April 2026

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

local p = {}

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

local SLOT_WIDTH = 340
local ANCHOR_CENTER = 90
local CHILD_GAP = 24

-- forward declarations for rendering helpers used earlier in the file
local renderCard
local renderSingleCard
local renderCouple
local renderGenerationRow

-- =========================================
-- 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

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 extractYear(v)
	v = trim(v)
	if not isRealValue(v) then return nil end
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)
end

local function sortKeyDate(union)
	if not union then return '9999-99-99' end
	return trim(union.marriageDate)
		or trim(union.startDate)
		or trim(union.engagementDate)
		or trim(union.endDate)
		or '9999-99-99'
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 findChildLinkBetween(people, parentName, childName)
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end
	local parent = people[parentName]
	if not parent or not parent.childLinks then return nil end

	for _, link in ipairs(parent.childLinks) do
		if link.child == childName then
			return link
		end
	end

	return nil
end

local function formatUnionMeta(unionType, status, dateValue)
	local bits = {}

	if isRealValue(unionType) then
		table.insert(bits, unionType)
	elseif isRealValue(status) then
		table.insert(bits, status)
	end

	local y = extractYear(dateValue)
	if isRealValue(y) then
		table.insert(bits, y)
	end

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

local function describeEdge(edge)
	if not edge then return nil end

	if edge.type == "parent" then
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')
		if rel:find('adopt') then return 'adopted child of' end
		if rel:find('step') then return 'stepchild of' end
		return 'child of'
	elseif edge.type == "child" then
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')
		if rel:find('adopt') then return 'adoptive parent of' end
		if rel:find('step') then return 'stepparent of' end
		return 'parent of'
	elseif edge.type == "partner" then
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')
		local status = mw.ustring.lower(trim(edge.status) or '')

		if unionType == 'marriage' then
			if status == 'ended' then return 'former spouse of' end
			return 'spouse of'
		end
		if unionType == 'affair' then return 'had an affair with' end
		if unionType == 'liaison' then return 'liaison with' end
		if unionType == 'engagement' then return 'engaged to' end

		if status == 'ended' then return 'former partner of' end
		return 'partner of'
	end

	return edge.type .. " of"
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 getOrderedSiblingsAroundRoot(people, root)
	local siblings = getSiblings(people, root)
	sortNames(people, siblings)
	return splitAroundCenter(siblings)
end

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

	local 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
			local union = nil
			if isRealValue(link.otherParent) then
				union = findUnionBetween(people, root, link.otherParent)
			end

			groups[key] = {
				key = key,
				unionID = link.unionID,
				partner = link.otherParent,
				children = {},
				unionType = union and union.unionType or nil,
				status = union and union.status or nil,
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,
				sortDate = union and sortKeyDate(union) or '9999-99-99'
			}
		end

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

	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] = {
					key = key,
					unionID = union and union.unionID or nil,
					partner = partner,
					children = {},
					unionType = union and union.unionType or nil,
					status = union and union.status or nil,
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,
					sortDate = union and sortKeyDate(union) or '9999-99-99'
				}
			end
		end
	end

	local out = {}
	for _, group in pairs(groups) do
		table.sort(group.children, function(a, b)
			if (a.birthOrder or 999) == (b.birthOrder or 999) then
				local ad = (people[a.name] and people[a.name].displayName) or a.name
				local bd = (people[b.name] and people[b.name].displayName) or b.name
				return mw.ustring.lower(ad) < mw.ustring.lower(bd)
			end
			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

		if a.sortDate ~= b.sortDate then
			return a.sortDate < b.sortDate
		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

local function choosePrimaryPartner(people, root, groups)
	local candidates = {}

	for _, group in ipairs(groups or {}) do
		if isRealValue(group.partner) then
			local score = 0
			local union = findUnionBetween(people, root, group.partner)

			if union then
				local status = mw.ustring.lower(trim(union.status) or '')
				local utype = mw.ustring.lower(trim(union.unionType) or '')

				if status == 'active' then score = score + 100 end
				if utype == 'marriage' then score = score + 50 end
				if utype == 'engagement' then score = score + 40 end
				if isRealValue(union.marriageDate) then score = score + 20 end
				if isRealValue(union.startDate) then score = score + 10 end
			end

			table.insert(candidates, {
				partner = group.partner,
				score = score,
				sortDate = group.sortDate or '9999-99-99'
			})
		end
	end

	table.sort(candidates, function(a, b)
		if a.score ~= b.score then
			return a.score > b.score
		end
		if a.sortDate ~= b.sortDate then
			return a.sortDate < b.sortDate
		end
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner
		return mw.ustring.lower(ad) < mw.ustring.lower(bd)
	end)

	return candidates[1] and candidates[1].partner or nil
end

-- =========================================
-- Graph builder + path finder
-- =========================================

local function buildGraph(people)
	local graph = {}

	for name, _ in pairs(people) do
		graph[name] = {}
	end

	for parentName, person in pairs(people) do
		for _, link in ipairs(person.childLinks or {}) do
			local childName = trim(link.child)

			if isRealValue(childName) and graph[parentName] and graph[childName] then
				table.insert(graph[parentName], {
					type = "child",
					target = childName,
					relationshipType = link.relationshipType,
					unionID = link.unionID
				})

				table.insert(graph[childName], {
					type = "parent",
					target = parentName,
					relationshipType = link.relationshipType,
					unionID = link.unionID
				})
			end
		end

		for _, partner in ipairs(person.partners or {}) do
			local union = findUnionBetween(people, parentName, partner)

			table.insert(graph[parentName], {
				type = "partner",
				target = partner,
				unionType = union and union.unionType or nil,
				status = union and union.status or nil,
				unionID = union and union.unionID or nil
			})
		end
	end

	return graph
end

local function clonePath(path)
	local newPath = {}
	for i, step in ipairs(path) do
		newPath[i] = {
			name = step.name,
			via = step.via
		}
	end
	return newPath
end

local function findPath(graph, start, goal)
	if start == goal then
		return {
			{ name = start, via = nil }
		}
	end

	local queue = {
		{
			{ name = start, via = nil }
		}
	}

	local visited = {}
	visited[start] = true

	while #queue > 0 do
		local path = table.remove(queue, 1)
		local current = path[#path].name

		for _, edge in ipairs(graph[current] or {}) do
			local nextNode = edge.target

			if not visited[nextNode] then
				local newPath = clonePath(path)

				table.insert(newPath, {
					name = nextNode,
					via = edge
				})

				if nextNode == goal then
					return newPath
				end

				visited[nextNode] = true
				table.insert(queue, newPath)
			end
		end
	end

	return nil
end

-- =========================================
-- Descendant traversal + family cluster
-- =========================================

local function getChildLinksOf(people, personName, includeNonBiological)
	local out = {}
	local person = people[personName]
	if not person then
		return out
	end

	for _, link in ipairs(person.childLinks or {}) do
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')
		local isNonBiological = rel:find('adopt') or rel:find('step')

		if includeNonBiological or not isNonBiological then
			table.insert(out, link)
		end
	end

	table.sort(out, function(a, b)
		local ao = tonumber(a.birthOrder) or 999
		local bo = tonumber(b.birthOrder) or 999
		if ao ~= bo then
			return ao < bo
		end
		local ad = (people[a.child] and people[a.child].displayName) or a.child
		local bd = (people[b.child] and people[b.child].displayName) or b.child
		return mw.ustring.lower(ad) < mw.ustring.lower(bd)
	end)

	return out
end

local function getChildrenOf(people, personName, includeNonBiological)
	local out = {}
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do
		addUnique(out, link.child)
	end
	return out
end

local function getDescendants(people, root, includeNonBiological)
	local results = {}
	local visited = {}

	local function walk(personName)
		if visited[personName] then
			return
		end
		visited[personName] = true

		local children = getChildrenOf(people, personName, includeNonBiological)
		for _, child in ipairs(children) do
			if not visited[child] then
				addUnique(results, child)
				walk(child)
			end
		end
	end

	walk(root)
	sortNames(people, results)
	return results
end

local function buildFamilyCluster(people, root, mode)
	local cluster = {}
	local visited = {}

	local includeNonBiological = (mode == 'all' or mode == 'extended')
	local includePartners = (mode == 'extended')

	local function addPerson(name)
		if isRealValue(name) and not visited[name] then
			visited[name] = true
			table.insert(cluster, name)
		end
	end

	addPerson(root)

	local descendants = getDescendants(people, root, includeNonBiological)
	for _, name in ipairs(descendants) do
		addPerson(name)
	end

	if includePartners then
		local snapshot = {}
		for i, name in ipairs(cluster) do
			snapshot[i] = name
		end

		for _, name in ipairs(snapshot) do
			local person = people[name]
			if person then
				for _, partner in ipairs(person.partners or {}) do
					addPerson(partner)
				end
			end
		end
	end

	sortNames(people, cluster)
	return cluster
end

-- =========================================
-- Traditional descendant familytree helpers
-- =========================================

local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)
	local links = getChildLinksOf(people, personName, includeNonBiological)
	if #links == 0 then
		return nil
	end

	local partnerSeen = {}
	local partnerList = {}
	local hasSolo = false

	for _, link in ipairs(links) do
		if isRealValue(link.otherParent) then
			if not partnerSeen[link.otherParent] then
				partnerSeen[link.otherParent] = true
				table.insert(partnerList, link.otherParent)
			end
		else
			hasSolo = true
		end
	end

	if hasSolo then
		return nil
	end

	if #partnerList == 1 then
		return partnerList[1]
	end

	return nil
end

local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)
	local childNames = getChildrenOf(people, personName, includeNonBiological)
	local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)

	local childNodes = {}
	local childRowWidth = 0

	for i, childName in ipairs(childNames) do
		local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)
		table.insert(childNodes, childNode)
		childRowWidth = childRowWidth + childNode.width
		if i > 1 then
			childRowWidth = childRowWidth + CHILD_GAP
		end
	end

	local nodeWidth = SLOT_WIDTH
	if childRowWidth > nodeWidth then
		nodeWidth = childRowWidth
	end

	local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)
	local selfAnchorAbs = selfLeft + ANCHOR_CENTER

	local node = html.create('div')
	node:addClass('kbft-ft-node')
	node:css('width', tostring(nodeWidth) .. 'px')

	local selfRow = node:tag('div')
	selfRow:addClass('kbft-ft-selfrow')
	selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')

	local selfSlot = selfRow:tag('div')
	selfSlot:addClass('kbft-ft-selfslot')

	local anchorWrap = selfSlot:tag('div')
	anchorWrap:addClass('kbft-ft-anchor')
	if focus then
		anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))
	else
		anchorWrap:node(renderCard(people, personName))
	end

	local unionLine = selfSlot:tag('div')
	unionLine:addClass('kbft-ft-unionline')
	if not isRealValue(partnerName) then
		unionLine:addClass('kbft-ft-hidden')
	end

	local partnerWrap = selfSlot:tag('div')
	partnerWrap:addClass('kbft-ft-partner')
	if isRealValue(partnerName) then
		partnerWrap:node(renderCard(people, partnerName))
	else
		partnerWrap:addClass('kbft-ft-partner-empty')
	end

	if #childNodes > 0 then
		local branch = node:tag('div')
		branch:addClass('kbft-ft-branch')
		branch:css('width', tostring(nodeWidth) .. 'px')

		local childRow = node:tag('div')
		childRow:addClass('kbft-ft-childrenrow')
		childRow:css('width', tostring(childRowWidth) .. 'px')
		childRow:css('margin-left', tostring(math.floor((nodeWidth - childRowWidth) / 2)) .. 'px')

		local childAbsAnchors = {}
		local runningX = 0

		for i, childNode in ipairs(childNodes) do
			local childWrap = childRow:tag('div')
			childWrap:addClass('kbft-ft-childwrap')
			childWrap:css('width', tostring(childNode.width) .. 'px')
			if i < #childNodes then
				childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')
			end

			local drop = childWrap:tag('div')
			drop:addClass('kbft-ft-childdrop')
			drop:css('left', tostring(childNode.anchorX) .. 'px')

			childWrap:wikitext(childNode.html)

			table.insert(childAbsAnchors, math.floor((nodeWidth - childRowWidth) / 2) + runningX + childNode.anchorX)
			runningX = runningX + childNode.width + (i < #childNodes and CHILD_GAP or 0)
		end

		local parentDrop = branch:tag('div')
		parentDrop:addClass('kbft-ft-parentdrop')
		parentDrop:css('left', tostring(selfAnchorAbs) .. 'px')

		local firstAnchor = childAbsAnchors[1]
		local lastAnchor = childAbsAnchors[#childAbsAnchors]

		if #childAbsAnchors == 1 then
			local onlyAnchor = childAbsAnchors[1]

			if selfAnchorAbs ~= onlyAnchor then
				local lineLeft = math.min(selfAnchorAbs, onlyAnchor)
				local lineWidth = math.abs(selfAnchorAbs - onlyAnchor)
				if lineWidth > 0 then
					local bar = branch:tag('div')
					bar:addClass('kbft-ft-childrenbar')
					bar:css('left', tostring(lineLeft) .. 'px')
					bar:css('width', tostring(lineWidth) .. 'px')
				end
			end
		else
			local bar = branch:tag('div')
			bar:addClass('kbft-ft-childrenbar')
			bar:css('left', tostring(firstAnchor) .. 'px')
			bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')
		end
	end

	return {
		html = tostring(node),
		width = nodeWidth,
		anchorX = selfAnchorAbs
	}
end

-- =========================================
-- Focal tree rendering helpers
-- =========================================

local function buildFocalLayout(people, root, groups)
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)

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

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

	local units = {}
	local unitIndex = {}
	local primaryPartner = choosePrimaryPartner(people, root, groups)

	local function addUnit(kind, name)
		if not isRealValue(name) then return end
		table.insert(units, { kind = kind, name = name })
		unitIndex[name] = #units
	end

	if #leftSibs > 0 or #rightSibs > 0 then
		for _, sib in ipairs(leftSibs) do
			addUnit('sibling', sib)
		end

		addUnit('root', root)

		if isRealValue(primaryPartner) then
			addUnit('partner', primaryPartner)
		end

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

		for _, partner in ipairs(partners) do
			if partner ~= primaryPartner then
				addUnit('partner', partner)
			end
		end
	else
		local others = {}
		for _, partner in ipairs(partners) do
			if partner ~= primaryPartner then
				table.insert(others, partner)
			end
		end

		table.sort(others, function(a, b)
			local ga = partnerGroups[a]
			local gb = partnerGroups[b]
			local da = ga and ga.sortDate or '9999-99-99'
			local db = gb and gb.sortDate or '9999-99-99'
			if da ~= db then
				return da < db
			end
			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)

		local leftPartners, rightPartners = splitAroundCenter(others)

		for _, partner in ipairs(leftPartners) do
			addUnit('partner', partner)
		end

		addUnit('root', root)

		if isRealValue(primaryPartner) then
			addUnit('partner', primaryPartner)
		end

		for _, partner in ipairs(rightPartners) do
			addUnit('partner', partner)
		end
	end

	return {
		units = units,
		unitIndex = unitIndex,
		partnerGroups = partnerGroups,
		soloGroup = soloGroup,
		primaryPartner = primaryPartner
	}
end

-- =========================================
-- Generic rendering helpers
-- =========================================

renderCard = function(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

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

renderCouple = function(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

renderGenerationRow = function(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, layout)
	local gen = html.create('div')
	gen:addClass('kbft-generation')
	gen:addClass('kbft-focal-generation')

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

	for _, unit in ipairs(layout.units) do
		local col = row:tag('div')
		col:addClass('kbft-focal-col')
		col:attr('data-kind', unit.kind)

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

	return gen
end

local function renderBranchColumn(people, group, isRootBranch)
	local col = html.create('div')
	col:addClass('kbft-branch-col')

	if group then
		local meta = nil

		if isRootBranch then
			local rel = nil
			if group.children and #group.children > 0 then
				rel = relationshipBadge(group.children[1].relationshipType)
			end
			meta = rel
		else
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)
		end

		if isRealValue(meta) then
			col:tag('div')
				:addClass('kbft-union-meta')
				:wikitext(meta)
		else
			col:tag('div')
				:addClass('kbft-union-meta kbft-union-meta-empty')
				:wikitext('&nbsp;')
		end

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

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

			for _, child in ipairs(group.children) do
				childrenWrap:node(
					renderCard(
						people,
						child.name,
						relationshipBadge(child.relationshipType)
					)
				)
			end
		end
	else
		col:tag('div')
			:addClass('kbft-union-meta kbft-union-meta-empty')
			:wikitext('&nbsp;')
	end

	return col
end

local function renderDescendantGeneration(people, layout)
	local hasAnything = false
	if layout.soloGroup then
		hasAnything = true
	end
	for _, _ in pairs(layout.partnerGroups or {}) do
		hasAnything = true
		break
	end

	if not hasAnything then return nil end

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

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

	for _, unit in ipairs(layout.units) do
		local group = nil
		local isRootBranch = false

		if unit.kind == 'root' then
			group = layout.soloGroup
			isRootBranch = true
		elseif unit.kind == 'partner' then
			group = layout.partnerGroups[unit.name]
		end

		row:node(renderBranchColumn(people, group, isRootBranch))
	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 layout = buildFocalLayout(people, root, groups)

	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, layout))

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

	return tostring(node)
end

local function renderFamilyIndex(people, root, mode)
	local members = buildFamilyCluster(people, root, mode)

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

	local title = 'Family Index: ' .. makeLink(root, root)
	if mode == 'extended' then
		title = title .. ' (extended)'
	elseif mode == 'all' then
		title = title .. ' (including adoptive)'
	end

	node:tag('div')
		:addClass('kbft-title')
		:wikitext(title)

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

	local units = {}
	for _, name in ipairs(members) do
		table.insert(units, renderSingleCard(people, name))
	end

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

	return tostring(node)
end

local function renderFamilyTreeForRoot(people, root, mode)
	local includeNonBiological = (mode == 'all' or mode == 'extended')

	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)

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

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

	local wrap = node:tag('div')
	wrap:addClass('kbft-familytree-wrap')
	wrap:wikitext(tree.html)

	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

function p.path(frame)
	local from = getArg(frame, 'from')
	local to = getArg(frame, 'to')

	if not isRealValue(from) or not isRealValue(to) then
		return "<strong>Error:</strong> Please provide |from= and |to="
	end

	local people = loadData()
	local graph = buildGraph(people)
	local path = findPath(graph, from, to)

	if not path then
		return "No connection found."
	end

	local out = {}

	for i, step in ipairs(path) do
		local name = step.name
		local displayName = (people[name] and people[name].displayName) or name
		local linkedName = makeLink(name, displayName)

		if i == 1 then
			table.insert(out, linkedName)
		else
			local label = describeEdge(step.via) or "connected to"
			table.insert(out, label .. " " .. linkedName)
		end
	end

	return table.concat(out, " → ")
end

function p.descendants(frame)
	local root = getArg(frame, 'root')
	local includeAll = getArg(frame, 'include')
	local includeNonBiological = (includeAll == 'all')

	if not isRealValue(root) then
		return "<strong>Error:</strong> Please provide |root="
	end

	local people = loadData()
	local descendants = getDescendants(people, root, includeNonBiological)

	if #descendants == 0 then
		return "No descendants found."
	end

	local out = {}
	for _, name in ipairs(descendants) do
		local displayName = (people[name] and people[name].displayName) or name
		table.insert(out, makeLink(name, displayName))
	end

	return table.concat(out, " • ")
end

function p.familyindex(frame)
	local root = getArg(frame, 'root')
	local mode = getArg(frame, 'mode') or 'blood'

	if not isRealValue(root) then
		return "<strong>Error:</strong> Please provide |root="
	end

	local people = loadData()
	return renderFamilyIndex(people, root, mode)
end

function p.familytree(frame)
	local root = getArg(frame, 'root')
	local mode = getArg(frame, 'mode') or 'blood'

	if not isRealValue(root) then
		return "<strong>Error:</strong> Please provide |root="
	end

	local people = loadData()
	return renderFamilyTreeForRoot(people, root, mode)
end

return p