Files
FamilyBook/templates/kin/tree.php
T
laoyang 11c13a4e9d v18: 家谱改成「仿亲友+」App 的画法(按用户给的参考实现)
用户反馈(原话):「不行,非常乱。我给你个链接
https://gitee.com/coku2333/FamilyTreeView 你去读这个代码,尤其是它那个仿亲友+app
的效果,就是我想要的,按照这个做。」

参考实现是 Android 的 NewFamilyTreeView(ViewGroup + onDraw 画线)。我读了它的
README 与源码,把它的**布局规则**逐条搬过来(不是搬代码,是搬算法):

  ① **从下往上定位**:先孙辈(+2)→ 子女(+1)→ 我(0)→ 父母(-1)→ 祖辈(-2)。
     因为"子层位置定了,父层就定了" —— 父居中于子之上。
  ② 每一代维护 **genLeft / genRight 两个游标**(该代已用到的左右边界);
     往右铺从 genRight 起、往左铺从 genLeft 起(对应它的 setPart)。
  ③ **配偶并排在主位右侧**(一对夫妻 = 相邻两张卡)。
  ④ 兄姐往左、弟妹往右;舅姨(母系)往右、叔伯姑(父系)往左 ——
     都是"以我/父母为中心"向两侧展开,而不是按格子平铺。
  ⑤ 我居中于我的子女之上;父母居中于我之上;祖辈居中于父母之上。
  ⑥ **连线画在卡片背后**(参考是 ViewGroup.onDraw 先画线再绘子 View)——
     所以线可以从卡中心连到卡中心,被卡面挡住即可,不用绕路。
  ⑦ 卡片改成参考的窄长比例(它 50×80,我们 88×106):头像占上半、姓名与称呼在下。

画线(沿用参考的三段式,子女一律从夫妻横线中点出):
  夫妻横线(两卡中心之间)→ 从**横线中点**往下垂到横梁 → 横梁分到每个孩子。

上一版(v17「整齐树」)的问题:线虽然不穿卡了,但整张图看着像表格 ——
每一代从左到右平铺、兄弟姐被推到最外侧、伯父排在爸爸右边。这一版才对上参考的观感。

验证(按用户要求只做简单测试):
  · `_fb_kin_v18.py`(新)—— 20 人夹具渲染:**0 重叠**、20 张卡、28 段连线、无 JS 报错;
    各行分布确认:祖辈居中于父母之上(575/653)、伯父在爸爸左边(403 < 575)、
    兄姐在我左(442/519)、弟妹在我右、堂兄紧贴伯父下方。
  · e2e 553 条全过(家谱段断言随 v18 更新为 takeSlot/placePart/putCentered/midX)
  · 修掉的既有 bug:`parentsOf` 的键是**户索引**,而多处按**人 id** 去查父母 ——
    混用会永远查不到,导致"户的主位永远取第一张卡"(伯父那户取成伯母)、
    父系/母系判定全错、叔伯姑全跑到右边。新增 personParents 映射统一。
  · 修掉的布局 bug:兄姐/弟妹原本从"整代游标末尾"起 → 被推到同代最外侧;
    堂表亲调 placePart 会与我的兄姐重叠(实测抓到 1 处)→ 改成紧贴其父母下方。

另:其他功能一行未动(用户明确要求)。
2026-10-02 13:26:11 +08:00

