Module:FamilyTree: Difference between revisions

From KB Lexicon
No edit summary
No edit summary
Tag: Reverted
Line 1: Line 1:
--[[
Module:FamilyTree — KnockturnBound Lexicon
Focus-person tree: root once; co-parents on the same row as the focus (grid); children row aligned per union/solo branch.
connected() and profile() unchanged in behavior.
]]
local p = {}
local p = {}


Line 353: Line 359:
end
end


local function buildFocusBranches(person)
--- Union rows involving `root`, sorted for stable display (year, partner name).
person = trim(person)
local function getUnionsForPerson(root)
if not person then
root = trim(root)
if not root then
return {}
return {}
end
end


local branches = {}
local rows = cargoQuery(
 
local unions = cargoQuery(
'Unions',
'Unions',
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,EngagementDate',
'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
{
{
where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
where = 'Partner1="' .. esc(root) .. '" OR Partner2="' .. esc(root) .. '"',
limit = 100
limit = 100
}
}
)
)


for _, union in ipairs(unions) do
table.sort(rows, function(a, b)
local p1 = trim(union.Partner1)
local ta = trim(a.UnionType) or ''
local p2 = trim(union.Partner2)
local tb = trim(b.UnionType) or ''
local partner = nil
local ya = formatYear(ta == 'Engagement' and a.EngagementDate or a.MarriageDate) or '9999'
local yb = formatYear(tb == 'Engagement' and b.EngagementDate or b.MarriageDate) or '9999'
if ya ~= yb then
return ya < yb
end
local pa = trim(a.Partner1) == root and trim(a.Partner2) or trim(a.Partner1)
local pb = trim(b.Partner1) == root and trim(b.Partner2) or trim(b.Partner1)
pa = pa or ''
pb = pb or ''
return pa:lower() < pb:lower()
end)


if p1 == person then
return rows
partner = p2
end
else
 
partner = p1
--- True if this ParentChild row lists `root` as a parent (excludes hijacked rows).
end
local function rowHasParent(row, root)
local p1 = trim(row.Parent1)
local p2 = trim(row.Parent2)
return p1 == root or p2 == root
end


local childRows = cargoQuery(
--- Children under a specific union where `root` is a parent; sorted by BirthOrder.
'ParentChild',
local function getChildrenForUnion(root, unionID)
'Child,BirthOrder',
root = trim(root)
{
unionID = trim(unionID)
where = 'UnionID="' .. esc(union.UnionID) .. '"',
if not root or not unionID then
limit = 100
return {}
}
end
)


table.sort(childRows, function(a, b)
local rows = cargoQuery(
local aOrder = tonumber(a.BirthOrder) or 9999
'ParentChild',
local bOrder = tonumber(b.BirthOrder) or 9999
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
if aOrder == bOrder then
{
return tostring(a.Child):lower() < tostring(b.Child):lower()
where = 'UnionID="' .. esc(unionID) .. '"',
end
limit = 200
return aOrder < bOrder
}
end)
)


local children = {}
local filtered = {}
for _, row in ipairs(childRows) do
for _, row in ipairs(rows) do
table.insert(children, row.Child)
if rowHasParent(row, root) then
table.insert(filtered, row)
end
end
end


local unionType = trim(union.UnionType) or 'Marriage'
table.sort(filtered, function(a, b)
local year = nil
local aOrder = tonumber(a.BirthOrder) or 9999
if unionType == 'Engagement' then
local bOrder = tonumber(b.BirthOrder) or 9999
year = formatYear(union.EngagementDate)
if aOrder == bOrder then
else
return tostring(a.Child):lower() < tostring(b.Child):lower()
year = formatYear(union.MarriageDate)
end
end
return aOrder < bOrder
end)


table.insert(branches, {
local children = {}
branchPerson = partner,
local seen = {}
unionType = unionType,
for _, row in ipairs(filtered) do
marriageYear = year,
addUnique(children, seen, row.Child)
children = children
end
})
return children
end
 
--[[
Branches below the root: one entry per union (co-parent + children) and at most one solo branch
(children of root with no UnionID / empty UnionID). No duplicate root card on solo branch.
]]
local function buildCoParentBranches(root)
root = trim(root)
if not root then
return {}
end
 
local branches = {}
local seenUnionIDs = {}
 
