用户反馈(原话):「还是有问题,主要是线的问题,很多线画的不到位,要么是连的不到位,
要么就是和下一代的节点没连上。你是用 convas 画的吗?应该把每个节点的位置都摆好之后,
再根据节点进行画线」。
我按这个思路把画线整段重写了,并用脚本把「哪张卡没连上」**量成数字**(新工具
`_tooling/_fb_kin_linecheck.py`)。量出来的真问题与修法:
1. **两套坐标混着用**(这是"连不到位"的根因):
横梁端点用了 `placed[k].uc`(**户中心**),而卡片真实中心是 `pos[id].x`;
「子女接父母」的竖线起点取"卡顶",横梁 y 却按"父户那一代的 y"推 —— 不在同一坐标系。
→ 现在统一:**先落定所有 pos[],再由 pos 反推每户的画线锚点**(left/right/mid/top/bottom),
画线只引用这些锚点(新 `anchor[]`,模板里显式标注了这条契约)。
2. **横梁 y 用错了代**:原用 `nextGenY(我的代数)` —— 但**叔伯的子女与我同代**(不是下一代),
于是横梁画到别处、孩子接不上。
→ 改成由**孩子自己的卡顶**算:`busY = min(孩子卡顶) - SP/2`。
3. **「接入父母横梁」用错了锚**:原是"本代卡顶 - SP/2"(只在相邻两代才成立),
姻亲旁支一错位就接不上(实测 6 张卡"有父母却没有线连上去")。
→ 改成查**父母那一户的 `busY`**。
4. **同胞被别家孩子插在中间**("线乱"的真正病根):
实测我这一代原为 哥哥(161) 嫂子(275) **堂兄(389) 堂嫂(503)** 老杨(617) ——
堂兄夹在哥哥和我之间,父母连子女的横梁被迫横跨,看着不像一家人。
→ 新增 `groupSiblings()`:同代按「父户的中心 x」重排,让同胞挨在一起;
重排后再跑防重叠 + 父户居中(收敛)。
5. **户宽算错 26px**(防重叠一直推不开的原因):
边界用 `n * SLOT`,而单人户实际是 `IW`、双人户是 `2*IW+SP` —— 处处偏大。
→ 全部改成真实宽度(新增 `halfW()`),并加 `hardAntiOverlap()` 按最终坐标硬推、循环收敛。
**战绩**(`_fb_kin_linecheck.py` 是这轮新写的诊断工具,它自己也被修了三次判据 ——
"端点恰好等于卡边"→"区间覆盖"→"半个槽位",前两版都误报):
· 20 人夹具:「应连上一代却无线」= **无**;「接不上的竖线」= **0 段**;重叠 0 对
· 版面质量 8 项全过(含 A 不重叠 / D 有空隙 / C 下垂线从中点出)
· **真实线上数据(11 人)**:重叠 0 对、19 段连线、无 JS 报错
· e2e 553 条全过
⚠️ 顺带发现的数据问题(**不是代码问题**,已告知用户):
真实数据里 **爸爸与妈妈之间没有登记夫妻关系**(妈妈是单独一户),
所以图上他们是并排但不算一对;补上「配偶」后会自动合并成一户。
967 lines
46 KiB
PHP
967 lines
46 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'),
|
||
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
|
||
// 不用生日硬排(很多人没填生日),用 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
/* ---------- 版式常量 ---------- */
|
||
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;
|
||
}
|
||
|
||
/** 一"户"的实际横向半宽(用于防重叠;**不是** n*SLOT/2 ——
|
||
* 单人是 IW/2、双人是 SLOT —— 用 n*SLOT 会把它算宽 26px)。 */
|
||
function halfW(ui) {
|
||
// ⚠️ 一户的**实际**半宽:单人 IW/2 = 44、双人 (2*IW+SP)/2 = 101。
|
||
// 以前写成 (n===2 ? SLOT : IW)/2 = 57 / 44 —— 双人户被算**窄**了 44px,
|
||
// 于是防重叠判据偏松、贴着也算"够宽"(实测"堂嫂/舅子"重叠 32px 一直推不开)。
|
||
var n = units[ui].cards.length >= 2 ? 2 : 1;
|
||
return (n === 2 ? (2 * IW + SP) : IW) / 2;
|
||
}
|
||
|
||
/** 同代防重叠:按中心排序,依次保证"前一个右沿 + SP ≤ 后一个左沿"。
|
||
* ⚠️ 统一成这一个函数,三处调用 —— 原来三处各写一遍、边界算法还不一样,
|
||
* 于是"儿子/女儿"这种同代相邻的单人户会漏掉(实测重叠 11px)。 */
|
||
function antiOverlapRow(gen) {
|
||
var row = [];
|
||
units.forEach(function (u, ui) {
|
||
if (placed[ui] && placed[ui].gen === gen) { row.push(ui); }
|
||
});
|
||
if (row.length < 2) { return; }
|
||
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
|
||
for (var i = 1; i < row.length; i++) {
|
||
var pv = row[i - 1], cu = row[i];
|
||
var need = placed[pv].uc + halfW(pv) + SP + halfW(cu); // 后一个的最小中心
|
||
if (placed[cu].uc < need) {
|
||
var d = need - placed[cu].uc;
|
||
(function shiftSub(x, dd) {
|
||
if (placed[x]) { placed[x].uc += dd; placed[x].x0 += dd; }
|
||
(kidsOf[x] || []).forEach(function (k) { if (placed[k]) { shiftSub(k, dd); } });
|
||
})(cu, d);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 一代的边界用"已放的户"刷新(对应参考的 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 === 2 ? 2 * IW + SP : IW));
|
||
});
|
||
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++) { antiOverlapRow(g); }
|
||
})();
|
||
|
||
/* ---------- 4b. 同代重排:让**同一对父母的同胞**挨在一起 ----------
|
||
*
|
||
* ⚠️⚠️ 这是"线乱"的真正病根。原来的顺序会让别家的孩子插进同胞之间:
|
||
* 实测我这一代是 哥哥(161) 嫂子(275) **堂兄(389) 堂嫂(503)** 老杨(617) …
|
||
* —— 堂兄(伯父家的)夹在哥哥和我之间,于是父母连子女的横梁要横跨老远,
|
||
* 看着就不像"一家人"。
|
||
* 参考实现是靠"按父辈的游标顺序递归摆放"天然做到这一点的;
|
||
* 我们的布局是"先按代平铺、再对齐父母",所以少了这一步,这里补上:
|
||
* 按「父户的中心 x」把同代的户重新排序(没有父户的按原相对位置排末尾),
|
||
* 然后**横向依次重排**(保持每户宽度与间距),再让父户重新居中。
|
||
*/
|
||
(function groupSiblings() {
|
||
for (var g2 = gmin; g2 <= gmax; g2++) {
|
||
var row = [];
|
||
units.forEach(function (u, ui) { if (placed[ui] && placed[ui].gen === g2) { row.push(ui); } });
|
||
if (row.length < 2) { continue; }
|
||
// 排序键:父户的中心 x(没有父户的用"当前 x",保证相对次序稳定)
|
||
function pkey(ui) {
|
||
var ps = parentsOf[String(ui)] || [];
|
||
var best = null;
|
||
ps.forEach(function (p) {
|
||
if (placed[p] && (best === null || placed[p].uc < best)) { best = placed[p].uc; }
|
||
});
|
||
return best === null ? (placed[ui].uc + 1e6) : best; // 无父户的排到最后
|
||
}
|
||
row.sort(function (a, b) { return pkey(a) - pkey(b) || placed[a].uc - placed[b].uc; });
|
||
// 依次重排(保持每户宽度、户间 SP)
|
||
var total = 0;
|
||
row.forEach(function (ui, i) {
|
||
var n = units[ui].cards.length >= 2 ? 2 : 1;
|
||
total += (n === 2 ? 2 * IW + SP : IW) + (i ? SP : 0);
|
||
});
|
||
var cur = placed[row[0]].x0; // 从原来的最左端起排
|
||
row.forEach(function (ui) {
|
||
var n = units[ui].cards.length >= 2 ? 2 : 1;
|
||
var w = (n === 2 ? 2 * IW + SP : IW); // ⚠️ 真实宽度,不是 n*SLOT
|
||
var d = cur - placed[ui].x0;
|
||
if (d) {
|
||
(function shift4(x, dd) {
|
||
if (placed[x]) { placed[x].uc += dd; placed[x].x0 += dd; }
|
||
(kidsOf[x] || []).forEach(function (k) { if (placed[k]) { shift4(k, dd); } });
|
||
})(ui, d);
|
||
}
|
||
cur += w + SP;
|
||
});
|
||
}
|
||
// ⚠️ 重排会打乱原有的横向间距 → 必须再跑一轮防重叠(用统一的 antiOverlapRow)
|
||
for (var g4 = gmin; g4 <= gmax; g4++) { antiOverlapRow(g4); }
|
||
// 再让父户居中于子女之上(防重叠可能又推歪了;一趟就够)
|
||
for (var g5 = gmax; g5 >= gmin; g5--) {
|
||
Object.keys(placed).forEach(function (kk) {
|
||
var ui = +kk;
|
||
if (placed[ui].gen !== g5 - 1) { return; }
|
||
var ks3 = (kidsOf[ui] || []).filter(function (x) { return placed[x]; });
|
||
if (!ks3.length) { return; }
|
||
var xs3 = ks3.map(function (x) { return placed[x].uc; }).sort(function (a, b) { return a - b; });
|
||
var want3 = (xs3[0] + xs3[xs3.length - 1]) / 2;
|
||
var d4 = want3 - placed[ui].uc;
|
||
if (Math.abs(d4) > 0.5) { placed[ui].uc += d4; placed[ui].x0 += d4; }
|
||
});
|
||
}
|
||
// 最后一遍防重叠(顺序:重排 → 防重叠 → 父居中 → 防重叠,收敛)
|
||
for (var g6 = gmin; g6 <= gmax; g6++) { antiOverlapRow(g6); }
|
||
})();
|
||
|
||
/* ---------- 4c. 收尾防重叠(最后一道,必须放在**所有**定位步骤之后) ----------
|
||
*
|
||
* ⚠️⚠️ 排序很重要:`groupSiblings()` 会**重排**同代(打乱原有间距),
|
||
* 而它若跑在防重叠**之后**,被它挪动过的户就再没人兜底了 ——
|
||
* 实测"舅子"(姻亲旁支,由兜底段放在最后)就与"堂嫂"叠了 35px。
|
||
* 所以这里在**所有**定位/重排步骤之后,再统一收一遍。
|
||
*/
|
||
(function hardAntiOverlap() {
|
||
// ⚠️⚠️ 兜底段(放姻亲旁支,如"舅子")与 groupSiblings() 都会**事后**挪动户,
|
||
// 而它们的挪动不走 antiOverlapRow,于是同代仍可能贴上
|
||
// (实测"堂嫂 289-347"与"舅子 315-372"重叠 32px,且多遍 antiOverlapRow 都推不开)。
|
||
// 这里改成**直接按最终坐标硬推**:同代按中心排序,谁跟前一个贴太近就整支右移,
|
||
// 循环几遍直到全部满足 —— 不依赖任何前置状态。
|
||
for (var hr = 0; hr < 6; hr++) {
|
||
var moved = false;
|
||
for (var hg = gmin; hg <= gmax; hg++) {
|
||
var hrow = [];
|
||
units.forEach(function (u, ui) {
|
||
if (placed[ui] && placed[ui].gen === hg) { hrow.push(ui); }
|
||
});
|
||
if (hrow.length < 2) { continue; }
|
||
hrow.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
|
||
for (var hi = 1; hi < hrow.length; hi++) {
|
||
var hp = hrow[hi - 1], hc = hrow[hi];
|
||
var hneed = placed[hp].uc + halfW(hp) + SP + halfW(hc);
|
||
if (placed[hc].uc < hneed - 0.5) {
|
||
var hd = hneed - placed[hc].uc;
|
||
(function hs(x, dd) {
|
||
if (placed[x]) { placed[x].uc += dd; placed[x].x0 += dd; }
|
||
(kidsOf[x] || []).forEach(function (k) { if (placed[k]) { hs(k, dd); } });
|
||
})(hc, hd);
|
||
moved = true;
|
||
}
|
||
}
|
||
}
|
||
if (!moved) { break; }
|
||
}
|
||
})();
|
||
|
||
(function finalAntiOverlap() {
|
||
// ⚠️ 跑**两遍**:单遍不足以收敛 —— 推开一户会连带它的子树一起移,
|
||
// 移完可能又与更右的一户贴上(实测"堂嫂 → 舅子"只差 39px,
|
||
// 单遍推开后它右边的"老杨"又被挤到,第二遍才稳)。
|
||
for (var round2 = 0; round2 < 3; round2++) {
|
||
for (var gf = gmin; gf <= gmax; gf++) { antiOverlapRow(gf); }
|
||
}
|
||
})();
|
||
|
||
/* ---------- 5. 落最终坐标 ---------- */
|
||
var xs = [];
|
||
units.forEach(function (u, ui) {
|
||
var p = placed[ui];
|
||
// ⚠️ 户的实际宽度:单人 IW、双人 2*IW+SP —— **不是** n*SLOT
|
||
if (p) { xs.push(p.x0, p.x0 + (p.n === 2 ? 2 * IW + SP : IW)); }
|
||
});
|
||
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 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
|
||
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
|
||
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
|
||
// 那个已经在下面的 `groupSiblings()` 里修掉了(同父的子女会被排到一起)。
|
||
// 所以这里恢复成"最左 → 最右"的整条横梁,看起来才是一家人。
|
||
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; ?>
|