Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
No edit summary
 
(41 intermediate revisions by the same user not shown)
Line 2: Line 2:


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


local function esc(value)
local SLOT_WIDTH = 340
if not value then
local ANCHOR_CENTER = 90
return ''
local CHILD_GAP = 24
end
local GROUP_GAP = 28
value = tostring(value)
 
value = value:gsub('\\', '\\\\')
-- forward declarations for rendering helpers used earlier in the file
value = value:gsub('"', '\\"')
local renderCard
return value
local renderSingleCard
end
local renderCouple
local renderGenerationRow


local function cargoQuery(tables, fields, args)
-- =========================================
args = args or {}
-- Helpers
local ok, result = pcall(function()
-- =========================================
return cargo.query(tables, fields, args)
end)
if ok and result then
return result
end
return {}
end


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


local function formatYear(dateValue)
local function isRealValue(v)
dateValue = trim(dateValue)
v = trim(v)
if not dateValue then
if not v then return false end
return nil
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
end
return tostring(dateValue):match('^(%d%d%d%d)')
table.insert(list, value)
end
end


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


local function addSet(set, value)
local function getArg(frame, key)
value = trim(value)
local v = frame.args[key]
if value then
if isRealValue(v) then return trim(v) end
set[value] = true
local parent = frame:getParent()
if parent then
v = parent.args[key]
if isRealValue(v) then return trim(v) end
end
end
return nil
end
local function getRoot(frame)
return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text
end
end


local function sorted(list)
local function makeLink(name, displayName)
table.sort(list, function(a, b)
if not isRealValue(name) then return '' end
return tostring(a):lower() < tostring(b):lower()
displayName = trim(displayName) or name
end)
return string.format('[[%s|%s]]', name, displayName)
return list
end
end


local function getCharacter(pageName)
local function ensurePerson(people, name)
pageName = trim(pageName)
name = trim(name)
if not pageName then
if not isRealValue(name) then return nil end
return nil
 
if not people[name] then
people[name] = {
name = name,
displayName = name,
parents = {},
children = {},
partners = {},
unions = {},
childLinks = {}
}
end
end


local rows = cargoQuery(
return people[name]
'Characters',
end
'Page,DisplayName,BirthDate,DeathDate,Status,Gender',
{
where = 'Page="' .. esc(pageName) .. '"',
limit = 1
}
)


return rows[1]
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
end


local function getDisplayName(pageName)
local function splitAroundCenter(items)
local c = getCharacter(pageName)
local left, right = {}, {}
if c and trim(c.DisplayName) then
local n = #items
return trim(c.DisplayName)
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
end
return pageName
 
return left, right
end
end


local function makeLinkedName(pageName)
local function extractYear(v)
return '[[' .. pageName .. '|' .. getDisplayName(pageName) .. ']]'
v = trim(v)
if not isRealValue(v) then return nil end
return tostring(v):match('^(%d%d%d%d)') or tostring(v)
end
end


local function linkList(list)
local function sortKeyDate(union)
local out = {}
if not union then return '9999-99-99' end
for _, pageName in ipairs(list or {}) do
return trim(union.marriageDate)
table.insert(out, makeLinkedName(pageName))
or trim(union.startDate)
end
or trim(union.engagementDate)
return table.concat(out, '<br>')
or trim(union.endDate)
or '9999-99-99'
end
end


local function getParents(person)
-- =========================================
person = trim(person)
-- Data loading
if not person then
-- =========================================
return {}, nil
 
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
end


local rows = cargoQuery(
return people
end
 
local function loadParentChild(people)
local results = cargo.query(
'ParentChild',
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
{ limit = 5000 }
where = 'Child="' .. esc(person) .. '"',
limit = 20
}
)
)


local parents = {}
for _, row in ipairs(results) do
local seen = {}
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)


for _, row in ipairs(rows) do
table.insert(people[p2].childLinks, {
addUnique(parents, seen, row.Parent1)
child = child,
addUnique(parents, seen, row.Parent2)
otherParent = p1,
unionID = unionID,
relationshipType = relationshipType,
birthOrder = birthOrder
})
end
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)
})


return sorted(parents), rows[1]
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
end


local function getChildren(person)
local function finalizePeople(people)
person = trim(person)
for _, person in pairs(people) do
if not person then
person.parents = uniq(person.parents)
return {}
person.children = uniq(person.children)
person.partners = uniq(person.partners)
end
end
end
local function loadData()
local people = loadCharacters()
loadParentChild(people)
loadUnions(people)
finalizePeople(people)
return people
end
-- =========================================
-- Relationship helpers
-- =========================================


local rows = cargoQuery(
local function relationshipBadge(relType)
'ParentChild',
if not isRealValue(relType) then return nil end
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
local t = mw.ustring.lower(relType)
{
if t:find('adopt') then return 'adopted' end
where = 'Parent1="' .. esc(person) .. '" OR Parent2="' .. esc(person) .. '"',
if t:find('step') then return 'step' end
limit = 200
if t:find('bio') then return nil end
}
return relType
)
end


table.sort(rows, function(a, b)
local function findUnionBetween(people, name1, name2)
local aOrder = tonumber(a.BirthOrder) or 9999
if not isRealValue(name1) or not isRealValue(name2) then return nil end
local bOrder = tonumber(b.BirthOrder) or 9999
local person = people[name1]
if aOrder == bOrder then
if not person or not person.unions then return nil end
return tostring(a.Child):lower() < tostring(b.Child):lower()
for _, union in ipairs(person.unions) do
if union.partner == name2 then
return union
end
end
return aOrder < bOrder
end
end)
return nil
end


local children = {}
local function findChildLinkBetween(people, parentName, childName)
local seen = {}
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 _, row in ipairs(rows) do
for _, link in ipairs(parent.childLinks) do
addUnique(children, seen, row.Child)
if link.child == childName then
return link
end
end
end


return children
return nil
end
end


local function getPartners(person)
local function formatUnionMeta(unionType, status, dateValue)
person = trim(person)
local bits = {}
if not person then
 
return {}, {}
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
end


local rows = cargoQuery(
if #bits == 0 then return nil end
'Unions',
return table.concat(bits, ' ')
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
end
{
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
limit = 100
}
)


local partners = {}
local function describeEdge(edge)
local seen = {}
if not edge then return nil end