for _, union in ipairs(getUnionsForPerson(root)) do
local uid = trim(union.UnionID)
if uid and not seenUnionIDs[uid] then
seenUnionIDs[uid] = true
local p1 = trim(union.Partner1)
local p2 = trim(union.Partner2)
local partner = (p1 == root) and p2 or p1
partner = trim(partner)
 
local children = getChildrenForUnion(root, uid)
 
local unionType = trim(union.UnionType) or 'Marriage'
local year = nil
if unionType == 'Engagement' then
year = formatYear(union.EngagementDate)
else
year = formatYear(union.MarriageDate)
end
 
table.insert(branches, {
kind = 'union',
partner = partner,
unionID = uid,
unionType = unionType,
marriageYear = year,
children = children
})
end
end
end


local orphanRows = cargoQuery(
local orphanRows = cargoQuery(
'ParentChild',
'ParentChild',
'Child,BirthOrder',
'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
{
{
where = '(Parent1="' .. esc(person) .. '" OR Parent2="' .. esc(person) .. '") AND (UnionID="" OR UnionID IS NULL)',
where = '(Parent1="' .. esc(root) .. '" OR Parent2="' .. esc(root) .. '") AND (UnionID="" OR UnionID IS NULL)',
limit = 100
limit = 200
}
}
)
)
Line 439: Line 503:
end)
end)


local children = {}
local soloChildren = {}
local seen = {}
for _, row in ipairs(orphanRows) do
for _, row in ipairs(orphanRows) do
table.insert(children, row.Child)
if rowHasParent(row, root) then
addUnique(soloChildren, seen, row.Child)
end
end
end


table.insert(branches, {
if #soloChildren > 0 then
branchPerson = person,
table.insert(branches, {
unionType = 'SoloParent',
kind = 'solo',
marriageYear = nil,
partner = nil,
children = children
unionType = 'SoloParent',
})
marriageYear = nil,
children = soloChildren
})
end
end
end


Line 525: Line 595:
end
end


local function makePartnerBranch(branchPerson, marriageYear, unionType)
--- Solo branch: no second-person card; short caption only.
local label = makeRelationLabel(unionType, marriageYear)
local function makeSoloParentCaption()
 
return '<div style="display:inline-flex;flex-direction:column;align-items:center;justify-content:flex-end;min-height:56px;width:120px;box-sizing:border-box;padding:8px 6px;">'
local html = {}
.. '<div style="font-size:0.72em;color:#8b7768;line-height:1.25;text-align:center;">Children with no union record</div>'
table.insert(html, '<div style="display:inline-flex;flex-direction:column;align-items:center;">')
.. '</div>'
table.insert(html, makeCard(branchPerson))
if label ~= '' then
table.insert(html, '<div style="font-size:0.7em;color:#7a6b60;margin-top:4px;white-space:nowrap;">' .. label .. '</div>')
end
table.insert(html, '</div>')
return table.concat(html)
end
end


Line 566: Line 630:
end
end


local function makeFocusBranches(branches)
--- Vertical stub + child cards under a co-parent column (aligned with grid row 2).
if not branches or #branches == 0 then
local function renderChildStack(children)
local html = {}
if children and #children > 0 then
table.insert(html, '<div style="width:2px;height:12px;background:#b8a79a;margin:0 auto;"></div>')
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:280px;">')
for _, child in ipairs(children) do
local rel = getRelationshipTypeForChild(child)
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
table.insert(html, makeCard(child))
if rel and rel ~= 'Biological' then
table.insert(html, '<div style="font-size:0.68em;color:#8b7768;margin-top:4px;text-transform:lowercase;">' .. string.lower(rel) .. '</div>')
end
table.insert(html, '</div>')
end
table.insert(html, '</div>')
else
table.insert(html, '<div style="width:2px;height:12px;margin:0 auto;"></div>')
end
return table.concat(html)
end
 
--[[
Focus + co-parents on one horizontal row (same generation); children on row below, column-aligned.
Root appears once in column 1 — not above partners as if partners were children.
]]
local function makeCoParentBranches(root, branches)
root = trim(root)
if not root or not branches or #branches == 0 then
return ''
return ''
end
end


