Files
FamilyBook/templates/kin/tree.php
T
laoyang e22202271a v20: 家谱布局推倒重写为区间法(tidy tree)—— 一次成型,无多轮推挤修补
用户反馈:"错的更离谱了,这次连位置都不对了"(v19 实测"我"跑到离父母 800px 的最右侧)。

根因:v18/v19 是「游标铺放 → 防重叠 → 同代重排 → 再防重叠 → 再硬推」
叠了四五层修补,每一层都在破坏上一层的"父居中于子女",多轮互相拉扯后整棵树漂移。

区间法规则(一次成型,天然不叠卡):
  ① 每棵户子树自底向上算需求宽度 needW = max(自身宽, Σ子女宽 + 间距);
  ② 自顶向下分配互不重叠的 x 区间,子女依次铺在父区间内;
  ③ 父户居中于子女的实际跨度。
跨树边(岳父母户也把妻子列为子女)只走「主父户」进树递归
(主位卡是我方血亲的那个父户,服务端给每张卡标 blood),
其余父户只留给画线段 —— 岳父母的子女横梁照样连到妻子,布局不重复铺人。

验证(20 人夹具 + 真实渲染):
  · 版面五判据 A-E 全过(8/8):不重叠 / 线在卡后 / 下垂线从夫妻横线中点出 /
    同代有空隙 / 横线在两卡缝隙;行分布:兄姐弟妹紧贴我、堂表在伯父下方、
    岳父母贴妻子右上、外甥在舅子下方;
  · 连线完整性:该连上一代的卡 0 缺线、悬空竖线 0 段;
  · 反向验证 3/3:注入坏法(下垂线从左卡出 / needW 砍半 / 中点取左卡)全部变红。
2026-10-02 17:35:27 +08:00