for _, row in ipairs(rows) do
if edge.type == "parent" then
local p1 = trim(row.Partner1)
local rel = mw.ustring.lower(trim(edge.relationshipType) or '')
local p2 = trim(row.Partner2)
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 p1 == person then
if unionType == 'marriage' then
addUnique(partners, seen, p2)
if status == 'ended' then return 'former spouse of' end
elseif p2 == person then
return 'spouse of'
addUnique(partners, seen, p1)
end
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
end


return sorted(partners), rows
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
end


local function getSiblingGeneration(person)
local function getGrandparents(people, root)
person = trim(person)
local out = {}
if not person then
local parents = getParents(people, root)
return {}
 
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
end


local targetRows = cargoQuery(
out = uniq(out)
'ParentChild',
sortNames(people, out)
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
return out
{
end
where = 'Child="' .. esc(person) .. '"',
 
limit = 10
local function getSiblings(people, root)
}
local out, seen = {}, {}
)
local person = people[root]
if not person then return out end


if not targetRows[1] then
for _, parentName in ipairs(person.parents) do
return { person }
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
end


local targetUnion = trim(targetRows[1].UnionID)
out = uniq(out)
local targetP1 = trim(targetRows[1].Parent1)
sortNames(people, out)
local targetP2 = trim(targetRows[1].Parent2)
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 = {}


local whereParts = {}
for _, link in ipairs(person.childLinks or {}) do
if targetUnion then
local key
table.insert(whereParts, 'UnionID="' .. esc(targetUnion) .. '"')
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
end
if targetP1 and targetP2 then
 
table.insert(whereParts, '(Parent1="' .. esc(targetP1) .. '" AND Parent2="' .. esc(targetP2) .. '")')
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
end


if #whereParts == 0 then
local out = {}
return { person }
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
end


local rows = cargoQuery(
table.sort(out, function(a, b)
'ParentChild',
local aSingle = not isRealValue(a.partner)
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
local bSingle = not isRealValue(b.partner)
{
 
where = table.concat(whereParts, ' OR '),
if aSingle ~= bSingle then
limit = 100
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(rows, function(a, b)
table.sort(candidates, function(a, b)
local aOrder = tonumber(a.BirthOrder) or 9999
if a.score ~= b.score then
local bOrder = tonumber(b.BirthOrder) or 9999
return a.score > b.score
if aOrder == bOrder then
end
return tostring(a.Child):lower() < tostring(b.Child):lower()
if a.sortDate ~= b.sortDate then
return a.sortDate < b.sortDate
end
end
return aOrder < bOrder
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)
end)


local people = {}
return candidates[1] and candidates[1].partner or nil
local seen = {}
end
for _, row in ipairs(rows) do
 
addUnique(people, seen, row.Child)
-- =========================================
-- Graph builder + path finder
-- =========================================
 
local function buildGraph(people)
local graph = {}
 
for name, _ in pairs(people) do
graph[name] = {}
end
end


if #people == 0 then
for parentName, person in pairs(people) do
table.insert(people, person)
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
end


return people
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
end


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


local rows = cargoQuery(
local queue = {
'ParentChild',
'RelationshipType',
{
{
where = 'Child="' .. esc(person) .. '"',
{ name = start, via = nil }
limit = 1
}
}
)
}
 
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


if rows[1] and trim(rows[1].RelationshipType) then
visited[nextNode] = true
return trim(rows[1].RelationshipType)
table.insert(queue, newPath)
end
end
end
end


Line 284: Line 678:
end
end


local function buildCoupleGroups(people)
-- =========================================
local groups = {}
-- Descendant traversal + family cluster
local used = {}
-- =========================================
local working = {}
 
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)


for _, person in ipairs(people or {}) do
local descendants = getDescendants(people, root, includeNonBiological)
table.insert(working, person)
for _, name in ipairs(descendants) do
addPerson(name)
end
end
sorted(working)


for _, person in ipairs(working) do
if includePartners then
if not used[person] then
local snapshot = {}
local partners = getPartners(person)
for i, name in ipairs(cluster) do
local matchedPartner = nil
snapshot[i] = name
local matchedUnion = nil
end


for _, partner in ipairs(partners) do
for _, name in ipairs(snapshot) do
for _, candidate in ipairs(working) do
local person = people[name]
if candidate == partner and not used[candidate] then
if person then
matchedPartner = partner
for _, partner in ipairs(person.partners or {}) do
break
addPerson(partner)
end
end
if matchedPartner then
local rows = cargoQuery(
'Unions',
'UnionID,Partner1,Partner2,UnionType,MarriageDate,EngagementDate',
{
where = '(Partner1="' .. esc(person) .. '" AND Partner2="' .. esc(matchedPartner) .. '") OR (Partner1="' .. esc(matchedPartner) .. '" AND Partner2="' .. esc(person) .. '")',
limit = 1
}
)
matchedUnion = rows[1]
break
end
end
end
end
end
end
sortNames(people, cluster)
return cluster
end
-- =========================================
-- Traditional descendant familytree helpers
-- =========================================
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)
local links = getChildLinksOf(people, personName, includeNonBiological)
local seen = {}
local out = {}
for _, link in ipairs(links) do
if isRealValue(link.otherParent) and not seen[link.otherParent] then
seen[link.otherParent] = true
table.insert(out, link.otherParent)
end
end
table.sort(out, function(a, b)
local ua = findUnionBetween(people, personName, a)
local ub = findUnionBetween(people, personName, b)
local da = ua and sortKeyDate(ua) or '9999-99-99'
local db = ub and sortKeyDate(ub) or '9999-99-99'
if da ~= db then
return da < db
end
local ad = (people[a] and people[a].displayName) or a
local bd = (people[b] and people[b].displayName) or b
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
end)
return out
end
local function buildPartnerLayoutForFamilyTree(partners)
local partnerCenters = {}
local unionCenters = {}
local tempCenters = {}
local leftPartners, rightPartners = splitAroundCenter(partners)
local minCenter = 0
local maxCenter = 0
for i, name in ipairs(leftPartners) do
local idxFromRoot = #leftPartners - i + 1
local c = -(idxFromRoot * 180)
tempCenters[name] = c
if c < minCenter then minCenter = c end
if c > maxCenter then maxCenter = c end
end
for i, name in ipairs(rightPartners) do
local c = i * 180
tempCenters[name] = c
if c < minCenter then minCenter = c end
if c > maxCenter then maxCenter = c end
end
local shift = -minCenter + 70
local rootCenter = shift
local rowWidth = (maxCenter - minCenter) + 140
for name, c in pairs(tempCenters) do
local shifted = c + shift
partnerCenters[name] = shifted
unionCenters[name] = math.floor((rootCenter + shifted) / 2)
end
return {
rowWidth = math.max(rowWidth, 140),
rootCenter = rootCenter,
partnerCenters = partnerCenters,
unionCenters = unionCenters
}
end
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)
local links = getChildLinksOf(people, personName, includeNonBiological)
local groupsByKey = {}
local order = {}
for _, link in ipairs(links) do
local key
local anchorKind
local partnerName = nil
if isRealValue(link.otherParent) then
key = 'union::' .. link.otherParent
anchorKind = 'union'
partnerName = link.otherParent
else
key = 'self::' .. personName
anchorKind = 'self'
end
if not groupsByKey[key] then
groupsByKey[key] = {
anchorKind = anchorKind,
partnerName = partnerName,
children = {},
relationshipLabels = {}
}
table.insert(order, key)
end
addUnique(groupsByKey[key].children, link.child)


