家谱 v21:改回 family-chart 库渲染

用户明确要求「还是用 family-chart 来实现吧,刚才查了这个库可以完美实现
我想要的效果,之前一定是你调用的方法不对」。自研区间法(v20)虽然
20/20 全显示且通过全部版面检查,但用户觉得不够顺滑细腻,改为交给成熟库。

== 关键修复(都是真跑出来的坑)==

1. **IIFE 首行就 return**:本地变量 `var f3 = root.querySelector(...)` 与
   全局库 `f3` 重名,`typeof f3 === 'undefined'` 因变量提升恒为真 →
   整段 JS 一行没执行、画布永远空白。改名 `f3Canvas`。

2. **「回到我」不动卡片层**:库的 zoom 行为绑在 `#f3Canvas` 上,不是
   `svg.main_svg`。拿 svg 的 `__zoomObj` 去驱动只会动 SVG 连线层,
   HTML 卡片层(`#htmlSvg .cards_view`)留在原地。改取 `#f3Canvas`。

3. **SVG 退化成 300×150**:库自带 CSS 写的是 `.f3 svg.main_svg{width:100%;
   height:100%}`,而我们的容器类名是 `.kin-chart`(不是 `.f3`)、也没引
   库的 CSS → SVG 根元素用浏览器默认尺寸,所有画到框外的连线被裁掉。
   症状很隐蔽:卡片层是 HTML div 不受影响,于是表现为「卡在、线没了」。
   补 `.kin-chart svg.main_svg{width:100%;height:100%}`。

4. **儿子/女儿这一辈不显示**:树根是最高祖先,`progeny_depth` 必须覆盖到
   「我」的下一代 → 从 2 调到 3。

5. **连线是白的看不见**:库给 path 写了 inline `stroke="#fff"`,普通 CSS
   覆盖不了,必须 `!important`。

== 免费版能力边界(已实测确认,非调用问题)==
库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
**不会从配偶继续向上/向下 walk**。20 人夹具里岳父/岳母/舅子/外甥
(4 位姻亲)画不出来(换树根、调 depth 都救不了)→ 图上 16/20。
服务端 data-datum 仍含全体(含姻亲),名册/称呼不受影响。

