Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
No edit summary
Line 262: Line 262:
-- =========================================
-- =========================================


local function getGrandparents(people, root)
local function relationshipBadge(relType)
local out = {}
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 getMarriageYear(union)
if not union then
return nil
end
 
local raw = union.marriageDate or union.engagementDate or union.startDate
if not isRealValue(raw) then
return nil
end
 
local year = tostring(raw):match('^(%d%d%d%d)')
return year or tostring(raw)
end
 
local function getParents(people, root)
local person = people[root]
local person = people[root]
if not person then
if not person then
return out
return {}
end
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(person.parents) do
for _, parentName in ipairs(parents) do
local parent = people[parentName]
local parent = people[parentName]
if parent then
if parent then
Line 278: Line 338:
end
end


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


Line 302: Line 364:
end
end


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


Line 322: Line 386:
for _, v in ipairs(grandparents) do addUnique(out, v) end
for _, v in ipairs(grandparents) do addUnique(out, v) end


return uniq(out)
out = uniq(out)
end
sortNames(people, out)
 
return out
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 getMarriageYear(union)
if not union then
return nil
end
 
local raw = union.marriageDate or union.engagementDate or union.startDate
if not isRealValue(raw) then
return nil
end
 
local year = tostring(raw):match('^(%d%d%d%d)')
return year or tostring(raw)
end
end


local function getRootSiblingSequence(people, root)
local function getRootSiblingSequence(people, root)
local siblings = getSiblings(people, root)
local siblings = getSiblings(people, root)
sortNames(people, siblings)
local seq = {}
local seq = {}
local inserted = false
local inserted = false
Line 411: Line 422:
for _, link in ipairs(person.childLinks) do
for _, link in ipairs(person.childLinks) do
local key
local key
if isRealValue(link.unionID) then
if isRealValue(link.unionID) then
key = 'union::' .. link.unionID
key = 'union::' .. link.unionID
Line 465: Line 475:
-- =========================================
-- =========================================


local function renderCard(people, name, badgeText)
local function renderCard(people, name, badgeText, extraClass)
if not isRealValue(name) then
if not isRealValue(name) then
return nil
return nil
Line 473: Line 483:


local card = html.create('div')
local card = html.create('div')
card:addClass('kbft-card')
card:addClass('kbftv2-card')
if isRealValue(extraClass) then
card:addClass(extraClass)
end
 
card:wikitext(makeLink(person.name, person.displayName))
card:wikitext(makeLink(person.name, person.displayName))


if isRealValue(badgeText) then
if isRealValue(badgeText) then
card:tag('div')
card:tag('div')
:addClass('kbft-years')
:addClass('kbftv2-badge')
:wikitext(badgeText)
:wikitext(badgeText)
end
end
Line 485: Line 499:
end
end


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


local function renderCouple(people, leftName, rightName, marriageYear)
local function renderCouple(people, leftName, rightName, marriageYear, leftClass, rightClass)
if isRealValue(leftName) and isRealValue(rightName) then
if isRealValue(leftName) and isRealValue(rightName) then
local wrap = html.create('div')
local wrap = html.create('div')
wrap:addClass('kbft-couple')
wrap:addClass('kbftv2-couple')


wrap:node(renderCard(people, leftName))
wrap:node(renderCard(people, leftName, nil, leftClass))


local marriage = wrap:tag('div')
local marriage = wrap:tag('div')
marriage:addClass('kbft-marriage')
marriage:addClass('kbftv2-marriage')


if isRealValue(marriageYear) then
if isRealValue(marriageYear) then
marriage:tag('div')
marriage:tag('div')
:addClass('kbft-marriage-year')
:addClass('kbftv2-marriage-year')
:wikitext(marriageYear)
:wikitext(marriageYear)
end
end


marriage:tag('div')
marriage:tag('div')
:addClass('kbft-marriage-line')
:addClass('kbftv2-marriage-line')