704 lines
31 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'),
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
'rank' => \App\Kinship::rankValueFor($people, $id),
];
}
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>
/*****************************************************************
* 家谱布局:仿「亲友+」App 的画法
*
* 参考实现:gitee.com/coku2333/FamilyTreeView 的 NewFamilyTreeView
* (用户 2026-10-02 指定「按照这个做」)。核心规则:
* ① 从下往上定位:孙辈 → 子女 → 我 → 父母 → 祖辈
* (子层位置定了,父层就定了 —— 父居中于子之上)
* ② 每一代维护 genLeft / genRight 两个游标;往右铺从 genRight 起、
* 往左铺从 genLeft 起
* ③ 配偶并排在主位右侧(一对夫妻 = 相邻两张卡)
* ④ 年长的兄弟姐妹往左、年幼的往右;舅姨往右、叔伯姑往左
* ⑤ 连线画在**卡片背后**(可以从卡中心连到卡中心,被卡面挡住)
*****************************************************************/
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof d3 === 'undefined') { return; }
var data;
try {
data = JSON.parse(root.getAttribute('data-kin') || '{}');
} catch (err) { data = {}; }
var units = (data.units || []);
if (!units.length) { return; }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/* ---------- 版式常量 ---------- */
var IW = 88; // 卡片宽
var IH = 106; // 卡片高
var SP = 26; // 卡片间距(参考实现里的 mSpacePX)
var SLOT = IW + SP; // 一个人的"槽位"宽(配偶间隔也用 SP,与参考一致)
var YS = IH + SP; // 代与代之间的行距
var PAD = 60; // 画布留白
/* ---------- 0. 基础索引 ---------- */
var byId = {}; // personId -> {u}
units.forEach(function (u, ui) {
u._i = ui;
u.cards.forEach(function (c) { byId[String(c.id)] = { u: ui, card: c }; });
});
// 户 → 户 的父子关系(由「户 → 子女」推)
var kidsOf = {}, parentsOf = {};
units.forEach(function (u, ui) {
(u.kids || []).forEach(function (kid) {
var ref = byId[String(kid)];
if (!ref || ref.u === ui) { return; }
(kidsOf[ui] = kidsOf[ui] || []);
if (kidsOf[ui].indexOf(ref.u) < 0) {
kidsOf[ui].push(ref.u);
(parentsOf[ref.u] = parentsOf[ref.u] || []);
if (parentsOf[ref.u].indexOf(ui) < 0) { parentsOf[ref.u].push(ui); }
}
});
});
// ⚠️⚠️ 关键:`parentsOf` 的键是**户索引**,而下面多处是按**人 id** 去查有没有父母
// (比如"哪张卡是本家的血亲位")。两者混用会**永远查不到** ——
// 症状是"主位永远取第一张卡"(伯父那一户的主位取成了伯母),
// 进而父系/母系判定全错。这里单独建一份"人 id → 父母户索引"。 */
var personParents = {}; // personId -> [父户 ui,...]
units.forEach(function (u, ui) {
u.cards.forEach(function (c) {
var ps = parentsOf[String(ui)];
if (ps && ps.length) { personParents[String(c.id)] = ps; }
});
});
// 我所在的户
var meUi = 0;
units.forEach(function (u, ui) {
u.cards.forEach(function (c) { if (c.me) { meUi = ui; } });
});
// 户的"血亲位"(哪张卡是本家的人):优先取"有父户"的那张
units.forEach(function (u) {
var idx = 0;
for (var i = 0; i < u.cards.length; i++) {
var ps = personParents[String(u.cards[i].id)];
if (ps && ps.length) { idx = i; break; }
}
u._headIdx = idx;
u._head = String(u.cards[idx].id);
// 主位的"排行键"(长幼):服务端已算好(birth_rank → 生日 → id 兜底),
// 前端要靠它分「哥哥/弟弟」「姐姐/妹妹」(以及"年长的往左排")。
u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank);
});
/* ---------- 1. 游标(每一代的左右边界)---------- */
var gmin = 0, gmax = 0;
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
var genL = {}, genR = {};
function resetCursor(lo, hi) {
for (var g = lo; g <= hi; g++) { genL[g] = 0; genR[g] = 0; }
}
resetCursor(gmin - 1, gmax + 1);
// 已定位的户:ui -> {gen, uc}(uc = 这一户的中心 x)
var placed = {};
/* 把一户放到某代的"下一个空位",返回它的中心 x
toRight=true 从 genR 往右接;false 从 genL 往左接 */
function takeSlot(ui, gen, toRight) {
var u = units[ui];
var n = u.cards.length >= 2 ? 2 : 1;
var span = n * SLOT;
var x0;
if (toRight) {
x0 = genR[gen];
genR[gen] = x0 + span;
} else {
genL[gen] -= span;
x0 = genL[gen];
}
// 这一户的中心:两张卡时是两卡中心的中点
var uc = x0 + IW / 2 + (n === 2 ? SLOT / 2 : 0);
placed[ui] = { gen: gen, uc: uc, n: n, x0: x0 };
return uc;
}
// 把一户**居中**放在 x = uc
function putCentered(ui, gen, uc) {
var n = units[ui].cards.length >= 2 ? 2 : 1;
placed[ui] = { gen: gen, uc: uc, n: n, x0: uc - (n === 2 ? SLOT : IW) / 2 };
return uc;
}
/* 一代的边界用"已放的户"刷新(对应参考的 measureMaxCoordinate) */
function refreshBounds(gen) {
var lo = Infinity, hi = -Infinity, any = false;
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p || p.gen !== gen) { return; }
any = true;
lo = Math.min(lo, p.x0);
hi = Math.max(hi, p.x0 + p.n * SLOT);
});
if (!any) { return; }
if (!isFinite(genL[gen]) || lo < genL[gen]) { genL[gen] = lo; }
if (!isFinite(genR[gen]) || hi > genR[gen]) { genR[gen] = hi; }
}
/* ---------- 2. 核心:放"一户 + 它的子女" ----------
childGen = 子女所在的那一代;这一户放在 childGen - 1。
完全对应参考的 setPart(position, isRight, ...)。 */
function placePart(childGen, toRight, ui) {
if (placed[ui]) { return; }
var u = units[ui];
var kids = (kidsOf[ui] || []).slice();
if (!toRight) { kids.reverse(); } // 往左铺时,长幼次序要反过来
if (!kids.length) {
// 没有子女:在"子女代"占位(参考实现就是推进游标留空),
// 自己这一户也跟着往前放 —— 这样不会和别人的子女挤在一起
var n0 = u.cards.length >= 2 ? 2 : 1;
if (toRight) { genR[childGen] += n0 * SLOT; }
else { genL[childGen] -= n0 * SLOT; }
takeSlot(ui, childGen - 1, toRight);
return;
}
kids.forEach(function (k) { placePart(childGen + 1, toRight, k); });
// 父(这一户)居中于子女之上
var first = placed[kids[0]].uc;
var last = placed[kids[kids.length - 1]].uc;
var mid = (first + last) / 2;
putCentered(ui, childGen - 1, mid);
refreshBounds(childGen - 1);
}
/* 只把"一户"放到某代(它的子女已另行处理)—— 用于兜底与姻亲 */
function placeLoose(ui, gen, toRight) {
if (placed[ui]) { return; }
takeSlot(ui, gen, toRight);
refreshBounds(gen);
}
/* ---------- 3. 按参考 order 逐代定位 ---------- */
(function layoutAll() {
/* (1) 我的子女(+1)与孙辈(+2)—— 先把"我"放在原点,再让子女居中于我之下 */
putCentered(meUi, 0, 0);
genL[0] = -IW / 2; genR[0] = (units[meUi].cards.length >= 2 ? SLOT + IW / 2 : IW / 2);
var myKids = (kidsOf[meUi] || []).slice();
myKids.forEach(function (k) { placePart(1, true, k); });
/* (2) 我居中于我的子女之上;整支(我 + 子女及以下)一起平移 */
if (myKids.length) {
var kf = placed[myKids[0]].uc, kl = placed[myKids[myKids.length - 1]].uc;
var dx = ((kf + kl) / 2) - placed[meUi].uc;
(function shift(ui, d) {
if (placed[ui]) { placed[ui].uc += d; placed[ui].x0 += d; }
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift(k, d); } });
})(meUi, dx);
}
resetCursor(gmin - 1, gmax + 1);
refreshBounds(0); refreshBounds(1); refreshBounds(2);
/* (3) 兄弟姐妹:以"父母的子女列表"的长幼序,我在中间 —— 兄姐往左、弟妹往右 */
var pu = (parentsOf[meUi] || [])[0];
var sibsAll = pu === undefined ? [] : (kidsOf[pu] || []).slice();
var myPos = sibsAll.indexOf(meUi);
var older = myPos > 0 ? sibsAll.slice(0, myPos) : []; // 长幼序里排在我前面的 = 兄姐
var younger = myPos >= 0 ? sibsAll.slice(myPos + 1) : []; // 排在我后面的 = 弟妹
// ⚠️⚠️ 兄姐/弟妹必须**紧贴着我**(在上一代"父母的")。参考实现里它们是
// "从 genLeft/genRight 往外铺",但那是**接着父母的子女块**往外长 ——
// 所以才有"哥哥在我左边、弟弟在我右边"的观感。
// 我这一版第一稿直接调 placePart() 从游标末尾起,结果兄姐/弟妹被推到
// 同代的最外侧(中间还夹着堂表亲),看着就像"外人"(实测截图确认)。
// 正解:以"我的中心"为基准,向左/向右**依次紧贴**摆放,
// 每放一户就把基准推进一户宽(并带上它的子女)。
var slotW = IW + SP;
// 兄姐:离我最近的先放(大哥最左 → 依次向左)
var cur = placed[meUi].x0; // 我这一户的左边缘
older.forEach(function (s) { // older 已是"长→幼",从最近(幼)开始向左
var n = units[s].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
var uc = cur - SP - w / 2;
putCentered(s, 0, uc);
// 它的子女(侄甥)放它下方
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
cur = uc - w / 2;
});
// 弟妹:往右紧贴
var curR = placed[meUi].x0 + (units[meUi].cards.length >= 2 ? 2 * slotW : IW);
younger.forEach(function (s) {
var n2 = units[s].cards.length >= 2 ? 2 : 1;
var w2 = n2 * slotW;
var uc2 = curR + SP + w2 / 2;
putCentered(s, 0, uc2);
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
curR = uc2 + w2 / 2;
});
// 兄姐是"长幼序里排在我前面的",最年长的应在最左 → 反向再摆一次
// (上面是从最近往左排,最年长的自然落在最左,正是要的效果)
/* (4) 父母(-1)居中于我之上;祖辈(-2)居中于父母之上 */
if (pu !== undefined && !placed[pu]) {
putCentered(pu, -1, placed[meUi].uc);
refreshBounds(-1);
var gps = (parentsOf[pu] || []).slice();
gps.forEach(function (gp, i) {
if (placed[gp]) { return; }
putCentered(gp, -2, placed[pu].uc + (gps.length > 1 ? (i === 0 ? -SLOT / 2 : SLOT / 2) : 0));
refreshBounds(-2);
});
/* (5) 父母的兄弟姐妹:舅姨(母系)往右、叔伯姑(父系)往左,他们的子女是堂表亲(0 代) */
// 判断哪个祖辈是父系:父 = 父母这一户里"性别为男"的那张卡(取不到就用主位)
var pr = units[pu];
var fatherId = null;
pr.cards.forEach(function (c) { if (c.gender === 'M') { fatherId = String(c.id); } });
if (!fatherId) { fatherId = pr._head; }
// ⚠️ 叔伯姑/舅姨要**紧挨着父母**摆:父系往父亲那侧(左)、母系往母亲那侧(右)。
// 第一稿直接 placePart(0, 左/右) —— 那是从"整代游标"的末尾起,
// 叔伯被推到很远的外侧,中间空一大段(实测截图里"伯父"跑到爸爸右边很远)。
// 正解:以"父母这一户"的两端为基准向外铺。
var puW = units[pu].cards.length >= 2 ? 2 * slotW : IW;
var puLeft = placed[pu].uc - puW / 2;
var puRight = placed[pu].uc + puW / 2;
var bySide = { left: [], right: [] };
// ⚠️⚠️ 判定「这一位是我父系的还是母系的」要看**他自己的父母**里
// 有没有那位父系祖辈 —— 而不是"去祖辈户里找爸爸"(那是恒假的写法,
// 第一稿就这么写,结果所有叔伯都被当成母系、全跑到右边)。
// fatherId = 我爸的 id;父系祖辈 = 我爸的父母里那一位。
var fatherSideGps = (personParents[String(fatherId)] || []).slice();
gps.forEach(function (gp) {
(kidsOf[gp] || []).forEach(function (s) {
if (s === pu || placed[s]) { return; }
// s 的父母里有没有"父系祖辈所在的户"
var ps2 = personParents[units[s]._head] || [];
var isFatherSide = ps2.indexOf(gp) >= 0 && fatherSideGps.indexOf(gp) >= 0;
bySide[isFatherSide ? 'left' : 'right'].push(s);
});
});
// 叔伯姑/舅姨的子女(堂表亲):**紧贴其父母下方**放。
// ⚠️ 不能调 placePart() —— 那会从"整代游标末尾"起、还会递归整支,
// 结果堂兄跑到我的兄姐位置上、两者重叠(实测抓到 1 处重叠)。
// 这里按"父户中心"居中摆,若与已有的撞上再顺延。
function placeCousins(s, gen) {
var ks = (kidsOf[s] || []).slice();
if (!ks.length) { return; }
var span = ks.length * slotW;
var startC = placed[s].uc - (span - slotW) / 2;
ks.forEach(function (k, i) {
if (placed[k]) { return; }
var n = units[k].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
putCentered(k, gen, startC + i * slotW + (slotW - w) / 2 + w / 2);
});
}
// 父系:从父亲的左边往外铺(长幼序倒过来 —— 长叔伯更靠外)
var lcur = puLeft;
bySide.left.slice().reverse().forEach(function (s) {
var n = units[s].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
var uc = lcur - SP - w / 2;
putCentered(s, -1, uc);
placeCousins(s, 0);
lcur = uc - w / 2;
});
// 母系:从母亲的右边往外铺
var rcur = puRight;
bySide.right.forEach(function (s) {
var n2 = units[s].cards.length >= 2 ? 2 : 1;
var w2 = n2 * slotW;
var uc2 = rcur + SP + w2 / 2;
putCentered(s, -1, uc2);
placeCousins(s, 0);
rcur = uc2 + w2 / 2;
});
}
/* (6) 兜底:还没定位的(姻亲岳父母、其它旁支)按"离我这支最近"贴到同代右侧 */
var rest = [];
units.forEach(function (u, ui) { if (!placed[ui]) { rest.push(ui); } });
// 先放"与我这一支有直接关系"的(比如配偶的父母),再放更远的
rest.sort(function (a, b) {
var da = nearDist(a), db = nearDist(b);
return (units[a].gen - units[b].gen) || (da - db) || (a - b);
});
rest.forEach(function (ui) {
var g = units[ui].gen;
// 尽量靠到"它的子女"旁边(姻亲的父母 → 贴在他们女儿(我配偶)那一支的右侧)
// ⚠️ 姻亲(岳父母):他们的位置应该**贴在他们女儿(我配偶)那一支的上方**,
// 而不是"飘到同代最右"。第一稿就是这么写的 —— 结果岳父母跑到爸爸右边、
// 离我妻子很远,看着像别人家的人(实测截图确认)。
// 正解:锚到"它的子女"(= 我配偶所在的那一户)的上方居中。
var ks = kidsOf[ui] || [];
var anchor = null;
ks.forEach(function (k) { if (placed[k] !== undefined && anchor === null) { anchor = placed[k]; } });
if (anchor !== undefined && anchor !== null && anchor.gen === g + 1) {
// 与"同代已有的"错开:若锚点上方已被别人占,就往右让
var want = anchor.uc;
var n = units[ui].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
// 与同代已有的户依次避让(只往右让,避免来回挪)
var row = [];
units.forEach(function (u2, ui2) {
if (placed[ui2] && placed[ui2].gen === g && ui2 !== ui) { row.push(ui2); }
});
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
var half = w / 2;
for (var ri = 0; ri < row.length; ri++) {
var rp = placed[row[ri]];
var rn = units[row[ri]].cards.length >= 2 ? 2 : 1;
var rHalf = rn * slotW / 2;
if (Math.abs(want - rp.uc) < (half + rHalf + SP / 2)) {
want = rp.uc + rHalf + SP / 2 + half;
}
}
putCentered(ui, g, want);
} else {
placeLoose(ui, g, true);
}
});
function nearDist(ui) {
var ps = parentsOf[ui] || [];
var best = 9;
ps.forEach(function (p) {
// 到「我」这一支的代数距离
best = Math.min(best, Math.abs(units[p].gen - 0) + 1);
});
return best;
}
})();
/* ---------- 4. 同代防重叠(保险):按 x 排序后依次推开,子树跟着走 ---------- */
(function antiOverlap() {
for (var g = gmin; g <= gmax; g++) {
var row = [];
units.forEach(function (u, ui) { if (placed[ui] && placed[ui].gen === g) { row.push(ui); } });
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var i = 1; i < row.length; i++) {
var prev = placed[row[i - 1]], cur = placed[row[i]];
var prevRight = prev.x0 + prev.n * SLOT;
var curLeft = cur.x0;
if (curLeft < prevRight + 0) {
var d = prevRight - curLeft;
(function shift3(ui, dd) {
if (placed[ui]) { placed[ui].uc += dd; placed[ui].x0 += dd; }
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift3(k, dd); } });
})(row[i], d);
}
}
}
})();
/* ---------- 5. 落最终坐标 ---------- */
var xs = [];
units.forEach(function (u, ui) {
var p = placed[ui];
if (p) { xs.push(p.x0, p.x0 + p.n * SLOT); }
});
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
var W = (maxX - minX) + PAD * 2;
var rows = [];
for (var g = gmin; g <= gmax; g++) { rows.push(g); }
var H = PAD * 2 + (rows.length - 1) * YS + IH;
var pos = {}; // personId -> {x, y}(卡中心)
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p) { return; }
var y = PAD + IH / 2 + rows.indexOf(u.gen) * YS;
var x0 = p.x0 - minX + PAD;
var ids = u.cards.map(function (c) { return String(c.id); });
if (ids.length >= 2) {
pos[ids[0]] = { x: x0 + IW / 2, y: y };
pos[ids[1]] = { x: x0 + SLOT + IW / 2, y: y };
} else {
pos[ids[0]] = { x: x0 + IW / 2, y: y };
}
});
/* ---------- 6. 卡片 ---------- */
var html = '';
units.forEach(function (u) {
u.cards.forEach(function (c) {
var p = pos[String(c.id)];
if (!p) { return; }
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
var g = (c.gender === 'M' || c.gender === 'F') ? '' : ' g-u';
html += '<a class="' + cls + g + '" data-kin-id="' + esc(c.id) + '"'
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + IW + 'px;height:' + IH + 'px"'
+ ' href="/index.php?r=people/show&id=' + encodeURIComponent(c.id) + '">'
+ '<span class="kin-ph"><img src="' + esc(c.avatar) + '" alt="" loading="lazy"></span>'
+ '<span class="kin-nm">' + esc(c.name) + '</span>'
+ '<span class="kin-lb">' + esc(c.label) + '</span>'
+ (c.ph ? '<span class="kin-mark">待确认</span>' : '')
+ '</a>';
});
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
/* ---------- 7. 连线(画在卡片背后,所以可以从卡中心连到卡中心)---------- */
var paths = [], bars = [], drops = [];
function line(x1, y1, x2, y2) {
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
}
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p) { return; }
var ids = u.cards.map(function (c) { return String(c.id); });
var yRow = pos[ids[0]].y; // 这一代卡片中心 y
var midX;
if (ids.length >= 2) {
var a = pos[ids[0]], b = pos[ids[1]];
midX = (a.x + b.x) / 2;
// ① 夫妻横线:两卡中心之间的水平线(画在卡后,被卡面挡住)
line(a.x, a.y, b.x, b.y);
bars.push({ ui: ui, x1: a.x, x2: b.x, y: a.y, has: true, mid: midX });
} else {
midX = pos[ids[0]].x;
bars.push({ ui: ui, x1: midX, x2: midX, y: yRow, has: false, mid: midX });
}
// ② 有子女 → 从夫妻横线**中点**往下垂到"横梁"
var kids = (kidsOf[ui] || []).filter(function (k) { return pos[units[k]._head] || true; });
if (kids.length) {
var busY = yRow + IH / 2 + SP / 2; // 参考:卡底 + SP/2
line(midX, yRow, midX, busY);
// ③ 横梁:从最左孩子到最右孩子
var kx = kids.map(function (k) { return placed[k].uc; });
var minK = Math.min.apply(null, kx), maxK = Math.max.apply(null, kx);
if (kids.length > 1 && maxK - minK > 0.5) { line(minK, busY, maxK, busY); }
drops.push({ ui: ui, x: midX, y0: yRow, busY: busY });
}
// ④ 每个人若有父母 → 从卡顶往上画一小段(接到上一代的横梁)
ids.forEach(function (id) {
var ps = parentsOf[id];
if (ps && ps.length) {
var pp = pos[id];
line(pp.x, pp.y - IH / 2, pp.x, pp.y - IH / 2 - SP / 2);
}
});
});
root.insertAdjacentHTML('afterbegin',
'<svg class="kin-lines" width="' + W + '" height="' + H + '">'
+ paths.map(function (d) { return '<path class="kin-link" d="' + d + '"></path>'; }).join('')
+ '</svg>');
window.__kinLayout = { W: W, H: H, IW: IW, IH: IH, SLOT: SLOT, bars: bars,
drops: drops, pos: pos, paths: paths };
/* ---------- 8. 拖拽 + 缩放 ---------- */
var stage = root.querySelector('.kin-stage');
var svg = root.querySelector('svg.kin-lines');
var zoom = d3.zoom().scaleExtent([0.15, 3]).on('zoom', function (ev) {
var t = ev.transform;
[svg, stage].forEach(function (el) {
el.style.transformOrigin = '0 0';
el.style.transform = 'translate(' + t.x + 'px,' + t.y + 'px) scale(' + t.k + ')';
});
});
var sel = d3.select(root);
sel.call(zoom);
function fit() {
var r = root.getBoundingClientRect();
if (!r.width || !r.height) { return; }
var k = Math.min(r.width / W, r.height / H) * 0.94;
k = Math.max(0.15, Math.min(3, k));
sel.call(zoom.transform, d3.zoomIdentity.translate(
(r.width - W * k) / 2, (r.height - H * k) / 2).scale(k));
}
function centerOn(id) {
var p = pos[String(id)];
if (!p) { return; }
var r = root.getBoundingClientRect();
var t = d3.zoomTransform(root);
sel.call(zoom.transform, d3.zoomIdentity.translate(
r.width / 2 - p.x * t.k, r.height / 2 - p.y * t.k).scale(t.k));
}
window.kinFit = fit;
window.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || '';
fit();
// 进来先把镜头对准「我」
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); }
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
if (btnFit) { btnFit.addEventListener('click', fit); }
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
})();
</script>
<?php endif; ?>