Files
FamilyBook/app/Kinship.php
T
laoyang e9b3b8666b v11: 家谱按户排布(夫妻并排/长幼排序/父母括号/姻亲);观影设置下拉收拢影院·搜索·统计·年度报告;观影人头像
家谱(用户第 2 点,重写模板与 Kinship::tree):
- 同代聚成「夫妻单元」(户):血亲当主位、配偶并排在其右,兄弟_姐妹按长幼(长子左)
- 血亲可达性判「姻亲」:只走 parent/child 边从「我」可达者为本家血亲,妻子的父母
  走不到 → 标「姻亲」并排到本代末尾(旧判据「上无父母且下无子女」对岳父母恒为假,是真 bug)
- 父母括号连线:横杆盖住父母列到其子女各列,竖线落父母列中心、每子女列一道下线
- 称呼按长幼定死:姐夫/妹夫、嫂子/弟媳、大/小舅子、大/小姨子、大伯子/小叔子、
  大姑子/小姑子、侄子/侄女、外甥/外甥女、堂/表
- 列宽地板 minmax(min-content,1fr):原 minmax(0,1fr) 在窄屏+户数多时把列压到卡片
  (96px)以下 → 同代卡片互相重叠;.tnode 加 flex:0 0 auto 防被 flex 压扁

观影(用户第 3~5 点):
- 顶栏删「影院」入口(影院是观影模块的子功能);观影列表右侧加「⚙ 设置」下拉,
  内含 影院管理 / 高级搜索 / 统计 / 年度报告(后三者从 MovieLog 迁入,
  app/MovieStats.php + 三个只读路由,MySQL 方言;图表全用纯 CSS,不引 CDN)
- 评价区删除「我」的长条徽标(被 flex column stretch 拉成整行宽的丑色带)
- 观影人弹窗列表与已选 chips 带头像;syncChips 点名取 .att-name/.att-av
  (旧写法取「第一个 span」,v11 起那是隐藏占位,chip 上会没名字)

验证:e2e 432/0、桌面几何 268/0、移动几何 464/0、交互几何 97/0、
静态空跑 199/0、反向验证 v11 十条 10抓/0漏。本轮无 DB 变更(Schema 仍 v10)。
移动几何抓出两个真溢出并已修:高级搜索页 select 长选项撑破 .field、
原生 date 输入撑破 .date-range(360/375px)。
2026-10-01 12:11:43 +08:00

