用户反馈(原话):「人数对了,但是连线什么的还是乱的,实在不行,用一个画图谱的库试试也行,
以自己为中心进行连线。」
先量清"乱"在哪(_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 项全过。
544 lines
26 KiB
PHP
544 lines
26 KiB
PHP
<?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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// ---- 版式常量 ----
|
||
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; ?>
|