Files
FamilyBook/app/Kinship.php
T

1273 lines
56 KiB
PHP
Raw Normal View History

<?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;
}
}