local n = 1 + #branches
local html = {}
local html = {}
table.insert(html, '<div style="position:relative;width:100%;padding-top:24px;">')
table.insert(html, '<div style="display:grid;grid-template-columns:repeat(' .. tostring(n) .. ',minmax(118px,auto));justify-content:center;justify-items:center;column-gap:14px;row-gap:10px;width:100%;max-width:100%;box-sizing:border-box;">')
table.insert(html, '<div style="position:absolute;top:10px;left:12%;right:12%;height:2px;background:#bdaea0;"></div>')
table.insert(html, '<div style="position:relative;z-index:1;display:flex;justify-content:center;align-items:flex-start;gap:24px;flex-wrap:wrap;width:100%;">')


-- Row 1: focus person, then each co-parent (or solo caption)
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;">' .. makeCard(root) .. '</div>')
for _, branch in ipairs(branches) do
for _, branch in ipairs(branches) do
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:8px;min-width:150px;flex:0 1 auto;">')
if branch.kind == 'solo' then
table.insert(html, '<div style="position:relative;display:inline-flex;justify-content:center;align-items:center;">')
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;">' .. makeSoloParentCaption() .. '</div>')
table.insert(html, '<div style="position:absolute;top:-18px;left:50%;transform:translateX(-50%);width:2px;height:16px;background:#b8a79a;z-index:2;"></div>')
else
table.insert(html, makePartnerBranch(branch.branchPerson, branch.marriageYear, branch.unionType))
local cell = {}
table.insert(html, '</div>')
if branch.partner and trim(branch.partner) then
 
table.insert(cell, makeCard(branch.partner))
if branch.children and #branch.children > 0 then
else
table.insert(html, '<div style="width:2px;height:16px;background:#b8a79a;margin:0 auto;"></div>')
table.insert(cell, '<div style="' .. cardStyle() .. ';color:#8b7768;font-size:0.78em;">(no partner record)</div>')
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:260px;">')
end
for _, child in ipairs(branch.children) do
local label = makeRelationLabel(branch.unionType, branch.marriageYear)
local rel = getRelationshipTypeForChild(child)
if label ~= '' then
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
table.insert(cell, '<div style="font-size:0.68em;color:#7a6b60;margin-top:4px;white-space:nowrap;">' .. label .. '</div>')
table.insert(html, makeCard(child))
if rel and rel ~= 'Biological' then
table.insert(html, '<div style="font-size:0.68em;color:#8b7768;margin-top:4px;text-transform:lowercase;">' .. string.lower(rel) .. '</div>')
end
table.insert(html, '</div>')
end
end
table.insert(html, '</div>')
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;">' .. table.concat(cell) .. '</div>')
end
end
end


table.insert(html, '</div>')
-- Row 2: short line under focus; child stacks under each branch column
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;width:100%;">'
.. '<div style="width:2px;height:14px;background:#b8a79a;"></div></div>')
for _, branch in ipairs(branches) do
table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;width:100%;">'
.. renderChildStack(branch.children) .. '</div>')
end
end


table.insert(html, '</div>')
table.insert(html, '</div>')
table.insert(html, '</div>')
return table.concat(html)
return table.concat(html)
Line 732: Line 824:
local grandparentGroups = buildCoupleGroups(grandparents)
local grandparentGroups = buildCoupleGroups(grandparents)
local parentGroups = buildCoupleGroups(parents)
local parentGroups = buildCoupleGroups(parents)
local branches = buildFocusBranches(root)
local branches = buildCoParentBranches(root)


local html = {}
local html = {}
Line 757: Line 849:
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
end
end
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(root) .. '</div>')
table.insert(html, '</div>')


if #branches > 0 then
if #branches > 0 then
table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, makeFocusBranches(branches))
table.insert(html, makeCoParentBranches(root, branches))
table.insert(html, '</div>')
else
table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(root) .. '</div>')
table.insert(html, '</div>')
table.insert(html, '</div>')
end
end

Revision as of 21:20, 29 March 2026

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

--[[
	Module:FamilyTree — KnockturnBound Lexicon
	Focus-person tree: root once; co-parents on the same row as the focus (grid); children row aligned per union/solo branch.
	connected() and profile() unchanged in behavior.
]]

local p = {}

local cargo = mw.ext.cargo

local function esc(value)
	if not value then
		return ''
	end
	value = tostring(value)
	value = value:gsub('\\', '\\\\')
	value = value:gsub('"', '\\"')
	return value
end

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

local function trim(s)
	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 formatYear(dateValue)
	dateValue = trim(dateValue)
	if not dateValue then
		return nil
	end
	return tostring(dateValue):match('^(%d%d%d%d)')