628 lines
29 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
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
*
* ============================ 布局算法(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);
};
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
// 「我」的血亲集合:只走 parent/child 边(不含配偶)能从「我」到达的人。
// 用途:给每张卡标 blood —— 前端据此判定一户的「主父户」
// (我的户同时被父母户与岳父母户认作子女,树递归只能走本家那条,
// 否则树变图、布局重复铺人;判定标准就是「父户的主位是不是我方血亲」)。
$blood = [$meId => true];
$bq = [$meId];
while ($bq) {
$bid = (int)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;
}
}
}
$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'),
// ⚠️ 本家血亲标记:前端用它判定一户的「主父户」(见上方 $blood 的说明)
'blood' => isset($blood[$id]),
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
'rank' => \App\Kinship::rankValueFor($people, $id),
];
}
if (!$cards) {
continue;
}
// ⚠️⚠️ 户内部:**本家血亲在前、配偶在后**(配偶排右手边,参考实现就是这样)。
//
// "血亲"不能只按"他有父母在本图上"判定 —— 一对夫妻**两边父母都可能在图上**
// (我的父母 + 妻子的父母),那样两边都算血亲,取到第一个就成了"妻子当主位、
// 我排在她右边"(实测线上就是这个症状:红围脖@639 在我的左边)。
//
// 正解:按「**与"我"的血缘距离**」判定 —— 这一户里离「我」更近的那一位才是主位。
// 血亲集合 $blood 已在循环外算好(只走 parent/child 边的 BFS),
// 两边都在血亲集合里时,退到「与我的步数」比大小。
$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>
/*****************************************************************
* 家谱布局:区间法(tidy tree)+ 先摆节点、后按节点画线
*
* 参考过 gitee.com/coku2333/FamilyTreeView(用户 2026-10-02 指定的
* 「亲友+」效果),但它的游标铺法在我们的"以我为中心、含姻亲旁支"的
* 图上要叠四五层防重叠/重排修补才能不叠卡,每层又互相破坏 —— 最终
* 整棵树漂移(用户:"错的更离谱了,这次连位置都不对了")。
* 现在的规则(详见下方第 1 节注释):
* ① 每代一行;一对夫妻 = 相邻两张卡;
* ② 树递归用「区间法」分配互不重叠的 x 区间,父户居中于子女跨度
* —— 天然不叠卡,长幼序天然"兄姐在左、弟妹在右";
* ③ 连线画在卡片背后,**先把所有节点坐标摆定,再统一按节点画线**。
*****************************************************************/
(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. 布局:区间法(tidy tree,一次成型) ----------
*
* 为什么整个推倒重写(用户 2026-10-02:"错的更离谱了,这次连位置都不对了"):
* 之前的版本是「游标铺放 → 防重叠 → 同代重排 → 再防重叠 → 再硬推」
* 叠了四五层修补,每一层都在破坏上一层的"父居中于子女",
* 多轮互相拉扯后整棵树漂移(实测"我"跑到离父母 800px 的最右侧)。
*
* 现在换成树布局的标准解法(区间法),只有三条规则,一次成型:
* ① 每棵「户子树」自底向上算**需求宽度**:
* needW = max( 自身宽, Σ子女needW + SP×(子女数−1) )
* ② 自顶向下分配**互不重叠的 x 区间**:子女依次铺在父区间内;
* ③ 父户居中于子女的实际跨度。
* 区间天然不重叠 ⇒ 任何两张卡绝不重叠,**不需要任何后续推挤修补**。
*
* 跨树边(岳父母户也把妻子列为子女):每户只认**主父户**进树递归
* (主位卡是"我方血亲"的那个父户,服务端已给每张卡标了 blood),
* 其余父户只在**画线**时用 —— 岳父母的子女横梁照样连到妻子,
* 但布局上岳父母树不会把"我的户"再铺一遍。
*/
var rawKids = kidsOf; // 原始子女关系(画线用;跨树边保留)
var mainParent = {}; // ui -> 主父户 ui
var kidList = {}; // ui -> [子女户 ui,...](只含"把我当主父户"的)
units.forEach(function (u, ui) { u._blood = !!(u.cards[0] && u.cards[0].blood); });
units.forEach(function (u, ki) {
var ps = parentsOf[ki] || [];
if (!ps.length) { return; }
var main = null;
ps.forEach(function (p) { if (main === null && units[p]._blood) { main = p; } });
if (main === null) { main = ps[0]; }
mainParent[ki] = main;
// kidList 沿 kidsOf 的遍历顺序 push —— 服务端的长幼序原样保留
(kidList[main] = kidList[main] || []).push(ki);
});
/** 一户的实际宽度:单人 IW、夫妻两张 2*IW+SP(不是 n*SLOT) */
function ownW(ui) {
return units[ui].cards.length >= 2 ? 2 * IW + SP : IW;
}
/* ① 需求宽度:自底向上 */
var needW = {};
function calcNeed(ui) {
if (needW[ui] !== undefined) { return needW[ui]; }
var total = 0;
(kidList[ui] || []).forEach(function (k, i) {
total += calcNeed(k) + (i ? SP : 0);
});
needW[ui] = Math.max(ownW(ui), total);
return needW[ui];
}
/* ②③ 分配区间:自顶向下;父户居中于子女的实际跨度 */
var placed = {}; // ui -> {gen, x0}
function assign(ui, x0) {
var own = ownW(ui);
var ks = kidList[ui] || [];
var total = 0;
ks.forEach(function (k, i) { total += needW[k] + (i ? SP : 0); });
var kx = x0 + (needW[ui] - total) / 2; // 子女在父区间内居中起铺
ks.forEach(function (k) { assign(k, kx); kx += needW[k] + SP; });
// 居中锚:所有子女(**含跨树子女**——他们已在别的树里放好)的实际跨度中点
var lo = null, hi = null;
(rawKids[ui] || []).forEach(function (k) {
if (!placed[k]) { return; }
var c = placed[k].x0 + ownW(k) / 2;
if (lo === null || c < lo) { lo = c; }
if (hi === null || c > hi) { hi = c; }
});
var want = (lo === null) ? x0 + (needW[ui] - own) / 2 : (lo + hi) / 2 - own / 2;
// 父户不得越出自己被分配的区间(越出会撞上左邻/右邻子树)
placed[ui] = {
gen: units[ui].gen,
x0: Math.max(x0, Math.min(want, x0 + needW[ui] - own))
};
}
/* 森林:无主父户的户是树根;含「我」的树先放,其余树(姻亲)依次排在右侧 */
function treeHasMe(root) {
var st = [root], seen = {};
while (st.length) {
var x = st.pop();
if (seen[x]) { continue; }
seen[x] = true;
if (x === meUi) { return true; }
(kidList[x] || []).forEach(function (k) { st.push(k); });
}
return false;
}
var roots = [];
units.forEach(function (u, ui) { if (mainParent[ui] === undefined) { roots.push(ui); } });
roots.sort(function (a, b) {
var ma = treeHasMe(a), mb = treeHasMe(b);
return (ma === mb) ? ((units[a].gen - units[b].gen) || (a - b)) : (ma ? -1 : 1);
});
function subtreeRight(root) {
var hi = -Infinity;
(function walk(x) {
var p = placed[x];
if (p) { hi = Math.max(hi, p.x0 + ownW(x)); }
(kidList[x] || []).forEach(walk);
})(root);
return hi;
}
var cursorX = 0;
var TREE_GAP = SP; // 树与树之间的留白(姻亲树贴着主树放,
// 让岳父母尽量靠近妻子;区间法保证不重叠)
roots.forEach(function (r) {
calcNeed(r);
assign(r, cursorX);
cursorX = subtreeRight(r) + TREE_GAP;
});
/* ---------- 5. 落最终坐标 ---------- */
var gmin = 0, gmax = 0;
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
var xs = [];
units.forEach(function (u, ui) {
var p = placed[ui];
if (p) { xs.push(p.x0, p.x0 + ownW(ui)); }
});
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. 连线 ----------
*
* ⚠️⚠️ v19:**先把所有节点坐标摆定,再统一按节点坐标画线**(用户 2026-10-02 的建议原话:
* 「应该把每个节点的位置都摆好之后,再根据节点进行画线」)。
* 上一版是"边摆放边画线",于是两套坐标混着用:
* · 横梁端点用了 `placed[k].uc`(**户中心**),而卡片的真实中心是 `pos[id].x` ——
* 户中心与卡中心只在"单人户"下相等,双人户差半个槽位;
* · 「子女接父母」的竖线起点取"卡顶",而横梁的 y 是按"父户那一代的 y"推的,
* 两者不在同一个坐标系里。
* 结果:实测 5 张卡「有父母却没有线连上去」、17 段线两端都不碰任何卡片(悬空)。
*
* 现在统一成三步:
* ① 每个"人"的坐标已经在 pos[] 里定死(那是唯一的真值);
* ② 每"户"从它的卡片坐标推出:左卡中心、右卡中心、横线中点、卡顶/卡底 y;
* ③ 画线只用这些**由节点坐标推出的值**,不再引用放置阶段的游标数据。
*
* 线的三段(与参考实现一致):
* A. 夫妻横线:两张卡中心之间(y = 卡中心高度)
* B. 下垂线:从**横线中点**往下 → 到"子女代的横梁"(y = 下一代卡顶 - SP/2)
* C. 横梁 + 分叉:横梁连到每个孩子的**卡顶中点**,再各自往下一点点接上卡
* 另外每人若有父母,从卡顶往上画一小段接到横梁(保证"和下一代连上"是闭合的)。
*/
var paths = [], bars = [], drops = [];
function line(x1, y1, x2, y2) {
if (Math.abs(x1 - x2) < 0.05 && Math.abs(y1 - y2) < 0.05) { return; }
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
}
// ---- ② 由「人」的坐标推出「户」的画线锚点 ----
// anchor[ui] = {gen, y, left, right, mid, top, bottom, hasBar, cardIds}
var anchor = {};
units.forEach(function (u, ui) {
var ids = u.cards.map(function (c) { return String(c.id); })
.filter(function (id) { return !!pos[id]; });
if (!ids.length) { return; }
var ys = ids.map(function (id) { return pos[id].y; });
var y = ys[0]; // 同户必然同一行
var cx = ids.map(function (id) { return pos[id].x; });
var left = Math.min.apply(null, cx), right = Math.max.apply(null, cx);
anchor[ui] = {
gen: u.gen, y: y, yRow: y,
left: left, right: right,
mid: (left + right) / 2, // ★ 夫妻横线中点(单亲 = 卡中心)
top: y - IH / 2, bottom: y + IH / 2,
hasBar: ids.length >= 2,
ids: ids
};
});
// 每一代的行 y(用于算"横梁"的位置:下一代卡顶再往上一点)
var genY = {};
Object.keys(anchor).forEach(function (ui) {
var a = anchor[ui];
if (genY[a.gen] === undefined) { genY[a.gen] = a.y; }
});
function nextGenY(gen) {
var best = null;
Object.keys(genY).forEach(function (g) {
var gg = +g;
if (gg > gen && (best === null || gg < best)) { best = gg; }
});
return best === null ? null : genY[best];
}
// ---- ③ 画线:只用 anchor 与 pos ----
// A. 夫妻横线
units.forEach(function (u, ui) {
var a = anchor[ui];
if (!a) { return; }
if (a.hasBar) { line(a.left, a.y, a.right, a.y); }
bars.push({ ui: ui, x1: a.left, x2: a.right, y: a.y, mid: a.mid, has: a.hasBar });
});
// B/C. 从"我"这一户往下连子女(横线中点 → 横梁 → 每个孩子的卡顶)
//
// ⚠️ 横梁 y 取「**子女那一代的卡片顶** 再往上 SP/2」——
// 上一版取的是"父户的 y + IH/2 + SP/2",当两代之间还有别的行时就会错位。
Object.keys(anchor).forEach(function (k) {
var ui = +k, a = anchor[ui];
var kids = (kidsOf[ui] || []).filter(function (kk) { return anchor[kk]; });
if (!kids.length) { return; }
// ⚠️⚠️ 横梁 y 必须由**孩子自己的卡顶**算,不能用"我这一代的下一代":
// 叔伯的子女与我同代(不是下一代),舅姨的子女也是同代 ——
// 用 nextGenY() 会把横梁画到别处,孩子的竖线于是接不上(实测 18 段悬空)。
var kidTops = kids.map(function (kk) { return anchor[kk].top; });
var busY = Math.min.apply(null, kidTops) - SP / 2; // 横梁:孩子卡顶再往上 SP/2
// 下垂线:从**卡底**(不是卡中心)→ 横梁,这样不会压在卡片身上
line(a.mid, a.bottom, a.mid, busY);
drops.push({ ui: ui, x: a.mid, y0: a.bottom, busY: busY });
// 横梁:从最左孩子连到最右孩子。
//
// ⚠️ 曾经担心"长横梁横穿全图",但那不是真正的问题 ——
// 横梁的 y 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
// 区间法布局里同一父户的子女在它的区间内相邻铺开,横梁自然是短的。
var ks = kids.map(function (kk) { return anchor[kk].mid; })
.sort(function (x, y2) { return x - y2; });
if (ks.length > 1) { line(ks[0], busY, ks[ks.length - 1], busY); }
// 每个孩子:从横梁往下接到**卡顶**
kids.forEach(function (kk) {
var c = anchor[kk];
line(c.mid, busY, c.mid, c.top);
});
});
// D. 每个"有父母"的人:**卡顶往上接到父母那一户的横梁**
//
// ⚠️ 这里必须用「**父母那一户**的下垂线终点 busY」,
// 而不是"本代卡顶 - SP/2" —— 后者只在"父母与我在相邻两代"时才相等,
// 姻亲/旁支一错位就接不上(实测 6 张卡"有父母却没有线连上去")。
// 上面 B/C 段已经把每户的 busY 算好放进 drops[],这里直接查。
var busOfUnit = {};
drops.forEach(function (d) { busOfUnit[d.ui] = d.busY; });
Object.keys(anchor).forEach(function (k) {
var ui = +k, a = anchor[ui];
var ps = parentsOf[String(ui)] || [];
if (!ps.length) { return; }
// 取"负责连他的那一户"(parentsOf 里的第一个)
var pu = ps[0];
var busY = busOfUnit[pu];
if (busY === undefined) {
// 父母那一户没有子女横梁(数据异常):退化到"本代卡顶 - SP/2"
busY = a.top - SP / 2;
}
line(a.mid, busY, a.mid, a.top);
});
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; ?>