Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
No edit summary
Line 3: Line 3:
local cargo = mw.ext.cargo
local cargo = mw.ext.cargo
local html = mw.html
local html = mw.html
-- =========================================
-- Helpers
-- =========================================


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
Line 22: Line 14:
local function isRealValue(v)
local function isRealValue(v)
v = trim(v)
v = trim(v)
if not v then
if not v then return false end
return false
end
local lowered = mw.ustring.lower(v)
local lowered = mw.ustring.lower(v)
return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'
return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'
Line 30: Line 20:


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


local function uniq(list)
local function uniq(list)
local out = {}
local out, seen = {}, {}
local seen = {}
for _, v in ipairs(list or {}) do
for _, v in ipairs(list or {}) do
if isRealValue(v) and not seen[v] then
if isRealValue(v) and not seen[v] then
Line 55: Line 40:
local function getArg(frame, key)
local function getArg(frame, key)
local v = frame.args[key]
local v = frame.args[key]
if isRealValue(v) then
if isRealValue(v) then return trim(v) end
return trim(v)
end
 
local parent = frame:getParent()
local parent = frame:getParent()
if parent then
if parent then
v = parent.args[key]
v = parent.args[key]
if isRealValue(v) then
if isRealValue(v) then return trim(v) end
return trim(v)
end
end
end
return nil
return nil
end
end
Line 75: Line 54:


local function makeLink(name, displayName)
local function makeLink(name, displayName)
if not isRealValue(name) then
if not isRealValue(name) then return '' end
return ''
end
displayName = trim(displayName) or name
displayName = trim(displayName) or name
return string.format('[[%s|%s]]', name, displayName)
return string.format('[[%s|%s]]', name, displayName)
Line 84: Line 61:
local function ensurePerson(people, name)
local function ensurePerson(people, name)
name = trim(name)
name = trim(name)
if not isRealValue(name) then
if not isRealValue(name) then return nil end
return nil
end


if not people[name] then
if not people[name] then
Line 99: Line 74:
}
}
end
end
return people[name]
return people[name]
end
end
Line 111: Line 85:
end
end


-- =========================================
-- Data loading
-- Data loading
-- =========================================


local function loadCharacters()
local function loadCharacters()
local results = cargo.query(
local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })
'Characters',
'Page,DisplayName',
{ limit = 5000 }
)
 
local people = {}
local people = {}


