Module:FamilyTree
From KB Lexicon
Documentation for this module may be created at Module:FamilyTree/doc
local p = {}
local cargo = mw.ext.cargo
local html = mw.html
-- =========================================
-- Helpers
-- =========================================
local function trim(s)
if s == nil then return nil end
s = tostring(s)
s = mw.text.trim(s)
if s == '' then return nil end
return s
end
local function isRealValue(v)
v = trim(v)
if not v then return false end
local lowered = mw.ustring.lower(v)
return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'
end
local function addUnique(list, value)
if not isRealValue(value) then return end
for _, existing in ipairs(list) do
if existing == value then return end
end
table.insert(list, value)
end
local function uniq(list)
local out, seen = {}, {}
for _, v in ipairs(list or {}) do
if isRealValue(v) and not seen[v] then
seen[v] = true
table.insert(out, v)
end
end
return out
end
local function getArg(frame, key)
local v = frame.args[key]
if isRealValue(v) then return trim(v) end
local parent = frame:getParent()
if parent then
v = parent.args[key]
if isRealValue(v) then return trim(v) end
end
return nil
end
local function getRoot(frame)
return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text
end
local function makeLink(name, displayName)
if not isRealValue(name) then return '' end
displayName = trim(displayName) or name
return string.format('[[%s|%s]]', name, displayName)
end
local function ensurePerson(people, name)
name = trim(name)
if not isRealValue(name) then return nil end
if not people[name] then
people[name] = {
name = name,
displayName = name,
parents = {},
children = {},
partners = {},
unions = {},
childLinks = {}
}
end
return people[name]
end
local function sortNames(people, names)
table.sort(names, function(a, b)
local ad = (people[a] and people[a].displayName) or a
local bd = (people[b] and people[b].displayName) or b
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
end)
end
-- =========================================
-- 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 getUnionMeta(people, root, partner)
local union = findUnionBetween(people, root, partner)
if not union then return nil end
local label = union.unionType or union.status
local year = union.marriageDate or union.startDate or union.engagementDate
local out = {}
if isRealValue(label) then
table.insert(out, label)
end
if isRealValue(year) then
local y = tostring(year):match('^(%d%d%d%d)') or tostring(year)
table.insert(out, y)
end
if #out == 0 then return nil end
return table.concat(out, ' • ')
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 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 getFamilyGroupsForRoot(people, root)
local person = people[root]
if not person then return {} end
local groups = {}
-- child-based groups
for _, link in ipairs(person.childLinks or {}) do
local key
if isRealValue(link.unionID) then
key = 'union::' .. link.unionID
elseif isRealValue(link.otherParent) then
key = 'partner::' .. link.otherParent
else
key = 'single::' .. root
end
if not groups[key] then
groups[key] = {
unionID = link.unionID,
partner = link.otherParent,
children = {}
}
end
table.insert(groups[key].children, {
name = link.child,
relationshipType = link.relationshipType,
birthOrder = tonumber(link.birthOrder) or 999
})
end
-- partner-only groups so spouse still shows with no children
for _, partner in ipairs(person.partners or {}) do
if isRealValue(partner) then
local found = false
for _, group in pairs(groups) do
if group.partner == partner then
found = true
break
end
end
if not found then
local union = findUnionBetween(people, root, partner)
local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)
groups[key] = {
unionID = union and union.unionID or nil,
partner = partner,
children = {}
}
end
end
end
local out = {}
for _, group in pairs(groups) do
table.sort(group.children, function(a, b)
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
local ap = a.partner or ''
local bp = b.partner or ''
local ad = (people[ap] and people[ap].displayName) or ap
local bd = (people[bp] and people[bp].displayName) or bp
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
end)
return out
end
-- =========================================
-- Rendering helpers
-- =========================================
local function renderCard(people, name, badgeText, extraClass)
if not isRealValue(name) then return nil end
local person = people[name] or { name = name, displayName = name }
local card = html.create('div')
card:addClass('kbft-card')
if isRealValue(extraClass) then
card:addClass(extraClass)
end
card:wikitext(makeLink(person.name, person.displayName))
if isRealValue(badgeText) then
card:tag('div')
:addClass('kbft-years')
:wikitext(badgeText)
end
return card
end
local function renderSingleCard(people, name, extraClass)
local wrap = html.create('div')
wrap:addClass('kbft-single')
wrap:node(renderCard(people, name, nil, extraClass))
return wrap
end
local function renderCouple(people, leftName, rightName)
if not isRealValue(leftName) and not isRealValue(rightName) then
return nil
end
if isRealValue(leftName) and isRealValue(rightName) then
local wrap = html.create('div')
wrap:addClass('kbft-couple')
wrap:node(renderCard(people, leftName))
local marriage = wrap:tag('div')
marriage:addClass('kbft-marriage')
marriage:tag('div'):addClass('kbft-marriage-line')
wrap:node(renderCard(people, rightName))
return wrap
end
if isRealValue(leftName) then return renderSingleCard(people, leftName) end
return renderSingleCard(people, rightName)
end
local function renderGenerationRow(units, className)
local row = html.create('div')
row:addClass(className or 'kbft-row')
for _, unit in ipairs(units) do
if unit then row:node(unit) end
end
return row
end
local function renderUpperCoupleGeneration(people, couples)
if #couples == 0 then return nil end
local gen = html.create('div')
gen:addClass('kbft-generation')
local units = {}
for _, pair in ipairs(couples) do
table.insert(units, renderCouple(people, pair[1], pair[2]))
end
gen:node(renderGenerationRow(units, 'kbft-row'))
return gen
end
local function buildGrandparentCouples(people, root)
local parents = getParents(people, root)
local couples = {}
for _, parentName in ipairs(parents) do
local parent = people[parentName]
if parent then
local gp = uniq(parent.parents)
sortNames(people, gp)
if #gp > 0 then
table.insert(couples, { gp[1], gp[2] })
end
end
end
return couples
end
local function buildParentCouples(people, root)
local parents = getParents(people, root)
if #parents == 0 then return {} end
return { { parents[1], parents[2] } }
end
local function renderFocalGeneration(people, root, groups)
local siblings = getSiblings(people, root)
local partners = {}
local seen = {}
for _, group in ipairs(groups or {}) do
if isRealValue(group.partner) and not seen[group.partner] then
seen[group.partner] = true
table.insert(partners, group.partner)
end
end
sortNames(people, partners)
local leftSibs, rightSibs = splitAroundCenter(siblings)
local leftPartners, rightPartners = splitAroundCenter(partners)
local gen = html.create('div')
gen:addClass('kbft-generation')
local wrap = gen:tag('div')
wrap:addClass('kbft-siblings')
wrap:tag('div')
:addClass('kbft-sibling-spine')
local row = wrap:tag('div')
row:addClass('kbft-union-row')
local function addCol(kind, name, meta)
local col = row:tag('div')
col:addClass('kbft-union-col')
col:attribute('data-kind', kind)
local connector = col:tag('div')
connector:addClass('kbft-sibling-up')
if kind == 'root' then
col:node(renderSingleCard(people, name, 'kbft-focus-card'))
else
col:node(renderSingleCard(people, name))
end
if isRealValue(meta) then
col:tag('div')
:addClass('kbft-union-meta')
:wikitext(meta)
end
end
for _, sib in ipairs(leftSibs) do
addCol('sibling', sib, nil)
end
for _, partner in ipairs(leftPartners) do
addCol('partner', partner, getUnionMeta(people, root, partner))
end
addCol('root', root, nil)
for _, partner in ipairs(rightPartners) do
addCol('partner', partner, getUnionMeta(people, root, partner))
end
for _, sib in ipairs(rightSibs) do
addCol('sibling', sib, nil)
end
return gen
end
local function renderDescendantGeneration(people, root, groups)
if #groups == 0 then return nil end
local partners = {}
local partnerGroups = {}
local soloGroup = nil
for _, group in ipairs(groups) do
if isRealValue(group.partner) then
table.insert(partners, group.partner)
partnerGroups[group.partner] = group
else
soloGroup = group
end
end
sortNames(people, partners)
local gen = html.create('div')
gen:addClass('kbft-generation')
gen:addClass('kbft-desc-generation')
local row = gen:tag('div')
row:addClass('kbft-branch-columns')
local function addBranchCol(kind, partnerName, group)
local col = row:tag('div')
col:addClass('kbft-branch-column')
col:attribute('data-kind', kind)
-- top spacer / hidden card to keep columns aligned to focal row
local top = col:tag('div')
top:addClass('kbft-branch-top')
if kind == 'solo' then
top:tag('div'):addClass('kbft-branch-hidden-card')
elseif kind == 'partner' then
top:tag('div'):addClass('kbft-branch-hidden-card')
end
if group and #group.children > 0 then
col:tag('div')
:addClass('kbft-child-down')
local childWrap = col:tag('div')
childWrap:addClass('kbft-children')
for _, child in ipairs(group.children) do
childWrap:node(
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
end
end
-- left side: solo first if it exists
if soloGroup then
addBranchCol('solo', nil, soloGroup)
end
-- then partner branches
for _, partner in ipairs(partners) do
addBranchCol('partner', partner, partnerGroups[partner])
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 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, root, groups))
local descGen = renderDescendantGeneration(people, root, groups)
if descGen then
node:tag('div'):addClass('kbft-connector')
node:node(descGen)
end
return tostring(node)
end
-- =========================================
-- Public functions
-- =========================================
function p.tree(frame)
local root = getRoot(frame)
local people = loadData()
return renderTreeForRoot(people, root)
end
function p.profile(frame)
local root = getRoot(frame)
local people = loadData()
return renderProfileForRoot(people, root)
end
function p.connected(frame)
local root = getRoot(frame)
local people = loadData()
return renderConnectedForRoot(people, root)
end
return p