wrap:node(renderCard(people, rightName))
wrap:node(renderCard(people, rightName, nil, rightClass))
return wrap
return wrap
end
end


if isRealValue(leftName) then
if isRealValue(leftName) then
return renderSingle(people, leftName)
return renderSingleCard(people, leftName, leftClass)
end
end


if isRealValue(rightName) then
if isRealValue(rightName) then
return renderSingle(people, rightName)
return renderSingleCard(people, rightName, rightClass)
end
end


Line 526: Line 540:
end
end


local function renderRowSingles(people, names)
local function renderGenerationRow(units)
local row = html.create('div')
local row = html.create('div')
row:addClass('kbft-row')
row:addClass('kbftv2-row')


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


Line 537: Line 553:
end
end


local function renderGrandparentGeneration(people, parents)
local function renderChildCards(people, children)
if #parents == 0 then
local childrenWrap = html.create('div')
return nil
childrenWrap:addClass('kbftv2-unit-children')
 
for _, child in ipairs(children) do
childrenWrap:node(
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
end


local gen = html.create('div')
return childrenWrap
gen:addClass('kbft-generation')
end
 
local function renderFamilyUnit(people, root, group)
local unit = html.create('div')
unit:addClass('kbftv2-unit')


local row = gen:tag('div')
local top = unit:tag('div')
row:addClass('kbft-row')
top:addClass('kbftv2-unit-top')


for _, parentName in ipairs(parents) do
if isRealValue(group.partner) then
local parent = people[parentName]
local union = findUnionBetween(people, root, group.partner)
local gp1 = nil
local marriageYear = getMarriageYear(union)
local gp2 = nil
top:node(renderCouple(people, root, group.partner, marriageYear, 'kbftv2-root-echo', nil))
else
local soloLabel = top:tag('div')
soloLabel:addClass('kbftv2-solo-label')
soloLabel:wikitext((people[root] and people[root].displayName) or root)
end


if parent then
if #group.children > 0 then
gp1 = parent.parents[1]
unit:tag('div')
gp2 = parent.parents[2]
:addClass('kbftv2-unit-drop')
end


local couple = renderCouple(people, gp1, gp2)
unit:node(renderChildCards(people, group.children))
if couple then
row:node(couple)
end
end
end


return gen
return unit
end
end


local function renderParentGeneration(people, root)
local function renderUpperCoupleGeneration(people, couples)
local person = people[root]
if #couples == 0 then
if not person then
return nil
return nil
end
end


local parents = uniq(person.parents)
local gen = html.create('div')
if #parents == 0 then
gen:addClass('kbftv2-generation')
return nil
 
local units = {}
for _, pair in ipairs(couples) do
table.insert(units, renderCouple(people, pair[1], pair[2], nil))
end
end


sortNames(people, parents)
gen:node(renderGenerationRow(units))
 
local gen = html.create('div')
gen:addClass('kbft-generation')
gen:node(renderCouple(people, parents[1], parents[2]))
return gen
return gen
end
end


local function renderSiblingGeneration(people, root)
local function buildGrandparentCouples(people, root)
local sequence = getRootSiblingSequence(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 container = html.create('div')
local function buildParentCouples(people, root)
container:addClass('kbft-siblings')
local parents = getParents(people, root)
if #parents == 0 then
return {}
end
return { { parents[1], parents[2] } }
end


container:tag('div')
local function renderFocalGeneration(people, root)
:addClass('kbft-sibling-spine')
local sequence = getRootSiblingSequence(people, root)


local row = container:tag('div')
local gen = html.create('div')
row:addClass('kbft-sibling-row')
gen:addClass('kbftv2-generation')


local units = {}
for _, name in ipairs(sequence) do
for _, name in ipairs(sequence) do
local unit = row:tag('div')
if name == root then
unit:addClass('kbft-sibling-unit')
table.insert(units, renderSingleCard(people, name, 'kbftv2-root-focus'))
else
table.insert(units, renderSingleCard(people, name))
end
end


unit:tag('div')
local groupWrap = gen:tag('div')
:addClass('kbft-sibling-up')
groupWrap:addClass('kbftv2-sibling-group')
 
groupWrap:tag('div')
:addClass('kbftv2-sibling-spine')


unit:node(renderSingle(people, name))
groupWrap:node(renderGenerationRow(units))
end


return container
return gen
end
end


Line 618: Line 673:


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


local units = {}
for _, group in ipairs(groups) do
for _, group in ipairs(groups) do
local unit = row:tag('div')
table.insert(units, renderFamilyUnit(people, root, group))
unit:addClass('kbft-sibling-unit')
 
local mainWrap = unit:tag('div')
mainWrap:addClass('kbft-family-main-wrap')
 
local marriageYear = nil
if isRealValue(group.partner) then
local union = findUnionBetween(people, root, group.partner)
marriageYear = getMarriageYear(union)
end
 
mainWrap:node(renderCouple(people, root, group.partner, marriageYear))
 
if #group.children > 0 then
unit:tag('div')
:addClass('kbft-child-down')
 
local childRow = unit:tag('div')
childRow:addClass('kbft-children')
 
for _, child in ipairs(group.children) do
childRow:node(
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
end
end
end


gen:node(renderGenerationRow(units))
return gen
return gen
end
end
Line 671: Line 695:


local connected = getConnectedPeople(people, root)
local connected = getConnectedPeople(people, root)
sortNames(people, connected)


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


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


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


return tostring(node)
return tostring(node)
Line 697: Line 722:


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


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


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


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


node:tag('div')
local units = {}
:addClass('kbft-generation')
for _, name in ipairs(names) do
:node(renderRowSingles(people, values))
table.insert(units, renderSingleCard(people, name))
end
 
local gen = node:tag('div')
gen:addClass('kbftv2-generation')
gen:node(renderGenerationRow(units))
end
end


Line 733: Line 764:


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


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


local parents = uniq(person.parents)
local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))
sortNames(people, parents)
if gpGen then
 
node:node(gpGen)
local grandparentGen = renderGrandparentGeneration(people, parents)
node:tag('div'):addClass('kbftv2-connector')
if grandparentGen then
node:node(grandparentGen)
node:tag('div'):addClass('kbft-connector')
end
end


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


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


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



Revision as of 07:39, 30 March 2026

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

local p = {}

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

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

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

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

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

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

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

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

	return nil
end

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

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

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

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

	return people[name]
end

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

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

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

	local people = {}

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

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

	return people
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

local function relationshipBadge(relType)
	if not isRealValue(relType) then
		return nil
	end

	local t = mw.ustring.lower(relType)

	if t:find('adopt') then
		return 'adopted'
	end
	if t:find('step') then
		return 'step'
	end
	if t:find('bio') then
		return nil
	end

	return relType
end

local function findUnionBetween(people, name1, name2)
	if not isRealValue(name1) or not isRealValue(name2) then
		return nil
	end

	local person = people[name1]
	if not person or not person.unions then
		return nil
	end

	for _, union in ipairs(person.unions) do
		if union.partner == name2 then
			return union
		end
	end

	return nil
end

local function getMarriageYear(union)
	if not union then
		return nil
	end

	local raw = union.marriageDate or union.engagementDate or union.startDate
	if not isRealValue(raw) then
		return nil
	end

	local year = tostring(raw):match('^(%d%d%d%d)')
	return year or tostring(raw)
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 = {}
	local 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

	local siblings = getSiblings(people, root)
	for _, v in ipairs(siblings) do addUnique(out, v) end

	local grandparents = getGrandparents(people, root)
	for _, v in ipairs(grandparents) do addUnique(out, v) end

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

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

	for i, sib in ipairs(siblings) do
		if i == midpoint then
			table.insert(seq, root)
			inserted = true
		end
		table.insert(seq, sib)
	end

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

	return seq
end

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

	local groups = {}

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

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

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

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

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

		if aSingle ~= bSingle then
			return aSingle
		end

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

	return out
end

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

local function renderCard(people, name, badgeText, extraClass)
	if not isRealValue(name) then
		return nil
	end

	local person = people[name] or { name = name, displayName = name }

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

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

	if isRealValue(badgeText) then
		card:tag('div')
			:addClass('kbftv2-badge')
			:wikitext(badgeText)
	end

	return card
end

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

local function renderCouple(people, leftName, rightName, marriageYear, leftClass, rightClass)
	if isRealValue(leftName) and isRealValue(rightName) then
		local wrap = html.create('div')
		wrap:addClass('kbftv2-couple')

		wrap:node(renderCard(people, leftName, nil, leftClass))

		local marriage = wrap:tag('div')
		marriage:addClass('kbftv2-marriage')

		if isRealValue(marriageYear) then
			marriage:tag('div')
				:addClass('kbftv2-marriage-year')
				:wikitext(marriageYear)
		end

		marriage:tag('div')
			:addClass('kbftv2-marriage-line')

		wrap:node(renderCard(people, rightName, nil, rightClass))
		return wrap
	end

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

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

	return nil
end

local function renderGenerationRow(units)
	local row = html.create('div')
	row:addClass('kbftv2-row')

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

	return row
end

local function renderChildCards(people, children)
	local childrenWrap = html.create('div')
	childrenWrap:addClass('kbftv2-unit-children')

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

	return childrenWrap
end

local function renderFamilyUnit(people, root, group)
	local unit = html.create('div')
	unit:addClass('kbftv2-unit')

	local top = unit:tag('div')
	top:addClass('kbftv2-unit-top')

	if isRealValue(group.partner) then
		local union = findUnionBetween(people, root, group.partner)
		local marriageYear = getMarriageYear(union)
		top:node(renderCouple(people, root, group.partner, marriageYear, 'kbftv2-root-echo', nil))
	else
		local soloLabel = top:tag('div')
		soloLabel:addClass('kbftv2-solo-label')
		soloLabel:wikitext((people[root] and people[root].displayName) or root)
	end

	if #group.children > 0 then
		unit:tag('div')
			:addClass('kbftv2-unit-drop')

		unit:node(renderChildCards(people, group.children))
	end

	return unit
end

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

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

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

	gen:node(renderGenerationRow(units))
	return gen
end

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

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

	return couples
end

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

local function renderFocalGeneration(people, root)
	local sequence = getRootSiblingSequence(people, root)

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

	local units = {}
	for _, name in ipairs(sequence) do
		if name == root then
			table.insert(units, renderSingleCard(people, name, 'kbftv2-root-focus'))
		else
			table.insert(units, renderSingleCard(people, name))
		end
	end

	local groupWrap = gen:tag('div')
	groupWrap:addClass('kbftv2-sibling-group')

	groupWrap:tag('div')
		:addClass('kbftv2-sibling-spine')

	groupWrap:node(renderGenerationRow(units))

	return gen
end

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

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

	local units = {}
	for _, group in ipairs(groups) do
		table.insert(units, renderFamilyUnit(people, root, group))
	end

	gen:node(renderGenerationRow(units))
	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('kbftv2-tree')

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

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

	local gen = node:tag('div')
	gen:addClass('kbftv2-generation')
	gen:node(renderGenerationRow(units))

	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('kbftv2-tree')

	node:tag('div')
		:addClass('kbftv2-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('kbftv2-section-title')
			:wikitext(label)

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

		local gen = node:tag('div')
		gen:addClass('kbftv2-generation')
		gen:node(renderGenerationRow(units))
	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 node = html.create('div')
	node:addClass('kbftv2-tree')

	node:tag('div')
		:addClass('kbftv2-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('kbftv2-connector')
	end

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

	node:node(renderFocalGeneration(people, root))

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

	return tostring(node)
end

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

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

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

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

return p