Module:FamilyTree: Difference between revisions
From KB Lexicon
No edit summary |
No edit summary |
||
| Line 587: | Line 587: | ||
local gen = html.create('div') | local gen = html.create('div') | ||
gen:addClass('kbft-generation') | gen:addClass('kbft-generation') | ||
-- ========================= | |||
-- ROW 1: ROOT + PARTNERS | |||
-- ========================= | |||
local topRow = gen:tag('div') | |||
topRow:addClass('kbft-row') | |||
-- root FIRST | |||
topRow:node(renderSingleCard(people, root, 'kbft-focus-card')) | |||
-- partners | |||
for _, group in ipairs(groups) do | |||
if isRealValue(group.partner) then | if isRealValue(group.partner) then | ||
topRow:node(renderSingleCard(people, group.partner)) | |||
end | end | ||
end | |||
-- ========================= | |||
-- CONNECTOR DOWN | |||
-- ========================= | |||
gen:tag('div') | |||
:addClass('kbft-connector') | |||
-- ========================= | |||
-- ROW 2: CHILDREN GROUPED PER PARTNER | |||
-- ========================= | |||
local bottomRow = gen:tag('div') | |||
bottomRow:addClass('kbft-row') | |||
for _, group in ipairs(groups) do | |||
local unit = bottomRow:tag('div') | |||
unit:addClass('kbft-sibling-unit') | |||
-- vertical drop line | |||
unit:tag('div') | |||
:addClass('kbft-child-down') | |||
local childrenWrap = unit:tag('div') | |||
childrenWrap:addClass('kbft-children') | |||
if #group.children > 0 then | if #group.children > 0 then | ||
for _, child in ipairs(group.children) do | |||
:addClass('kbft-branch- | childrenWrap:node( | ||
renderCard( | |||
people, | |||
child.name, | |||
relationshipBadge(child.relationshipType) | |||
) | |||
) | |||
end | |||
else | |||
-- no children: spacer so alignment stays intact | |||
childrenWrap:tag('div') | |||
:addClass('kbft-branch-spacer') | |||
end | end | ||
end | end | ||
Revision as of 12:43, 30 March 2026
Documentation for this module may be created at Module:FamilyTree/doc
local p = {}
local cargo = mw.ext.cargo
local html = mw.html
-- =========================================
-- Helpers
-- =========================================
local function trim(s)
if s == nil then return nil end
s = tostring(s)
s = mw.text.trim(s)
if s == '' then return nil end
return s
end
local function isRealValue(v)
v = trim(v)
if not v then return false end
local lowered = mw.ustring.lower(v)
return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'
end
local function addUnique(list, value)
if not isRealValue(value) then return end
for _, existing in ipairs(list) do
if existing == value then return end
end
table.insert(list, value)
end
local function uniq(list)
local out, seen = {}, {}
for _, v in ipairs(list or {}) do
if isRealValue(v) and not seen[v] then
seen[v] = true
table.insert(out, v)
end
end
return out
end
local function getArg(frame, key)
local v = frame.args[key]
if isRealValue(v) then return trim(v) end
local parent = frame:getParent()
if parent then
v = parent.args[key]
if isRealValue(v) then return trim(v) end
end
return nil
end
local function getRoot(frame)
return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text
end
local function makeLink(name, displayName)
if not isRealValue(name) then return '' end
displayName = trim(displayName) or name
return string.format('[[%s|%s]]', name, displayName)
end
local function ensurePerson(people, name)
name = trim(name)
if not isRealValue(name) then return nil end
if not people[name] then
people[name] = {
name = name,
displayName = name,
parents = {},
children = {},
partners = {},
unions = {},
childLinks = {}
}
end
return people[name]
end
local function sortNames(people, names)
table.sort(names, function(a, b)
local ad = (people[a] and people[a].displayName) or a
local bd = (people[b] and people[b].displayName) or b
return mw.ustring.lower(ad) < mw.ustring.lower(bd)
end)
end
-- =========================================
-- Data loading
-- =========================================
local function loadCharacters()
local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })
local people = {}
for _, row in ipairs(results) do
local page = trim(row.Page)
local displayName = trim(row.DisplayName)
if isRealValue(page) then
people[page] = {
name = page,
displayName = displayName or page,
parents = {},
children = {},
partners = {},
unions = {},
childLinks = {}
}
end
end
return people
end
local function loadParentChild(people)
local results = cargo.query(
'ParentChild',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{ limit = 5000 }
)
for _, row in ipairs(results) do
local child = trim(row.Child)
local p1 = trim(row.Parent1)
local p2 = trim(row.Parent2)
local unionID = trim(row.UnionID)
local relationshipType = trim(row.RelationshipType)
local birthOrder = tonumber(trim(row.BirthOrder)) or 999
if isRealValue(child) then
ensurePerson(people, child)
if isRealValue(p1) then
ensurePerson(people, p1)
addUnique(people[child].parents, p1)
addUnique(people[p1].children, child)
table.insert(people[p1].childLinks, {
child = child,
otherParent = p2,
unionID = unionID,
relationshipType = relationshipType,
birthOrder = birthOrder
})
end
if isRealValue(p2) then
ensurePerson(people, p2)
addUnique(people[child].parents, p2)
addUnique(people[p2].children, child)
table.insert(people[p2].childLinks, {
child = child,
otherParent = p1,
unionID = unionID,
relationshipType = relationshipType,
birthOrder = birthOrder
})
end
end
end
end
local function loadUnions(people)
local results = cargo.query(
'Unions',
'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',
{ limit = 5000 }
)
for _, row in ipairs(results) do
local p1 = trim(row.Partner1)
local p2 = trim(row.Partner2)
if isRealValue(p1) then ensurePerson(people, p1) end
if isRealValue(p2) then ensurePerson(people, p2) end
if isRealValue(p1) and isRealValue(p2) then
addUnique(people[p1].partners, p2)
addUnique(people[p2].partners, p1)
table.insert(people[p1].unions, {
unionID = trim(row.UnionID),
partner = p2,
unionType = trim(row.UnionType),
status = trim(row.Status),
startDate = trim(row.StartDate),
endDate = trim(row.EndDate),
marriageDate = trim(row.MarriageDate),
divorceDate = trim(row.DivorceDate),
engagementDate = trim(row.EngagementDate)
})
table.insert(people[p2].unions, {
unionID = trim(row.UnionID),
partner = p1,
unionType = trim(row.UnionType),
status = trim(row.Status),
startDate = trim(row.StartDate),
endDate = trim(row.EndDate),
marriageDate = trim(row.MarriageDate),
divorceDate = trim(row.DivorceDate),
engagementDate = trim(row.EngagementDate)
})
end
end
end
local function finalizePeople(people)
for _, person in pairs(people) do
person.parents = uniq(person.parents)
person.children = uniq(person.children)
person.partners = uniq(person.partners)
end
end
local function loadData()
local people = loadCharacters()
loadParentChild(people)
loadUnions(people)
finalizePeople(people)
return people
end
-- =========================================
-- Relationship helpers
-- =========================================
local function relationshipBadge(relType)
if not isRealValue(relType) then return nil end
local t = mw.ustring.lower(relType)
if t:find('adopt') then return 'adopted' end
if t:find('step') then return 'step' end
if t:find('bio') then return nil end
return relType
end
local function findUnionBetween(people, name1, name2)
if not isRealValue(name1) or not isRealValue(name2) then return nil end
local person = people[name1]
if not person or not person.unions then return nil end
for _, union in ipairs(person.unions) do
if union.partner == name2 then return union end
end
return nil
end
local function getMarriageYear(union)
if not union then return nil end
local raw = union.marriageDate or union.engagementDate or union.startDate
if not isRealValue(raw) then return nil end
return tostring(raw):match('^(%d%d%d%d)') or tostring(raw)
end
local function getParents(people, root)
local person = people[root]
if not person then return {} end
local parents = uniq(person.parents)
sortNames(people, parents)
return parents
end
local function getGrandparents(people, root)
local out = {}
local parents = getParents(people, root)
for _, parentName in ipairs(parents) do
local parent = people[parentName]
if parent then
for _, gp in ipairs(parent.parents) do
addUnique(out, gp)
end
end
end
out = uniq(out)
sortNames(people, out)
return out
end
local function getSiblings(people, root)
local out, 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 getRootSiblingSequence(people, root)
local siblings = getSiblings(people, root)
local seq, inserted = {}, false
local midpoint = math.floor(#siblings / 2) + 1
for i, sib in ipairs(siblings) do
if i == midpoint then
table.insert(seq, root)
inserted = true
end
table.insert(seq, sib)
end
if not inserted then
table.insert(seq, root)
end
return seq
end
local function getFamilyGroupsForRoot(people, root)
local person = people[root]
if not person 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 spouses show even without 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, marriageYear, leftClass, rightClass)
if isRealValue(leftName) and isRealValue(rightName) then
local wrap = html.create('div')
wrap:addClass('kbft-couple')
wrap:node(renderCard(people, leftName, nil, leftClass))
local marriage = wrap:tag('div')
marriage:addClass('kbft-marriage')
if isRealValue(marriageYear) then
marriage:tag('div')
:addClass('kbft-marriage-year')
:wikitext(marriageYear)
end
marriage:tag('div')
:addClass('kbft-marriage-line')
wrap:node(renderCard(people, rightName, nil, rightClass))
return wrap
end
if isRealValue(leftName) then return renderSingleCard(people, leftName, leftClass) end
if isRealValue(rightName) then return renderSingleCard(people, rightName, rightClass) end
return nil
end
local function renderGenerationRow(units)
local row = html.create('div')
row:addClass('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], nil))
end
gen:node(renderGenerationRow(units))
return gen
end
local function buildGrandparentCouples(people, root)
local parents = getParents(people, root)
local couples = {}
for _, parentName in ipairs(parents) do
local parent = people[parentName]
if parent then
local gp = uniq(parent.parents)
sortNames(people, gp)
if #gp > 0 then
table.insert(couples, { gp[1], gp[2] })
end
end
end
return couples
end
local function buildParentCouples(people, root)
local parents = getParents(people, root)
if #parents == 0 then return {} end
return { { parents[1], parents[2] } }
end
local function renderFocalGeneration(people, root)
local sequence = getRootSiblingSequence(people, root)
local gen = html.create('div')
gen:addClass('kbft-generation')
local groupWrap = gen:tag('div')
groupWrap:addClass('kbft-siblings')
groupWrap:tag('div')
:addClass('kbft-sibling-spine')
local row = groupWrap:tag('div')
row:addClass('kbft-sibling-row')
for _, name in ipairs(sequence) do
local unit = row:tag('div')
unit:addClass('kbft-sibling-unit')
unit:tag('div')
:addClass('kbft-sibling-up')
if name == root then
unit:node(renderSingleCard(people, name, 'kbft-focus-card'))
else
unit:node(renderSingleCard(people, name))
end
end
return gen
end
local function renderBranchChildren(people, children)
local childWrap = html.create('div')
childWrap:addClass('kbft-children')
for _, child in ipairs(children) do
childWrap:node(
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
return childWrap
end
local function renderFamilyGroupsGeneration(people, root)
local groups = getFamilyGroupsForRoot(people, root)
if #groups == 0 then return nil end
local gen = html.create('div')
gen:addClass('kbft-generation')
-- =========================
-- ROW 1: ROOT + PARTNERS
-- =========================
local topRow = gen:tag('div')
topRow:addClass('kbft-row')
-- root FIRST
topRow:node(renderSingleCard(people, root, 'kbft-focus-card'))
-- partners
for _, group in ipairs(groups) do
if isRealValue(group.partner) then
topRow:node(renderSingleCard(people, group.partner))
end
end
-- =========================
-- CONNECTOR DOWN
-- =========================
gen:tag('div')
:addClass('kbft-connector')
-- =========================
-- ROW 2: CHILDREN GROUPED PER PARTNER
-- =========================
local bottomRow = gen:tag('div')
bottomRow:addClass('kbft-row')
for _, group in ipairs(groups) do
local unit = bottomRow:tag('div')
unit:addClass('kbft-sibling-unit')
-- vertical drop line
unit:tag('div')
:addClass('kbft-child-down')
local childrenWrap = unit:tag('div')
childrenWrap:addClass('kbft-children')
if #group.children > 0 then
for _, child in ipairs(group.children) do
childrenWrap:node(
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
else
-- no children: spacer so alignment stays intact
childrenWrap:tag('div')
:addClass('kbft-branch-spacer')
end
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))
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))
end
addSection('Parents', person.parents)
addSection('Partners', person.partners)
addSection('Children', person.children)
return tostring(node)
end
local function renderTreeForRoot(people, root)
local person = people[root]
if not person then
return '<strong>FamilyTree error:</strong> No character found for "' .. tostring(root) .. '".'
end
local node = html.create('div')
node:addClass('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))
local familyGen = renderFamilyGroupsGeneration(people, root)
if familyGen then
node:tag('div'):addClass('kbft-connector')
node:node(familyGen)
end
return tostring(node)
end
-- =========================================
-- Public functions
-- =========================================
function p.tree(frame)
local root = getRoot(frame)
local people = loadData()
return renderTreeForRoot(people, root)
end
function p.profile(frame)
local root = getRoot(frame)
local people = loadData()
return renderProfileForRoot(people, root)
end
function p.connected(frame)
local root = getRoot(frame)
local people = loadData()
return renderConnectedForRoot(people, root)
end
return p