Module:FamilyTree

From KB Lexicon
Revision as of 14:55, 15 April 2026 by Wylder Merrow (talk | contribs)

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