Files
FamilyBook/templates/kin/tree.php
T

750 lines
34 KiB
PHP
Raw Normal View History

<?php
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
*
* ============================ 布局算法(v16 重写) ============================
*
* 历史:v13/v14 用第三方库 family-chart → 数据一直不全(实测 20 人只画出 9 人,
* 因为它只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
* **横向亲戚与姻亲旁支永远进不来**)。v15 改成自己排版,人数终于对了,
* 但布局是"每代按固定格子摊开",于是**线是乱的**:
* · 同代卡片**会重叠**(一对夫妻宽 254px,却按 200px 一格摆);
* · 夫妻横线画在卡片**内部**(y = 卡中心 + 半个卡高 − 6),所以线从卡上穿过去;
* · 子女不落在父母下方,横梁跨得老远、线与线互相交叉。
*
* v16 换成**整齐树(tidy tree)**——这是家谱/组织图的标准排法,规则只有三条:
* ① 后序给每棵子树算「轮廓」(每一行被占用的 x 区间),按轮廓从左往右放,
* 保证**任何两张卡都不重叠**(同代不同代的都不会);
* ② 父(户)落在**其子女整体跨度的中点**上 —— 这样"子女从父母下方连出来"
* 天然成立,横梁最短、最不容易交叉;
* ③ 同代按长幼排序(长子左、次子右),兄弟姊妹相对次序稳定。
*
* 连线画法(用户点名的要求,一字不改):
* · 一对夫妻之间一条短横线;
* · 他们的子女**一律从这条横线正中间垂下来**(单亲户从本人卡正下方),
* 再经横梁分到每个孩子 —— 与"谁登录""孩子登记了几位父母"都无关。
*
* 拖拽 / 滚轮缩放保留(d3.zoom);另给「看全部 / 回到我」两个按钮。
*/
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
$people = $people ?? [];
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 2, 2);
$gen = $map['gen'];
$units = $map['units'];
$unitOf = $map['unit_of'];
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)----
$unitKids = \App\Kinship::householdChildren($graph, $unitOf);
// ---- ③ 同一代里的户按长幼排(长子左、次子右)----
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id);
};
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
$payloadUnits = [];
foreach ($units as $i => $u) {
$sorted = array_map('intval', $u['ids']);
usort($sorted, static function (int $a, int $b) use ($rankKey): int {
return strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b);
});
$cards = [];
foreach ($sorted as $id) {
if (!isset($people[$id]) || !is_array($people[$id])) {
continue;
}
$p = $people[$id];
$g = strtolower((string)($p['gender'] ?? ''));
$cards[] = [
'id' => $id,
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $id, $people),
'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $id === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'),
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
'rank' => \App\Kinship::rankValueFor($people, $id),
];
}
if (!$cards) {
continue;
}
// ⚠️⚠️ 户内部:**本家血亲在前、配偶在后**(配偶排右手边,参考实现就是这样)。
//
// "血亲"不能只按"他有父母在本图上"判定 —— 一对夫妻**两边父母都可能在图上**
// (我的父母 + 妻子的父母),那样两边都算血亲,取到第一个就成了"妻子当主位、
// 我排在她右边"(实测线上就是这个症状:红围脖@639 在我的左边)。
//
// 正解:按「**与"我"的血缘距离**」判定 —— 这一户里离「我」更近的那一位才是主位。
// 我用一次不带配偶边的 BFS 算出血亲集合(Kinship::bloodRelatives 语义),
// 两边都在血亲集合里时,退到「与我的步数」比大小。
$blood = [];
$bq = [$meId];
$blood[$meId] = true;
while ($bq) {
$bid = array_shift($bq);
foreach (array_merge($graph['parents'][$bid] ?? [], $graph['children'][$bid] ?? []) as $nb) {
$nb = (int)$nb;
if (!isset($blood[$nb])) {
$blood[$nb] = true;
$bq[] = $nb;
}
}
}
$head = null;
$headScore = null;
foreach ($cards as $c) {
$cid = (int)$c['id'];
// 分数越小越"本家":0 = 血亲本身,1 = 配偶,2 = 其它
$sc = isset($blood[$cid]) ? 0 : 1;
if ($head === null || $sc < $headScore) {
$head = $c;
$headScore = $sc;
}
}
if ($head !== null) {
$rest = [];
foreach ($cards as $c) {
if ((int)$c['id'] !== (int)$head['id']) {
$rest[] = $c;
}
}
$cards = array_merge([$head], $rest);
}
$kids = [];
foreach ($unitKids[$i] ?? [] as $cid) {
if (isset($people[$cid])) {
$kids[] = (int)$cid;
}
}
usort($kids, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
$payloadUnits[] = ['gen' => (int)$u['gen'], 'cards' => $cards, 'kids' => $kids];
}
// 同代之间按「主位长幼」排序,保证顺序稳定
usort($payloadUnits, static function (array $a, array $b) use ($rankKey): int {
return ($a['gen'] <=> $b['gen'])
?: strcmp($rankKey((int)$a['cards'][0]['id']), $rankKey((int)$b['cards'][0]['id']))
?: ((int)$a['cards'][0]['id'] <=> (int)$b['cards'][0]['id']);
});
$totalPeople = 0;
foreach ($payloadUnits as $u) {
$totalPeople += count($u['cards']);
}
$canChart = $totalPeople > 1;
$meName = (string)($people[$meId]['name'] ?? '自己');
$payloadJson = json_encode(
['units' => $payloadUnits, 'me' => (int)$meId],
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
?>
<div class="page-head">
<h1>我的家谱</h1>
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女从夫妻横线中点连出</span>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<div class="kin-tools">
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
</div>
<div class="kin-chart" id="kinChart"
data-kin="<?= e($payloadJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
每代一行,一代里按「户」排开;<b>一对夫妻之间有一条短横线</b>,
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上),
再由横梁分到每个孩子。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
</p>
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script>
/*****************************************************************
* 家谱布局:仿「亲友+」App 的画法
*
* 参考实现:gitee.com/coku2333/FamilyTreeView 的 NewFamilyTreeView
* (用户 2026-10-02 指定「按照这个做」)。核心规则:
* ① 从下往上定位:孙辈 → 子女 → 我 → 父母 → 祖辈
* (子层位置定了,父层就定了 —— 父居中于子之上)
* ② 每一代维护 genLeft / genRight 两个游标;往右铺从 genRight 起、
* 往左铺从 genLeft 起
* ③ 配偶并排在主位右侧(一对夫妻 = 相邻两张卡)
* ④ 年长的兄弟姐妹往左、年幼的往右;舅姨往右、叔伯姑往左
* ⑤ 连线画在**卡片背后**(可以从卡中心连到卡中心,被卡面挡住)
*****************************************************************/
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof d3 === 'undefined') { return; }
var data;
try {
data = JSON.parse(root.getAttribute('data-kin') || '{}');
} catch (err) { data = {}; }
var units = (data.units || []);
if (!units.length) { return; }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/* ---------- 版式常量 ---------- */
var IW = 88; // 卡片宽
var IH = 106; // 卡片高
var SP = 26; // 卡片间距(参考实现里的 mSpacePX)
var SLOT = IW + SP; // 一个人的"槽位"宽(配偶间隔也用 SP,与参考一致)
var YS = IH + SP; // 代与代之间的行距
var PAD = 60; // 画布留白
/* ---------- 0. 基础索引 ---------- */
var byId = {}; // personId -> {u}
units.forEach(function (u, ui) {
u._i = ui;
u.cards.forEach(function (c) { byId[String(c.id)] = { u: ui, card: c }; });
});
// 户 → 户 的父子关系(由「户 → 子女」推)
var kidsOf = {}, parentsOf = {};
units.forEach(function (u, ui) {
(u.kids || []).forEach(function (kid) {
var ref = byId[String(kid)];
if (!ref || ref.u === ui) { return; }
(kidsOf[ui] = kidsOf[ui] || []);
if (kidsOf[ui].indexOf(ref.u) < 0) {
kidsOf[ui].push(ref.u);
(parentsOf[ref.u] = parentsOf[ref.u] || []);
if (parentsOf[ref.u].indexOf(ui) < 0) { parentsOf[ref.u].push(ui); }
}
});
});
// ⚠️⚠️ 关键:`parentsOf` 的键是**户索引**,而下面多处是按**人 id** 去查有没有父母
// (比如"哪张卡是本家的血亲位")。两者混用会**永远查不到** ——
// 症状是"主位永远取第一张卡"(伯父那一户的主位取成了伯母),
// 进而父系/母系判定全错。这里单独建一份"人 id → 父母户索引"。 */
var personParents = {}; // personId -> [父户 ui,...]
units.forEach(function (u, ui) {
u.cards.forEach(function (c) {
var ps = parentsOf[String(ui)];
if (ps && ps.length) { personParents[String(c.id)] = ps; }
});
});
// 我所在的户
var meUi = 0;
units.forEach(function (u, ui) {
u.cards.forEach(function (c) { if (c.me) { meUi = ui; } });
});
// 户的"血亲位"(哪张卡是本家的人):优先取"有父户"的那张
units.forEach(function (u) {
var idx = 0;
for (var i = 0; i < u.cards.length; i++) {
var ps = personParents[String(u.cards[i].id)];
if (ps && ps.length) { idx = i; break; }
}
u._headIdx = idx;
u._head = String(u.cards[idx].id);
// 主位的"排行键"(长幼):服务端已算好(birth_rank → 生日 → id 兜底),
// 前端要靠它分「哥哥/弟弟」「姐姐/妹妹」(以及"年长的往左排")。
u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank);
});
/* ---------- 1. 游标(每一代的左右边界)---------- */
var gmin = 0, gmax = 0;
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
var genL = {}, genR = {};
function resetCursor(lo, hi) {
for (var g = lo; g <= hi; g++) { genL[g] = 0; genR[g] = 0; }
}
resetCursor(gmin - 1, gmax + 1);
// 已定位的户:ui -> {gen, uc}(uc = 这一户的中心 x)
var placed = {};
/* 把一户放到某代的"下一个空位",返回它的中心 x
toRight=true 从 genR 往右接;false 从 genL 往左接 */
function takeSlot(ui, gen, toRight) {
var u = units[ui];
var n = u.cards.length >= 2 ? 2 : 1;
var span = n * SLOT;
var x0;
if (toRight) {
x0 = genR[gen];
genR[gen] = x0 + span;
} else {
genL[gen] -= span;
x0 = genL[gen];
}
// 这一户的中心:两张卡时是两卡中心的中点
var uc = x0 + IW / 2 + (n === 2 ? SLOT / 2 : 0);
placed[ui] = { gen: gen, uc: uc, n: n, x0: x0 };
return uc;
}
// 把一户**居中**放在 x = uc
function putCentered(ui, gen, uc) {
var n = units[ui].cards.length >= 2 ? 2 : 1;
placed[ui] = { gen: gen, uc: uc, n: n, x0: uc - (n === 2 ? SLOT : IW) / 2 };
return uc;
}
/* 一代的边界用"已放的户"刷新(对应参考的 measureMaxCoordinate) */
function refreshBounds(gen) {
var lo = Infinity, hi = -Infinity, any = false;
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p || p.gen !== gen) { return; }
any = true;
lo = Math.min(lo, p.x0);
hi = Math.max(hi, p.x0 + p.n * SLOT);
});
if (!any) { return; }
if (!isFinite(genL[gen]) || lo < genL[gen]) { genL[gen] = lo; }
if (!isFinite(genR[gen]) || hi > genR[gen]) { genR[gen] = hi; }
}
/* ---------- 2. 核心:放"一户 + 它的子女" ----------
childGen = 子女所在的那一代;这一户放在 childGen - 1。
完全对应参考的 setPart(position, isRight, ...)。 */
function placePart(childGen, toRight, ui) {
if (placed[ui]) { return; }
var u = units[ui];
var kids = (kidsOf[ui] || []).slice();
if (!toRight) { kids.reverse(); } // 往左铺时,长幼次序要反过来
if (!kids.length) {
// 没有子女:在"子女代"占位(参考实现就是推进游标留空),
// 自己这一户也跟着往前放 —— 这样不会和别人的子女挤在一起
var n0 = u.cards.length >= 2 ? 2 : 1;
if (toRight) { genR[childGen] += n0 * SLOT; }
else { genL[childGen] -= n0 * SLOT; }
takeSlot(ui, childGen - 1, toRight);
return;
}
kids.forEach(function (k) { placePart(childGen + 1, toRight, k); });
// 父(这一户)居中于子女之上
var first = placed[kids[0]].uc;
var last = placed[kids[kids.length - 1]].uc;
var mid = (first + last) / 2;
putCentered(ui, childGen - 1, mid);
refreshBounds(childGen - 1);
}
/* 只把"一户"放到某代(它的子女已另行处理)—— 用于兜底与姻亲 */
function placeLoose(ui, gen, toRight) {
if (placed[ui]) { return; }
takeSlot(ui, gen, toRight);
refreshBounds(gen);
}
/* ---------- 3. 按参考 order 逐代定位 ---------- */
(function layoutAll() {
/* (1) 我的子女(+1)与孙辈(+2)—— 先把"我"放在原点,再让子女居中于我之下 */
putCentered(meUi, 0, 0);
genL[0] = -IW / 2; genR[0] = (units[meUi].cards.length >= 2 ? SLOT + IW / 2 : IW / 2);
var myKids = (kidsOf[meUi] || []).slice();
myKids.forEach(function (k) { placePart(1, true, k); });
/* (2) 我居中于我的子女之上;整支(我 + 子女及以下)一起平移 */
if (myKids.length) {
var kf = placed[myKids[0]].uc, kl = placed[myKids[myKids.length - 1]].uc;
var dx = ((kf + kl) / 2) - placed[meUi].uc;
(function shift(ui, d) {
if (placed[ui]) { placed[ui].uc += d; placed[ui].x0 += d; }
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift(k, d); } });
})(meUi, dx);
}
resetCursor(gmin - 1, gmax + 1);
refreshBounds(0); refreshBounds(1); refreshBounds(2);
/* (3) 兄弟姐妹:以"父母的子女列表"的长幼序,我在中间 —— 兄姐往左、弟妹往右 */
var pu = (parentsOf[meUi] || [])[0];
var sibsAll = pu === undefined ? [] : (kidsOf[pu] || []).slice();
var myPos = sibsAll.indexOf(meUi);
var older = myPos > 0 ? sibsAll.slice(0, myPos) : []; // 长幼序里排在我前面的 = 兄姐
var younger = myPos >= 0 ? sibsAll.slice(myPos + 1) : []; // 排在我后面的 = 弟妹
// ⚠️⚠️ 兄姐/弟妹必须**紧贴着我**(在上一代"父母的")。参考实现里它们是
// "从 genLeft/genRight 往外铺",但那是**接着父母的子女块**往外长 ——
// 所以才有"哥哥在我左边、弟弟在我右边"的观感。
// 我这一版第一稿直接调 placePart() 从游标末尾起,结果兄姐/弟妹被推到
// 同代的最外侧(中间还夹着堂表亲),看着就像"外人"(实测截图确认)。
// 正解:以"我的中心"为基准,向左/向右**依次紧贴**摆放,
// 每放一户就把基准推进一户宽(并带上它的子女)。
var slotW = IW + SP;
// 兄姐:离我最近的先放(大哥最左 → 依次向左)
var cur = placed[meUi].x0; // 我这一户的左边缘
older.forEach(function (s) { // older 已是"长→幼",从最近(幼)开始向左
var n = units[s].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
var uc = cur - SP - w / 2;
putCentered(s, 0, uc);
// 它的子女(侄甥)放它下方
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
cur = uc - w / 2;
});
// 弟妹:往右紧贴
var curR = placed[meUi].x0 + (units[meUi].cards.length >= 2 ? 2 * slotW : IW);
younger.forEach(function (s) {
var n2 = units[s].cards.length >= 2 ? 2 : 1;
var w2 = n2 * slotW;
var uc2 = curR + SP + w2 / 2;
putCentered(s, 0, uc2);
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
curR = uc2 + w2 / 2;
});
// 兄姐是"长幼序里排在我前面的",最年长的应在最左 → 反向再摆一次
// (上面是从最近往左排,最年长的自然落在最左,正是要的效果)
/* (4) 父母(-1)居中于我之上;祖辈(-2)居中于父母之上 */
if (pu !== undefined && !placed[pu]) {
putCentered(pu, -1, placed[meUi].uc);
refreshBounds(-1);
var gps = (parentsOf[pu] || []).slice();
gps.forEach(function (gp, i) {
if (placed[gp]) { return; }
putCentered(gp, -2, placed[pu].uc + (gps.length > 1 ? (i === 0 ? -SLOT / 2 : SLOT / 2) : 0));
refreshBounds(-2);
});
/* (5) 父母的兄弟姐妹:舅姨(母系)往右、叔伯姑(父系)往左,他们的子女是堂表亲(0 代) */
// 判断哪个祖辈是父系:父 = 父母这一户里"性别为男"的那张卡(取不到就用主位)
var pr = units[pu];
var fatherId = null;
pr.cards.forEach(function (c) { if (c.gender === 'M') { fatherId = String(c.id); } });
if (!fatherId) { fatherId = pr._head; }
// ⚠️ 叔伯姑/舅姨要**紧挨着父母**摆:父系往父亲那侧(左)、母系往母亲那侧(右)。
// 第一稿直接 placePart(0, 左/右) —— 那是从"整代游标"的末尾起,
// 叔伯被推到很远的外侧,中间空一大段(实测截图里"伯父"跑到爸爸右边很远)。
// 正解:以"父母这一户"的两端为基准向外铺。
var puW = units[pu].cards.length >= 2 ? 2 * slotW : IW;
var puLeft = placed[pu].uc - puW / 2;
var puRight = placed[pu].uc + puW / 2;
var bySide = { left: [], right: [] };
// ⚠️⚠️ 判定「这一位是我父系的还是母系的」要看**他自己的父母**里
// 有没有那位父系祖辈 —— 而不是"去祖辈户里找爸爸"(那是恒假的写法,
// 第一稿就这么写,结果所有叔伯都被当成母系、全跑到右边)。
// fatherId = 我爸的 id;父系祖辈 = 我爸的父母里那一位。
var fatherSideGps = (personParents[String(fatherId)] || []).slice();
gps.forEach(function (gp) {
(kidsOf[gp] || []).forEach(function (s) {
if (s === pu || placed[s]) { return; }
// s 的父母里有没有"父系祖辈所在的户"
var ps2 = personParents[units[s]._head] || [];
var isFatherSide = ps2.indexOf(gp) >= 0 && fatherSideGps.indexOf(gp) >= 0;
bySide[isFatherSide ? 'left' : 'right'].push(s);
});
});
// 叔伯姑/舅姨的子女(堂表亲):**紧贴其父母下方**放。
// ⚠️ 不能调 placePart() —— 那会从"整代游标末尾"起、还会递归整支,
// 结果堂兄跑到我的兄姐位置上、两者重叠(实测抓到 1 处重叠)。
// 这里按"父户中心"居中摆,若与已有的撞上再顺延。
function placeCousins(s, gen) {
var ks = (kidsOf[s] || []).slice();
if (!ks.length) { return; }
var span = ks.length * slotW;
var startC = placed[s].uc - (span - slotW) / 2;
ks.forEach(function (k, i) {
if (placed[k]) { return; }
var n = units[k].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
putCentered(k, gen, startC + i * slotW + (slotW - w) / 2 + w / 2);
});
}
// 父系:从父亲的左边往外铺(长幼序倒过来 —— 长叔伯更靠外)
var lcur = puLeft;
bySide.left.slice().reverse().forEach(function (s) {
var n = units[s].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
var uc = lcur - SP - w / 2;
putCentered(s, -1, uc);
placeCousins(s, 0);
lcur = uc - w / 2;
});
// 母系:从母亲的右边往外铺
var rcur = puRight;
bySide.right.forEach(function (s) {
var n2 = units[s].cards.length >= 2 ? 2 : 1;
var w2 = n2 * slotW;
var uc2 = rcur + SP + w2 / 2;
putCentered(s, -1, uc2);
placeCousins(s, 0);
rcur = uc2 + w2 / 2;
});
}
/* (6) 兜底:还没定位的(姻亲岳父母、其它旁支)按"离我这支最近"贴到同代右侧 */
var rest = [];
units.forEach(function (u, ui) { if (!placed[ui]) { rest.push(ui); } });
// 先放"与我这一支有直接关系"的(比如配偶的父母),再放更远的
rest.sort(function (a, b) {
var da = nearDist(a), db = nearDist(b);
return (units[a].gen - units[b].gen) || (da - db) || (a - b);
});
rest.forEach(function (ui) {
var g = units[ui].gen;
// 尽量靠到"它的子女"旁边(姻亲的父母 → 贴在他们女儿(我配偶)那一支的右侧)
// ⚠️ 姻亲(岳父母):他们的位置应该**贴在他们女儿(我配偶)那一支的上方**,
// 而不是"飘到同代最右"。第一稿就是这么写的 —— 结果岳父母跑到爸爸右边、
// 离我妻子很远,看着像别人家的人(实测截图确认)。
// 正解:锚到"它的子女"(= 我配偶所在的那一户)的上方居中。
var ks = kidsOf[ui] || [];
var anchor = null;
ks.forEach(function (k) { if (placed[k] !== undefined && anchor === null) { anchor = placed[k]; } });
if (anchor !== undefined && anchor !== null && anchor.gen === g + 1) {
// 与"同代已有的"错开:若锚点上方已被别人占,就往右让
var want = anchor.uc;
var n = units[ui].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
// 与同代已有的户依次避让(只往右让,避免来回挪)
var row = [];
units.forEach(function (u2, ui2) {
if (placed[ui2] && placed[ui2].gen === g && ui2 !== ui) { row.push(ui2); }
});
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
var half = w / 2;
for (var ri = 0; ri < row.length; ri++) {
var rp = placed[row[ri]];
var rn = units[row[ri]].cards.length >= 2 ? 2 : 1;
var rHalf = rn * slotW / 2;
if (Math.abs(want - rp.uc) < (half + rHalf + SP / 2)) {
want = rp.uc + rHalf + SP / 2 + half;
}
}
putCentered(ui, g, want);
} else {
// ⚠️ 没有子女、也不在主干上的旁支(比如"舅子"= 我妻子的兄弟):
// 要**贴到它父母的户旁边**,而不是"推到整代最右"。
// 第一稿用 placeLoose(往右) —— 结果舅子跑到离我很远的最右侧,
// 还和同代的人撞上(实测 D 项最小间隙 -36px)。
var ps3 = parentsOf[String(ui)] || [];
var panchor = null;
ps3.forEach(function (p3) { if (placed[p3] && panchor === null) { panchor = p3; } });
if (panchor !== null) {
// 放在父母那一户的右侧紧邻处(父母的其它子女已经在旁边了)
var pn = units[panchor].cards.length >= 2 ? 2 : 1;
var pw = pn * slotW;
var want2 = placed[panchor].uc + pw / 2 + SP + slotW / 2;
// 与同代已有的人避让
var row2 = [];
units.forEach(function (u3, ui3) {
if (placed[ui3] && placed[ui3].gen === g && ui3 !== ui) { row2.push(ui3); }
});
row2.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var rj = 0; rj < row2.length; rj++) {
var rp2 = placed[row2[rj]];
var rn2 = units[row2[rj]].cards.length >= 2 ? 2 : 1;
if (Math.abs(want2 - rp2.uc) < (slotW / 2 + rn2 * slotW / 2)) {
want2 = rp2.uc + rn2 * slotW / 2 + SP + slotW / 2;
}
}
putCentered(ui, g, want2);
} else {
placeLoose(ui, g, true);
}
}
});
function nearDist(ui) {
var ps = parentsOf[ui] || [];
var best = 9;
ps.forEach(function (p) {
// 到「我」这一支的代数距离
best = Math.min(best, Math.abs(units[p].gen - 0) + 1);
});
return best;
}
})();
/* ---------- 4. 同代防重叠(保险):按 x 排序后依次推开,子树跟着走 ---------- */
(function antiOverlap() {
for (var g = gmin; g <= gmax; g++) {
var row = [];
units.forEach(function (u, ui) { if (placed[ui] && placed[ui].gen === g) { row.push(ui); } });
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var i = 1; i < row.length; i++) {
var prev = placed[row[i - 1]], cur = placed[row[i]];
var prevRight = prev.x0 + prev.n * SLOT;
var curLeft = cur.x0;
if (curLeft < prevRight + 0) {
var d = prevRight - curLeft;
(function shift3(ui, dd) {
if (placed[ui]) { placed[ui].uc += dd; placed[ui].x0 += dd; }
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift3(k, dd); } });
})(row[i], d);
}
}
}
})();
/* ---------- 5. 落最终坐标 ---------- */
var xs = [];
units.forEach(function (u, ui) {
var p = placed[ui];
if (p) { xs.push(p.x0, p.x0 + p.n * SLOT); }
});
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
var W = (maxX - minX) + PAD * 2;
var rows = [];
for (var g = gmin; g <= gmax; g++) { rows.push(g); }
var H = PAD * 2 + (rows.length - 1) * YS + IH;
var pos = {}; // personId -> {x, y}(卡中心)
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p) { return; }
var y = PAD + IH / 2 + rows.indexOf(u.gen) * YS;
var x0 = p.x0 - minX + PAD;
var ids = u.cards.map(function (c) { return String(c.id); });
if (ids.length >= 2) {
pos[ids[0]] = { x: x0 + IW / 2, y: y };
pos[ids[1]] = { x: x0 + SLOT + IW / 2, y: y };
} else {
pos[ids[0]] = { x: x0 + IW / 2, y: y };
}
});
/* ---------- 6. 卡片 ---------- */
var html = '';
units.forEach(function (u) {
u.cards.forEach(function (c) {
var p = pos[String(c.id)];
if (!p) { return; }
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
var g = (c.gender === 'M' || c.gender === 'F') ? '' : ' g-u';
html += '<a class="' + cls + g + '" data-kin-id="' + esc(c.id) + '"'
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + IW + 'px;height:' + IH + 'px"'
+ ' href="/index.php?r=people/show&id=' + encodeURIComponent(c.id) + '">'
+ '<span class="kin-ph"><img src="' + esc(c.avatar) + '" alt="" loading="lazy"></span>'
+ '<span class="kin-nm">' + esc(c.name) + '</span>'
+ '<span class="kin-lb">' + esc(c.label) + '</span>'
+ (c.ph ? '<span class="kin-mark">待确认</span>' : '')
+ '</a>';
});
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
/* ---------- 7. 连线(画在卡片背后,所以可以从卡中心连到卡中心)---------- */
var paths = [], bars = [], drops = [];
function line(x1, y1, x2, y2) {
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
}
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p) { return; }
var ids = u.cards.map(function (c) { return String(c.id); });
var yRow = pos[ids[0]].y; // 这一代卡片中心 y
var midX;
if (ids.length >= 2) {
var a = pos[ids[0]], b = pos[ids[1]];
midX = (a.x + b.x) / 2;
// ① 夫妻横线:两卡中心之间的水平线(画在卡后,被卡面挡住)
line(a.x, a.y, b.x, b.y);
bars.push({ ui: ui, x1: a.x, x2: b.x, y: a.y, has: true, mid: midX });
} else {
midX = pos[ids[0]].x;
bars.push({ ui: ui, x1: midX, x2: midX, y: yRow, has: false, mid: midX });
}
// ② 有子女 → 从夫妻横线**中点**往下垂到"横梁"
var kids = (kidsOf[ui] || []).filter(function (k) { return pos[units[k]._head] || true; });
if (kids.length) {
var busY = yRow + IH / 2 + SP / 2; // 参考:卡底 + SP/2
line(midX, yRow, midX, busY);
// ③ 横梁:从最左孩子到最右孩子
var kx = kids.map(function (k) { return placed[k].uc; });
var minK = Math.min.apply(null, kx), maxK = Math.max.apply(null, kx);
if (kids.length > 1 && maxK - minK > 0.5) { line(minK, busY, maxK, busY); }
drops.push({ ui: ui, x: midX, y0: yRow, busY: busY });
}
// ④ 每个人若有父母 → 从卡顶往上画一小段(接到上一代的横梁)
ids.forEach(function (id) {
var ps = parentsOf[id];
if (ps && ps.length) {
var pp = pos[id];
line(pp.x, pp.y - IH / 2, pp.x, pp.y - IH / 2 - SP / 2);
}
});
});
root.insertAdjacentHTML('afterbegin',
'<svg class="kin-lines" width="' + W + '" height="' + H + '">'
+ paths.map(function (d) { return '<path class="kin-link" d="' + d + '"></path>'; }).join('')
+ '</svg>');
window.__kinLayout = { W: W, H: H, IW: IW, IH: IH, SLOT: SLOT, bars: bars,
drops: drops, pos: pos, paths: paths };
/* ---------- 8. 拖拽 + 缩放 ---------- */
var stage = root.querySelector('.kin-stage');
var svg = root.querySelector('svg.kin-lines');
var zoom = d3.zoom().scaleExtent([0.15, 3]).on('zoom', function (ev) {
var t = ev.transform;
[svg, stage].forEach(function (el) {
el.style.transformOrigin = '0 0';
el.style.transform = 'translate(' + t.x + 'px,' + t.y + 'px) scale(' + t.k + ')';
});
});
var sel = d3.select(root);
sel.call(zoom);
function fit() {
var r = root.getBoundingClientRect();
if (!r.width || !r.height) { return; }
var k = Math.min(r.width / W, r.height / H) * 0.94;
k = Math.max(0.15, Math.min(3, k));
sel.call(zoom.transform, d3.zoomIdentity.translate(
(r.width - W * k) / 2, (r.height - H * k) / 2).scale(k));
}
function centerOn(id) {
var p = pos[String(id)];
if (!p) { return; }
var r = root.getBoundingClientRect();
var t = d3.zoomTransform(root);
sel.call(zoom.transform, d3.zoomIdentity.translate(
r.width / 2 - p.x * t.k, r.height / 2 - p.y * t.k).scale(t.k));
}
window.kinFit = fit;
window.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || '';
fit();
// 进来先把镜头对准「我」
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); }
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
if (btnFit) { btnFit.addEventListener('click', fit); }
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
})();
</script>
<?php endif; ?>