if matchedPartner then
if anchorKind == 'self' then
used[person] = true
local badge = relationshipBadge(link.relationshipType)
used[matchedPartner] = true
if isRealValue(badge) then
addUnique(groupsByKey[key].relationshipLabels, badge)
end
end
end


local unionType = trim(matchedUnion and matchedUnion.UnionType) or 'Marriage'
local out = {}
local year = nil
for _, key in ipairs(order) do
if unionType == 'Engagement' then
local group = groupsByKey[key]
year = formatYear(matchedUnion and matchedUnion.EngagementDate)
else
year = formatYear(matchedUnion and matchedUnion.MarriageDate)
end


table.insert(groups, {
if group.anchorKind == 'union' and isRealValue(group.partnerName) then
type = 'couple',
local union = findUnionBetween(people, personName, group.partnerName)
left = person,
if union then
right = matchedPartner,
group.label = formatUnionMeta(
unionType = unionType,
union.unionType,
marriageYear = year
union.status,
})
union.marriageDate or union.startDate or union.engagementDate
)
end
else
if #group.relationshipLabels == 1 then
group.label = group.relationshipLabels[1]
elseif #group.relationshipLabels > 1 then
group.label = table.concat(group.relationshipLabels, ' / ')
else
else
used[person] = true
group.label = nil
table.insert(groups, {
type = 'single',
person = person
})
end
end
end
end
table.insert(out, group)
end
end


return groups
return out
end
end


local function buildFocusBranches(person)
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)
person = trim(person)
local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)
if not person then
local layout = buildPartnerLayoutForFamilyTree(displayPartners)
return {}
local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)
 
local childGroups = {}
local groupsRowWidth = 0
 
for _, rawGroup in ipairs(rawGroups) do
local group = {
anchorKind = rawGroup.anchorKind,
partnerName = rawGroup.partnerName,
label = rawGroup.label,
nodes = {},
width = 0
}
 
for i, childName in ipairs(rawGroup.children) do
local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)
table.insert(group.nodes, childNode)
group.width = group.width + childNode.width
if i > 1 then
group.width = group.width + CHILD_GAP
end
end
 
if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then
group.sourceCenter = layout.unionCenters[group.partnerName]
else
group.sourceCenter = layout.rootCenter
end
 
table.insert(childGroups, group)
end
 
table.sort(childGroups, function(a, b)
if a.sourceCenter ~= b.sourceCenter then
return a.sourceCenter < b.sourceCenter
end
 
local an = (a.partnerName or '')
local bn = (b.partnerName or '')
return mw.ustring.lower(an) < mw.ustring.lower(bn)
end)
 
for gi, group in ipairs(childGroups) do
groupsRowWidth = groupsRowWidth + group.width
if gi > 1 then
groupsRowWidth = groupsRowWidth + GROUP_GAP
end
end
 
local topWidth = layout.rowWidth
local nodeWidth = math.max(topWidth, groupsRowWidth, 140)
 
local selfLeft = math.floor((nodeWidth - topWidth) / 2)
local rootCenterAbs = selfLeft + layout.rootCenter
 
local selfRowHeight = 56
local unionY = 28
local stemHeight = selfRowHeight - unionY
local labelY = 0
local barTop = 18
local branchHeight = 28
local childDropHeight = branchHeight - barTop
 
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('position', 'relative')
selfRow:css('height', tostring(selfRowHeight) .. 'px')
selfRow:css('width', tostring(topWidth) .. 'px')
selfRow:css('margin-left', tostring(selfLeft) .. 'px')
 
-- partner line segments
for _, partnerName in ipairs(displayPartners) do
local partnerCenter = layout.partnerCenters[partnerName]
local lineLeft = math.min(layout.rootCenter, partnerCenter)
local lineWidth = math.abs(layout.rootCenter - partnerCenter)
 
if lineWidth > 0 then
local seg = selfRow:tag('div')
seg:addClass('kbft-ft-unionseg')
seg:css('position', 'absolute')
seg:css('top', tostring(unionY) .. 'px')
seg:css('left', tostring(lineLeft) .. 'px')
seg:css('width', tostring(lineWidth) .. 'px')
seg:css('height', '2px')
seg:css('background', '#bca88e')
end
end
end


local branches = {}
-- source stems from root/union line down to branch layer
do
local seenCenters = {}
for _, group in ipairs(childGroups) do
local center = group.sourceCenter
if center and not seenCenters[center] then
seenCenters[center] = true


local unions = cargoQuery(
local stem = selfRow:tag('div')
'Unions',
stem:addClass('kbft-ft-sourcestem')
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,EngagementDate',
stem:css('position', 'absolute')
{
stem:css('top', tostring(unionY) .. 'px')
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
stem:css('left', tostring(center) .. 'px')
limit = 100
stem:css('width', '2px')
}
stem:css('height', tostring(stemHeight) .. 'px')
)
stem:css('margin-left', '-1px')
stem:css('background', '#bca88e')
end
end
end


for _, union in ipairs(unions) do
-- root card
local p1 = trim(union.Partner1)
do
local p2 = trim(union.Partner2)
local slot = selfRow:tag('div')
local partner = nil
slot:addClass('kbft-ft-cardslot')
slot:css('position', 'absolute')
slot:css('top', '0')
slot:css('left', tostring(layout.rootCenter) .. 'px')
slot:css('transform', 'translateX(-50%)')
slot:css('z-index', '2')