end

local function addUnique(list, seen, value)
	value = trim(value)
	if value and not seen[value] then
		seen[value] = true
		table.insert(list, value)
	end
end

local function addSet(set, value)
	value = trim(value)
	if value then
		set[value] = true
	end
end

local function sorted(list)
	table.sort(list, function(a, b)
		return tostring(a):lower() < tostring(b):lower()
	end)
	return list
end

local function getCharacter(pageName)
	pageName = trim(pageName)
	if not pageName then
		return nil
	end

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

	return rows[1]
end

local function getDisplayName(pageName)
	local c = getCharacter(pageName)
	if c and trim(c.DisplayName) then
		return trim(c.DisplayName)
	end
	return pageName
end

local function makeLinkedName(pageName)
	return '[[' .. pageName .. '|' .. getDisplayName(pageName) .. ']]'
end

local function linkList(list)
	local out = {}
	for _, pageName in ipairs(list or {}) do
		table.insert(out, makeLinkedName(pageName))
	end
	return table.concat(out, '<br>')
end

local function getParents(person)
	person = trim(person)
	if not person then
		return {}, nil
	end

	local rows = cargoQuery(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = 'Child="' .. esc(person) .. '"',
			limit = 20
		}
	)

	local parents = {}
	local seen = {}

	for _, row in ipairs(rows) do
		addUnique(parents, seen, row.Parent1)
		addUnique(parents, seen, row.Parent2)
	end

	return sorted(parents), rows[1]
end

local function getChildren(person)
	person = trim(person)
	if not person then
		return {}
	end

	local rows = cargoQuery(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = 'Parent1="' .. esc(person) .. '" OR Parent2="' .. esc(person) .. '"',
			limit = 200
		}
	)

	table.sort(rows, function(a, b)
		local aOrder = tonumber(a.BirthOrder) or 9999
		local bOrder = tonumber(b.BirthOrder) or 9999
		if aOrder == bOrder then
			return tostring(a.Child):lower() < tostring(b.Child):lower()
		end
		return aOrder < bOrder
	end)

	local children = {}
	local seen = {}

	for _, row in ipairs(rows) do
		addUnique(children, seen, row.Child)
	end

	return children
end

local function getPartners(person)
	person = trim(person)
	if not person then
		return {}, {}
	end

	local rows = cargoQuery(
		'Unions',
		'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
		{
			where = 'Partner1="' .. esc(person) .. '" OR Partner2="' .. esc(person) .. '"',
			limit = 100
		}
	)

	local partners = {}
	local seen = {}

	for _, row in ipairs(rows) do
		local p1 = trim(row.Partner1)
		local p2 = trim(row.Partner2)

		if p1 == person then
			addUnique(partners, seen, p2)
		elseif p2 == person then
			addUnique(partners, seen, p1)
		end
	end

	return sorted(partners), rows
end

local function getSiblingGeneration(person)
	person = trim(person)
	if not person then
		return {}
	end

	local targetRows = cargoQuery(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = 'Child="' .. esc(person) .. '"',
			limit = 10
		}
	)

	if not targetRows[1] then
		return { person }
	end

	local targetUnion = trim(targetRows[1].UnionID)
	local targetP1 = trim(targetRows[1].Parent1)
	local targetP2 = trim(targetRows[1].Parent2)

	local whereParts = {}
	if targetUnion then
		table.insert(whereParts, 'UnionID="' .. esc(targetUnion) .. '"')
	end
	if targetP1 and targetP2 then
		table.insert(whereParts, '(Parent1="' .. esc(targetP1) .. '" AND Parent2="' .. esc(targetP2) .. '")')
	end

	if #whereParts == 0 then
		return { person }
	end

	local rows = cargoQuery(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = table.concat(whereParts, ' OR '),
			limit = 100
		}
	)

	table.sort(rows, function(a, b)
		local aOrder = tonumber(a.BirthOrder) or 9999
		local bOrder = tonumber(b.BirthOrder) or 9999
		if aOrder == bOrder then
			return tostring(a.Child):lower() < tostring(b.Child):lower()
		end
		return aOrder < bOrder
	end)

	local people = {}
	local seen = {}
	for _, row in ipairs(rows) do
		addUnique(people, seen, row.Child)
	end

	if #people == 0 then
		table.insert(people, person)
	end

	return people
end

