Files
FamilyBook/app/Kinship.php
T
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

900 lines
38 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));
}
/**
* 祖父母那一级:往上走一步再取父母。
* 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。
*/
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;
}
}