if p1 == person then
if focus then
partner = p2
slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))
else
else
partner = p1
slot:node(renderCard(people, personName))
end
end
 
-- partner cards
for _, partnerName in ipairs(displayPartners) do
local slot = selfRow:tag('div')
slot:addClass('kbft-ft-cardslot')
slot:css('position', 'absolute')
slot:css('top', '0')
slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')
slot:css('transform', 'translateX(-50%)')
slot:css('z-index', '2')
slot:node(renderCard(people, partnerName))
end
 
if #childGroups > 0 then
local branch = node:tag('div')
branch:addClass('kbft-ft-branch')
branch:css('position', 'relative')
branch:css('height', tostring(branchHeight) .. 'px')
branch:css('width', tostring(nodeWidth) .. 'px')
branch:css('margin-top', '0')
 
local childRow = node:tag('div')
childRow:addClass('kbft-ft-childrenrow')
childRow:css('width', tostring(groupsRowWidth) .. 'px')
childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')
 
local groupAnchors = {}
local runningGroupX = 0
local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)
 
for gi, group in ipairs(childGroups) do
local groupWrap = childRow:tag('div')
groupWrap:addClass('kbft-ft-groupwrap')
groupWrap:css('position', 'relative')
groupWrap:css('display', 'inline-block')
groupWrap:css('vertical-align', 'top')
groupWrap:css('width', tostring(group.width) .. 'px')
if gi < #childGroups then
groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')
end
 
local childAnchorsAbs = {}
local runningChildX = 0
 
for ci, childNode in ipairs(group.nodes) do
local childWrap = groupWrap:tag('div')
childWrap:addClass('kbft-ft-childwrap')
childWrap:css('width', tostring(childNode.width) .. 'px')
if ci < #group.nodes then
childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')
end
 
local drop = childWrap:tag('div')
drop:addClass('kbft-ft-childdrop')
drop:css('top', tostring(-childDropHeight) .. 'px')
drop:css('height', tostring(childDropHeight) .. 'px')
drop:css('left', tostring(childNode.anchorX) .. 'px')
 
childWrap:wikitext(childNode.html)
 
table.insert(
childAnchorsAbs,
groupsStart + runningGroupX + runningChildX + childNode.anchorX
)
 
