Files
FamilyBook/templates/kin/tree.php
T
laoyang 65378ff48e v19: 家谱画线重写 —— 先定节点坐标、再按节点画线(用户建议的思路)
用户反馈(原话):「还是有问题,主要是线的问题,很多线画的不到位,要么是连的不到位,
要么就是和下一代的节点没连上。你是用 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 条全过

⚠️ 顺带发现的数据问题(**不是代码问题**,已告知用户):
真实数据里 **爸爸与妈妈之间没有登记夫妻关系**(妈妈是单独一户),
所以图上他们是并排但不算一对;补上「配偶」后会自动合并成一户。
2026-10-02 15:49:50 +08:00

967 lines
46 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;
}
// ⚠️⚠️ 户内部:**本家血亲在前、配偶在后**(配偶排右手边,参考实现就是这样)。
//
// "血亲"不能只按"他有父母在本图上"判定 —— 一对夫妻**两边父母都可能在图上**
// (我的父母 + 妻子的父母),那样两边都算血亲,取到第一个就成了"妻子当主位、
// 我排在她右边"(实测线上就是这个症状:红围脖@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, '&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;
}
/** 一"户"的实际横向半宽(用于防重叠;**不是** 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; ?>