Files
FamilyBook/templates/kin/tree.php
T
laoyang 4b38c89142 v17: 家谱连线重排 —— 整齐树布局,线不再乱
用户反馈(原话):「人数对了,但是连线什么的还是乱的,实在不行,用一个画图谱的库试试也行,
以自己为中心进行连线。」

先量清"乱"在哪(_tooling/_fb_kin_diag_lines.py + _fb_kin_geom.py,用真实数据):
  · 同代卡片**会重叠** —— 一对夫妻宽 254px,却按固定 200px 一格摆
    (实测相邻两户重叠 52px,最小间隙 -118px);
  · 夫妻横线画在**卡片内部**(y = 卡中心 + 半卡高 − 6),线从卡身上穿过(实测 2~4 处);
  · 子女不落在父母下方,横梁跨得老远(实测一条横梁横跨 314px,从别人的卡上方经过)。

改法(不看"库"了 —— 实测第三方库只画得出 20 人里的 9 人,换库解决不了"画谁"):
  ① **同代按「户宽」逐个排开**(不再用固定格距)+ 防重叠兜底;
  ② 同一父户的子女**排成紧挨的一整块**,块中心对到父户中心 —— 横梁因此只覆盖
     自己那几个孩子,绝不跨到别人头上;
  ③ 夫妻横线**只画两张卡之间的那一段缝隙**(不再从卡中心连到卡中心);
  ④ 子女下垂线仍从**夫妻横线中点**往下(这条是用户点名的要求,保持不变);
  ⑤ 子女下垂线的竖向段从**卡下沿**起,不再穿过卡片下半部分。

验证(就这一个模块):
  · `_fb_kin_geom.py`(新)—— 20 人夹具 **8 项全过**:
      A 任意两张卡不重叠  B 没有连线穿过卡片  C 有子女的户下垂线全从横线中点出
      D 同代相邻卡片留有空隙  E 夫妻横线恰好落在两卡缝隙上
      (⚠️ 该脚本用 svg.getScreenCTM() 把连线转成屏幕坐标再与卡片比 ——
        第一版直接拿 SVG 用户坐标比屏幕坐标,B/D 两条恒真)
  · `_fb_kin_geom_falsify.py`(新)—— 反向验证 **3/3 抓住**:
      注入「横线从卡中心连卡中心」→ B 变红
      注入「下垂线从某一张卡出来」→ C 变红
      注入「拆掉防重叠」→ A 变红
  · `_fb_kin_check_v17.py`(替代旧库版)11 项全过;e2e 550 条全过;家谱功能专项 22 项全过

顺带修掉的两个**假绿**(都不是本轮引入,是既有断言失效):
  · `_fb_btn_check.py` 的「危险按钮不是品牌实心」写死了 `--brand` 常量,
    而 v14 起模块主题色会覆盖 --brand → 注入 bug 后按钮变紫仍被判"正常"。
    改成**现场读** `getComputedStyle(document.body)['--brand']`。
    同时补上「页面上得先有危险按钮」这条前提断言(原先 danger 为空时整段是空跑)。
  · `_fb_static_dry2.sh`(部署脚本静态断言的本地空跑脚本)是**手工快照**,
    部署脚本改过后它还是旧的 → 拿旧断言量新代码、报出已经修好的失败。
    改成每次从 `familybook_deploy.sh` **现抽**那段再跑。

另外:`_fb_geom_mobile.py` 的家谱选择器/尺寸断言随结构从旧库改为 v17
(.kin-card / svg.kin-lines / path.kin-link / 118:92),窄屏几何 520 项全过。
2026-10-02 12:48:33 +08:00