runningChildX = runningChildX + childNode.width + (ci < #group.nodes and CHILD_GAP or 0)
end
 
table.insert(groupAnchors, {
sourceAnchorAbs = selfLeft + group.sourceCenter,
childAnchorsAbs = childAnchorsAbs,
label = group.label
})
 
runningGroupX = runningGroupX + group.width + (gi < #childGroups and GROUP_GAP or 0)
end
end


local childRows = cargoQuery(
for _, info in ipairs(groupAnchors) do
'ParentChild',
local sourceAnchorAbs = info.sourceAnchorAbs
'Child,BirthOrder',
local childAbsAnchors = info.childAnchorsAbs
{
local firstAnchor = childAbsAnchors[1]
where = 'UnionID="' .. esc(union.UnionID) .. '"',
local lastAnchor = childAbsAnchors[#childAbsAnchors]
limit = 100
 
}
local lineLeft, lineWidth
)
if #childAbsAnchors == 1 then
local onlyAnchor = childAbsAnchors[1]
lineLeft = math.min(sourceAnchorAbs, onlyAnchor)
lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)
else
lineLeft = firstAnchor
lineWidth = lastAnchor - firstAnchor
end
 
local parentDrop = branch:tag('div')
parentDrop:addClass('kbft-ft-parentdrop')
parentDrop:css('position', 'absolute')
parentDrop:css('top', '0')
parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')
parentDrop:css('width', '2px')
parentDrop:css('height', tostring(barTop) .. 'px')
parentDrop:css('margin-left', '-1px')
parentDrop:css('background', '#bca88e')


table.sort(childRows, function(a, b)
if lineWidth > 0 then
local aOrder = tonumber(a.BirthOrder) or 9999
local bar = branch:tag('div')
local bOrder = tonumber(b.BirthOrder) or 9999
bar:addClass('kbft-ft-childrenbar')
if aOrder == bOrder then
bar:css('position', 'absolute')
return tostring(a.Child):lower() < tostring(b.Child):lower()
bar:css('top', tostring(barTop) .. 'px')
bar:css('left', tostring(lineLeft) .. 'px')
bar:css('width', tostring(lineWidth) .. 'px')
bar:css('height', '2px')
bar:css('background', '#bca88e')
end
end
return aOrder < bOrder
end)


local children = {}
if isRealValue(info.label) then
for _, row in ipairs(childRows) do
local labelLeft
table.insert(children, row.Child)
local labelWidth
 
if lineWidth > 0 then
labelLeft = lineLeft
labelWidth = lineWidth
else
labelWidth = 90
labelLeft = sourceAnchorAbs - math.floor(labelWidth / 2)
end
 
local labelNode = branch:tag('div')
labelNode:addClass('kbft-ft-grouplabel')
labelNode:css('position', 'absolute')
labelNode:css('top', tostring(labelY) .. 'px')
labelNode:css('left', tostring(labelLeft) .. 'px')
labelNode:css('width', tostring(labelWidth) .. 'px')
labelNode:css('text-align', 'center')
labelNode:css('font-size', '0.8em')
labelNode:css('line-height', '1')
labelNode:css('color', '#5f4b36')
labelNode:wikitext(info.label)
end
end
end
end
return {
html = tostring(node),
width = nodeWidth,
anchorX = rootCenterAbs
}
end
-- =========================================
-- Focal tree rendering helpers
-- =========================================
local function buildFocalLayout(people, root, groups)
local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)
local soloGroup = nil
local partnerGroups = {}
local partners = {}


local unionType = trim(union.UnionType) or 'Marriage'
for _, group in ipairs(groups or {}) do
local year = nil
if isRealValue(group.partner) then
if unionType == 'Engagement' then
partnerGroups[group.partner] = group
year = formatYear(union.EngagementDate)
table.insert(partners, group.partner)
else
else
year = formatYear(union.MarriageDate)
soloGroup = group
end
end
end
local units = {}
local unitIndex = {}
local primaryPartner = choosePrimaryPartner(people, root, groups)


table.insert(branches, {
local function addUnit(kind, name)
branchPerson = partner,
if not isRealValue(name) then return end
unionType = unionType,
table.insert(units, { kind = kind, name = name })
marriageYear = year,
unitIndex[name] = #units
children = children,
isSoloParentBranch = false
})
end
end


local orphanRows = cargoQuery(
if #leftSibs > 0 or #rightSibs > 0 then
'ParentChild',
for _, sib in ipairs(leftSibs) do
'Child,BirthOrder',
addUnit('sibling', sib)
{
end
where = '(Parent1="' .. esc(person) .. '" OR Parent2="' .. esc(person) .. '") AND (UnionID="" OR UnionID IS NULL)',
 
limit = 100
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


if #orphanRows > 0 then
table.sort(others, function(a, b)
table.sort(orphanRows, function(a, b)
local ga = partnerGroups[a]
local aOrder = tonumber(a.BirthOrder) or 9999
local gb = partnerGroups[b]
local bOrder = tonumber(b.BirthOrder) or 9999
local da = ga and ga.sortDate or '9999-99-99'
if aOrder == bOrder then
local db = gb and gb.sortDate or '9999-99-99'
return tostring(a.Child):lower() < tostring(b.Child):lower()
if da ~= db then
return da < db
end
end
return aOrder < bOrder
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 children = {}
local leftPartners, rightPartners = splitAroundCenter(others)
for _, row in ipairs(orphanRows) do
 
table.insert(children, row.Child)
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
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))


table.insert(branches, {
if isRealValue(badgeText) then
branchPerson = person,
card:tag('div')
unionType = 'SoloParent',
:addClass('kbft-years')
marriageYear = nil,
:wikitext(badgeText)
children = children,
isSoloParentBranch = true
})
end
end


return branches
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
end


local function cardStyle()
renderCouple = function(people, leftName, rightName)
return table.concat({
if not isRealValue(leftName) and not isRealValue(rightName) then
'width:120px',
return nil
'min-height:56px',
end
'padding:8px 10px',
 
'border:1px solid #cab8aa',
if isRealValue(leftName) and isRealValue(rightName) then
'background:#fffdf9',
local wrap = html.create('div')
'border-radius:10px',
wrap:addClass('kbft-couple')
'box-shadow:0 2px 4px rgba(0,0,0,0.08)',
wrap:node(renderCard(people, leftName))
'text-align:center',
local marriage = wrap:tag('div')
'box-sizing:border-box',
marriage:addClass('kbft-marriage')
'font-size:0.92em',
marriage:tag('div'):addClass('kbft-marriage-line')
'line-height:1.2',
wrap:node(renderCard(people, rightName))
'display:flex',
return wrap
'flex-direction:column',
end
'justify-content:center'
 
}, ';')
if isRealValue(leftName) then return renderSingleCard(people, leftName) end
return renderSingleCard(people, rightName)
end
end


local function yearsStyle()
renderGenerationRow = function(units, className)
return 'margin-top:4px;font-size:0.74em;color:#7a6b60;'
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
end


local function makeCard(pageName)
local function renderUpperCoupleGeneration(people, couples)
pageName = trim(pageName)
if #couples == 0 then return nil end
if not pageName then
 
return ''
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
end


local c = getCharacter(pageName)
gen:node(renderGenerationRow(units, 'kbft-row'))
local displayName = getDisplayName(pageName)
return gen
local birthYear = c and formatYear(c.BirthDate) or nil
end
local deathYear = c and formatYear(c.DeathDate) or nil
 
local function buildGrandparentCouples(people, root)
local parents = getParents(people, root)
local couples = {}


local years = ''
for _, parentName in ipairs(parents) do
if birthYear or deathYear then
local parent = people[parentName]
years = '<div style="' .. yearsStyle() .. '">' .. (birthYear or '?') .. '–' .. (deathYear or '') .. '</div>'
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
end


return '<div style="' .. cardStyle() .. '">[[' .. pageName .. '|' .. displayName .. ']]' .. years .. '</div>'
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
end


local function makeRelationLabel(unionType, marriageYear)
local function renderFocalGeneration(people, layout)
local label = ''
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 unionType == 'Marriage' and marriageYear then
if unit.kind == 'root' then
label = 'm. ' .. marriageYear
col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))
elseif unionType == 'Marriage' then
label = 'm.'
elseif unionType == 'Engagement' and marriageYear then
label = 'eng. ' .. marriageYear
elseif unionType == 'Engagement' then
label = 'eng.'
elseif unionType == 'Affair' then
label = 'affair'
elseif unionType == 'Liaison' then
label = 'liaison'
elseif unionType == 'SoloParent' then
label = ''
elseif unionType and unionType ~= '' then
if marriageYear then
label = unionType .. ' ' .. marriageYear
else
else
label = unionType
col:node(renderSingleCard(people, unit.name))
end
end
end
end


return label
return gen
end
end


local function makePartnerBranch(branchPerson, marriageYear, unionType)
local function renderBranchColumn(people, group, isRootBranch)
local label = makeRelationLabel(unionType, marriageYear)
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')


local html = {}
for _, child in ipairs(group.children) do
table.insert(html, '<div style="display:inline-flex;flex-direction:column;align-items:center;">')
childrenWrap:node(
table.insert(html, makeCard(branchPerson))
renderCard(
if label ~= '' then
people,
table.insert(html, '<div style="font-size:0.7em;color:#7a6b60;margin-top:4px;white-space:nowrap;">' .. label .. '</div>')
child.name,
relationshipBadge(child.relationshipType)
)
)
end
end
else
col:tag('div')
:addClass('kbft-union-meta kbft-union-meta-empty')
:wikitext('&nbsp;')
end
end
table.insert(html, '</div>')
 
return table.concat(html)
return col
end
end


local function makeCoupleRow(groups)
local function renderDescendantGeneration(people, layout)
if not groups or #groups == 0 then
local hasAnything = false
return ''
if layout.soloGroup then
hasAnything = true
end
for _, _ in pairs(layout.partnerGroups or {}) do
hasAnything = true
break
end
end


local html = {}
if not hasAnything then return nil end
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:22px;flex-wrap:wrap;width:100%;">')
 
for _, group in ipairs(groups) do
local gen = html.create('div')
if group.type == 'single' then
gen:addClass('kbft-generation')
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(group.person) .. '</div>')
gen:addClass('kbft-desc-generation')
else
 
