Files
FamilyBook/app/Kinship.php
T
laoyang 80f5c4f0fc v15: 家谱重写 —— 以我为中心检索全体亲属 + 自己排版画线
用户 2026-10-02 的反馈(原话):
  「显示不齐全,应该显示本人的上一代,这一代,和下一代的所有人…以本人为中心去搜索,
    只要有关系的,都检索出来进行展示」
  「夫妻之间有一条横线…这对夫妻下面所有的子女,都应该从夫妻之间的这个横线出来,
    而不是从这对夫妻的其中一个出来,这样可能会出现一个 bug,就是如果从一个出来,
    例如从父亲出来,母亲登录进去竟然看不到自己的孩子」
  「数据仍然不全,差很多」

根因(实测,不是猜):第三方库 family-chart **只画了 20 人里的 9 人**。
  _tooling/_fb_kin_cover_probe.py 的硬数据:
    画出的:祖父/祖母/父亲/母亲/我/妻子/哥哥/儿子/女儿(9)
    没画的:伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥(11)
  库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
  **横向亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来** ——
  换树根也救不了(换到最高祖先,"妻子的兄弟"照样不在树里)。
  「母亲看不到孩子」还有第二层原因:孩子只登记了父亲时,母亲那一侧根本没有边。

改法(不再依赖库的树算法):
  · app/Kinship::familyMap() —— 以「我」为中心的两轮贯穿检索,带 seen 防环:
      父母(-1 代)/ 子女(+1 代)/ 配偶(同代)/ 每一层的配偶
      / 配偶的父母与子女(姻亲旁支由此进来)/ 兄弟姐妹的子女(侄甥)
    → 上一代、这一代、下一代的**所有有关系的人**都在名单里。
  · app/Kinship::householdChildren() —— 子女按**父母所在的户**归堆,
    而不是按"某个人的 children"。于是孩子只登记了一位父母时,
    照样挂到这对夫妻的户上(语义:这孩子是我们俩的)。
  · templates/kin/tree.php —— 自己排版:每代一行、按「户」水平排开;
    夫妻两张卡之间画短横线;子女一律从**这条横线的中点**垂下来
    (x = (左卡中心 + 右卡中心) / 2,与"谁登录"无关)。
  · 拖拽/缩放保留(用户明确要),改用原生 d3.zoom;不再引 family-chart。

验证(只跑这一个模块,按用户要求):
  · _tooling/_fb_kin_v15.py —— 22 项全过:
      ✔ 20/20 全员画出(== 从「我」出发可达的人数)
      ✔ 7 对夫妻都有横线;7 个有子女的户**下垂线全部落在横线中点**
      ✔ 用户点名的场景一:删掉「母亲→儿子」这条边(模拟只登记父亲)后,
        下垂线**仍然**从夫妻横线中点出来,孩子仍在图上
      ✔ 用户点名的场景二:给母亲开账号、**用她的手机号登录**,
        家谱里能看到自己的孩子,且与父亲视角画的是同一批人
      ✔ 截图存档(全图 / 母亲视角 / 窄屏)
  · _tooling/_fb_kin_v15_falsify.py —— 反向验证 2/2 抓住:
      注入「从其中一张卡出来」→「从横线中点出来」变红
      注入「只走直系血亲」→「每个人都被画出来」变红
  · e2e 550 条全过(家谱段改到 v15 的判据上)

顺带修的既有问题:
  · app/Kinship.php 老库升级回填按实际存在的列动态拼(缺列不再 1054 炸断升级)
  · _fb_e2e.py 家谱断言改到 kin_v15_nodes(旧解析器在新页面上只会解析出空)
2026-10-02 10:26:14 +08:00

1050 lines
45 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),判断「跟我有关系」时用。
*
* ⚠️ 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];
}
// ---- ① 代数:以「我」为 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];
}
/**
* 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 ? '表兄弟' : '表姐妹') : '表亲';
}
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;
}
}