Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
No edit summary
Line 88: Line 88:
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
end)
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
end


Line 185: Line 216:
addUnique(people[p2].partners, p1)
addUnique(people[p2].partners, p1)


table.insert(people[p1].unions, {
local unionData = {
unionID = trim(row.UnionID),
unionID = trim(row.UnionID),
partner = p2,
partner1 = p1,
partner2 = p2,
unionType = trim(row.UnionType),
unionType = trim(row.UnionType),
status = trim(row.Status),
status = trim(row.Status),
Line 195: Line 227:
divorceDate = trim(row.DivorceDate),
divorceDate = trim(row.DivorceDate),
engagementDate = trim(row.EngagementDate)
engagementDate = trim(row.EngagementDate)
}
table.insert(people[p1].unions, {
unionID = unionData.unionID,
partner = p2,
unionType = unionData.unionType,
status = unionData.status,
startDate = unionData.startDate,
endDate = unionData.endDate,
marriageDate = unionData.marriageDate,
divorceDate = unionData.divorceDate,
engagementDate = unionData.engagementDate
})
})


table.insert(people[p2].unions, {
table.insert(people[p2].unions, {
unionID = trim(row.UnionID),
unionID = unionData.unionID,
partner = p1,
partner = p1,
unionType = trim(row.UnionType),
unionType = unionData.unionType,
status = trim(row.Status),
status = unionData.status,
startDate = trim(row.StartDate),
startDate = unionData.startDate,
endDate = trim(row.EndDate),
endDate = unionData.endDate,
marriageDate = trim(row.MarriageDate),
marriageDate = unionData.marriageDate,
divorceDate = trim(row.DivorceDate),
divorceDate = unionData.divorceDate,
engagementDate = trim(row.EngagementDate)
engagementDate = unionData.engagementDate
})
})
end
end
Line 246: Line 290:
if not person or not person.unions then return nil end
if not person or not person.unions then return nil end
for _, union in ipairs(person.unions) do
for _, union in ipairs(person.unions) do
if union.partner == name2 then return union end
if union.partner == name2 then
return union
end
end
end
return nil
return nil
end
end


local function getUnionMeta(people, root, partner)
local function formatUnionMeta(unionType, status, dateValue)
local union = findUnionBetween(people, root, partner)
local bits = {}
if not union then return nil end


local label = union.unionType or union.status
if isRealValue(unionType) then
local year = union.marriageDate or union.startDate or union.engagementDate
table.insert(bits, unionType)
elseif isRealValue(status) then
table.insert(bits, status)
end


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


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


Line 335: Line 379:
end
end


local function splitAroundCenter(items)
local function getOrderedSiblingsAroundRoot(people, root)
local left, right = {}, {}
local siblings = getSiblings(people, root)
local n = #items
sortNames(people, siblings)
local leftCount = math.floor(n / 2)
return splitAroundCenter(siblings)
 
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
end


Line 369: Line 403:


if not groups[key] then
if not groups[key] then
local union = nil
if isRealValue(link.otherParent) then
union = findUnionBetween(people, root, link.otherParent)
end
groups[key] = {
groups[key] = {
key = key,
unionID = link.unionID,
unionID = link.unionID,
partner = link.otherParent,
partner = link.otherParent,
children = {}
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
Line 383: Line 427:
end
end


-- partner-only groups so spouse still shows with no children
-- partner-only groups so a partner still appears even if no children
for _, partner in ipairs(person.partners or {}) do
for _, partner in ipairs(person.partners or {}) do
if isRealValue(partner) then
if isRealValue(partner) then
Line 399: Line 443:


groups[key] = {
groups[key] = {
key = key,
unionID = union and union.unionID or nil,
unionID = union and union.unionID or nil,
partner = partner,
partner = partner,
children = {}
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
Line 410: Line 459:
for _, group in pairs(groups) do
for _, group in pairs(groups) do
table.sort(group.children, function(a, b)
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)
return (a.birthOrder or 999) < (b.birthOrder or 999)
end)
end)
Line 421: Line 475:
if aSingle ~= bSingle then
if aSingle ~= bSingle then
return aSingle
return aSingle
end
if a.sortDate ~= b.sortDate then
return a.sortDate < b.sortDate
end
end


Line 431: Line 489:


return out
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
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
-- sibling-focused layout: siblings + root + inline primary partner + remaining siblings
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
-- multi-union layout: left partners + root + primary partner + right partners
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
end


Line 535: Line 730:
end
end


local function renderFocalGeneration(people, root, groups)
local function renderFocalGeneration(people, layout)
local siblings = getSiblings(people, root)
 
local partners = {}
local seen = {}
for _, group in ipairs(groups or {}) do
if isRealValue(group.partner) and not seen[group.partner] then
seen[group.partner] = true
table.insert(partners, group.partner)
end
end
sortNames(people, partners)
 
local leftSibs, rightSibs = splitAroundCenter(siblings)
local leftPartners, rightPartners = splitAroundCenter(partners)
 
local gen = html.create('div')
local gen = html.create('div')
gen:addClass('kbft-generation')
gen:addClass('kbft-generation')
gen:addClass('kbft-focal-generation')


local wrap = gen:tag('div')
local row = gen:tag('div')
wrap:addClass('kbft-siblings')
row:addClass('kbft-focal-row')
 
wrap:tag('div')
:addClass('kbft-sibling-spine')
 
local row = wrap:tag('div')
row:addClass('kbft-union-row')


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


if kind == 'root' then
if unit.kind == 'root' then
col:node(renderSingleCard(people, name, 'kbft-focus-card'))
col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))
else
else
col:node(renderSingleCard(people, name))
col:node(renderSingleCard(people, unit.name))
end
 