local label = makeRelationLabel(group.unionType, group.marriageYear)
local row = gen:tag('div')
table.insert(html, '<div style="display:inline-flex;flex-direction:row;align-items:center;gap:8px;">')
row:addClass('kbft-desc-row')
table.insert(html, makeCard(group.left))
 
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
for _, unit in ipairs(layout.units) do
if label ~= '' then
local group = nil
table.insert(html, '<div style="font-size:0.7em;color:#7a6b60;margin-bottom:4px;white-space:nowrap;">' .. label .. '</div>')
local isRootBranch = false
end
 
table.insert(html, '<div style="width:40px;height:2px;background:#bdaea0;"></div>')
if unit.kind == 'root' then
table.insert(html, '</div>')
group = layout.soloGroup
table.insert(html, makeCard(group.right))
isRootBranch = true
table.insert(html, '</div>')
elseif unit.kind == 'partner' then
group = layout.partnerGroups[unit.name]
end
end
row:node(renderBranchColumn(people, group, isRootBranch))
end
end
table.insert(html, '</div>')
 
return table.concat(html)
return gen
end
end


function p.connected(frame)
-- =========================================
local args = frame.args
-- Public renderers
local parentArgs = frame:getParent() and frame:getParent().args or {}
-- =========================================
local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])
 
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


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


local visited = {}
local node = html.create('div')
local queue = {}
node:addClass('kbft-tree')
local head = 1
 
node:tag('div')
:addClass('kbft-title')
:wikitext(makeLink(person.name, person.displayName))


visited[root] = true
local function addSection(label, names)
table.insert(queue, root)
names = uniq(names)
if #names == 0 then return end
sortNames(people, names)


while head <= #queue do
node:tag('div')
local current = queue[head]
:addClass('kbft-title')
head = head + 1
:css('margin-top', '22px')
:wikitext(label)


local neighbors = {}
local gen = node:tag('div')
local parents = getParents(current)
gen:addClass('kbft-generation')
local children = getChildren(current)
local partners = getPartners(current)


for _, person in ipairs(parents) do
local units = {}
addSet(neighbors, person)
for _, name in ipairs(names) do
end
table.insert(units, renderSingleCard(people, name))
for _, person in ipairs(children) do
addSet(neighbors, person)
end
for _, person in ipairs(partners) do
addSet(neighbors, person)
end
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))


for neighbor, _ in pairs(neighbors) do
local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))
if neighbor and not visited[neighbor] then
if gpGen then
visited[neighbor] = true
node:node(gpGen)
table.insert(queue, neighbor)
node:tag('div'):addClass('kbft-connector')
end
end
end
end


local people = {}
local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))
for name, _ in pairs(visited) do
if parentGen then
table.insert(people, name)
node:node(parentGen)
node:tag('div'):addClass('kbft-connector')
end
end
sorted(people)


local lines = {}
node:node(renderFocalGeneration(people, layout))
table.insert(lines, "'''Connected component for " .. getDisplayName(root) .. "'''")
 
table.insert(lines, '* Total people found: ' .. tostring(#people))
local descGen = renderDescendantGeneration(people, layout)
for _, person in ipairs(people) do
if descGen then
table.insert(lines, '* ' .. makeLinkedName(person))
node:tag('div'):addClass('kbft-connector')
node:node(descGen)
end
end


return table.concat(lines, '\n')
return tostring(node)
end
end


function p.profile(frame)
local function renderFamilyIndex(people, root, mode)
local args = frame.args
local members = buildFamilyCluster(people, root, mode)
local parentArgs = frame:getParent() and frame:getParent().args or {}
 
local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])
local node = html.create('div')
node:addClass('kbft-tree')


if not root then
local title = 'Family Index: ' .. makeLink(root, root)
return 'Error: no root provided. Use root=Character Name'
if mode == 'extended' then
title = title .. ' (extended)'
elseif mode == 'all' then
title = title .. ' (including adoptive)'
end
end


local parents = getParents(root)
node:tag('div')
local siblings = getSiblingGeneration(root)
:addClass('kbft-title')
local partners = getPartners(root)
:wikitext(title)
local children = getChildren(root)
 
local gen = node:tag('div')
gen:addClass('kbft-generation')


local siblingList = {}
local units = {}
for _, person in ipairs(siblings) do
for _, name in ipairs(members) do
if person ~= root then
table.insert(units, renderSingleCard(people, name))
table.insert(siblingList, person)
end
end
end


local lines = {}
gen:node(renderGenerationRow(units, 'kbft-row'))
table.insert(lines, '{| class="wikitable" style="width:100%; max-width:900px;"')
 