local function getRelationshipTypeForChild(person)
	person = trim(person)
	if not person then
		return nil
	end

	local rows = cargoQuery(
		'ParentChild',
		'RelationshipType',
		{
			where = 'Child="' .. esc(person) .. '"',
			limit = 1
		}
	)

	if rows[1] and trim(rows[1].RelationshipType) then
		return trim(rows[1].RelationshipType)
	end

	return nil
end

local function buildCoupleGroups(people)
	local groups = {}
	local used = {}
	local working = {}

	for _, person in ipairs(people or {}) do
		table.insert(working, person)
	end
	sorted(working)

	for _, person in ipairs(working) do
		if not used[person] then
			local partners = getPartners(person)
			local matchedPartner = nil
			local matchedUnion = nil

			for _, partner in ipairs(partners) do
				for _, candidate in ipairs(working) do
					if candidate == partner and not used[candidate] then
						matchedPartner = partner
						break
					end
				end
				if matchedPartner then
					local rows = cargoQuery(
						'Unions',
						'UnionID,Partner1,Partner2,UnionType,MarriageDate,EngagementDate',
						{
							where = '(Partner1="' .. esc(person) .. '" AND Partner2="' .. esc(matchedPartner) .. '") OR (Partner1="' .. esc(matchedPartner) .. '" AND Partner2="' .. esc(person) .. '")',
							limit = 1
						}
					)
					matchedUnion = rows[1]
					break
				end
			end

			if matchedPartner then
				used[person] = true
				used[matchedPartner] = true

				local unionType = trim(matchedUnion and matchedUnion.UnionType) or 'Marriage'
				local year = nil
				if unionType == 'Engagement' then
					year = formatYear(matchedUnion and matchedUnion.EngagementDate)
				else
					year = formatYear(matchedUnion and matchedUnion.MarriageDate)
				end

				table.insert(groups, {
					type = 'couple',
					left = person,
					right = matchedPartner,
					unionType = unionType,
					marriageYear = year
				})
			else
				used[person] = true
				table.insert(groups, {
					type = 'single',
					person = person
				})
			end
		end
	end

	return groups
end

--- Union rows involving `root`, sorted for stable display (year, partner name).
local function getUnionsForPerson(root)
	root = trim(root)
	if not root then
		return {}
	end

	local rows = cargoQuery(
		'Unions',
		'UnionID,Partner1,Partner2,UnionType,Status,MarriageDate,DivorceDate,EngagementDate',
		{
			where = 'Partner1="' .. esc(root) .. '" OR Partner2="' .. esc(root) .. '"',
			limit = 100
		}
	)

	table.sort(rows, function(a, b)
		local ta = trim(a.UnionType) or ''
		local tb = trim(b.UnionType) or ''
		local ya = formatYear(ta == 'Engagement' and a.EngagementDate or a.MarriageDate) or '9999'
		local yb = formatYear(tb == 'Engagement' and b.EngagementDate or b.MarriageDate) or '9999'
		if ya ~= yb then
			return ya < yb
		end
		local pa = trim(a.Partner1) == root and trim(a.Partner2) or trim(a.Partner1)
		local pb = trim(b.Partner1) == root and trim(b.Partner2) or trim(b.Partner1)
		pa = pa or ''
		pb = pb or ''
		return pa:lower() < pb:lower()
	end)

	return rows
end

--- True if this ParentChild row lists `root` as a parent (excludes hijacked rows).
local function rowHasParent(row, root)
	local p1 = trim(row.Parent1)
	local p2 = trim(row.Parent2)
	return p1 == root or p2 == root
end

--- Children under a specific union where `root` is a parent; sorted by BirthOrder.
local function getChildrenForUnion(root, unionID)
	root = trim(root)
	unionID = trim(unionID)
	if not root or not unionID then
		return {}
	end

	local rows = cargoQuery(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = 'UnionID="' .. esc(unionID) .. '"',
			limit = 200
		}
	)

	local filtered = {}
	for _, row in ipairs(rows) do
		if rowHasParent(row, root) then
			table.insert(filtered, row)
		end
	end

	table.sort(filtered, function(a, b)
		local aOrder = tonumber(a.BirthOrder) or 9999
		local bOrder = tonumber(b.BirthOrder) or 9999
		if aOrder == bOrder then
			return tostring(a.Child):lower() < tostring(b.Child):lower()
		end
		return aOrder < bOrder
	end)

	local children = {}
	local seen = {}
	for _, row in ipairs(filtered) do
		addUnique(children, seen, row.Child)
	end
	return children