if isRealValue(meta) then
col:tag('div')
:addClass('kbft-union-meta')
:wikitext(meta)
end
end
end
for _, sib in ipairs(leftSibs) do
addCol('sibling', sib, nil)
end
for _, partner in ipairs(leftPartners) do
addCol('partner', partner, getUnionMeta(people, root, partner))
end
addCol('root', root, nil)
for _, partner in ipairs(rightPartners) do
addCol('partner', partner, getUnionMeta(people, root, partner))
end
for _, sib in ipairs(rightSibs) do
addCol('sibling', sib, nil)
end
end


Line 605: Line 753:
end
end


local function renderDescendantGeneration(people, root, groups)
local function renderBranchColumn(people, group, isRootBranch)
if #groups == 0 then return nil end
local col = html.create('div')
col:addClass('kbft-branch-col')


local partners = {}
if group then
local partnerGroups = {}
local meta = nil
local soloGroup = nil


for _, group in ipairs(groups) do
if isRootBranch then
if isRealValue(group.partner) then
local rel = nil
table.insert(partners, group.partner)
if group.children and #group.children > 0 then
partnerGroups[group.partner] = group
rel = relationshipBadge(group.children[1].relationshipType)
end
meta = rel
else
else
soloGroup = group
meta = formatUnionMeta(group.unionType, group.status, group.dateValue)
end
end
end
sortNames(people, partners)


local gen = html.create('div')
if isRealValue(meta) then
gen:addClass('kbft-generation')
col:tag('div')
gen:addClass('kbft-desc-generation')
:addClass('kbft-union-meta')
 
:wikitext(meta)
local row = gen:tag('div')
else
row:addClass('kbft-branch-columns')
col:tag('div')
 
:addClass('kbft-union-meta kbft-union-meta-empty')
local function addBranchCol(kind, partnerName, group)
:wikitext('&nbsp;')
local col = row:tag('div')
col:addClass('kbft-branch-column')
col:attribute('data-kind', kind)
 
-- top spacer / hidden card to keep columns aligned to focal row
local top = col:tag('div')
top:addClass('kbft-branch-top')
 
if kind == 'solo' then
top:tag('div'):addClass('kbft-branch-hidden-card')
elseif kind == 'partner' then
top:tag('div'):addClass('kbft-branch-hidden-card')
end
end


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


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


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


-- left side: solo first if it exists
return col
if soloGroup then
end
addBranchCol('solo', nil, soloGroup)
 
local function renderDescendantGeneration(people, layout)
local hasAnything = false
if layout.soloGroup then
hasAnything = true
end
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


-- then partner branches
row:node(renderBranchColumn(people, group, isRootBranch))
for _, partner in ipairs(partners) do
addBranchCol('partner', partner, partnerGroups[partner])
end
end


Line 753: Line 918:


local groups = getFamilyGroupsForRoot(people, root)
local groups = getFamilyGroupsForRoot(people, root)
local layout = buildFocalLayout(people, root, groups)


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


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


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

Revision as of 14:17, 30 March 2026

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

local p = {}

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

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

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

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

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

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

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

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

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

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

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

	return people[name]
end

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

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)

			local unionData = {
				unionID = trim(row.UnionID),
				partner1 = p1,
				partner2 = 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[p1].unions, {
				unionID = unionData.unionID,
				partner = p2,
				unionType = unionData.unionType,
				status = unionData.status,
				startDate = unionData.startDate,
				endDate = unionData.endDate,
				marriageDate = unionData.marriageDate,
				divorceDate = unionData.divorceDate,
				engagementDate = unionData.engagementDate
			})

			table.insert(people[p2].unions, {
				unionID = unionData.unionID,
				partner = p1,
				unionType = unionData.unionType,
				status = unionData.status,
				startDate = unionData.startDate,
				endDate = unionData.endDate,
				marriageDate = unionData.marriageDate,
				divorceDate = unionData.divorceDate,
				engagementDate = unionData.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 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 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 = {}

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

		if not groups[key] then
			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

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

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

				groups[key] = {
					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

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
		-- sibling-focused layout: siblings + root + inline primary partner + remaining siblings
		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
		-- multi-union layout: left partners + root + primary partner + right partners
		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

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

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

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

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

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

	return card
end

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

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

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

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

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

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

	return row
end

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

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

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

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

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

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

	return couples
end

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

local function renderFocalGeneration(people, 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:attribute('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

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