Line 127: Line 94:
local page = trim(row.Page)
local page = trim(row.Page)
local displayName = trim(row.DisplayName)
local displayName = trim(row.DisplayName)
if isRealValue(page) then
if isRealValue(page) then
people[page] = {
people[page] = {
Line 140: Line 106:
end
end
end
end
return people
return people
end
end
Line 166: Line 131:
addUnique(people[child].parents, p1)
addUnique(people[child].parents, p1)
addUnique(people[p1].children, child)
addUnique(people[p1].children, child)
table.insert(people[p1].childLinks, {
table.insert(people[p1].childLinks, {
child = child,
child = child,
Line 180: Line 144:
addUnique(people[child].parents, p2)
addUnique(people[child].parents, p2)
addUnique(people[p2].children, child)
addUnique(people[p2].children, child)
table.insert(people[p2].childLinks, {
table.insert(people[p2].childLinks, {
child = child,
child = child,
Line 204: Line 167:
local p2 = trim(row.Partner2)
local p2 = trim(row.Partner2)


if isRealValue(p1) then
if isRealValue(p1) then ensurePerson(people, p1) end
ensurePerson(people, p1)
if isRealValue(p2) then ensurePerson(people, p2) end
end
if isRealValue(p2) then
ensurePerson(people, p2)
end


if isRealValue(p1) and isRealValue(p2) then
if isRealValue(p1) and isRealValue(p2) then
Line 258: Line 217:
end
end


-- =========================================
-- Relationship helpers
-- Relationship helpers
-- =========================================


local function relationshipBadge(relType)
local function relationshipBadge(relType)
if not isRealValue(relType) then
if not isRealValue(relType) then return nil end
return nil
end
 
local t = mw.ustring.lower(relType)
local t = mw.ustring.lower(relType)
 
if t:find('adopt') then return 'adopted' end
if t:find('adopt') then
if t:find('step') then return 'step' end
return 'adopted'
if t:find('bio') then return nil end
end
if t:find('step') then
return 'step'
end
if t:find('bio') then
return nil
end
 
return relType
return relType
end
end


local function findUnionBetween(people, name1, name2)
local function findUnionBetween(people, name1, name2)
if not isRealValue(name1) or not isRealValue(name2) then
if not isRealValue(name1) or not isRealValue(name2) then return nil end
return nil
end
 
local person = people[name1]
local person = people[name1]
if not person or not person.unions then
if not person or not person.unions then return nil end
return nil
end
 
for _, union in ipairs(person.unions) do
for _, union in ipairs(person.unions) do
if union.partner == name2 then
if union.partner == name2 then return union end
return union
end
end
end
return nil
return nil
end
end


local function getMarriageYear(union)
local function getMarriageYear(union)
if not union then
if not union then return nil end
return nil
end
 
local raw = union.marriageDate or union.engagementDate or union.startDate
local raw = union.marriageDate or union.engagementDate or union.startDate
if not isRealValue(raw) then
if not isRealValue(raw) then return nil end
return nil
return tostring(raw):match('^(%d%d%d%d)') or tostring(raw)
end
 
local year = tostring(raw):match('^(%d%d%d%d)')
return year or tostring(raw)
end
end


local function getParents(people, root)
local function getParents(people, root)
local person = people[root]
local person = people[root]
if not person then
if not person then return {} end
return {}
end
local parents = uniq(person.parents)
local parents = uniq(person.parents)
sortNames(people, parents)
sortNames(people, parents)
Line 328: Line 256:
local out = {}
local out = {}
local parents = getParents(people, root)
local parents = getParents(people, root)
for _, parentName in ipairs(parents) do
for _, parentName in ipairs(parents) do
local parent = people[parentName]
local parent = people[parentName]
Line 337: Line 264:
end
end
end
end
out = uniq(out)
out = uniq(out)
sortNames(people, out)
sortNames(people, out)
Line 344: Line 270:


local function getSiblings(people, root)
local function getSiblings(people, root)
local out = {}
local out, seen = {}, {}
local seen = {}
local person = people[root]
local person = people[root]
 
if not person then return out end
if not person then
return out
end


for _, parentName in ipairs(person.parents) do
for _, parentName in ipairs(person.parents) do
Line 372: Line 294:
local out = {}
local out = {}
local person = people[root]
local person = people[root]
if not person then
if not person then return out end
return out
end


for _, v in ipairs(person.parents) do addUnique(out, v) 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.children) do addUnique(out, v) end
for _, v in ipairs(person.partners) 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
local siblings = getSiblings(people, root)
for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end
for _, v in ipairs(siblings) do addUnique(out, v) end
 
local grandparents = getGrandparents(people, root)
for _, v in ipairs(grandparents) do addUnique(out, v) end


out = uniq(out)
out = uniq(out)
Line 393: Line 309:
local function getRootSiblingSequence(people, root)
local function getRootSiblingSequence(people, root)
local siblings = getSiblings(people, root)
local siblings = getSiblings(people, root)
local seq = {}
local seq, inserted = {}, false
local inserted = false
local midpoint = math.floor(#siblings / 2) + 1
local midpoint = math.floor(#siblings / 2) + 1


Line 414: Line 329:
local function getFamilyGroupsForRoot(people, root)
local function getFamilyGroupsForRoot(people, root)
local person = people[root]
local person = people[root]
if not person or not person.childLinks then
if not person or not person.childLinks then return {} end
return {}
end


local groups = {}
local groups = {}
Line 447: Line 360:
local out = {}
local out = {}
for _, group in pairs(groups) do
for _, group in pairs(groups) do
table.sort(group.children, function(a, b)
table.sort(group.children, function(a, b) return a.birthOrder < b.birthOrder end)
return a.birthOrder < b.birthOrder
end)
table.insert(out, group)
table.insert(out, group)
end
end
Line 456: Line 367:
local aSingle = not isRealValue(a.partner)
local aSingle = not isRealValue(a.partner)
local bSingle = not isRealValue(b.partner)
local bSingle = not isRealValue(b.partner)
if aSingle ~= bSingle then return aSingle end


if aSingle ~= bSingle then
local ap, bp = a.partner or '', b.partner or ''
return aSingle
end
 
local ap = a.partner or ''
local bp = b.partner or ''
local ad = (people[ap] and people[ap].displayName) or ap
local ad = (people[ap] and people[ap].displayName) or ap
local bd = (people[bp] and people[bp].displayName) or bp
local bd = (people[bp] and people[bp].displayName) or bp
Line 471: Line 378:
end
end


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


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


local card = html.create('div')
local card = html.create('div')
card:addClass('kbftv2-card')
card:addClass('kbft-card')
if isRealValue(extraClass) then
if isRealValue(extraClass) then
card:addClass(extraClass)
card:addClass(extraClass)
end
end
card:wikitext(makeLink(person.name, person.displayName))
card:wikitext(makeLink(person.name, person.displayName))


if isRealValue(badgeText) then
if isRealValue(badgeText) then
card:tag('div')
card:tag('div')
:addClass('kbftv2-badge')
:addClass('kbft-years')
:wikitext(badgeText)
:wikitext(badgeText)
end
end
Line 501: Line 402:
local function renderSingleCard(people, name, extraClass)
local function renderSingleCard(people, name, extraClass)
local wrap = html.create('div')
local wrap = html.create('div')
wrap:addClass('kbftv2-single')
wrap:addClass('kbft-single')
wrap:node(renderCard(people, name, nil, extraClass))
wrap:node(renderCard(people, name, nil, extraClass))
return wrap
return wrap
Line 509: Line 410:
if isRealValue(leftName) and isRealValue(rightName) then
if isRealValue(leftName) and isRealValue(rightName) then
local wrap = html.create('div')
local wrap = html.create('div')
wrap:addClass('kbftv2-couple')
wrap:addClass('kbft-couple')


wrap:node(renderCard(people, leftName, nil, leftClass))
wrap:node(renderCard(people, leftName, nil, leftClass))


local marriage = wrap:tag('div')
local marriage = wrap:tag('div')
marriage:addClass('kbftv2-marriage')
marriage:addClass('kbft-marriage')


if isRealValue(marriageYear) then
if isRealValue(marriageYear) then
marriage:tag('div')
marriage:tag('div')
:addClass('kbftv2-marriage-year')
:addClass('kbft-marriage-year')
:wikitext(marriageYear)
:wikitext(marriageYear)
end
end


marriage:tag('div')
marriage:tag('div')
:addClass('kbftv2-marriage-line')
:addClass('kbft-marriage-line')


wrap:node(renderCard(people, rightName, nil, rightClass))
wrap:node(renderCard(people, rightName, nil, rightClass))
Line 529: Line 430:
end
end


if isRealValue(leftName) then
if isRealValue(leftName) then return renderSingleCard(people, leftName, leftClass) end
return renderSingleCard(people, leftName, leftClass)
if isRealValue(rightName) then return renderSingleCard(people, rightName, rightClass) end
end
 
if isRealValue(rightName) then
return renderSingleCard(people, rightName, rightClass)
end
 
return nil
return nil
end
end
Line 542: Line 437:
local function renderGenerationRow(units)
local function renderGenerationRow(units)
local row = html.create('div')
local row = html.create('div')
row:addClass('kbftv2-row')
row:addClass('kbft-row')
 
for _, unit in ipairs(units) do
for _, unit in ipairs(units) do
if unit then
if unit then row:node(unit) end
row:node(unit)
end
end
end
return row
return row
end
end
Line 555: Line 446:
local function renderChildCards(people, children)
local function renderChildCards(people, children)
local childrenWrap = html.create('div')
local childrenWrap = html.create('div')
childrenWrap:addClass('kbftv2-unit-children')
childrenWrap:addClass('kbft-children')
 
for _, child in ipairs(children) do
for _, child in ipairs(children) do
childrenWrap:node(
childrenWrap:node(renderCard(people, child.name, relationshipBadge(child.relationshipType)))
renderCard(
people,
child.name,
relationshipBadge(child.relationshipType)
)
)
end
end
return childrenWrap
return childrenWrap
end
end
Line 572: Line 455:
local function renderFamilyUnit(people, root, group)
local function renderFamilyUnit(people, root, group)
local unit = html.create('div')
local unit = html.create('div')
unit:addClass('kbftv2-unit')
unit:addClass('kbft-sibling-unit')


local top = unit:tag('div')
local top = unit:tag('div')
top:addClass('kbftv2-unit-top')
top:addClass('kbft-family-main-wrap')


if isRealValue(group.partner) then
if isRealValue(group.partner) then
local union = findUnionBetween(people, root, group.partner)
local union = findUnionBetween(people, root, group.partner)
local marriageYear = getMarriageYear(union)
local marriageYear = getMarriageYear(union)
top:node(renderCouple(people, root, group.partner, marriageYear, 'kbftv2-root-echo', nil))
top:node(renderCouple(people, root, group.partner, marriageYear))
else
else
local soloLabel = top:tag('div')
top:node(renderSingleCard(people, root))
soloLabel:addClass('kbftv2-solo-label')
soloLabel:wikitext((people[root] and people[root].displayName) or root)
end
end


if #group.children > 0 then
if #group.children > 0 then
unit:tag('div')
unit:tag('div'):addClass('kbft-child-down')
:addClass('kbftv2-unit-drop')
 
unit:node(renderChildCards(people, group.children))
unit:node(renderChildCards(people, group.children))
end
end
Line 598: Line 477:


local function renderUpperCoupleGeneration(people, couples)
local function renderUpperCoupleGeneration(people, couples)
if #couples == 0 then
if #couples == 0 then return nil end
return nil
end
 
local gen = html.create('div')
local gen = html.create('div')
gen:addClass('kbftv2-generation')
gen:addClass('kbft-generation')


local units = {}
local units = {}
Line 634: Line 510:
local function buildParentCouples(people, root)
local function buildParentCouples(people, root)
local parents = getParents(people, root)
local parents = getParents(people, root)
if #parents == 0 then
if #parents == 0 then return {} end
return {}
end
return { { parents[1], parents[2] } }
return { { parents[1], parents[2] } }
end
end
Line 644: Line 518:


local gen = html.create('div')
local gen = html.create('div')
gen:addClass('kbftv2-generation')
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')


local units = {}
for _, name in ipairs(sequence) do
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
if name == root then
table.insert(units, renderSingleCard(people, name, 'kbftv2-root-focus'))
unit:node(renderSingleCard(people, name, 'kbft-focus-card'))
else
else
table.insert(units, renderSingleCard(people, name))
unit:node(renderSingleCard(people, name))
end
end
end
end
local groupWrap = gen:tag('div')
groupWrap:addClass('kbftv2-sibling-group')
groupWrap:tag('div')
:addClass('kbftv2-sibling-spine')
groupWrap:node(renderGenerationRow(units))


return gen
return gen
Line 668: Line 548:
local function renderFamilyGroupsGeneration(people, root)
local function renderFamilyGroupsGeneration(people, root)
local groups = getFamilyGroupsForRoot(people, root)
local groups = getFamilyGroupsForRoot(people, root)
if #groups == 0 then
if #groups == 0 then return nil end
return nil
end


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


local units = {}
local units = {}
Line 684: Line 562:
end
end


-- =========================================
-- Public renderers
-- Public renderers
-- =========================================


local function renderConnectedForRoot(people, root)
local function renderConnectedForRoot(people, root)
Line 695: Line 571:


local connected = getConnectedPeople(people, root)
local connected = getConnectedPeople(people, root)
local node = html.create('div')
local node = html.create('div')
node:addClass('kbftv2-tree')
node:addClass('kbft-tree')


node:tag('div')
node:tag('div')
:addClass('kbftv2-title')
:addClass('kbft-title')
:wikitext('Connected to ' .. makeLink(person.name, person.displayName))
:wikitext('Connected to ' .. makeLink(person.name, person.displayName))
local gen = node:tag('div')
gen:addClass('kbft-generation')


local units = {}
local units = {}
Line 707: Line 585:
table.insert(units, renderSingleCard(people, name))
table.insert(units, renderSingleCard(people, name))
end
end
local gen = node:tag('div')
gen:addClass('kbftv2-generation')
gen:node(renderGenerationRow(units))
gen:node(renderGenerationRow(units))


Line 722: Line 597:


local node = html.create('div')
local node = html.create('div')
node:addClass('kbftv2-tree')
node:addClass('kbft-tree')


node:tag('div')
node:tag('div')
:addClass('kbftv2-title')
:addClass('kbft-title')
:wikitext(makeLink(person.name, person.displayName))
:wikitext(makeLink(person.name, person.displayName))


local function addSection(label, names)
local function addSection(label, names)
names = uniq(names)
names = uniq(names)
if #names == 0 then
if #names == 0 then return end
return
end
 
sortNames(people, names)
sortNames(people, names)


node:tag('div')
node:tag('div')
:addClass('kbftv2-section-title')
:addClass('kbft-title')
:css('margin-top', '22px')
:wikitext(label)
:wikitext(label)
local gen = node:tag('div')
gen:addClass('kbft-generation')


local units = {}
local units = {}
Line 744: Line 620:
table.insert(units, renderSingleCard(people, name))
table.insert(units, renderSingleCard(people, name))
end
end
local gen = node:tag('div')
gen:addClass('kbftv2-generation')
gen:node(renderGenerationRow(units))
gen:node(renderGenerationRow(units))
end
end
Line 764: Line 637:


local node = html.create('div')
local node = html.create('div')
node:addClass('kbftv2-tree')
node:addClass('kbft-tree')


node:tag('div')
node:tag('div')
:addClass('kbftv2-title')
:addClass('kbft-title')
:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))
:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))


Line 773: Line 646:
if gpGen then
if gpGen then
node:node(gpGen)
node:node(gpGen)
node:tag('div'):addClass('kbftv2-connector')
node:tag('div'):addClass('kbft-connector')
end
end


Line 779: Line 652:
if parentGen then
if parentGen then
node:node(parentGen)
node:node(parentGen)
node:tag('div'):addClass('kbftv2-connector')
node:tag('div'):addClass('kbft-connector')
end
end


Line 786: Line 659:
local familyGen = renderFamilyGroupsGeneration(people, root)
local familyGen = renderFamilyGroupsGeneration(people, root)
if familyGen then
if familyGen then
node:tag('div'):addClass('kbftv2-connector')
node:tag('div'):addClass('kbft-connector')
node:node(familyGen)
node:node(familyGen)
end
end
Line 793: Line 666:
end
end


-- =========================================
-- Public functions
-- Public functions
-- =========================================


function p.tree(frame)
function p.tree(frame)

Revision as of 12:08, 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

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 or not person.childLinks then return {} end

	local groups = {}

	for _, link in ipairs(person.childLinks) 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

	local out = {}
	for _, group in pairs(groups) do
		table.sort(group.children, function(a, b) return a.birthOrder < b.birthOrder 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, bp = a.partner or '', 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

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 renderChildCards(people, children)
	local childrenWrap = html.create('div')
	childrenWrap:addClass('kbft-children')
	for _, child in ipairs(children) do
		childrenWrap:node(renderCard(people, child.name, relationshipBadge(child.relationshipType)))
	end
	return childrenWrap
end

local function renderFamilyUnit(people, root, group)
	local unit = html.create('div')
	unit:addClass('kbft-sibling-unit')

	local top = unit:tag('div')
	top:addClass('kbft-family-main-wrap')

	if isRealValue(group.partner) then
		local union = findUnionBetween(people, root, group.partner)
		local marriageYear = getMarriageYear(union)
		top:node(renderCouple(people, root, group.partner, marriageYear))
	else
		top:node(renderSingleCard(people, root))
	end

	if #group.children > 0 then
		unit:tag('div'):addClass('kbft-child-down')
		unit:node(renderChildCards(people, group.children))
	end

	return unit
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 renderFamilyGroupsGeneration(people, root)
	local groups = getFamilyGroupsForRoot(people, root)
	if #groups == 0 then return nil end

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

	local units = {}
	for _, group in ipairs(groups) do
		table.insert(units, renderFamilyUnit(people, root, group))
	end

	gen:node(renderGenerationRow(units))
	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