end

--[[
	Branches below the root: one entry per union (co-parent + children) and at most one solo branch
	(children of root with no UnionID / empty UnionID). No duplicate root card on solo branch.
]]
local function buildCoParentBranches(root)
	root = trim(root)
	if not root then
		return {}
	end

	local branches = {}
	local seenUnionIDs = {}

	for _, union in ipairs(getUnionsForPerson(root)) do
		local uid = trim(union.UnionID)
		if uid and not seenUnionIDs[uid] then
			seenUnionIDs[uid] = true
			local p1 = trim(union.Partner1)
			local p2 = trim(union.Partner2)
			local partner = (p1 == root) and p2 or p1
			partner = trim(partner)

			local children = getChildrenForUnion(root, uid)

			local unionType = trim(union.UnionType) or 'Marriage'
			local year = nil
			if unionType == 'Engagement' then
				year = formatYear(union.EngagementDate)
			else
				year = formatYear(union.MarriageDate)
			end

			table.insert(branches, {
				kind = 'union',
				partner = partner,
				unionID = uid,
				unionType = unionType,
				marriageYear = year,
				children = children
			})
		end
	end

	local orphanRows = cargoQuery(
		'ParentChild',
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',
		{
			where = '(Parent1="' .. esc(root) .. '" OR Parent2="' .. esc(root) .. '") AND (UnionID="" OR UnionID IS NULL)',
			limit = 200
		}
	)

	if #orphanRows > 0 then
		table.sort(orphanRows, function(a, b)
			local aOrder = tonumber(a.BirthOrder) or 9999
			local bOrder = tonumber(b.BirthOrder) or 9999
			if aOrder == bOrder then
				return tostring(a.Child):lower() < tostring(b.Child):lower()
			end
			return aOrder < bOrder
		end)

		local soloChildren = {}
		local seen = {}
		for _, row in ipairs(orphanRows) do
			if rowHasParent(row, root) then
				addUnique(soloChildren, seen, row.Child)
			end
		end

		if #soloChildren > 0 then
			table.insert(branches, {
				kind = 'solo',
				partner = nil,
				unionType = 'SoloParent',
				marriageYear = nil,
				children = soloChildren
			})
		end
	end

	return branches
end

local function cardStyle()
	return table.concat({
		'width:120px',
		'min-height:56px',
		'padding:8px 10px',
		'border:1px solid #cab8aa',
		'background:#fffdf9',
		'border-radius:10px',
		'box-shadow:0 2px 4px rgba(0,0,0,0.08)',
		'text-align:center',
		'box-sizing:border-box',
		'font-size:0.92em',
		'line-height:1.2',
		'display:flex',
		'flex-direction:column',
		'justify-content:center'
	}, ';')
end

local function yearsStyle()
	return 'margin-top:4px;font-size:0.74em;color:#7a6b60;'
end

local function makeCard(pageName)
	pageName = trim(pageName)
	if not pageName then
		return ''
	end

	local c = getCharacter(pageName)
	local displayName = getDisplayName(pageName)
	local birthYear = c and formatYear(c.BirthDate) or nil
	local deathYear = c and formatYear(c.DeathDate) or nil

	local years = ''
	if birthYear or deathYear then
		years = '<div style="' .. yearsStyle() .. '">' .. (birthYear or '?') .. '–' .. (deathYear or '') .. '</div>'
	end

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

local function makeRelationLabel(unionType, marriageYear)
	local label = ''

	if unionType == 'Marriage' and marriageYear then
		label = 'm. ' .. marriageYear
	elseif unionType == 'Marriage' then
		label = 'm.'
	elseif unionType == 'Engagement' and marriageYear then
		label = 'eng. ' .. marriageYear
	elseif unionType == 'Engagement' then
		label = 'eng.'
	elseif unionType == 'Affair' then
		label = 'affair'
	elseif unionType == 'Liaison' then
		label = 'liaison'
	elseif unionType == 'SoloParent' then
		label = ''
	elseif unionType and unionType ~= '' then
		if marriageYear then
			label = unionType .. ' ' .. marriageYear
		else
			label = unionType
		end
	end

	return label
end

