Files
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

1273 lines
56 KiB
PHP
Raw Permalink 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;
}
}
// ==================== 推导:把「只记了一头」的边补成双向 ====================
//
// ⚠️ 这一段是家谱"人少一半"的根因修复(用户 2026-10-02 报的 4 处全是它)。
//
// 本系统的设计是「只存相邻两代之间的边,其余**全部推导**」(见文件头)。
// 但家人手工录入时,只会填**最直接的那一条**:
// · 给几个孩子都填了「父亲 = 杨永全」,从没点过「母亲」
// → 孩子只有一位家长;母亲那边看不出自己有孩子(用户问题②④)
// · 填了孩子的父母两位,却忘了把这两位再标一次「夫妻」
// → 夫妻边缺失,两位家长各自孤零零(用户问题③)
// 这些**不是数据错,是数据本来就只记一头**,必须在这里补全;
// 否则下游(家谱/称呼/名册)会大面积少人。
//
// ⚠️ 只在**信息唯一**时推导,绝不猜:
// · 补夫妻:仅当两人**同时是同一个孩子的父母** ⇒ 认定夫妻;
// · 补家长:仅当那位家长**恰好只有一个配偶**时,才把该配偶补成另一位家长;
// 有多个配偶(再婚/数据混乱)时不猜,原样保留。
$addSpouse = static function (int $a, int $b) use (&$spouses): bool {
if ($a === $b || $a <= 0 || $b <= 0) { return false; }
$spouses[$a] ??= [];
$spouses[$b] ??= [];
$ch = false;
if (!in_array($b, $spouses[$a], true)) { $spouses[$a][] = $b; $ch = true; }
if (!in_array($a, $spouses[$b], true)) { $spouses[$b][] = $a; $ch = true; }
return $ch;
};
$addParent = static function (int $child, int $parent, string $role) use (&$parents, &$children): bool {
if ($child === $parent || $child <= 0 || $parent <= 0) { return false; }
if (isset($parents[$child][$parent])) { return false; }
$parents[$child][$parent] = $role;
$children[$parent][] = $child;
return true;
};
// ---- 推导 ①:同一个孩子的两位家长 ⇒ 他们是夫妻 ----
// ⚠️ 跳过「被显式解除过」的对:用户点了「解除配偶」,推导不能凭共同孩子把人配回来
// (否则表现为"解除了却还在")。名单存在 settings 的 kin_undone_pairs。
$undone = array_fill_keys(Relations::undonePairs($familyId), true);
$pairKey = static function (int $x, int $y): string {
return $x < $y ? "$x-$y" : "$y-$x";
};
foreach ($parents as $ps) {
if (count($ps) < 2) { continue; }
$ids = array_map('intval', array_keys($ps));
$n = count($ids);
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
if (isset($undone[$pairKey($ids[$i], $ids[$j])])) { continue; }
$addSpouse($ids[$i], $ids[$j]);
}
}
}
// ---- 推导 ②:孩子只有一位家长、而该家长只有一个配偶 ⇒ 配偶即另一位家长 ----
// 迭代到稳定:推导 ① 新加的夫妻边会让更多孩子可补(一眼看不出,跑两轮最稳)。
for ($round = 0; $round < 4; $round++) {
$added = 0;
foreach ($parents as $child => $ps) {
if (count($ps) >= 2) { continue; } // 已有两位家长,不再补
$known = (int)array_key_first($ps);
$role = (string)$ps[$known];
$mates = array_values(array_unique($spouses[$known] ?? []));
if (count($mates) !== 1) { continue; } // 有多个配偶 → 无法判定,不乱补
$other = (int)$mates[0];
$newRole = $role === 'father' ? 'mother' : 'father';
if ($addParent((int)$child, $other, $newRole)) { $added++; }
}
if ($added === 0) { break; }
}
foreach ($children as $k => $v) {
$children[$k] = array_values(array_unique(array_map('intval', $v)));
}
foreach ($spouses as $k => $v) {
$spouses[$k] = array_values(array_unique(array_map('intval', $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),判断「跟我有关系」时用。
*
* ⚠️ v14:这里**直接读图里的边**,不再绕 `parents()` 那对 father/mother 槽位。
* 绕槽位会**丢人**:`parents()` 是按角色往两个槽里填的,一旦数据里
* 同一个角色被记了两个人(典型:母亲那条边漏填 from_role,于是"父亲"这个
* 槽被母亲覆盖/挤掉),`parentIds()` 就只剩一个人 ——
* 下游的家谱图于是只剩一位家长,**母亲那边整条线消失**(正是用户报的那个
* 「母亲登录进去看不到自己的孩子」的另一种成因)。
*/
public static function parentIds(array $g, int $id): array
{
$out = [];
foreach (array_keys($g['parents'][$id] ?? []) as $pid) {
$out[] = (int)$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));
}
/**
* v15:**家谱图的完整数据出口**(以本人为中心,上一代 / 这一代 / 下一代全员)。
*
* 为什么重写(用户 2026-10-02 的原话):
* 「显示不齐全,应该显示本人的上一代,这一代,和下一代的所有人…
* 应该以本人为中心去搜索,只要有关系的,都检索出来进行展示」
* 「数据仍然不全,差很多」
*
* 实测(_fb_kin_cover_probe.py):第三方库 family-chart **只画了 20 人里的 9 人** ——
* 伯父/伯母/岳父母/嫂子/堂兄/堂嫂/舅子/侄子/堂侄/外甥一个都没画。
* 库只会画「树根的祖先链 + 后代 + 树根的兄弟姐妹」,**横向的亲戚与姻亲旁支
* 永远进不来**(换树根也救不了:换到最高祖先,"妻子的兄弟"照样不在树里)。
* 所以家谱不再依赖库的树算法,改由这里给出**完整的名单 + 代数 + 户**,
* 前端按「代分层、户并排」自己排版、自己画线。
*
* 算法(以本人为中心的两轮贯穿,带 $seen 防环):
* ① 从「我」出发:父母(-1 代)、子女(+1 代)、配偶(同代);
* ② 从**每一位配偶**出发:其父母与子女也要进来 —— 配偶的父母是姻亲(同 -1 代),
* 这样「妻子的兄弟」「妻子的侄子」这些姻亲旁支才有路子进来;
* ③ 血亲每往上一层,就把那层的**配偶们**也补进来(伯母/婶婶/姑父…);
* ④ 血亲每往下一层,同样补配偶,并**跨层补侄甥**(兄弟姐妹的子女 = 下一代);
* ⑤ 代数越界(超过 up/down 代)的人只到边界那一层为止,不再向外扩 ——
* 否则"有关系"会一路串到远亲,图会无限大。
*
* @return array{
* gen: array<int,int>, id => 相对代数(我 = 0)
* units: array<int,array>, 户(夫妻单元)列表,见下
* unit_of: array<int,int>, id => 户序号
* me: int
* }
*/
public static function familyMap(int $familyId, int $me, array $people, int $up = 2, int $down = 2): array
{
$g = self::graph($familyId);
if (!isset($people[$me])) {
return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me];
}
// ⚠️ $up / $down 是**代数标号的上下限**(clamp),不是遍历深度 ——
// 走是走遍整个连通图的。所以同一个人可能通过两条路算出不同代数,
// 下面取「离我最近」的那一档。
// ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ----
$gen = [$me => 0];
$seen = [$me => true];
$q = [$me];
// 卡片能容纳的代数有限,越界就不再往外走(但边界那一层的人照样收)
$clamp = static function (int $v) use ($up, $down): int {
return max(-$up, min($down, $v));
};
while ($q) {
$id = (int)array_shift($q);
$lv = (int)$gen[$id];
$push = static function (int $nid, int $nlv) use (&$gen, &$seen, &$q, $people, $clamp): void {
$nlv = $clamp($nlv);
if (!isset($people[$nid])) {
return; // 名册里没有这个人
}
if (!isset($seen[$nid])) {
$seen[$nid] = true;
$gen[$nid] = $nlv;
$q[] = $nid;
} elseif ($nlv > $gen[$nid]) {
// ⚠️ 同一人可能有两条路到达、算出不同代数(堂兄既是"伯父的孩子"、
// 也可能通过某条配偶边走到)。取**离我最近**的那一代,
// 否则他会跑到下一代的层里去。
$gen[$nid] = $nlv;
}
};
foreach (self::parentIds($g, $id) as $p) { $push((int)$p, $lv - 1); }
foreach (self::childIds($g, $id) as $c) { $push((int)$c, $lv + 1); }
// 配偶永远同代 —— 这一条让「配偶的父母/子女」也能顺着扩出去
foreach (self::spouseIds($g, $id) as $s) { $push((int)$s, $lv); }
}
// ---- ② 户(夫妻单元):一对互为配偶的两人合成一户 ----
// 只在「同一代 + 互为配偶」时成户;跨代的配偶边(脏数据)各自成户,不硬凑。
$units = [];
$unitOf = [];
$assigned = [];
foreach (array_keys($gen) as $id) {
$id = (int)$id;
if (isset($assigned[$id])) {
continue;
}
$assigned[$id] = true;
$partner = 0;
foreach (self::spouseIds($g, $id) as $s) {
$s = (int)$s;
if (isset($gen[$s]) && (int)$gen[$s] === (int)$gen[$id] && !isset($assigned[$s])) {
$partner = $s;
break;
}
}
if ($partner > 0) {
$assigned[$partner] = true;
}
$units[] = ['gen' => (int)$gen[$id], 'ids' => $partner > 0 ? [$id, $partner] : [$id]];
}
foreach ($units as $i => $u) {
foreach ($u['ids'] as $id) {
$unitOf[(int)$id] = $i;
}
}
return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me];
}
/**
* v24 用户第 12 条:谁的姻亲「没画在家谱上」。
*
* 家谱画的是「同一位祖先的血脉后代 + 各人配偶」,所以**配偶那一侧的
* 父母/兄弟姐妹**天然不在图内(洪博为的父母不会出现在以杨家为根的树上)。
* 用户要的是:这些人的卡片上给一个入口,点一下就以 TA 为中心重画一张,
* 把 TA 那侧的姻亲露出来,再给个「返回」回到原图。
*
* 判据:**这个人有父母,但父母不在"库实际画出来的那批人"里** →
* TA 是有独立亲缘家族的人,点一下就以 TA 为中心重画。
*
* ⚠️ 这里的关键是"实际画出来的" ≠ "我们遍历到的":
* familyMap 为了算称呼会走遍**整个连通图**(连配偶的父母都拉进来),
* 但 family-chart 只画「根的祖先链 + 根的后代 + 根的兄弟姐妹 + 这些人的配偶」——
* **配偶的父母不在画布上**。拿遍历集合当判据,就会得出"岳父母已经在家谱里了"
* 这种与眼睛看到的相反的结论(实测踩到)。
*
* @param array<int,true> $onTree 遍历到的人(用于确定根与限制范围)
* @return array<int, array{name:string, count:int}> 人 id => 提示文案
*/
public static function branchOuts(array $g, array $people, array $onTree): array
{
// 本图的根 = 辈分最高的那个人(与 kin/tree 的取法一致)
$rootId = 0;
foreach (array_keys($onTree) as $id) {
$id = (int)$id;
if (!isset($people[$id])) {
continue;
}
$hasParentOnTree = false;
foreach (self::parentIds($g, $id) as $p) {
if (isset($onTree[(int)$p])) {
$hasParentOnTree = true;
break;
}
}
if (!$hasParentOnTree) {
$rootId = $id;
break;
}
}
if ($rootId === 0) {
return [];
}
$drawn = self::drawnOf($g, $rootId, $onTree);
$out = [];
foreach (array_keys($drawn) as $id) {
$id = (int)$id;
if ($id === $rootId || !isset($people[$id])) {
continue;
}
$parents = self::parentIds($g, $id);
if (!$parents) {
continue; // 不知道父母的(根、待确认的占位人)没什么可展开
}
$outside = 0;
foreach ($parents as $p) {
if (!isset($drawn[(int)$p])) {
$outside++;
}
}
if ($outside === 0) {
continue; // 父母都在画布上,这一侧已经露出来了
}
$out[$id] = [
'name' => (string)($people[$id]['name'] ?? ''),
'count' => $outside,
];
}
return $out;
}
/**
* family-chart 实际会画出来的人(近似复刻它的选择规则)。
*
* 规则(按库的行为):
* ① 根的**祖先链**(一直往上);
* ② 根的**后代**(一直往下 —— 库在未设上限时不截断);
* ③ 根的**兄弟姐妹**(与根共享父母的人);
* ④ 以上所有人的**配偶**(夫妻并排是库的基本单位)。
*
* ⚠️ **配偶的父母不在其中** —— 这正是"姻亲看不到"的根源,也是本函数存在的理由。
*
* @param array<int,true> $inScope 只在遍历到的人里挑(脏数据不越界)
* @return array<int,true>
*/
private static function drawnOf(array $g, int $rootId, array $inScope): array
{
$drawn = [];
$mark = static function (int $id) use (&$drawn, $inScope): bool {
if (!isset($inScope[$id]) || isset($drawn[$id])) {
return false;
}
$drawn[$id] = true;
return true;
};
$mark($rootId);
// ① 祖先链
$cur = $rootId;
for ($i = 0; $i < 12; $i++) {
$next = 0;
foreach (self::parentIds($g, $cur) as $p) {
if (isset($inScope[(int)$p])) { $next = (int)$p; break; }
}
if ($next === 0) { break; }
$mark($next);
$cur = $next;
}
// ③ 根的兄弟姐妹(共享父母)
foreach (self::parentIds($g, $rootId) as $p) {
foreach (self::childIds($g, (int)$p) as $sib) {
$mark((int)$sib);
}
}
// ② 后代:从根出发逐层往下;④ 每加进一个人,配偶跟着进来
$queue = array_keys($drawn);
while ($queue) {
$id = (int)array_shift($queue);
foreach (self::spouseIds($g, $id) as $s) {
if ($mark((int)$s)) { $queue[] = (int)$s; }
}
foreach (self::childIds($g, $id) as $c) {
if ($mark((int)$c)) { $queue[] = (int)$c; }
}
}
// 收尾再来一轮配偶(新进来的孩子要带上他们的配偶)
foreach (array_keys($drawn) as $id) {
foreach (self::spouseIds($g, (int)$id) as $s) {
$mark((int)$s);
}
}
return $drawn;
}
/**
* v15:把「孩子」按**父母所在的户**归堆。
*
* 用户的核心要求(原话):「这对夫妻下面所有的子女,都应该从夫妻之间的这个横线出来,
* 而不是从这对夫妻的其中一个出来,这样可能会出现一个 bug,就是如果从一个出来,
* 例如从父亲出来,母亲登录进去之后竟然看不到自己的孩子,这是非常大的问题。」
*
* 所以这里**不以"某个人的 children"为准** —— 那正是"从父亲一个人出来"的根源
* (孩子只登记了父亲时,父亲有边、母亲没边,母亲登录就看不到孩子)。
* 改成:对每个孩子取其**父母们**,再看这些父母落在哪一户,把这条子女关系
* 挂到**那一户**上。于是:
* · 孩子登记了父母双方 → 挂到这对夫妻的户 → 从夫妻横线中点垂线;
* · 孩子只登记了一位父/母 → 也挂到那位父/母**所在的户**(该户若有配偶,
* 照样从两人的横线中点出来 —— 语义上"这孩子是我们俩的")。
*
* @param array<int,int> $unitOf id => 户序号
* @return array<int,int[]> 户序号 => [孩子 id, ...]
*/
public static function householdChildren(array $g, array $unitOf): array
{
$out = [];
foreach (array_keys($unitOf) as $child) {
$child = (int)$child;
$u = (int)$unitOf[$child];
// 只认「这位孩子的父母」,且父母必须在图上
$parentsHere = [];
foreach (self::parentIds($g, $child) as $p) {
$p = (int)$p;
if (isset($unitOf[$p]) && (int)$unitOf[$p] !== $u) {
// ⚠️ 父母的户不能是孩子自己所在的户(把配偶记成父母这类脏数据)
$parentsHere[] = $p;
}
}
if (!$parentsHere) {
continue; // 没有父母在图上 → 不是"某户的孩子"
}
// 同一个孩子可能被多位父母指向不同户(脏数据):取第一户,避免重复画线
$home = (int)$unitOf[$parentsHere[0]];
$out[$home][] = $child;
}
foreach ($out as $k => $v) {
$out[$k] = array_values(array_unique(array_map('intval', $v)));
}
return $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 ? '表兄弟' : '表姐妹') : '表亲';
}
// ⚠️ v24(用户 2026-10-02 第 1 条):这里原来兜底返回「亲属」。
// 后果是名册里**每个没有血缘关系的人**(同事、朋友、老师…)都被贴上「亲属」——
// 人家明明只是个联系人,叫"亲属"是错的,而且人一多满屏都是同一个词,
// 看不出任何信息。「亲属」这个词本身也没有定义:关系推导不出来就是没关系,
// 硬套一个称呼反而是编造。
// 现在改为**返回空串**:没关系就不显示标签。调用方一律用 empty() 判断。
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 → birthday → id」的兜底(写一次就会跟这里不一致),不如开个口子。
*/
public static function rankValueFor(array $people, int $id): string
{
return self::rankValue($people, $id);
}
/** 排序用的键:显式 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;
}
// ================= 家谱渲染所需的结构化数据 =================
/**
* v14:**以我为中心,把有关系的都检索出来**,只保留「上一代 / 这一代 / 下一代」。
*
* 为什么重写(用户 2026-10-01 第 5 条,原话「显示不齐全…应该以本人为中心去搜索,
* 只要有关系的,都检索出来进行展示」):
* 旧写法是「从我出发上下各 N 步」的**步数**限制,而真正要的是**代数**限制。
* 两者在「横向」关系上差很远:我的堂兄弟要先**上一步**到伯父、**再下一步**才到,
* 两步就耗尽了配额,于是「这一代」里只剩亲兄弟姐妹,堂表亲全不见了。
* 改成按代数筛选之后,横向绕多远都不算远 —— 只要落回这三代里就要。
*
* 三条边都要走(parent / child / spouse),缺一条就会漏人:
* · 只走上下 → 配偶、嫂子弟媳、姐夫妹夫全没了;
* · 不走配偶 → 连「我配偶的兄弟姐妹」都到不了(要先经过配偶这个人)。
*
* ⚠️ 用 BFS + $gen 防环:数据脏了(互为父母、自己给自己当父母)不会死循环。
* ⚠️ 只把「落在 [-up, +down] 里的人」入队 —— 超出三代的不展开,
* 但仍要允许**经过**上一代去够到同代的堂表亲(所以筛选的是代数,不是步数)。
*
* @return array<int,int> personId => 相对我的代数(-1 上一代 / 0 这一代 / +1 下一代)
*/
public static function focusMap(array $g, int $me, array $people, int $up = 1, int $down = 1): array
{
if (!isset($people[$me])) {
return [];
}
$gen = [$me => 0];
$queue = [$me];
while ($queue) {
$id = array_shift($queue);
$lv = (int)$gen[$id];
$add = static function (int $nid, int $nlv) use (&$gen, &$queue, $people, $up, $down): void {
if ($nlv < -$up || $nlv > $down) {
return; // 落在三代之外:不收、也不从它继续展开
}
if (!isset($people[$nid]) || isset($gen[$nid])) {
return; // 名册里没有 / 已经有更靠前的走法
}
$gen[$nid] = $nlv;
$queue[] = $nid;
};
// 配偶永远同代
foreach (self::spouseIds($g, $id) as $sp) {
$add((int)$sp, $lv);
}
foreach (self::parentIds($g, $id) as $pid) {
$add((int)$pid, $lv - 1);
}
foreach (self::childIds($g, $id) as $cid) {
$add((int)$cid, $lv + 1);
}
}
return $gen;
}
/**
* v14:**子女必须挂在「夫妻」上,而不是挂在其中一个人身上**。
*
* 用户点名的重大 bug(原话):「如果从一个出来,例如从父亲出来,母亲登录进去之后
* 竟然看不到自己的孩子」。根因不是显示,是**数据**:只要某个孩子的 parents 只写了
* 父亲一个人,那张图对母亲来说就是"别人的孩子"。
*
* 所以这里做一次**对称补齐**:父母双方互为配偶时,孩子同时挂到两个人身上,
* 两个人的 children 里也都有这个孩子。库(family-chart)在「孩子有两位父母、
* 且这两位互为配偶」时,会把连线从**夫妻之间那条横线**的中点拉下来 ——
* 这正是用户要的效果,也顺手解决了"母亲看不到孩子"。
*
* ⚠️ 只补**真实存在的**父/母(relations 里有的边),绝不凭空造人。
*
* @param array<int,int> $ids 图上会出现的人(过滤悬空引用用)
* @return array{parents:array<int,int[]>,children:array<int,int[]>,spouses:array<int,int[]>}
*/
public static function normalizeLinks(array $g, array $ids): array
{
$onTree = array_fill_keys(array_map('intval', array_values($ids)), true);
$parents = [];
$children = [];
$spouses = [];
foreach (array_keys($onTree) as $id) {
$id = (int)$id;
// ---- 配偶 ----
$sp = [];
foreach (self::spouseIds($g, $id) as $s) {
$s = (int)$s;
if (isset($onTree[$s]) && !in_array($s, $sp, true)) {
$sp[] = $s;
}
}
$spouses[$id] = $sp;
// ---- 父母 ----
// ⚠️ 一律取**图里所有的 parent 边**,绝不只取 father/mother 两个槽位 ——
// 槽位会丢人(见 parentIds 的注释)。
$pa = [];
foreach (self::parentIds($g, $id) as $p) {
$p = (int)$p;
if (isset($onTree[$p]) && !in_array($p, $pa, true)) {
$pa[] = $p;
}
}
$parents[$id] = $pa;
}
// ---- 子女:由 parents 反推,天然对称 ----
foreach ($parents as $child => $pa) {
foreach ($pa as $p) {
$children[$p][] = (int)$child;
}
}
foreach ($children as $k => $v) {
$v = array_values(array_unique(array_map('intval', $v)));
usort($v, fn($a, $b) => $a <=> $b);
$children[$k] = $v;
}
return ['parents' => $parents, 'children' => $children, 'spouses' => $spouses];
}
/**
* 以 $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;
}
}