765 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 亲属关系:**只存相邻两代之间的边,其余全部推导**。
*
* 设计(这是整个家谱功能的地基,改动前请先读懂):
* - 库里只有一张 relations 表,两种边:
* parent : from 是 to 的父或母(from_role 记 father/mother)
* spouse : from 与 to 是配偶(对称,只存一条)
* - 不做「爷爷」「岳母」这种一次性称呼的存储。要知道奶奶是谁,
* 就是「我的父亲」再「我父亲的母亲」——两级边串起来。这样:
* ① 关系只有两个方向要维护,不会出现「我记了他是我爸,他那边没记我」
* ② 同一条边在不同人眼里自动呈现不同称呼
* (我的「父亲」= 我孩子的「爷爷」= 我爱人的「公公」)
* ③ 虚拟人物(is_placeholder)可以参与关系网,等弄清真实身份再改名即可
* - 兄弟姐妹**不存边**:他们共享同一对父母,由「父母的子女」反查得出。
* 排行由 persons.birth_rank 决定,用来显示长子/次女/三弟。
*/
declare(strict_types=1);
namespace App;
final class Kinship
{
/** 一次把全家的边读进内存 —— 几十人的家庭,比反复查库快得多,也让推导逻辑干净 */
public static function graph(int $familyId): array
{
$parents = []; // childId => [parentId => 'father'|'mother']
$children = []; // parentId => [childId, ...]
$spouses = []; // id => [spouseId, ...]
$st = Database::get()->prepare(
'SELECT from_id, to_id, kind, from_role FROM relations WHERE family_id = ?'
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$f = (int)$r['from_id'];
$t = (int)$r['to_id'];
if ($r['kind'] === 'parent') {
$role = $r['from_role'] === 'mother' ? 'mother' : 'father';
$parents[$t][$f] = $role;
$children[$f][] = $t;
} else {
$spouses[$f][] = $t;
$spouses[$t][] = $f;
}
}
foreach ($children as $k => $v) {
$children[$k] = array_values(array_unique($v));
}
return ['parents' => $parents, 'children' => $children, 'spouses' => $spouses];
}
/** 某人的父母(father / mother 两个键,可能缺) */
public static function parents(array $g, int $id): array
{
$out = ['father' => null, 'mother' => null];
foreach ($g['parents'][$id] ?? [] as $pid => $role) {
$out[$role] = (int)$pid;
}
return $out;
}
/** 父母的 id 列表(不含 null),判断「跟我有关系」时用 */
public static function parentIds(array $g, int $id): array
{
$out = [];
foreach (self::parents($g, $id) as $pid) {
if ($pid !== null) {
$out[] = $pid;
}
}
return $out;
}
public static function spouseIds(array $g, int $id): array
{
return $g['spouses'][$id] ?? [];
}
/** 兄弟姐妹:与「我」共享至少一位父母的人(不含自己) */
public static function siblingIds(array $g, int $id): array
{
$sib = [];
foreach ($g['parents'][$id] ?? [] as $pid => $_) {
foreach ($g['children'][$pid] ?? [] as $c) {
if ((int)$c !== $id) {
$sib[(int)$c] = true;
}
}
}
return array_keys($sib);
}
/** 子女 */
public static function childIds(array $g, int $id): array
{
return array_map('intval', $g['children'][$id] ?? []);
}
/**
* 「我」的血亲集合:只走 parent / child 边能到达的人(**不含配偶边**)。
*
* 用途:家谱页判断某一户是不是「姻亲」。定义就取本家语义 ——
* 我妻子的父母只走血缘边从「我」出发是走不到的,所以是姻亲;
* 我妻子本人也一样走不到(她是配偶,不是血亲)。
*
* ⚠️ 用 BFS 并带上 $seen 防环:数据脏了(互为父母、自己给自己当父母)时
* 不会死循环 —— 这类脏数据在手工录入姓名和关系时真的会进来。
*/
private static function bloodRelatives(array $g, int $me): array
{
$out = [$me => true];
$q = [$me];
while ($q) {
$id = array_shift($q);
$nbrs = array_merge(self::parentIds($g, $id), self::childIds($g, $id));
foreach ($nbrs as $n) {
$n = (int)$n;
if (!isset($out[$n])) {
$out[$n] = true;
$q[] = $n;
}
}
}
return array_map('intval', array_keys($out));
}
/**
* 祖父母那一级:往上走一步再取父母。
* 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。
*/
public static function grandparents(array $g, int $id): array
{
$out = ['paternal' => ['father' => null, 'mother' => null],
'maternal' => ['father' => null, 'mother' => null]];
$p = self::parents($g, $id);
if ($p['father']) {
$out['paternal'] = self::parents($g, $p['father']);
}
if ($p['mother']) {
$out['maternal'] = self::parents($g, $p['mother']);
}
return $out;
}
/**
* 我该怎么称呼对方 —— 整个系统的「以我为中心」就落在这个函数上。
*
* 推导顺序:自己 → 配偶 → 父母/子女 → 兄弟姐妹 → 父母的兄弟姐妹 →
* 配偶的父母 → 祖父母/孙辈 → 姻亲 → 兜底「亲属」
*/
public static function label(array $g, int $me, int $other, array $people): string
{
if ($me === $other) {
return '我';
}
$gender = strtolower((string)($people[$other]['gender'] ?? 'x'));
$isMale = $gender === 'm';
$isKnown = $gender === 'm' || $gender === 'f';
// ---- 配偶 ----
if (in_array($other, self::spouseIds($g, $me), true)) {
return self::spouseLabel($g, $me, $isMale);
}
// ---- 直系:父母 / 子女 ----
$myParents = self::parents($g, $me);
foreach (['father', 'mother'] as $role) {
if ($myParents[$role] === $other) {
return $role === 'father' ? '父亲' : '母亲';
}
}
if (in_array($other, self::childIds($g, $me), true)) {
return self::rankLabel($g, $people, $me, $other, $isMale, $isKnown);
}
// ---- 兄弟姐妹(含排行)----
foreach (self::siblingIds($g, $me) as $sib) {
if ($sib === $other) {
// compareRank(我, 他) < 0 表示他的排行键更小 —— 也就是他比我年长。
// 方向很容易写反:这里比的是「他 vs 我」,不是「我 vs 他」。
$heIsOlder = self::compareRank($people, $other, $me) < 0;
if ($isKnown) {
if ($isMale) {
return $heIsOlder ? '哥哥' : '弟弟';
}
return $heIsOlder ? '姐姐' : '妹妹';
}
return $heIsOlder ? '哥哥' : '弟弟';
}
}
// ---- 父母的兄弟姐妹:伯/叔/姑/舅/姨 ----
$uncle = self::uncleLabel($g, $people, $me, $other);
if ($uncle !== null) {
return $uncle;
}
// ---- 配偶的父母:公公婆婆 / 岳父岳母 ----
foreach (self::spouseIds($g, $me) as $sp) {
$spParents = self::parents($g, $sp);
foreach (['father', 'mother'] as $role) {
if ($spParents[$role] === $other) {
$iAmMale = strtolower((string)($people[$me]['gender'] ?? 'x')) === 'm';
if ($role === 'father') {
return $iAmMale ? '岳父' : '公公';
}
return $iAmMale ? '岳母' : '婆婆';
}
}
}
// ---- 祖父母 / 外祖父母 ----
$gp = self::grandparents($g, $me);
foreach (['paternal' => '爷爷', 'maternal' => '外公'] as $side => $maleWord) {
$femaleWord = $side === 'paternal' ? '奶奶' : '外婆';
if (($gp[$side]['father'] ?? null) === $other) {
return $maleWord;
}
if (($gp[$side]['mother'] ?? null) === $other) {
return $femaleWord;
}
}
// ---- 孙辈 / 曾孙辈 ----
// 只往下走两级:孙辈(子女的子女)与曾孙辈(子女的孙辈)。
// 再往下就不必细分了,统一「后代」。
foreach (self::childIds($g, $me) as $child) {
foreach (self::childIds($g, $child) as $gc) {
if ($gc === $other) {
if (!$isKnown) {
return '孙辈';
}
return $isMale ? '孙子' : '孙女';
}
if (in_array($other, self::childIds($g, $gc), true)) {
if (!$isKnown) {
return '后代';
}
return $isMale ? '曾孙' : '曾孙女';
}
}
}
// ---- 兄弟姐妹的子女:侄子/侄女(兄弟的孩子)· 外甥/外甥女(姐妹的孩子)----
//
// v11 新增。这里原来会一路落到兜底的「亲属」—— 家谱第二层里,
// 我兄弟姐妹的孩子就混在「亲属」里,用户明确要求「外甥、侄子按关系完全明确下来」。
// 判据是**孩子父母(我的兄弟姐妹)的性别**,不是孩子的性别:
// 兄弟的孩子 = 侄子/侄女;姐妹的孩子 = 外甥/外甥女。
// 与排行无关(这里不分长幼)。
foreach (self::siblingIds($g, $me) as $sib) {
if (!in_array($other, self::childIds($g, $sib), true)) {
continue;
}
$sibGender = strtolower((string)($people[$sib]['gender'] ?? 'x'));
if ($sibGender === 'm') {
if (!$isKnown) {
return '侄辈';
}
return $isMale ? '侄子' : '侄女';
}
if ($sibGender === 'f') {
if (!$isKnown) {
return '外甥辈';
}
return $isMale ? '外甥' : '外甥女';
}
return '侄辈'; // 连兄弟姐妹的性别都没填:不瞎猜,给个中性的
}
// ---- 姻亲①:配偶的兄弟姐妹 ----
//
// v11 起**按长幼定死**,不再输出「大舅子/小舅子」这种斜杠并列(用户明确要求)。
// 长幼比较的对象是**我的配偶**(不是我自己):比配偶大 = 大X子/大X姨…
// 用哪一套词,取决于「我随谁叫」:
// · 我(男)随妻子叫 → 她的兄弟 大/小舅子、她的姐妹 大/小姨子
// · 我(女)随丈夫叫 → 他的兄弟 大/小叔子、他的姐妹 大/小姑子
// 我没填性别时退而看配偶性别推断(异性夫妻的常见情形);两个都不知道才给「姻亲」。
$myGender = strtolower((string)($people[$me]['gender'] ?? 'x'));
foreach (self::spouseIds($g, $me) as $sp) {
if (!in_array($other, self::siblingIds($g, $sp), true)) {
continue;
}
if (!$isKnown) {
return '姻亲';
}
$spGender = strtolower((string)($people[$sp]['gender'] ?? 'x'));
$older = self::compareRank($people, $other, $sp) < 0; // 比我的配偶年长
$byWife = ($myGender === 'm') || ($myGender === 'x' && $spGender === 'f');
$byHusband= ($myGender === 'f') || ($myGender === 'x' && $spGender === 'm');
if ($byWife) {
return $isMale ? ($older ? '大舅子' : '小舅子') : ($older ? '大姨子' : '小姨子');
}
if ($byHusband) {
return $isMale ? ($older ? '大伯子' : '小叔子') : ($older ? '大姑子' : '小姑子');
}
return '姻亲';
}
// ---- 姻亲②:兄弟姐妹的配偶(姐夫/妹夫 · 嫂子/弟媳)----
//
// v11 起按**我那个兄弟姐妹相对我的长幼**定死(用户原话:「既然知道了每一代的长幼顺序,
// 那么谁是姐姐,谁是妹妹是很明确的事情,是妹夫就是妹夫,是姐夫就是姐夫」)。
foreach (self::siblingIds($g, $me) as $sib) {
if (!in_array($other, self::spouseIds($g, $sib), true)) {
continue;
}
if (!$isKnown) {
return '姻亲';
}
$sibOlder = self::compareRank($people, $sib, $me) < 0; // 我的这个兄弟姐妹比我年长
if ($isMale) {
return $sibOlder ? '姐夫' : '妹夫';
}
return $sibOlder ? '嫂子' : '弟媳';
}
// ---- 堂亲 / 表亲:父母的兄弟姐妹的子女 ----
// v11 起把「堂」与「表」分开 —— 旧写法一律「表亲」,把父亲兄弟的孩子
// (同姓同宗,是**堂**兄弟姐妹)说成表亲,辈分上没错但宗族关系是错的。
foreach (self::parentSiblings($g, $me) as $ps) {
if (!in_array($other, self::childIds($g, $ps), true)) {
continue;
}
$psGender = strtolower((string)($people[$ps]['gender'] ?? 'x'));
// 只有「父亲这一侧的兄弟」的孩子才是堂亲
$isPaternalUncle = $psGender === 'm' && in_array($ps, self::siblingIds($g, $myParents['father'] ?? 0), true);
if ($isPaternalUncle) {
return $isKnown ? ($isMale ? '堂兄弟' : '堂姐妹') : '堂亲';
}
return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲';
}
return '亲属';
}
/** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */
private static function spouseLabel(array $g, int $me, bool $otherIsMale): string
{
if ($otherIsMale) {
return '丈夫';
}
return '妻子';
}
/**
* 子女的排行称呼:长子/次子/三子…,长女/次女/三女…
* 优先用 persons.birth_rank;没填就按生日或 id 推。
*
* ⚠️ $isMale 必须由调用方按 gender 传入 —— 早期这里写死成「子」,
* 结果女儿一律显示成「次子」,是很容易漏的一类 bug。
* 性别未知时退化成中性的「孩子」,不要瞎猜成儿子。
*/
private static function rankLabel(array $g, array $people, int $parent, int $child, bool $isMale, bool $known): string
{
$base = $known ? ($isMale ? '子' : '女') : '孩子';
$sibs = self::childIds($g, $parent);
if (count($sibs) <= 1) {
return $known ? ($isMale ? '儿子' : '女儿') : '孩子';
}
$rank = self::rankOf($people, $sibs, $child);
$prefix = ['', '长', '次', '三', '四', '五', '六', '七', '八', '九'][$rank] ?? ($rank . '');
return $prefix === '' ? $base : $prefix . $base;
}
/** 我在兄弟姐妹里排第几(1 起) */
private static function rankOf(array $people, array $sibs, int $id): int
{
$sorted = $sibs;
usort($sorted, function ($a, $b) use ($people) {
$ra = self::rankValue($people, $a);
$rb = self::rankValue($people, $b);
return $ra <=> $rb;
});
$pos = array_search($id, $sorted, true);
return $pos === false ? 1 : $pos + 1;
}
/** 排序用的键:显式 birth_rank 优先,否则用生日,再否则用 id */
private static function rankValue(array $people, int $id): string
{
$p = $people[$id] ?? [];
if (!empty($p['birth_rank'])) {
return sprintf('%06d', (int)$p['birth_rank']);
}
if (!empty($p['birthday'])) {
return '5' . (string)$p['birthday'];
}
return '9' . sprintf('%09d', $id);
}
/** 谁是哥哥谁是弟弟:比较排行键 */
private static function compareRank(array $people, int $a, int $b): int
{
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
}
/** 父母的兄弟姐妹(伯父/叔叔/姑姑/舅舅/姨妈) */
private static function uncleLabel(array $g, array $people, int $me, int $other): ?string
{
$sibs = [];
$myParents = self::parents($g, $me);
$paternal = $myParents['father'] !== null;
foreach (['father' => true, 'mother' => false] as $role => $isPat) {
$pid = $myParents[$role];
if ($pid === null) {
continue;
}
foreach (self::siblingIds($g, $pid) as $sib) {
$sibs[$sib] = ['side' => $isPat ? 'paternal' : 'maternal', 'parent' => $pid];
}
}
if (!isset($sibs[$other])) {
return null;
}
$side = $sibs[$other]['side'];
$parent = $sibs[$other]['parent'];
$gender = strtolower((string)($people[$other]['gender'] ?? 'x'));
$isMale = $gender === 'm';
$older = self::compareRank($people, $other, $parent) < 0; // 比我的父母年长
if ($side === 'paternal') {
if ($isMale) {
return $older ? '伯父' : '叔叔';
}
return $gender === 'f' ? '姑姑' : '姑姑';
}
if ($isMale) {
return '舅舅';
}
return $gender === 'f' ? '姨妈' : '姨妈';
}
/** 父母的兄弟姐妹(不含配偶那边的) */
private static function parentSiblings(array $g, int $me): array
{
$out = [];
foreach (self::parents($g, $me) as $pid) {
if ($pid === null) {
continue;
}
foreach (self::siblingIds($g, $pid) as $sib) {
$out[] = $sib;
}
}
return $out;
}
// ================= 家谱渲染所需的结构化数据 =================
/**
* 以 $me 为中心,向上向下各展开若干代,产出可渲染的家谱树。
*
* 返回结构:
* [
* 'generations' => [ -2 => [id,...], -1 => [...], 0 => [...], 1 => [...] ],
* —— 同代的人,**夫妻相邻**(旧版模板 people/show 的迷你家谱仍按它渲染)
* 'units' => [ level => [ 夫妻单元, ... ] ],
* —— v11 家谱页按「夫妻单元」渲染。单元 = 这一代里的一户:
* ids[0] 是主位(血亲),ids[1] 是配偶,配偶排在家谱上主位的右手边。
* 单元带 parent_key —— 上一代哪个单元是它的父母,
* 家谱页据此画「谁是谁的父母与孩子」的括号连线。
* 'unit_names' => [ 单元key => ['head'=>名, 'spouse'=>名|null] ],连线标题用
* 'nodes' => [ id => ['id','name','label','rel','unit','is_head',...] ]
* 'gen' => [ id => 相对代数 ]
* ]
*/
public static function tree(array $g, int $me, array $people, int $up = 3, int $down = 3): array
{
$gen = []; // id => 相对我的代数(我 = 0,父母 = -1,子女 = +1)
$seen = [];
$queue = [[$me, 0]];
$seen[$me] = true;
while ($queue) {
[$id, $level] = array_shift($queue);
// 配偶永远同代
foreach (self::spouseIds($g, $id) as $sp) {
if (!isset($seen[$sp])) {
$seen[$sp] = true;
$gen[$sp] = $level;
$queue[] = [$sp, $level];
}
}
// 往上
if ($level > -$up) {
foreach (self::parents($g, $id) as $pid) {
if ($pid !== null && !isset($seen[$pid])) {
$seen[$pid] = true;
$gen[$pid] = $level - 1;
$queue[] = [$pid, $level - 1];
}
}
}
// 往下
if ($level < $down) {
foreach (self::childIds($g, $id) as $cid) {
if (!isset($seen[$cid])) {
$seen[$cid] = true;
$gen[$cid] = $level + 1;
$queue[] = [$cid, $level + 1];
}
}
}
}
$gen[$me] = 0;
// 按代数分堆(只留名册里真的有的人)
$byGen = [];
foreach ($gen as $id => $level) {
if (!isset($people[$id])) {
continue;
}
$byGen[$level][] = (int)$id;
}
ksort($byGen);
// ---- 逐代建「夫妻单元」→ 按长幼排序 → 记录单元位置,供下一代定位父母 ----
$unitsByLevel = [];
$unitOrder = []; // 单元 key => 该代内的下标(第几个单元,从左到右)
$nodeUnit = []; // person id => 单元 key
foreach (array_keys($byGen) as $level) {
$units = self::buildUnits($g, $people, $byGen[$level], $gen);
foreach ($units as &$u) {
$u['key'] = $level . ':' . $u['head'];
foreach ($u['ids'] as $m) {
$nodeUnit[$m] = $u['key'];
}
// 上一代里哪一户是它的父母(优先父、其次母)—— 连线挂到这一户上
$u['parent_key'] = self::bracketParent($g, $gen, $u['head'], $nodeUnit);
$u['parent_idx'] = $u['parent_key'] !== null
? ($unitOrder[$u['parent_key']] ?? PHP_INT_MAX) : -1;
}
unset($u);
// 排序:先按「父母那一户从左到右的位置」(同一对父母的孩子会挨在一起,
// 家谱页才能用一个括号把它们连到父母那一户),再按长幼(长子左、次子右)。
usort($units, function ($a, $b) use ($people) {
if ($a['parent_idx'] !== $b['parent_idx']) {
return $a['parent_idx'] <=> $b['parent_idx'];
}
$c = strcmp(self::rankValue($people, $a['head']), self::rankValue($people, $b['head']));
return $c !== 0 ? $c : ($a['head'] <=> $b['head']);
});
foreach ($units as $i => $u) {
$unitOrder[$u['key']] = $i;
}
$unitsByLevel[$level] = $units;
}
// ---- 谁是「姻亲」(配偶那边漂在树上的旁支)----
//
// 判据:这一户**不是我的血亲**。血亲 = 只走 parent/child 边能从「我」走到的人
// (不含配偶边)—— 这正是「本家」的定义,也和模板图例那句话一一对应。
//
// ⚠️ 这里原来用的是「上无父母 且 下无子女单元」,对**我妻子的父母**恒为假:
// 岳父母在我的树里当然有子女(我妻子就在树上),于是永远标不上「姻亲」,
// 而模板注释里点名的例子恰好就是「我妻子的父母」。实测(_fb_probe_kin_units.py)
// 那一户真的没标、还排在血亲左边。改成血亲可达性之后语义才对得上。
$blood = self::bloodRelatives($g, $me);
foreach ($unitsByLevel as $level => &$units) {
foreach ($units as &$u) {
$u['inlaw'] = !in_array((int)$u['head'], $blood, true)
&& !($u['spouse'] !== null && in_array((int)$u['spouse'], $blood, true));
}
unset($u);
// 姻亲排到本代**末尾**:血亲从左到右是按长幼的,姻亲插在中间会把
// 「长子/次子」的读数打乱(岳父母排到我父亲左边,看着像我家长辈)。
// 用两趟分区而不是 asort —— asort 在键相同(都是 false)时会打乱相对顺序,
// 而这里血亲的相对顺序(即长幼)是必须原样保留的。
$bloodU = [];
$inlawU = [];
foreach ($units as $u) {
if ($u['inlaw']) {
$inlawU[] = $u;
} else {
$bloodU[] = $u;
}
}
$units = array_merge($bloodU, $inlawU);
foreach ($units as $i => $u) {
$unitOrder[$u['key']] = $i;
}
}
unset($units);
// ---- 摊平成渲染用的数据 ----
$generations = [];
$nodes = [];
$unitNames = [];
foreach ($unitsByLevel as $level => $units) {
$generations[$level] = [];
foreach ($units as $u) {
$hn = (string)($people[$u['head']]['name'] ?? '');
$sn = $u['spouse'] !== null ? (string)($people[$u['spouse']]['name'] ?? '') : null;
$unitNames[$u['key']] = ['head' => $hn, 'spouse' => $sn];
foreach ($u['ids'] as $k => $id) {
$generations[$level][] = $id;
$p = $people[$id];
$nodes[$id] = [
'id' => (int)$id,
'name' => (string)$p['name'],
'label' => self::label($g, $me, (int)$id, $people),
'rel' => self::relationWord($g, $me, (int)$id),
'avatar' => (string)($p['avatar'] ?? ''),
'gender' => (string)($p['gender'] ?? 'x'),
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'unit' => $u['key'],
'is_head' => $k === 0,
'person' => $p,
];
}
}
}
return [
'generations' => $generations,
'units' => $unitsByLevel,
'unit_names' => $unitNames,
'nodes' => $nodes,
'gen' => $gen,
];
}
/**
* 把同一代的人聚成「夫妻单元」:夫妻并排(男左女右),单身自成单元。
*
* 单元的 ids[0] 是**主位** —— 优先给「在树里往上还有父母的那一个」(血亲),
* 因为连线是从主位往上接到父母那一户的。都/都不连上一代时按男左女右;
* 同性或性别未知就退而让排行靠前的当主位。
*
* @param int[] $ids 同一代的人
* @return array<int,array{ids:int[],head:int,spouse:?int}>
*/
private static function buildUnits(array $g, array $people, array $ids, array $gen): array
{
$ids = array_values(array_unique(array_map('intval', $ids)));
$inGen = array_fill_keys($ids, true);
$used = [];
$units = [];
$hasUp = static function (int $id) use ($g, $gen): bool {
foreach (self::parentIds($g, $id) as $p) {
if (isset($gen[$p]) && (int)$gen[$p] === (int)$gen[$id] - 1) {
return true;
}
}
return false;
};
$isMale = static function (int $id) use ($people): bool {
return strtolower((string)($people[$id]['gender'] ?? 'x')) === 'm';
};
foreach ($ids as $id) {
if (isset($used[$id])) {
continue;
}
$used[$id] = true;
$spouse = null;
foreach (self::spouseIds($g, $id) as $sp) {
$sp = (int)$sp;
if (isset($inGen[$sp]) && !isset($used[$sp])) {
$spouse = $sp;
break;
}
}
$head = $id;
$tail = null;
if ($spouse !== null) {
$used[$spouse] = true;
$tail = $spouse;
$idUp = $hasUp($id);
$spUp = $hasUp($spouse);
if ($spUp && !$idUp) {
$head = $spouse;
$tail = $id;
} elseif ($idUp === $spUp) {
if ($isMale($spouse) !== $isMale($id)) {
if ($isMale($spouse)) { // 男左女右
$head = $spouse;
$tail = $id;
}
} elseif (self::compareRank($people, $spouse, $id) < 0) {
$head = $spouse; // 同性/未知:排行靠前的当主位
$tail = $id;
}
}
}
$unitIds = [$head];
if ($tail !== null) {
$unitIds[] = $tail;
}
$units[] = ['ids' => $unitIds, 'head' => $head, 'spouse' => $tail];
}
return $units;
}
/** 这个人的父母落在上一代的哪个单元里(优先父、其次母);没有就 null */
private static function bracketParent(array $g, array $gen, int $head, array $nodeUnit): ?string
{
$level = (int)($gen[$head] ?? 0) - 1;
foreach (self::parents($g, $head) as $pid) {
if ($pid !== null
&& isset($nodeUnit[$pid])
&& (int)($gen[$pid] ?? 999) === $level) {
return $nodeUnit[$pid];
}
}
return null;
}
/** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */
public static function relationWord(array $g, int $me, int $other): string
{
if ($me === $other) {
return 'self';
}
if (in_array($other, self::spouseIds($g, $me), true)) {
return 'spouse';
}
if (self::parents($g, $me)['father'] === $other || self::parents($g, $me)['mother'] === $other) {
return 'parent';
}
if (in_array($other, self::childIds($g, $me), true)) {
return 'child';
}
if (in_array($other, self::siblingIds($g, $me), true)) {
return 'sibling';
}
foreach (self::childIds($g, $me) as $c) {
foreach (self::childIds($g, $c) as $gc) {
if ($gc === $other || in_array($other, self::childIds($g, $gc), true)) {
return 'grandchild';
}
}
}
foreach (self::grandparents($g, $me) as $side) {
if ($side['father'] === $other || $side['mother'] === $other) {
return 'grandparent';
}
}
return 'kin';
}
/** 把 id 列表变成 名字 => 称谓 的列表,供页面直接渲染 */
public static function describe(array $g, int $me, array $ids, array $people): array
{
$out = [];
foreach ($ids as $id) {
if (isset($people[$id])) {
$out[] = [
'id' => (int)$id,
'name' => (string)$people[$id]['name'],
'label' => self::label($g, $me, (int)$id, $people),
];
}
}
return $out;
}
}