--- Solo branch: no second-person card; short caption only.
local function makeSoloParentCaption()
	return '<div style="display:inline-flex;flex-direction:column;align-items:center;justify-content:flex-end;min-height:56px;width:120px;box-sizing:border-box;padding:8px 6px;">'
		.. '<div style="font-size:0.72em;color:#8b7768;line-height:1.25;text-align:center;">Children with no union record</div>'
		.. '</div>'
end

local function makeCoupleRow(groups)
	if not groups or #groups == 0 then
		return ''
	end

	local html = {}
	table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:22px;flex-wrap:wrap;width:100%;">')
	for _, group in ipairs(groups) do
		if group.type == 'single' then
			table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(group.person) .. '</div>')
		else
			local label = makeRelationLabel(group.unionType, group.marriageYear)
			table.insert(html, '<div style="display:inline-flex;flex-direction:row;align-items:center;gap:8px;">')
			table.insert(html, makeCard(group.left))
			table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
			if label ~= '' then
				table.insert(html, '<div style="font-size:0.7em;color:#7a6b60;margin-bottom:4px;white-space:nowrap;">' .. label .. '</div>')
			end
			table.insert(html, '<div style="width:40px;height:2px;background:#bdaea0;"></div>')
			table.insert(html, '</div>')
			table.insert(html, makeCard(group.right))
			table.insert(html, '</div>')
		end
	end
	table.insert(html, '</div>')
	return table.concat(html)
end

--- Vertical stub + child cards under a co-parent column (aligned with grid row 2).
local function renderChildStack(children)
	local html = {}
	if children and #children > 0 then
		table.insert(html, '<div style="width:2px;height:12px;background:#b8a79a;margin:0 auto;"></div>')
		table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:280px;">')
		for _, child in ipairs(children) do
			local rel = getRelationshipTypeForChild(child)
			table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;">')
			table.insert(html, makeCard(child))
			if rel and rel ~= 'Biological' then
				table.insert(html, '<div style="font-size:0.68em;color:#8b7768;margin-top:4px;text-transform:lowercase;">' .. string.lower(rel) .. '</div>')
			end
			table.insert(html, '</div>')
		end
		table.insert(html, '</div>')
	else
		table.insert(html, '<div style="width:2px;height:12px;margin:0 auto;"></div>')
	end
	return table.concat(html)
end

--[[
	Focus + co-parents on one horizontal row (same generation); children on row below, column-aligned.
	Root appears once in column 1 — not above partners as if partners were children.
]]
local function makeCoParentBranches(root, branches)
	root = trim(root)
	if not root or not branches or #branches == 0 then
		return ''
	end

	local n = 1 + #branches
	local html = {}
	table.insert(html, '<div style="display:grid;grid-template-columns:repeat(' .. tostring(n) .. ',minmax(118px,auto));justify-content:center;justify-items:center;column-gap:14px;row-gap:10px;width:100%;max-width:100%;box-sizing:border-box;">')

	-- Row 1: focus person, then each co-parent (or solo caption)
	table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;">' .. makeCard(root) .. '</div>')
	for _, branch in ipairs(branches) do
		if branch.kind == 'solo' then
			table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;">' .. makeSoloParentCaption() .. '</div>')
		else
			local cell = {}
			if branch.partner and trim(branch.partner) then
				table.insert(cell, makeCard(branch.partner))
			else
				table.insert(cell, '<div style="' .. cardStyle() .. ';color:#8b7768;font-size:0.78em;">(no partner record)</div>')
			end
			local label = makeRelationLabel(branch.unionType, branch.marriageYear)
			if label ~= '' then
				table.insert(cell, '<div style="font-size:0.68em;color:#7a6b60;margin-top:4px;white-space:nowrap;">' .. label .. '</div>')
			end
			table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;">' .. table.concat(cell) .. '</div>')
		end
	end

	-- Row 2: short line under focus; child stacks under each branch column
	table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;width:100%;">'
		.. '<div style="width:2px;height:14px;background:#b8a79a;"></div></div>')
	for _, branch in ipairs(branches) do
		table.insert(html, '<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;width:100%;">'
			.. renderChildStack(branch.children) .. '</div>')
	end

	table.insert(html, '</div>')
	return table.concat(html)
end