table.insert(lines, '|-')
return tostring(node)
table.insert(lines, '! colspan="2" | Family profile for ' .. getDisplayName(root))
table.insert(lines, '|-')
table.insert(lines, '! style="width:20%;" | Person')
table.insert(lines, '| ' .. makeLinkedName(root))
table.insert(lines, '|-')
table.insert(lines, '! Parents')
table.insert(lines, '| ' .. (#parents > 0 and linkList(parents) or '—'))
table.insert(lines, '|-')
table.insert(lines, '! Siblings')
table.insert(lines, '| ' .. (#siblingList > 0 and linkList(siblingList) or '—'))
table.insert(lines, '|-')
table.insert(lines, '! Partners')
table.insert(lines, '| ' .. (#partners > 0 and linkList(partners) or '—'))
table.insert(lines, '|-')
table.insert(lines, '! Children')
table.insert(lines, '| ' .. (#children > 0 and linkList(children) or '—'))
table.insert(lines, '|}')
return table.concat(lines)
end
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)
function p.tree(frame)
local args = frame.args
local root = getRoot(frame)
local parentArgs = frame:getParent() and frame:getParent().args or {}
local people = loadData()
local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])
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


if not root then
function p.path(frame)
return 'Error: no root provided. Use root=Character Name'
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
end


local parents = getParents(root)
local people = loadData()
local grandparents = {}
local graph = buildGraph(people)
local gpSeen = {}
local path = findPath(graph, from, to)


for _, parentName in ipairs(parents) do
if not path then
local parentParents = getParents(parentName)
return "No connection found."
for _, gp in ipairs(parentParents) do
end
addUnique(grandparents, gpSeen, gp)
 
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
end
end
sorted(grandparents)


local grandparentGroups = buildCoupleGroups(grandparents)
return table.concat(out, " → ")
local parentGroups = buildCoupleGroups(parents)
end
local branches = buildFocusBranches(root)


local html = {}
function p.descendants(frame)
table.insert(html, '<div style="border:1px solid #cdbfb2;background:#f8f4ee;padding:28px 24px;margin:20px 0;border-radius:14px;text-align:center;">')
local root = getArg(frame, 'root')
table.insert(html, '<div style="text-align:center;font-weight:700;font-size:1.2em;margin-bottom:28px;color:#4e4036;">Family tree for ' .. getDisplayName(root) .. '</div>')
local includeAll = getArg(frame, 'include')
local includeNonBiological = (includeAll == 'all')


if #grandparentGroups > 0 then
if not isRealValue(root) then
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
return "<strong>Error:</strong> Please provide |root="
table.insert(html, makeCoupleRow(grandparentGroups))
table.insert(html, '</div>')
end
end


if #grandparentGroups > 0 and #parentGroups > 0 then
local people = loadData()
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
local descendants = getDescendants(people, root, includeNonBiological)
 
if #descendants == 0 then
return "No descendants found."
end
end


if #parentGroups > 0 then
local out = {}
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
for _, name in ipairs(descendants) do
table.insert(html, makeCoupleRow(parentGroups))
local displayName = (people[name] and people[name].displayName) or name
table.insert(html, '</div>')
table.insert(out, makeLink(name, displayName))
end
end


if #parentGroups > 0 then
return table.concat(out, " • ")
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
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
end


table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
local people = loadData()
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(root) .. '</div>')
return renderFamilyIndex(people, root, mode)
table.insert(html, '</div>')
end
 
function p.familytree(frame)
local root = getArg(frame, 'root')
local mode = getArg(frame, 'mode') or 'blood'


if #branches > 0 then
if not isRealValue(root) then
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
return "<strong>Error:</strong> Please provide |root="
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, makeFocusBranches(branches))
table.insert(html, '</div>')
end
end


table.insert(html, '</div>')
local people = loadData()
return table.concat(html)
return renderFamilyTreeForRoot(people, root, mode)
end
end


return p
return p

Latest revision as of 18:02, 15 April 2026

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

local p = {}

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

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

-- 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 getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)
	local links = getChildLinksOf(people, personName, includeNonBiological)
	local seen = {}
	local out = {}

	for _, link in ipairs(links) do
		if isRealValue(link.otherParent) and not seen[link.otherParent] then
			seen[link.otherParent] = true
			table.insert(out, link.otherParent)
		end
	end

	table.sort(out, function(a, b)
		local ua = findUnionBetween(people, personName, a)
		local ub = findUnionBetween(people, personName, b)

		local da = ua and sortKeyDate(ua) or '9999-99-99'
		local db = ub and sortKeyDate(ub) or '9999-99-99'

		if da ~= db then
			return da < db
		end

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

	return out
end

local function buildPartnerLayoutForFamilyTree(partners)
	local partnerCenters = {}
	local unionCenters = {}
	local tempCenters = {}

	local leftPartners, rightPartners = splitAroundCenter(partners)

	local minCenter = 0
	local maxCenter = 0

	for i, name in ipairs(leftPartners) do
		local idxFromRoot = #leftPartners - i + 1
		local c = -(idxFromRoot * 180)
		tempCenters[name] = c
		if c < minCenter then minCenter = c end
		if c > maxCenter then maxCenter = c end
	end

	for i, name in ipairs(rightPartners) do
		local c = i * 180
		tempCenters[name] = c
		if c < minCenter then minCenter = c end
		if c > maxCenter then maxCenter = c end
	end

	local shift = -minCenter + 70
	local rootCenter = shift
	local rowWidth = (maxCenter - minCenter) + 140

	for name, c in pairs(tempCenters) do
		local shifted = c + shift
		partnerCenters[name] = shifted
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)
	end

	return {
		rowWidth = math.max(rowWidth, 140),
		rootCenter = rootCenter,
		partnerCenters = partnerCenters,
		unionCenters = unionCenters
	}
end

local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)
	local links = getChildLinksOf(people, personName, includeNonBiological)
	local groupsByKey = {}
	local order = {}

	for _, link in ipairs(links) do
		local key
		local anchorKind
		local partnerName = nil

		if isRealValue(link.otherParent) then
			key = 'union::' .. link.otherParent
			anchorKind = 'union'
			partnerName = link.otherParent
		else
			key = 'self::' .. personName
			anchorKind = 'self'
		end

		if not groupsByKey[key] then
			groupsByKey[key] = {
				anchorKind = anchorKind,
				partnerName = partnerName,
				children = {},
				relationshipLabels = {}
			}
			table.insert(order, key)
		end

		addUnique(groupsByKey[key].children, link.child)

		if anchorKind == 'self' then
			local badge = relationshipBadge(link.relationshipType)
			if isRealValue(badge) then
				addUnique(groupsByKey[key].relationshipLabels, badge)
			end
		end
	end

	local out = {}
	for _, key in ipairs(order) do
		local group = groupsByKey[key]

		if group.anchorKind == 'union' and isRealValue(group.partnerName) then
			local union = findUnionBetween(people, personName, group.partnerName)
			if union then
				group.label = formatUnionMeta(
					union.unionType,
					union.status,
					union.marriageDate or union.startDate or union.engagementDate
				)
			end
		else
			if #group.relationshipLabels == 1 then
				group.label = group.relationshipLabels[1]
			elseif #group.relationshipLabels > 1 then
				group.label = table.concat(group.relationshipLabels, ' / ')
			else
				group.label = nil
			end
		end

		table.insert(out, group)
	end

	return out
end

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

	local childGroups = {}
	local groupsRowWidth = 0

	for _, rawGroup in ipairs(rawGroups) do
		local group = {
			anchorKind = rawGroup.anchorKind,
			partnerName = rawGroup.partnerName,
			label = rawGroup.label,
			nodes = {},
			width = 0
		}

		for i, childName in ipairs(rawGroup.children) do
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)
			table.insert(group.nodes, childNode)
			group.width = group.width + childNode.width
			if i > 1 then
				group.width = group.width + CHILD_GAP
			end
		end

		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then
			group.sourceCenter = layout.unionCenters[group.partnerName]
		else
			group.sourceCenter = layout.rootCenter
		end

		table.insert(childGroups, group)
	end

	table.sort(childGroups, function(a, b)
		if a.sourceCenter ~= b.sourceCenter then
			return a.sourceCenter < b.sourceCenter
		end

		local an = (a.partnerName or '')
		local bn = (b.partnerName or '')
		return mw.ustring.lower(an) < mw.ustring.lower(bn)
	end)

	for gi, group in ipairs(childGroups) do
		groupsRowWidth = groupsRowWidth + group.width
		if gi > 1 then
			groupsRowWidth = groupsRowWidth + GROUP_GAP
		end
	end

	local topWidth = layout.rowWidth
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)

	local selfLeft = math.floor((nodeWidth - topWidth) / 2)
	local rootCenterAbs = selfLeft + layout.rootCenter

	local selfRowHeight = 56
	local unionY = 28
	local stemHeight = selfRowHeight - unionY
	local labelY = 0
	local barTop = 18
	local branchHeight = 28
	local childDropHeight = branchHeight - barTop

	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('position', 'relative')
	selfRow:css('height', tostring(selfRowHeight) .. 'px')
	selfRow:css('width', tostring(topWidth) .. 'px')
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')

	-- partner line segments
	for _, partnerName in ipairs(displayPartners) do
		local partnerCenter = layout.partnerCenters[partnerName]
		local lineLeft = math.min(layout.rootCenter, partnerCenter)
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)

		if lineWidth > 0 then
			local seg = selfRow:tag('div')
			seg:addClass('kbft-ft-unionseg')
			seg:css('position', 'absolute')
			seg:css('top', tostring(unionY) .. 'px')
			seg:css('left', tostring(lineLeft) .. 'px')
			seg:css('width', tostring(lineWidth) .. 'px')
			seg:css('height', '2px')
			seg:css('background', '#bca88e')
		end
	end

	-- source stems from root/union line down to branch layer
	do
		local seenCenters = {}
		for _, group in ipairs(childGroups) do
			local center = group.sourceCenter
			if center and not seenCenters[center] then
				seenCenters[center] = true

				local stem = selfRow:tag('div')
				stem:addClass('kbft-ft-sourcestem')
				stem:css('position', 'absolute')
				stem:css('top', tostring(unionY) .. 'px')
				stem:css('left', tostring(center) .. 'px')
				stem:css('width', '2px')
				stem:css('height', tostring(stemHeight) .. 'px')
				stem:css('margin-left', '-1px')
				stem:css('background', '#bca88e')
			end
		end
	end

	-- root card
	do
		local slot = selfRow:tag('div')
		slot:addClass('kbft-ft-cardslot')
		slot:css('position', 'absolute')
		slot:css('top', '0')
		slot:css('left', tostring(layout.rootCenter) .. 'px')
		slot:css('transform', 'translateX(-50%)')
		slot:css('z-index', '2')

		if focus then
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))
		else
			slot:node(renderCard(people, personName))
		end
	end

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

	if #childGroups > 0 then
		local branch = node:tag('div')
		branch:addClass('kbft-ft-branch')
		branch:css('position', 'relative')
		branch:css('height', tostring(branchHeight) .. 'px')
		branch:css('width', tostring(nodeWidth) .. 'px')
		branch:css('margin-top', '0')

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

		local groupAnchors = {}
		local runningGroupX = 0
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)

		for gi, group in ipairs(childGroups) do
			local groupWrap = childRow:tag('div')
			groupWrap:addClass('kbft-ft-groupwrap')
			groupWrap:css('position', 'relative')
			groupWrap:css('display', 'inline-block')
			groupWrap:css('vertical-align', 'top')
			groupWrap:css('width', tostring(group.width) .. 'px')
			if gi < #childGroups then
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')
			end

			local childAnchorsAbs = {}
			local runningChildX = 0

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

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

				childWrap:wikitext(childNode.html)

				table.insert(
					childAnchorsAbs,
					groupsStart + runningGroupX + runningChildX + childNode.anchorX
				)

				runningChildX = runningChildX + childNode.width + (ci < #group.nodes and CHILD_GAP or 0)
			end

			table.insert(groupAnchors, {
				sourceAnchorAbs = selfLeft + group.sourceCenter,
				childAnchorsAbs = childAnchorsAbs,
				label = group.label
			})

			runningGroupX = runningGroupX + group.width + (gi < #childGroups and GROUP_GAP or 0)
		end

		for _, info in ipairs(groupAnchors) do
			local sourceAnchorAbs = info.sourceAnchorAbs
			local childAbsAnchors = info.childAnchorsAbs
			local firstAnchor = childAbsAnchors[1]
			local lastAnchor = childAbsAnchors[#childAbsAnchors]

			local lineLeft, lineWidth
			if #childAbsAnchors == 1 then
				local onlyAnchor = childAbsAnchors[1]
				lineLeft = math.min(sourceAnchorAbs, onlyAnchor)
				lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)
			else
				lineLeft = firstAnchor
				lineWidth = lastAnchor - firstAnchor
			end

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

			if lineWidth > 0 then
				local bar = branch:tag('div')
				bar:addClass('kbft-ft-childrenbar')
				bar:css('position', 'absolute')
				bar:css('top', tostring(barTop) .. 'px')
				bar:css('left', tostring(lineLeft) .. 'px')
				bar:css('width', tostring(lineWidth) .. 'px')
				bar:css('height', '2px')
				bar:css('background', '#bca88e')
			end

			if isRealValue(info.label) then
				local labelLeft
				local labelWidth

				if lineWidth > 0 then
					labelLeft = lineLeft
					labelWidth = lineWidth
				else
					labelWidth = 90
					labelLeft = sourceAnchorAbs - math.floor(labelWidth / 2)
				end

				local labelNode = branch:tag('div')
				labelNode:addClass('kbft-ft-grouplabel')
				labelNode:css('position', 'absolute')
				labelNode:css('top', tostring(labelY) .. 'px')
				labelNode:css('left', tostring(labelLeft) .. 'px')
				labelNode:css('width', tostring(labelWidth) .. 'px')
				labelNode:css('text-align', 'center')
				labelNode:css('font-size', '0.8em')
				labelNode:css('line-height', '1')
				labelNode:css('color', '#5f4b36')
				labelNode:wikitext(info.label)
			end
		end
	end

	return {
		html = tostring(node),
		width = nodeWidth,
		anchorX = rootCenterAbs
	}
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