Module:FamilyTree: Difference between revisions
From KB Lexicon
No edit summary |
No edit summary |
||
| (One intermediate revision by the same user not shown) | |||
| Line 6: | Line 6: | ||
local SLOT_WIDTH = 340 | local SLOT_WIDTH = 340 | ||
local ANCHOR_CENTER = 90 | local ANCHOR_CENTER = 90 | ||
local CHILD_GAP = 24 | local CHILD_GAP = 24 | ||
local GROUP_GAP = 28 | local GROUP_GAP = 28 | ||
| Line 887: | Line 886: | ||
anchorKind = anchorKind, | anchorKind = anchorKind, | ||
partnerName = partnerName, | partnerName = partnerName, | ||
children = {} | children = {}, | ||
relationshipLabels = {} | |||
} | } | ||
table.insert(order, key) | table.insert(order, key) | ||
| Line 893: | Line 893: | ||
addUnique(groupsByKey[key].children, link.child) | 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 | end | ||
local out = {} | local out = {} | ||
for _, key in ipairs(order) do | for _, key in ipairs(order) do | ||
table.insert(out, | 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 | end | ||
| Line 909: | Line 937: | ||
local childGroups = {} | local childGroups = {} | ||
local groupsRowWidth = 0 | |||
for _, rawGroup in ipairs(rawGroups) do | for _, rawGroup in ipairs(rawGroups) do | ||
| Line 914: | Line 943: | ||
anchorKind = rawGroup.anchorKind, | anchorKind = rawGroup.anchorKind, | ||
partnerName = rawGroup.partnerName, | partnerName = rawGroup.partnerName, | ||
label = rawGroup.label, | |||
nodes = {}, | nodes = {}, | ||
width = 0 | width = 0 | ||
| Line 946: | Line 976: | ||
end) | end) | ||
for gi, group in ipairs(childGroups) do | for gi, group in ipairs(childGroups) do | ||
groupsRowWidth = groupsRowWidth + group.width | groupsRowWidth = groupsRowWidth + group.width | ||
| Line 959: | Line 988: | ||
local selfLeft = math.floor((nodeWidth - topWidth) / 2) | local selfLeft = math.floor((nodeWidth - topWidth) / 2) | ||
local rootCenterAbs = selfLeft + layout.rootCenter | 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') | local node = html.create('div') | ||
| Line 966: | Line 1,003: | ||
local selfRow = node:tag('div') | local selfRow = node:tag('div') | ||
selfRow:addClass('kbft-ft-selfrow') | selfRow:addClass('kbft-ft-selfrow') | ||
selfRow:css('position', 'relative') | |||
selfRow:css('height', tostring(selfRowHeight) .. 'px') | |||
selfRow:css('width', tostring(topWidth) .. 'px') | selfRow:css('width', tostring(topWidth) .. 'px') | ||
selfRow:css('margin-left', tostring(selfLeft) .. 'px') | selfRow:css('margin-left', tostring(selfLeft) .. 'px') | ||
| Line 979: | Line 1,018: | ||
seg:addClass('kbft-ft-unionseg') | seg:addClass('kbft-ft-unionseg') | ||
seg:css('position', 'absolute') | seg:css('position', 'absolute') | ||
seg:css('top', ' | seg:css('top', tostring(unionY) .. 'px') | ||
seg:css('left', tostring(lineLeft) .. 'px') | seg:css('left', tostring(lineLeft) .. 'px') | ||
seg:css('width', tostring(lineWidth) .. 'px') | seg:css('width', tostring(lineWidth) .. 'px') | ||
seg:css('height', '2px') | seg:css('height', '2px') | ||
seg:css('background', '#bca88e') | 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 | ||
end | end | ||
| Line 995: | Line 1,055: | ||
slot:css('left', tostring(layout.rootCenter) .. 'px') | slot:css('left', tostring(layout.rootCenter) .. 'px') | ||
slot:css('transform', 'translateX(-50%)') | slot:css('transform', 'translateX(-50%)') | ||
slot:css('z-index', '2') | |||
if focus then | if focus then | ||
| Line 1,011: | Line 1,072: | ||
slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px') | slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px') | ||
slot:css('transform', 'translateX(-50%)') | slot:css('transform', 'translateX(-50%)') | ||
slot:css('z-index', '2') | |||
slot:node(renderCard(people, partnerName)) | slot:node(renderCard(people, partnerName)) | ||
end | end | ||
| Line 1,017: | Line 1,079: | ||
local branch = node:tag('div') | local branch = node:tag('div') | ||
branch:addClass('kbft-ft-branch') | branch:addClass('kbft-ft-branch') | ||
branch:css('position', 'relative') | |||
branch:css('height', tostring(branchHeight) .. 'px') | |||
branch:css('width', tostring(nodeWidth) .. 'px') | branch:css('width', tostring(nodeWidth) .. 'px') | ||
branch:css('margin-top', '0') | |||
local childRow = node:tag('div') | local childRow = node:tag('div') | ||
| Line 1,031: | Line 1,096: | ||
local groupWrap = childRow:tag('div') | local groupWrap = childRow:tag('div') | ||
groupWrap:addClass('kbft-ft-groupwrap') | 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') | groupWrap:css('width', tostring(group.width) .. 'px') | ||
if gi < #childGroups then | if gi < #childGroups then | ||
| Line 1,049: | Line 1,117: | ||
local drop = childWrap:tag('div') | local drop = childWrap:tag('div') | ||
drop:addClass('kbft-ft-childdrop') | drop:addClass('kbft-ft-childdrop') | ||
drop:css('top', tostring(-childDropHeight) .. 'px') | |||
drop:css('height', tostring(childDropHeight) .. 'px') | |||
drop:css('left', tostring(childNode.anchorX) .. 'px') | drop:css('left', tostring(childNode.anchorX) .. 'px') | ||
| Line 1,063: | Line 1,133: | ||
table.insert(groupAnchors, { | table.insert(groupAnchors, { | ||
sourceAnchorAbs = selfLeft + group.sourceCenter, | sourceAnchorAbs = selfLeft + group.sourceCenter, | ||
childAnchorsAbs = childAnchorsAbs | childAnchorsAbs = childAnchorsAbs, | ||
label = group.label | |||
}) | }) | ||
| Line 1,072: | Line 1,143: | ||
local sourceAnchorAbs = info.sourceAnchorAbs | local sourceAnchorAbs = info.sourceAnchorAbs | ||
local childAbsAnchors = info.childAnchorsAbs | local childAbsAnchors = info.childAnchorsAbs | ||
local firstAnchor = childAbsAnchors[1] | local firstAnchor = childAbsAnchors[1] | ||
local lastAnchor = childAbsAnchors[#childAbsAnchors] | local lastAnchor = childAbsAnchors[#childAbsAnchors] | ||
local lineLeft, lineWidth | |||
if #childAbsAnchors == 1 then | if #childAbsAnchors == 1 then | ||
local onlyAnchor = childAbsAnchors[1] | local onlyAnchor = childAbsAnchors[1] | ||
lineLeft = math.min(sourceAnchorAbs, onlyAnchor) | |||
lineWidth = math.abs(sourceAnchorAbs - onlyAnchor) | |||
else | 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') | local bar = branch:tag('div') | ||
bar:addClass('kbft-ft-childrenbar') | bar:addClass('kbft-ft-childrenbar') | ||
bar:css('left', tostring( | bar:css('position', 'absolute') | ||
bar:css('width', tostring( | 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 | end | ||
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(' ')
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(' ')
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