== 验证战绩 ==
- e2e 549 通过 / 0 失败
- 桌面几何 368 / 0
- 移动端几何 516 / 0(家谱断言改到 family-chart 选择器)
- 交互 97 / 0;升级专测 123 / 0
- 家谱可视化 24 / 0(含「svg 不是 300×150」这条,正是坑 3 的捕手)
- 家谱版面 10 / 0
- 反向验证 3 / 3(间距压扁→叠卡;depth=1→缺人;写坏 centerOn 平移量→偏离中心)
This commit is contained in:
2026-10-02 19:24:08 +08:00
parent e22202271a
commit c1a97c7d81
2 changed files with 230 additions and 579 deletions
+48 -35
View File
@@ -432,19 +432,14 @@ textarea.input { min-height: 76px; resize: vertical; }
} }
/* ---------------- 家谱(以我为中心) ---------------- /* ---------------- 家谱(以我为中心) ----------------
v15:**自己排版、自己画线**,不再用 family-chart。 v21:改用 family-chart 库渲染。库把 data[0].id 当 main_id,
所以把「最高祖先」排第一位以画出尽可能多的血亲旁支;
加载后再把镜头平移回「我」,兼顾"看到全树"与"以自己为中心"。
为什么把库卸掉(实测数据,不是口味问题): 免费版 family-chart 的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
20 人的夹具,库只画出 **9 人** —— 伯父/伯母/岳父母/嫂子/堂兄/堂嫂/舅子/侄子/ **不会从配偶继续向上/向下 walk**,因此岳父岳母、舅子、外甥等姻亲旁支
堂侄/外甥一个都没画。库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」, 在单棵树里天然画不出来(换树根、调 depth 都救不了)。
**横向亲戚与姻亲旁支永远进不来**(换树根也救不了)。用户两次反馈 这是库的能力边界,不是调用方式问题;如要姻亲,需要 Premium 或多树拼接。 */
「显示不齐全」「数据仍然不全,差很多」说的就是这个。
自己排版之后,「画谁」与「线从哪出」都变成几行代码:
· 每代一行,按「户」(夫妻单元)水平排开;
· 夫妻两张卡之间画一条短横线;
· 子女一律从这条**横线的中点**垂下来 —— 与"谁登录"无关、与"孩子只登记了一位父母"无关。
拖拽/缩放继续保留(用户明确说「反正可以缩放和拖动」),用原生 d3.zoom 实现。 */
.kin-tools { display: flex; gap: 8px; justify-content: flex-end; margin: 0 0 10px; } .kin-tools { display: flex; gap: 8px; justify-content: flex-end; margin: 0 0 10px; }
@@ -458,37 +453,46 @@ textarea.input { min-height: 76px; resize: vertical; }
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
cursor: grab; cursor: grab;
/* 拖拽/缩放自己接管,别让浏览器插手滚动 */ /* 拖拽/缩放由库接管,别让浏览器插手滚动 */
touch-action: none; touch-action: none;
} }
.kin-chart:active { cursor: grabbing; } .kin-chart:active { cursor: grabbing; }
/* 卡片层与连线层都靠 transform 一起平移缩放(两层必须同源,否则线会跟卡错位) */ /* 隐藏 family-chart 自带的空导航栏(我们用自己的「看全部 / 回到我」按钮) */
.kin-stage, .kin-chart svg.kin-lines { .kin-chart .f3-nav-cont { display: none !important; }
position: absolute; left: 0; top: 0;
will-change: transform;
}
.kin-chart svg.kin-lines { pointer-events: none; z-index: 1; }
/* ⚠️ v18:参考实现(亲友+)把连线画在**卡片背后**(ViewGroup.onDraw 在子 View 之前),
所以线可以从卡中心连到卡中心、被卡面挡住即可 —— 不需要绕路。
这里用 z-index 保证「连线在卡片下面」。 */
.kin-stage { z-index: 2; }
/* 连线:夫妻短横线 + 子女下垂线,全部一条笔触 */ /* ⚠️ 这条是**必须**的:库自带的 CSS 里写着 `.f3 svg.main_svg { width:100%; height:100% }`,
.kin-chart path.kin-link { 而我们的容器类名是 `.kin-chart`(不是 `.f3`),也没引库的 CSS ——
fill: none; 于是 SVG 根元素退化成浏览器默认的 **300×150**,所有画到框外的连线被裁掉
stroke: #C9BDA9; (卡片层是 HTML div,不受影响,所以"卡在、线没了"这个症状很隐蔽)。
stroke-width: 1.6; 实测:不写这条时 svg 只有 300×150(`_fb_kin_check_v17.py` 第②条会红)。 */
vector-effect: non-scaling-stroke; /* 缩放时线不跟着变粗变细 */ .kin-chart svg.main_svg { width: 100%; height: 100%; display: block; z-index: 1; }
}
/* v18:卡片改成「上图下名 + 称呼」的窄长形,对齐参考实现(亲友+)的比例: /* family-chart 的 HTML 卡片层 #htmlSvg 与 SVG 连线层由库的 onZoom 同步 transform。
参考里 50dp 宽 × 80dp 高,头像占满上半、姓名与称呼各一行贴在下半。 卡片容器 .card_cont 已经 absolute + translate(x,y);里面再放我们的 .kin-card。 */
我们把 88×106 按同样比例放大 —— 窄一点才排得下"好几代 + 兄弟姐妹"。 */ .kin-chart #htmlSvg { z-index: 2; }
.kin-chart .card_cont { pointer-events: none; }
/* 库给卡片套了一层 <div class="card"> / <div class="card-inner">,
我们只接管最里面那层(.kin-card)。把外层的中转容器归零,避免它自带的内边距/背景
让卡片看起来"胖了一圈"或有底色。 */
.kin-chart .card_cont > .card,
.kin-chart .card_cont .card-inner {
background: transparent;
padding: 0;
border-radius: 0;
min-height: 0;
width: auto;
height: auto;
}
.kin-chart .card_cont .card-inner { display: block; }
/* v21:卡片尺寸与 setCardDim({w:140, h:120}) 对齐 */
.kin-card { .kin-card {
position: absolute; position: absolute;
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
gap: 0; gap: 0;
width: 140px; height: 120px;
box-sizing: border-box; padding: 4px 4px 3px; box-sizing: border-box; padding: 4px 4px 3px;
text-align: center; text-align: center;
background: var(--surface); background: var(--surface);
@@ -499,19 +503,20 @@ textarea.input { min-height: 76px; resize: vertical; }
transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */ transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */
transition: border-color .15s ease, box-shadow .15s ease; transition: border-color .15s ease, box-shadow .15s ease;
overflow: hidden; overflow: hidden;
pointer-events: auto; /* 父 .card_cont 是 none,这里恢复 */
} }
.kin-card:hover { .kin-card:hover {
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none; box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
} }
.kin-card .kin-ph { .kin-card .kin-ph {
width: 100%; height: 58px; border-radius: 7px; overflow: hidden; width: 100%; height: 70px; border-radius: 7px; overflow: hidden;
background: #F1E7D6; flex: 0 0 auto; background: #F1E7D6; flex: 0 0 auto;
} }
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; } .kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
.kin-card .kin-nm { .kin-card .kin-nm {
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%; font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 3px; margin-top: 4px;
} }
/* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */ /* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */
.kin-card .kin-lb { .kin-card .kin-lb {
@@ -529,6 +534,14 @@ textarea.input { min-height: 76px; resize: vertical; }
/* 「待确认」(只填了称呼、没对应到真人):虚线 */ /* 「待确认」(只填了称呼、没对应到真人):虚线 */
.kin-card.is-ph { border-style: dashed; background: #FCFAF6; } .kin-card.is-ph { border-style: dashed; background: #FCFAF6; }
/* family-chart 默认连线颜色改淡,与之前自研版一致。
库在 path 上写了 inline stroke="#fff",必须用 !important 覆盖。 */
.kin-chart svg.main_svg .links_view path.link {
fill: none;
stroke: #C9BDA9 !important;
stroke-width: 1.6;
}
.tree-legend { font-size: 12px; color: var(--muted); margin: 14px 2px 0; } .tree-legend { font-size: 12px; color: var(--muted); margin: 14px 2px 0; }
/* 详情页里的迷你家谱 */ /* 详情页里的迷你家谱 */
+176 -538
View File
@@ -1,30 +1,15 @@
<?php <?php
/** /**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。 * 我的家谱:以我为中心,上一代 / 这一代 / 下一代所有有关系的人。
* *
* ============================ 布局算法(v16 重写) ============================ * v21 —— 回退到 family-chart 库(用户 2026-10-02 明确要求)。
* *
* 历史:v13/v14 用第三方库 family-chart → 数据一直不全(实测 20 人只画出 9 人, * 之前自研布局叠了多层修补后位置漂移,用户决定还是交给成熟库来做。
* 因为它只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」, * 数据准备要点:
* **横向亲戚与姻亲旁支永远进不来**)。v15 改成自己排版,人数终于对了, * · 用 familyMap 把「上两代 / 本代 / 下两代」所有有关系的人(含姻亲旁支)全捞出来;
* 但布局是"每代按固定格子摊开",于是**线是乱的**: * · 转成 family-chart 的 Datum 格式 {id, data, rels: {parents, spouses, children}};
* · 同代卡片**会重叠**(一对夫妻宽 254px,却按 200px 一格摆); * · 「我」排数组第一位(库把 data[0].id 当 main_id);
* · 夫妻横线画在卡片**内部**(y = 卡中心 + 半个卡高 − 6),所以线从卡上穿过去; * · 子女按长幼排序后写进 rels.children,库按数组顺序摆左右。
* · 子女不落在父母下方,横梁跨得老远、线与线互相交叉。
*
* v16 换成**整齐树(tidy tree)**——这是家谱/组织图的标准排法,规则只有三条:
* ① 后序给每棵子树算「轮廓」(每一行被占用的 x 区间),按轮廓从左往右放,
* 保证**任何两张卡都不重叠**(同代不同代的都不会);
* ② 父(户)落在**其子女整体跨度的中点**上 —— 这样"子女从父母下方连出来"
* 天然成立,横梁最短、最不容易交叉;
* ③ 同代按长幼排序(长子左、次子右),兄弟姊妹相对次序稳定。
*
* 连线画法(用户点名的要求,一字不改):
* · 一对夫妻之间一条短横线;
* · 他们的子女**一律从这条横线正中间垂下来**(单亲户从本人卡正下方),
* 再经横梁分到每个孩子 —— 与"谁登录""孩子登记了几位父母"都无关。
*
* 拖拽 / 滚轮缩放保留(d3.zoom);另给「看全部 / 回到我」两个按钮。
*/ */
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId()); $graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
@@ -36,128 +21,113 @@ $gen = $map['gen'];
$units = $map['units']; $units = $map['units'];
$unitOf = $map['unit_of']; $unitOf = $map['unit_of'];
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)---- if (!isset($people[$meId]) || !is_array($people[$meId])) {
$unitKids = \App\Kinship::householdChildren($graph, $unitOf); $canChart = false;
} else {
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
// ---- ③ 同一代里的户按长幼排(长子左、次子右)---- // ---- ② 树根 = 「我」往上走到的最高那一代 ----
$rankKey = static function (int $id) use ($people): string { //
// family-chart 画的是「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」。
// 树根 = 我 时,伯父/堂兄/嫂子/侄子 不在「我的后代」里,也不会被画出来;
// 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」,
// 上一代、这一代、下一代的血亲与旁支才能全部落进来。
// 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。
$rootId = (int)$meId;
for ($guard = 0; $guard < 8; $guard++) {
$up = \App\Kinship::parentIds($graph, $rootId);
$next = 0;
foreach ($up as $p) {
$p = (int)$p;
if (isset($onTree[$p]) && (int)$gen[$p] < (int)$gen[$rootId]) {
$next = $p;
break;
}
}
if ($next === 0) { break; }
$rootId = $next;
}
// ---- ③ 连边:子女一律挂在「夫妻」上(normalizeLinks 的对称补齐)----
// 这样库才会把连线从夫妻横线中点垂下来,母亲登录也能看到孩子。
$links = \App\Kinship::normalizeLinks($graph, array_keys($onTree));
// ---- ④ 转成 family-chart 的 Datum 格式 ----
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id); return \App\Kinship::rankValueFor($people, $id);
}; };
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)---- // 树根排最前(库默认以 data[0].id 为 main_id);其余按「代数 → 长幼」跟上
$order = [$rootId];
// 「我」的血亲集合:只走 parent/child 边(不含配偶)能从「我」到达的人。 $rest = [];
// 用途:给每张卡标 blood —— 前端据此判定一户的「主父户」 foreach (array_keys($onTree) as $pid) {
// (我的户同时被父母户与岳父母户认作子女,树递归只能走本家那条, $pid = (int)$pid;
// 否则树变图、布局重复铺人;判定标准就是「父户的主位是不是我方血亲」)。 if ($pid !== $rootId) { $rest[] = $pid; }
$blood = [$meId => true];
$bq = [$meId];
while ($bq) {
$bid = (int)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;
} }
} usort($rest, static function (int $a, int $b) use ($gen, $rankKey): int {
} return ((int)$gen[$a] <=> (int)$gen[$b])
?: strcmp($rankKey($a), $rankKey($b))
$payloadUnits = []; ?: ($a <=> $b);
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 = []; $order = array_merge($order, $rest);
foreach ($sorted as $id) {
if (!isset($people[$id]) || !is_array($people[$id])) { $mapIds = static function (array $ids) use ($onTree): array {
continue; $out = [];
foreach ($ids as $x) {
$x = (int)$x;
if (isset($onTree[$x])) { $out[] = (string)$x; }
} }
$p = $people[$id]; return array_values(array_unique($out));
};
$datum = [];
foreach ($order as $pid) {
if (!isset($people[$pid]) || !is_array($people[$pid])) { continue; }
$p = $people[$pid];
$parents = $mapIds($links['parents'][$pid] ?? []);
$spouses = $mapIds($links['spouses'][$pid] ?? []);
$kids = $mapIds($links['children'][$pid] ?? []);
// 子女按长幼排序:库按 rels.children 的数组顺序摆左右
$kidsInt = array_map('intval', $kids);
usort($kidsInt, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
$kids = array_map('strval', $kidsInt);
$g = strtolower((string)($p['gender'] ?? '')); $g = strtolower((string)($p['gender'] ?? ''));
$cards[] = [ $gender = $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U');
'id' => $id,
$datum[] = [
'id' => (string)$pid,
'data' => [
'gender' => $gender,
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''), 'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $id, $people), 'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
'avatar' => avatar_url($p['avatar'] ?? null), 'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $id === (int)$meId, 'me' => $pid === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1, 'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'), ],
// ⚠️ 本家血亲标记:前端用它判定一户的「主父户」(见上方 $blood 的说明) 'rels' => [
'blood' => isset($blood[$id]), 'parents' => $parents,
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。 'spouses' => $spouses,
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor —— 'children' => $kids,
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。 ],
'rank' => \App\Kinship::rankValueFor($people, $id),
]; ];
} }
if (!$cards) {
continue;
}
// ⚠️⚠️ 户内部:**本家血亲在前、配偶在后**(配偶排右手边,参考实现就是这样)。
//
// "血亲"不能只按"他有父母在本图上"判定 —— 一对夫妻**两边父母都可能在图上**
// (我的父母 + 妻子的父母),那样两边都算血亲,取到第一个就成了"妻子当主位、
// 我排在她右边"(实测线上就是这个症状:红围脖@639 在我的左边)。
//
// 正解:按「**与"我"的血缘距离**」判定 —— 这一户里离「我」更近的那一位才是主位。
// 血亲集合 $blood 已在循环外算好(只走 parent/child 边的 BFS),
// 两边都在血亲集合里时,退到「与我的步数」比大小。
$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]; $canChart = count($datum) > 1;
} }
// 同代之间按「主位长幼」排序,保证顺序稳定
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'] ?? '自己'); $meName = (string)($people[$meId]['name'] ?? '自己');
$payloadJson = json_encode( $datumJson = json_encode(
['units' => $payloadUnits, 'me' => (int)$meId], $datum ?? [],
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
); );
?> ?>
<div class="page-head"> <div class="page-head">
<h1>我的家谱</h1> <h1>我的家谱</h1>
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女从夫妻横线中点连出</span> <span class="sub">以我为中心 · 上下一共五代 · 夫妻并排,子女从两人之间的连线连出</span>
</div> </div>
<?php if (!$canChart): ?> <?php if (!$canChart): ?>
@@ -173,42 +143,31 @@ $payloadJson = json_encode(
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button> <button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
</div> </div>
<div class="kin-chart" id="kinChart" <div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
data-kin="<?= e($payloadJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
<p class="tree-legend"> <p class="tree-legend">
每代一行,一代里按「户」排开;<b>一对夫妻之间有一条短横线</b>, 每代一行;<b>一对夫妻之间有一条短横线</b>,
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上), <b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上)。
再由横梁分到每个孩子。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。 「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。 可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
<br><span class="muted">注:本图由 family-chart 绘制。免费版只画「同一位祖先的血脉后代 + 各人配偶」,
妻子那侧的岳父母、舅子等姻亲不在图内(在名册与成员详情页仍可看到)。</span>
</p> </p>
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script> <script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script src="<?= e(asset('/assets/vendor/family-chart/family-chart.min.js')) ?>"></script>
<script> <script>
/*****************************************************************
* 家谱布局:区间法(tidy tree)+ 先摆节点、后按节点画线
*
* 参考过 gitee.com/coku2333/FamilyTreeView(用户 2026-10-02 指定的
* 「亲友+」效果),但它的游标铺法在我们的"以我为中心、含姻亲旁支"的
* 图上要叠四五层防重叠/重排修补才能不叠卡,每层又互相破坏 —— 最终
* 整棵树漂移(用户:"错的更离谱了,这次连位置都不对了")。
* 现在的规则(详见下方第 1 节注释):
* ① 每代一行;一对夫妻 = 相邻两张卡;
* ② 树递归用「区间法」分配互不重叠的 x 区间,父户居中于子女跨度
* —— 天然不叠卡,长幼序天然"兄姐在左、弟妹在右";
* ③ 连线画在卡片背后,**先把所有节点坐标摆定,再统一按节点画线**。
*****************************************************************/
(function () { (function () {
var root = document.getElementById('kinChart'); var root = document.getElementById('kinChart');
if (!root || typeof d3 === 'undefined') { return; } if (!root || typeof window.f3 === 'undefined') { return; }
var data; var raw;
try { try {
data = JSON.parse(root.getAttribute('data-kin') || '{}'); raw = JSON.parse(root.getAttribute('data-datum') || '[]');
} catch (err) { data = {}; } } catch (err) {
var units = (data.units || []); raw = [];
if (!units.length) { return; } }
if (!raw.length) { return; }
function esc(s) { function esc(s) {
return String(s == null ? '' : s) return String(s == null ? '' : s)
@@ -216,412 +175,91 @@ $payloadJson = json_encode(
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
/* ---------- 版式常量 ---------- */ var chart = f3.createChart('#kinChart', raw)
var IW = 88; // 卡片宽 .setTransitionTime(600)
var IH = 106; // 卡片高 .setOrientationVertical()
var SP = 26; // 卡片间距(参考实现里的 mSpacePX) .setShowSiblingsOfMain(true)
var SLOT = IW + SP; // 一个人的"槽位"宽(配偶间隔也用 SP,与参考一致) .setAncestryDepth(2)
var YS = IH + SP; // 代与代之间的行距 // 树根是最高祖先;progeny_depth 要覆盖 "儿子 / 女儿" 这一代,
var PAD = 60; // 画布留白 // 所以要比 "我" 到树根的世代再多一代。
.setProgenyDepth(3)
.setCardXSpacing(180)
.setCardYSpacing(180);
/* ---------- 0. 基础索引 ---------- */ chart.setCardHtml()
var byId = {}; // personId -> {u} .setStyle('rect')
units.forEach(function (u, ui) { .setCardDim({ w: 140, h: 120, mini_tree: false })
u._i = ui; .setCardInnerHtmlCreator(function (d) {
u.cards.forEach(function (c) { byId[String(c.id)] = { u: ui, card: c }; }); var p = (d.data && d.data.data) || {};
}); var id = (d.data && d.data.id) || '';
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
// 户 → 户 的父子关系(由「户 → 子女」推) return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
var kidsOf = {}, parentsOf = {}; + encodeURIComponent(id) + '">'
units.forEach(function (u, ui) { + '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
(u.kids || []).forEach(function (kid) { + '<span class="kin-nm">' + esc(p.name) + '</span>'
var ref = byId[String(kid)]; + '<span class="kin-lb">' + esc(p.label) + '</span>'
if (!ref || ref.u === ui) { return; } + (p.ph ? '<span class="kin-mark">待确认</span>' : '')
(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. 布局:区间法(tidy tree,一次成型) ----------
*
* 为什么整个推倒重写(用户 2026-10-02:"错的更离谱了,这次连位置都不对了"):
* 之前的版本是「游标铺放 → 防重叠 → 同代重排 → 再防重叠 → 再硬推」
* 叠了四五层修补,每一层都在破坏上一层的"父居中于子女",
* 多轮互相拉扯后整棵树漂移(实测"我"跑到离父母 800px 的最右侧)。
*
* 现在换成树布局的标准解法(区间法),只有三条规则,一次成型:
* ① 每棵「户子树」自底向上算**需求宽度**:
* needW = max( 自身宽, Σ子女needW + SP×(子女数−1) )
* ② 自顶向下分配**互不重叠的 x 区间**:子女依次铺在父区间内;
* ③ 父户居中于子女的实际跨度。
* 区间天然不重叠 ⇒ 任何两张卡绝不重叠,**不需要任何后续推挤修补**。
*
* 跨树边(岳父母户也把妻子列为子女):每户只认**主父户**进树递归
* (主位卡是"我方血亲"的那个父户,服务端已给每张卡标了 blood),
* 其余父户只在**画线**时用 —— 岳父母的子女横梁照样连到妻子,
* 但布局上岳父母树不会把"我的户"再铺一遍。
*/
var rawKids = kidsOf; // 原始子女关系(画线用;跨树边保留)
var mainParent = {}; // ui -> 主父户 ui
var kidList = {}; // ui -> [子女户 ui,...](只含"把我当主父户"的)
units.forEach(function (u, ui) { u._blood = !!(u.cards[0] && u.cards[0].blood); });
units.forEach(function (u, ki) {
var ps = parentsOf[ki] || [];
if (!ps.length) { return; }
var main = null;
ps.forEach(function (p) { if (main === null && units[p]._blood) { main = p; } });
if (main === null) { main = ps[0]; }
mainParent[ki] = main;
// kidList 沿 kidsOf 的遍历顺序 push —— 服务端的长幼序原样保留
(kidList[main] = kidList[main] || []).push(ki);
});
/** 一户的实际宽度:单人 IW、夫妻两张 2*IW+SP(不是 n*SLOT) */
function ownW(ui) {
return units[ui].cards.length >= 2 ? 2 * IW + SP : IW;
}
/* ① 需求宽度:自底向上 */
var needW = {};
function calcNeed(ui) {
if (needW[ui] !== undefined) { return needW[ui]; }
var total = 0;
(kidList[ui] || []).forEach(function (k, i) {
total += calcNeed(k) + (i ? SP : 0);
});
needW[ui] = Math.max(ownW(ui), total);
return needW[ui];
}
/* ②③ 分配区间:自顶向下;父户居中于子女的实际跨度 */
var placed = {}; // ui -> {gen, x0}
function assign(ui, x0) {
var own = ownW(ui);
var ks = kidList[ui] || [];
var total = 0;
ks.forEach(function (k, i) { total += needW[k] + (i ? SP : 0); });
var kx = x0 + (needW[ui] - total) / 2; // 子女在父区间内居中起铺
ks.forEach(function (k) { assign(k, kx); kx += needW[k] + SP; });
// 居中锚:所有子女(**含跨树子女**——他们已在别的树里放好)的实际跨度中点
var lo = null, hi = null;
(rawKids[ui] || []).forEach(function (k) {
if (!placed[k]) { return; }
var c = placed[k].x0 + ownW(k) / 2;
if (lo === null || c < lo) { lo = c; }
if (hi === null || c > hi) { hi = c; }
});
var want = (lo === null) ? x0 + (needW[ui] - own) / 2 : (lo + hi) / 2 - own / 2;
// 父户不得越出自己被分配的区间(越出会撞上左邻/右邻子树)
placed[ui] = {
gen: units[ui].gen,
x0: Math.max(x0, Math.min(want, x0 + needW[ui] - own))
};
}
/* 森林:无主父户的户是树根;含「我」的树先放,其余树(姻亲)依次排在右侧 */
function treeHasMe(root) {
var st = [root], seen = {};
while (st.length) {
var x = st.pop();
if (seen[x]) { continue; }
seen[x] = true;
if (x === meUi) { return true; }
(kidList[x] || []).forEach(function (k) { st.push(k); });
}
return false;
}
var roots = [];
units.forEach(function (u, ui) { if (mainParent[ui] === undefined) { roots.push(ui); } });
roots.sort(function (a, b) {
var ma = treeHasMe(a), mb = treeHasMe(b);
return (ma === mb) ? ((units[a].gen - units[b].gen) || (a - b)) : (ma ? -1 : 1);
});
function subtreeRight(root) {
var hi = -Infinity;
(function walk(x) {
var p = placed[x];
if (p) { hi = Math.max(hi, p.x0 + ownW(x)); }
(kidList[x] || []).forEach(walk);
})(root);
return hi;
}
var cursorX = 0;
var TREE_GAP = SP; // 树与树之间的留白(姻亲树贴着主树放,
// 让岳父母尽量靠近妻子;区间法保证不重叠)
roots.forEach(function (r) {
calcNeed(r);
assign(r, cursorX);
cursorX = subtreeRight(r) + TREE_GAP;
});
/* ---------- 5. 落最终坐标 ---------- */
var gmin = 0, gmax = 0;
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
var xs = [];
units.forEach(function (u, ui) {
var p = placed[ui];
if (p) { xs.push(p.x0, p.x0 + ownW(ui)); }
});
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>'; + '</a>';
}); });
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
/* ---------- 7. 连线 ---------- chart.editTree().setCanAdd(false);
* chart.setSingleParentEmptyCard(false);
* ⚠️⚠️ 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) { chart.updateTree({ initial: true, tree_position: 'fit' });
if (Math.abs(x1 - x2) < 0.05 && Math.abs(y1 - y2) < 0.05) { return; } window.kinChart = chart;
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
// family-chart 的 zoom 行为绑在 #f3Canvas 上,SVG 与 HTML 卡片层由库的 onZoom 同步。
// 必须拿到 f3Canvas 才能正确驱动镜头;直接操作 svg.__zoomObj 或只动 svg 都会让
// HTML 卡片层不同步。
var f3Canvas = root.querySelector('#f3Canvas');
function zoomObj() {
if (!f3Canvas) { f3Canvas = root.querySelector('#f3Canvas'); }
// __zoomObj 挂在 #f3Canvas 上;库的 d() helper 也会往父节点找
var el = f3Canvas && f3Canvas.__zoomObj ? f3Canvas
: (f3Canvas && f3Canvas.parentNode && f3Canvas.parentNode.__zoomObj ? f3Canvas.parentNode : null);
return el ? el.__zoomObj : null;
} }
function applyTransform(t) {
// ---- ② 由「人」的坐标推出「户」的画线锚点 ---- var z = zoomObj(), d3 = window.d3;
// anchor[ui] = {gen, y, left, right, mid, top, bottom, hasBar, cardIds} if (!z || !d3 || !f3Canvas) return;
var anchor = {}; d3.select(f3Canvas).call(z.transform, t);
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 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
// 区间法布局里同一父户的子女在它的区间内相邻铺开,横梁自然是短的。
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));
} }
// 把镜头挪到指定人:用库算出的树坐标,固定比例 0.9,避免「看全部」时缩得太小
function centerOn(id) { function centerOn(id) {
var p = pos[String(id)]; var d3 = window.d3;
if (!p) { return; } if (!d3) return;
var r = root.getBoundingClientRect(); var tree = chart.store.getTree();
var t = d3.zoomTransform(root); if (!tree || !tree.data) return;
sel.call(zoom.transform, d3.zoomIdentity.translate( var node = tree.data.find(function (n) { return n.data && n.data.id === id; });
r.width / 2 - p.x * t.k, r.height / 2 - p.y * t.k).scale(t.k)); if (!node) return;
var rr = root.getBoundingClientRect();
var k = 0.9;
var tx = rr.width / 2 - node.x * k;
var ty = rr.height / 2 - node.y * k;
// d3 zoom 的 {x,y} 是「缩放后再平移」的屏幕像素偏移,
// 所以先 translate(tx,ty) 再 scale(k) 才能得到 screen = node * k + offset。
applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
} }
window.kinFit = fit;
window.kinCenterOn = centerOn; window.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || ''; var meId = root.getAttribute('data-me') || '';
fit(); // fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
// 进来先把镜头对准「我」 if (meId) {
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); } setTimeout(function () { centerOn(meId); }, 900);
// 万一 zoom 对象还没初始化好,再补一次
setTimeout(function () { centerOn(meId); }, 1400);
}
var btnFit = document.getElementById('kinFit'); var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe'); var btnMe = document.getElementById('kinMe');
if (btnFit) { btnFit.addEventListener('click', fit); } if (btnFit) {
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); } btnFit.addEventListener('click', function () {
chart.updateTree({ tree_position: 'fit' });
});
}
if (btnMe && meId) {
btnMe.addEventListener('click', function () { centerOn(meId); });
}
})(); })();
</script> </script>