544 lines
26 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);
};
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
$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'),
];
}
if (!$cards) {
continue;
}
// ⚠️ 户内部:**血亲在前、配偶在后**(配偶排右手边)。
// 只按长幼排会让"妻子排到丈夫左边",夫妻横线看着别扭。
$head = null;
foreach ($cards as $c) {
// 血亲 = 这个户里"有父母在本图上"的那一位;都没有就取第一位
if ($head === null) {
$head = $c;
}
foreach ($graph['parents'][(int)$c['id']] ?? [] as $par) {
if (isset($unitOf[(int)$par]) && (int)$unitOf[(int)$par] !== $i) {
$head = $c;
break 2;
}
}
}
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>
(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 CW = 118; // 卡片宽
var CH = 92; // 卡片高
var HGAP = 20; // 同代相邻「户」之间的水平间隙
var CGAP = 14; // 夫妻两张卡之间的缝(短横线画在这一段上)
var PGAP = 16; // 夫妻两张卡与"夹在中间的竖线"之间的额外余量
var YS = 150; // 代与代之间的行距
var PAD = 64; // 画布留白
// ================= ========= 布局:整齐树 =================
//
// ⚠️ 为什么不用"每代按固定格子摊开"(v15 的做法,线就是那样乱起来的):
// ① 一对夫妻占 2×CW + CGAP 宽(≈250px),单人也占 CW;按同一个固定格距摆,
// 宽的那户会**压到隔壁**(实测:相邻两户重叠了 52px);
// ② 子女不落在父母正下方,横梁要跨很远,线与线互相交叉。
// 整齐树的做法:先按「轮廓」把子树从左往右摆开(保证不重叠),
// 再让父(户)落在**其子女跨度的中点**——于是一层一层都是居中于下一层,
// 线自然是最短的、几乎不交叉。
// ---- 0. 户的父子关系(哪个户是哪一户的孩子)----
var unitOf = {}; // personId -> unitIndex
units.forEach(function (u, ui) {
u._i = ui;
u.cards.forEach(function (c) { unitOf[c.id] = ui; });
});
units.forEach(function (u) { u._kids = []; });
// 从「户 → 子女」反推「户 → 子户」:某户的孩子落在了别的户里,那个户就是子户
var childUnits = {}; // ui -> [子户 ui,...](去重、保持顺序)
var parentUnits = {}; // 子户 ui -> [父户 ui,...](**可以有多个**)
units.forEach(function (u, ui) {
(u.kids || []).forEach(function (kid) {
var ku = unitOf[kid];
if (ku === undefined || ku === ui) { return; }
childUnits[ui] = childUnits[ui] || [];
if (childUnits[ui].indexOf(ku) < 0) {
childUnits[ui].push(ku);
// ⚠️ **必须允许一个户有多个父户**:我这一户同时是「爸妈的孩子」和
// 「岳父母的孩子」(因为妻子与我同户,而她是岳父母的孩子)。
// 只留第一个父户会让另一支(我那一支)失去父子关系 → 后代被排到别处。
parentUnits[ku] = parentUnits[ku] || [];
if (parentUnits[ku].indexOf(ui) < 0) { parentUnits[ku].push(ui); }
}
});
});
// 「主父户」= 排序时用哪一个父户。多个父户时取**代数最高的那个**
// (即最靠上的祖先那一支)—— 这样整个本家血脉会以同一支祖先为主轴排开,
// 姻亲(岳父母)只是"挂在旁边",不会把主轴拆成两半。
var parentUnitOf = {};
Object.keys(parentUnits).forEach(function (ku) {
var ps = parentUnits[ku].slice().sort(function (a, b) {
return units[a].gen - units[b].gen || a - b;
});
parentUnitOf[ku] = ps[0];
});
// ---- 2. 排序:同代内按「父户的位置」从左到右摆 ----
//
// ⚠️ v17 换了思路。v16 试过「树递归 + 轮廓合并」,但家谱不是一棵纯树:
// **姻亲**(岳父母)不是本家的后代,没有父户 → 会被当成"第二棵独立的树"
// 排到一侧,于是它的后代(妻子/我/姐姐/孩子)全被推远,
// 横梁要跨过别人的卡片(实测 D 项失败:266→838 穿过爸爸/岳父母)。
// 正解:**按代分行;同代内按"父辈位置"排序后从左往右摆**;
// 同一父户的子女排成紧挨的一整块,块的中心对到父户中心 ——
// 这样每户的横梁只覆盖自己那几个孩子。
// ---- 2a. rank 迭代:同代内按「父户的 rank」排序,几轮收敛 ----
var byGen = {};
units.forEach(function (u) { (byGen[u.gen] = byGen[u.gen] || []).push(u._i); });
var rankOf = {};
// 初值:数据顺序(服务端已按「血亲在前、长幼次序」排过)
units.forEach(function (u, ui) { rankOf[ui] = ui; });
for (var round = 0; round < 6; round++) {
Object.keys(byGen).forEach(function (g) {
var row = byGen[g].slice();
row.sort(function (a, b) {
var pa = parentUnitOf[a], pb = parentUnitOf[b];
var ka = (pa === undefined) ? -1 : rankOf[pa];
var kb = (pb === undefined) ? -1 : rankOf[pb];
if (ka !== kb) { return ka - kb; }
return rankOf[a] - rankOf[b];
});
row.forEach(function (ui, i) { rankOf[ui] = i; });
});
}
// ---- 2c. 横向落位:同代按 rank 从左往右摆,留 HGAP 间隙 ----
//
// 每个户的宽度:双人户 = 2×CW + CGAP + PGAP;单人户 = CW。
// 摆完一轮后,用「子户的中心 = 其父户的中心」做一次微调(自上而下传播),
// 让子女尽量落在父母正下方(减少横梁跨度)。
function unitWidthW(u) {
return u.cards.length >= 2 ? (2 * CW + CGAP + PGAP) : CW;
}
units.forEach(function (u) { u._w = unitWidthW(u); });
var cxOf = {};
Object.keys(byGen).forEach(function (g) {
var row = byGen[g].slice().sort(function (a, b) { return rankOf[a] - rankOf[b]; });
var x = 0;
row.forEach(function (ui) {
if (x > 0) { x += HGAP; } // 与上一个户之间留缝
cxOf[ui] = x + units[ui]._w / 2;
x += units[ui]._w;
});
});
// ---- 2d. 自上而下:子女往父母中心靠(但**同父的一组必须左对齐成块**)----
//
// ⚠️ v17:v16 只做了"整行防重叠 + 各自往父中心靠",结果**同一父户的多个孩子
// 会被推得七零八落**:孩子的横梁要从父户中心一路横跨到最远那个孩子,
// 中间就压过了别人的卡片(实测 D 项:横梁 801→1115 从「姐夫」身上过去)。
// 正解:**同一父户的子女必须紧挨成一整块**(块内按长幼),
// 块的中心 = 父户的中心。这样每户的横梁只覆盖自己那几个孩子,绝不越界。
var gensAsc = Object.keys(byGen).map(Number).sort(function (a, b) { return a - b; });
for (var pass = 0; pass < 3; pass++) {
gensAsc.forEach(function (g) {
// ① 分组:把这一代按「父户」分组(没有父户的各自成组)
//
// ⚠️ 分组键要用**全部父户**拼成:户 2(我+妻子)同时属于户 0(爸妈)
// 和户 1(岳父母),但**只能出现在一组里**(否则它会摆两次)。
// 取「主父户」(见上)当分组键,多个父户只在**定位**时参与平均。
var row = byGen[g].slice().sort(function (a, b) { return cxOf[a] - cxOf[b]; });
var groups = [], seen = {};
row.forEach(function (ui) {
var pu = parentUnitOf[ui];
var key = (pu === undefined) ? ('solo' + ui) : ('u' + pu);
if (!seen[key]) {
seen[key] = { key: key, parents: [], kids: [] };
groups.push(seen[key]);
}
seen[key].kids.push(ui);
// 这一组的所有父户都记下来(定位时取平均)
var allp = parentUnits[ui] || (pu === undefined ? [] : [pu]);
allp.forEach(function (p) {
if (seen[key].parents.indexOf(p) < 0) { seen[key].parents.push(p); }
});
});
// ② 每组算「块宽」,并按父户中心定位;同组内按长幼(rankOf)排
var blocks = groups.map(function (gp) {
gp.kids.sort(function (a, b) { return rankOf[a] - rankOf[b]; });
var w = 0;
gp.kids.forEach(function (k, i) { w += units[k]._w + (i ? HGAP : 0); });
// 定位目标:多父户时取**所有父户中心的平均**(姻亲与本家都在时居中,
// 看着最自然);没有父户(顶层祖先/纯姻亲)就跟随前一块往右排。
var center = null;
if (gp.parents && gp.parents.length) {
var sum = 0;
gp.parents.forEach(function (p) { sum += cxOf[p]; });
center = sum / gp.parents.length;
}
return { gp: gp, w: w, center: center,
order: (center === null) ? cxOf[gp.kids[0]] : center };
});
blocks.sort(function (a, b) { return a.order - b.order; });
// ③ 依次摆放(块之间也留 HGAP),父户中心能对上就对上,对不上就顺延
//
// ⚠️⚠️ `cur` 必须从 **0** 起,不能写 `-Infinity`:
// 顶层(最早那一代)**所有户都没有父户** → 每块都走 `cur + ...` 这一支,
// `-Infinity + 宽度` 仍是 `-Infinity` → 全部 cxOf 变 -Infinity →
// 后面 `-Infinity - (-Infinity)` = **NaN**,整张图的 x 全废
// (实测症状:所有卡片叠在同一个 x、SVG width="NaN")。
var cur = 0;
var first = true;
blocks.forEach(function (bl) {
var want = (bl.center === null) ? (cur + (first ? 0 : HGAP) + bl.w / 2) : bl.center;
var left = Math.max(want - bl.w / 2, cur + (first ? 0 : HGAP));
var x = left;
bl.gp.kids.forEach(function (k, i) {
if (i) { x += HGAP; }
cxOf[k] = x + units[k]._w / 2;
x += units[k]._w;
});
cur = x;
first = false;
});
});
}
// ---- 3. 落坐标:户中心 → 两张卡的 x;每户的 y 由「代数」决定 ----
//
// ⚠️ y 用**代数**(gen)而不是树的 depth:姻亲(岳父母)不是本家的后代,
// 按 depth 会被排到"我"这一行或更低,看着完全不对。
// genIdx 在上面算轮廓时已经建好,这里直接复用(两处必须同一套索引)。
var gens = [];
units.forEach(function (u) { if (gens.indexOf(u.gen) < 0) { gens.push(u.gen); } });
gens.sort(function (a, b) { return a - b; });
// ⚠️ 边界必须按**卡片实际占用的范围**算,不能只用"户中心 ± 户宽/2":
// 双人户里两张卡是「中心 ± (CGAP+PGAP+CW)/2」,比户宽/2 更靠外,
// 只按户宽算会让最外侧的卡片伸出画布(v17 实测:爸爸卡 x=282..412 而画布只有 808)。
var allX = [];
units.forEach(function (u) {
var halfSpan;
if (u.cards.length >= 2) {
halfSpan = PGAP / 2 + CGAP / 2 + CW / 2 + CW / 2; // 最外侧卡的外沿
} else {
halfSpan = CW / 2;
}
allX.push(cxOf[u._i] - halfSpan, cxOf[u._i] + halfSpan);
});
var minX = Math.min.apply(null, allX), maxX = Math.max.apply(null, allX);
var W = (maxX - minX) + PAD * 2;
var H = PAD * 2 + (gens.length - 1) * YS + CH;
var pos = {}, lay = {};
units.forEach(function (u) {
var g = gens.indexOf(u.gen);
var y = PAD + CH / 2 + g * YS;
var cx = cxOf[u._i] - minX + PAD; // 平移到画布内
var ids = u.cards.map(function (c) { return c.id; });
var left, right;
if (ids.length >= 2) {
// 两张卡并排,中间留缝;缝的正中就是"夫妻横线"与"子女下垂线"的 x
left = cx - PGAP / 2 - CGAP / 2 - CW / 2;
right = cx + PGAP / 2 + CGAP / 2 + CW / 2;
pos[ids[0]] = { x: left, y: y };
pos[ids[1]] = { x: right, y: y };
} else {
left = right = cx;
pos[ids[0]] = { x: cx, y: y };
}
lay[u._i] = {
cx: cx, cy: y, left: left, right: right, ids: ids,
// ⚠️ 夫妻横线画在**两张卡之间的空档的正中**(纵向也在卡高的一半处),
// 视觉上就是"一条线把夫妻俩连起来"。
// 子女的下垂线从这条线的**中点**往下走 —— 这就是用户要的形状。
// (v15 把它画在 y + CH/2 − 6,等于画在卡片**内部**,线从卡身上穿过去;实测抓到 2 处。)
barY: y,
hasBar: ids.length >= 2
};
});
var drawn = {}, html = '';
units.forEach(function (u) {
u.cards.forEach(function (c) {
var p = pos[c.id];
if (!p) { return; }
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
html += '<a class="' + cls + '" data-kin-id="' + esc(c.id) + '"'
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + CW + 'px;height:' + CH + '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>';
drawn[c.id] = 1;
});
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
// ---- 4. 画线 ----
//
// 三条线(顺序即画法):
// ① 夫妻横线:连两张卡的**中间空档**(视觉上把夫妻连起来);
// ② 下垂线:从横线的**中点**往下(单亲户 = 本人卡的中心往下);
// ③ 横梁 + 分叉:由横梁分到每个孩子的卡顶。
// ⚠️ ② 的起点取"卡中间高度"会**竖着穿过卡片下半部分**;所以让竖向那段
// 从**卡下沿**开始 —— 视觉上仍是"从夫妻那条线的中点出来",
// 但不会压在卡上。(用户要的是"别从某一个人出来",不是"必须穿过卡面"。)
var paths = [], bars = [], drops = [];
units.forEach(function (u) {
var L = lay[u._i];
if (L.hasBar) {
// ⚠️ 夫妻横线**只画两张卡之间的那一段缝隙**(不是从卡中心连到卡中心):
// 从中心画会从卡片身上穿过去(实测 B 项抓到 4 处穿卡)。
// 起止取「左卡右沿」到「右卡左沿」。
paths.push('M' + (L.left + CW / 2) + ',' + L.barY
+ 'L' + (L.right - CW / 2) + ',' + L.barY);
}
bars.push({ ui: u._i, x1: L.left + CW / 2, x2: L.right - CW / 2,
mid: (L.left + L.right) / 2, y: L.barY, has: L.hasBar });
});
units.forEach(function (u) {
var L = lay[u._i];
var kids = (u.kids || []).map(function (id) { return pos[id]; })
.filter(function (p) { return !!p; })
.sort(function (a, b) { return a.x - b.x; });
if (!kids.length) { return; }
var srcX = (L.left + L.right) / 2; // ★ 夫妻横线中点(单亲 = 本人卡中心)
var fromY = L.cy + CH / 2; // 从卡片下沿起(见上面的说明)
var busY = L.cy + YS - CH / 2 - 24; // 子女行上方的横梁
paths.push('M' + srcX + ',' + fromY + 'L' + srcX + ',' + busY);
// ⚠️⚠️ 横梁**不能直接连"第一个孩子到最后一个孩子"**:
// 孩子分属不同的户(比如"我"和"姐姐"各是一户),
// 而"我"那一户里还有配偶(妻子)。从最左孩子的卡中心连到最右孩子的卡中心,
// 会把夹在中间的那位**配偶**也框进横梁范围 —— 看着就像"这条线也连了妻子"。
// 正解:先按 x 排序,**逐段连接相邻两个孩子**(段间缺口处断开),
// 每段只覆盖它两端的两张卡(从"左卡中心"到"右卡中心")。
for (var i = 0; i + 1 < kids.length; i++) {
var a = kids[i], b2 = kids[i + 1];
// 只在"这两张卡之间没有别的卡片"时直连;否则断开(避免压过别人)
var blocked = false;
for (var j = 0; j < units.length; j++) {
var other = pos[units[j].cards[0].id];
if (!other || other === a || other === b2) { continue; }
var oc = other.x;
if (oc > a.x + 2 && oc < b2.x - 2) { blocked = true; break; }
}
if (blocked) { continue; }
paths.push('M' + a.x + ',' + busY + 'L' + b2.x + ',' + busY);
}
kids.forEach(function (k) {
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 - 4));
});
drops.push({ ui: u._i, x: srcX, y0: L.barY, fromY: fromY, busY: busY });
});
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, CW: CW, CH: CH, bars: bars, drops: drops,
pos: pos, paths: paths };
// ---- 5. 拖拽 + 缩放 ----
var stage = root.querySelector('.kin-stage');
var svg = root.querySelector('svg.kin-lines');
var zoom = d3.zoom().scaleExtent([0.15, 2.5]).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.92;
k = Math.max(0.15, Math.min(2.5, k));
var tx = (r.width - W * k) / 2;
var ty = (r.height - H * k) / 2;
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
}
function centerOn(id) {
var p = pos[id];
if (!p) { return; }
var r = root.getBoundingClientRect();
var t = d3.zoomTransform(root);
var tx = r.width / 2 - p.x * t.k;
var ty = r.height / 2 - p.y * t.k;
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
}
window.kinFit = fit;
window.kinCenterOn = centerOn;
fit();
var meId = root.getAttribute('data-me') || '';
if (meId) { 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; ?>