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