function p.connected(frame)
	local args = frame.args
	local parentArgs = frame:getParent() and frame:getParent().args or {}
	local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])

	if not root then
		return 'Error: no root provided. Use root=Character Name'
	end

	local visited = {}
	local queue = {}
	local head = 1

	visited[root] = true
	table.insert(queue, root)

	while head <= #queue do
		local current = queue[head]
		head = head + 1

		local neighbors = {}
		local parents = getParents(current)
		local children = getChildren(current)
		local partners = getPartners(current)

		for _, person in ipairs(parents) do
			addSet(neighbors, person)
		end
		for _, person in ipairs(children) do
			addSet(neighbors, person)
		end
		for _, person in ipairs(partners) do
			addSet(neighbors, person)
		end

		for neighbor, _ in pairs(neighbors) do
			if neighbor and not visited[neighbor] then
				visited[neighbor] = true
				table.insert(queue, neighbor)
			end
		end
	end

	local people = {}
	for name, _ in pairs(visited) do
		table.insert(people, name)
	end
	sorted(people)

	local lines = {}
	table.insert(lines, "'''Connected component for " .. getDisplayName(root) .. "'''")
	table.insert(lines, '* Total people found: ' .. tostring(#people))
	for _, person in ipairs(people) do
		table.insert(lines, '* ' .. makeLinkedName(person))
	end

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

function p.profile(frame)
	local args = frame.args
	local parentArgs = frame:getParent() and frame:getParent().args or {}
	local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])

	if not root then
		return 'Error: no root provided. Use root=Character Name'
	end

	local parents = getParents(root)
	local siblings = getSiblingGeneration(root)
	local partners = getPartners(root)
	local children = getChildren(root)

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

	local lines = {}
	table.insert(lines, '{| class="wikitable" style="width:100%; max-width:900px;"')
	table.insert(lines, '|-')
	table.insert(lines, '! colspan="2" | Family profile for ' .. getDisplayName(root))
	table.insert(lines, '|-')
	table.insert(lines, '! style="width:20%;" | Person')
	table.insert(lines, '| ' .. makeLinkedName(root))
	table.insert(lines, '|-')
	table.insert(lines, '! Parents')
	table.insert(lines, '| ' .. (#parents > 0 and linkList(parents) or '—'))
	table.insert(lines, '|-')
	table.insert(lines, '! Siblings')
	table.insert(lines, '| ' .. (#siblingList > 0 and linkList(siblingList) or '—'))
	table.insert(lines, '|-')
	table.insert(lines, '! Partners')
	table.insert(lines, '| ' .. (#partners > 0 and linkList(partners) or '—'))
	table.insert(lines, '|-')
	table.insert(lines, '! Children')
	table.insert(lines, '| ' .. (#children > 0 and linkList(children) or '—'))
	table.insert(lines, '|}')
	return table.concat(lines)
end

function p.tree(frame)
	local args = frame.args
	local parentArgs = frame:getParent() and frame:getParent().args or {}
	local root = trim(args.root or parentArgs.root or args[1] or parentArgs[1])

	if not root then
		return 'Error: no root provided. Use root=Character Name'
	end

	local parents = getParents(root)
	local grandparents = {}
	local gpSeen = {}

	for _, parentName in ipairs(parents) do
		local parentParents = getParents(parentName)
		for _, gp in ipairs(parentParents) do
			addUnique(grandparents, gpSeen, gp)
		end
	end
	sorted(grandparents)

	local grandparentGroups = buildCoupleGroups(grandparents)
	local parentGroups = buildCoupleGroups(parents)
	local branches = buildCoParentBranches(root)

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

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

	if #grandparentGroups > 0 and #parentGroups > 0 then
		table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
	end

	if #parentGroups > 0 then
		table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
		table.insert(html, makeCoupleRow(parentGroups))
		table.insert(html, '</div>')
	end

	if #parentGroups > 0 then
		table.insert(html, '<div style="width:2px;height:28px;background:#b8a79a;margin:0 auto;"></div>')
	end

	if #branches > 0 then
		table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
		table.insert(html, makeCoParentBranches(root, branches))
		table.insert(html, '</div>')
	else
		table.insert(html, '<div style="display:flex;justify-content:center;align-items:flex-start;width:100%;margin:22px 0;">')
		table.insert(html, '<div style="display:inline-flex;align-items:center;justify-content:center;">' .. makeCard(root) .. '</div>')
		table.insert(html, '</div>')
	end

	table.insert(html, '</div>')
	return table.concat(html)
end

return p