家谱 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:
+48
-35
@@ -432,19 +432,14 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
}
|
||||
|
||||
/* ---------------- 家谱(以我为中心) ----------------
|
||||
v15:**自己排版、自己画线**,不再用 family-chart。
|
||||
v21:改用 family-chart 库渲染。库把 data[0].id 当 main_id,
|
||||
所以把「最高祖先」排第一位以画出尽可能多的血亲旁支;
|
||||
加载后再把镜头平移回「我」,兼顾"看到全树"与"以自己为中心"。
|
||||
|
||||
为什么把库卸掉(实测数据,不是口味问题):
|
||||
20 人的夹具,库只画出 **9 人** —— 伯父/伯母/岳父母/嫂子/堂兄/堂嫂/舅子/侄子/
|
||||
堂侄/外甥一个都没画。库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
|
||||
**横向亲戚与姻亲旁支永远进不来**(换树根也救不了)。用户两次反馈
|
||||
「显示不齐全」「数据仍然不全,差很多」说的就是这个。
|
||||
自己排版之后,「画谁」与「线从哪出」都变成几行代码:
|
||||
· 每代一行,按「户」(夫妻单元)水平排开;
|
||||
· 夫妻两张卡之间画一条短横线;
|
||||
· 子女一律从这条**横线的中点**垂下来 —— 与"谁登录"无关、与"孩子只登记了一位父母"无关。
|
||||
|
||||
拖拽/缩放继续保留(用户明确说「反正可以缩放和拖动」),用原生 d3.zoom 实现。 */
|
||||
免费版 family-chart 的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
|
||||
**不会从配偶继续向上/向下 walk**,因此岳父岳母、舅子、外甥等姻亲旁支
|
||||
在单棵树里天然画不出来(换树根、调 depth 都救不了)。
|
||||
这是库的能力边界,不是调用方式问题;如要姻亲,需要 Premium 或多树拼接。 */
|
||||
|
||||
.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);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
/* 拖拽/缩放自己接管,别让浏览器插手滚动 */
|
||||
/* 拖拽/缩放由库接管,别让浏览器插手滚动 */
|
||||
touch-action: none;
|
||||
}
|
||||
.kin-chart:active { cursor: grabbing; }
|
||||
|
||||
/* 卡片层与连线层都靠 transform 一起平移缩放(两层必须同源,否则线会跟卡错位) */
|
||||
.kin-stage, .kin-chart svg.kin-lines {
|
||||
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; }
|
||||
/* 隐藏 family-chart 自带的空导航栏(我们用自己的「看全部 / 回到我」按钮) */
|
||||
.kin-chart .f3-nav-cont { display: none !important; }
|
||||
|
||||
/* 连线:夫妻短横线 + 子女下垂线,全部一条笔触 */
|
||||
.kin-chart path.kin-link {
|
||||
fill: none;
|
||||
stroke: #C9BDA9;
|
||||
stroke-width: 1.6;
|
||||
vector-effect: non-scaling-stroke; /* 缩放时线不跟着变粗变细 */
|
||||
}
|
||||
/* ⚠️ 这条是**必须**的:库自带的 CSS 里写着 `.f3 svg.main_svg { width:100%; height:100% }`,
|
||||
而我们的容器类名是 `.kin-chart`(不是 `.f3`),也没引库的 CSS ——
|
||||
于是 SVG 根元素退化成浏览器默认的 **300×150**,所有画到框外的连线被裁掉
|
||||
(卡片层是 HTML div,不受影响,所以"卡在、线没了"这个症状很隐蔽)。
|
||||
实测:不写这条时 svg 只有 300×150(`_fb_kin_check_v17.py` 第②条会红)。 */
|
||||
.kin-chart svg.main_svg { width: 100%; height: 100%; display: block; z-index: 1; }
|
||||
|
||||
/* v18:卡片改成「上图下名 + 称呼」的窄长形,对齐参考实现(亲友+)的比例:
|
||||
参考里 50dp 宽 × 80dp 高,头像占满上半、姓名与称呼各一行贴在下半。
|
||||
我们把 88×106 按同样比例放大 —— 窄一点才排得下"好几代 + 兄弟姐妹"。 */
|
||||
/* family-chart 的 HTML 卡片层 #htmlSvg 与 SVG 连线层由库的 onZoom 同步 transform。
|
||||
卡片容器 .card_cont 已经 absolute + translate(x,y);里面再放我们的 .kin-card。 */
|
||||
.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 {
|
||||
position: absolute;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0;
|
||||
width: 140px; height: 120px;
|
||||
box-sizing: border-box; padding: 4px 4px 3px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
@@ -499,19 +503,20 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
overflow: hidden;
|
||||
pointer-events: auto; /* 父 .card_cont 是 none,这里恢复 */
|
||||
}
|
||||
.kin-card:hover {
|
||||
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.kin-card .kin-nm {
|
||||
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
margin-top: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */
|
||||
.kin-card .kin-lb {
|
||||
@@ -529,6 +534,14 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
/* 「待确认」(只填了称呼、没对应到真人):虚线 */
|
||||
.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; }
|
||||
|
||||
/* 详情页里的迷你家谱 */
|
||||
|
||||
+181
-543
@@ -1,30 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
|
||||
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代所有有关系的人。
|
||||
*
|
||||
* ============================ 布局算法(v16 重写) ============================
|
||||
* v21 —— 回退到 family-chart 库(用户 2026-10-02 明确要求)。
|
||||
*
|
||||
* 历史:v13/v14 用第三方库 family-chart → 数据一直不全(实测 20 人只画出 9 人,
|
||||
* 因为它只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
|
||||
* **横向亲戚与姻亲旁支永远进不来**)。v15 改成自己排版,人数终于对了,
|
||||
* 但布局是"每代按固定格子摊开",于是**线是乱的**:
|
||||
* · 同代卡片**会重叠**(一对夫妻宽 254px,却按 200px 一格摆);
|
||||
* · 夫妻横线画在卡片**内部**(y = 卡中心 + 半个卡高 − 6),所以线从卡上穿过去;
|
||||
* · 子女不落在父母下方,横梁跨得老远、线与线互相交叉。
|
||||
*
|
||||
* v16 换成**整齐树(tidy tree)**——这是家谱/组织图的标准排法,规则只有三条:
|
||||
* ① 后序给每棵子树算「轮廓」(每一行被占用的 x 区间),按轮廓从左往右放,
|
||||
* 保证**任何两张卡都不重叠**(同代不同代的都不会);
|
||||
* ② 父(户)落在**其子女整体跨度的中点**上 —— 这样"子女从父母下方连出来"
|
||||
* 天然成立,横梁最短、最不容易交叉;
|
||||
* ③ 同代按长幼排序(长子左、次子右),兄弟姊妹相对次序稳定。
|
||||
*
|
||||
* 连线画法(用户点名的要求,一字不改):
|
||||
* · 一对夫妻之间一条短横线;
|
||||
* · 他们的子女**一律从这条横线正中间垂下来**(单亲户从本人卡正下方),
|
||||
* 再经横梁分到每个孩子 —— 与"谁登录""孩子登记了几位父母"都无关。
|
||||
*
|
||||
* 拖拽 / 滚轮缩放保留(d3.zoom);另给「看全部 / 回到我」两个按钮。
|
||||
* 之前自研布局叠了多层修补后位置漂移,用户决定还是交给成熟库来做。
|
||||
* 数据准备要点:
|
||||
* · 用 familyMap 把「上两代 / 本代 / 下两代」所有有关系的人(含姻亲旁支)全捞出来;
|
||||
* · 转成 family-chart 的 Datum 格式 {id, data, rels: {parents, spouses, children}};
|
||||
* · 「我」排数组第一位(库把 data[0].id 当 main_id);
|
||||
* · 子女按长幼排序后写进 rels.children,库按数组顺序摆左右。
|
||||
*/
|
||||
|
||||
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
|
||||
@@ -36,128 +21,113 @@ $gen = $map['gen'];
|
||||
$units = $map['units'];
|
||||
$unitOf = $map['unit_of'];
|
||||
|
||||
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)----
|
||||
$unitKids = \App\Kinship::householdChildren($graph, $unitOf);
|
||||
if (!isset($people[$meId]) || !is_array($people[$meId])) {
|
||||
$canChart = false;
|
||||
} else {
|
||||
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
|
||||
|
||||
// ---- ③ 同一代里的户按长幼排(长子左、次子右)----
|
||||
$rankKey = static function (int $id) use ($people): string {
|
||||
return \App\Kinship::rankValueFor($people, $id);
|
||||
};
|
||||
|
||||
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
|
||||
|
||||
// 「我」的血亲集合:只走 parent/child 边(不含配偶)能从「我」到达的人。
|
||||
// 用途:给每张卡标 blood —— 前端据此判定一户的「主父户」
|
||||
// (我的户同时被父母户与岳父母户认作子女,树递归只能走本家那条,
|
||||
// 否则树变图、布局重复铺人;判定标准就是「父户的主位是不是我方血亲」)。
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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'),
|
||||
// ⚠️ 本家血亲标记:前端用它判定一户的「主父户」(见上方 $blood 的说明)
|
||||
'blood' => isset($blood[$id]),
|
||||
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
|
||||
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
|
||||
// 它先看 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
$cards = array_merge([$head], $rest);
|
||||
if ($next === 0) { break; }
|
||||
$rootId = $next;
|
||||
}
|
||||
$kids = [];
|
||||
foreach ($unitKids[$i] ?? [] as $cid) {
|
||||
if (isset($people[$cid])) {
|
||||
$kids[] = (int)$cid;
|
||||
|
||||
// ---- ③ 连边:子女一律挂在「夫妻」上(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);
|
||||
};
|
||||
|
||||
// 树根排最前(库默认以 data[0].id 为 main_id);其余按「代数 → 长幼」跟上
|
||||
$order = [$rootId];
|
||||
$rest = [];
|
||||
foreach (array_keys($onTree) as $pid) {
|
||||
$pid = (int)$pid;
|
||||
if ($pid !== $rootId) { $rest[] = $pid; }
|
||||
}
|
||||
usort($rest, static function (int $a, int $b) use ($gen, $rankKey): int {
|
||||
return ((int)$gen[$a] <=> (int)$gen[$b])
|
||||
?: strcmp($rankKey($a), $rankKey($b))
|
||||
?: ($a <=> $b);
|
||||
});
|
||||
$order = array_merge($order, $rest);
|
||||
|
||||
$mapIds = static function (array $ids) use ($onTree): array {
|
||||
$out = [];
|
||||
foreach ($ids as $x) {
|
||||
$x = (int)$x;
|
||||
if (isset($onTree[$x])) { $out[] = (string)$x; }
|
||||
}
|
||||
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'] ?? ''));
|
||||
$gender = $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U');
|
||||
|
||||
$datum[] = [
|
||||
'id' => (string)$pid,
|
||||
'data' => [
|
||||
'gender' => $gender,
|
||||
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
|
||||
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
|
||||
'avatar' => avatar_url($p['avatar'] ?? null),
|
||||
'me' => $pid === (int)$meId,
|
||||
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
|
||||
],
|
||||
'rels' => [
|
||||
'parents' => $parents,
|
||||
'spouses' => $spouses,
|
||||
'children' => $kids,
|
||||
],
|
||||
];
|
||||
}
|
||||
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'] ?? '自己');
|
||||
$payloadJson = json_encode(
|
||||
['units' => $payloadUnits, 'me' => (int)$meId],
|
||||
$datumJson = json_encode(
|
||||
$datum ?? [],
|
||||
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>
|
||||
<span class="sub">以我为中心 · 上下一共五代 · 夫妻并排,子女从两人之间的连线连出</span>
|
||||
</div>
|
||||
|
||||
<?php if (!$canChart): ?>
|
||||
@@ -173,42 +143,31 @@ $payloadJson = json_encode(
|
||||
<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>
|
||||
<div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
|
||||
|
||||
<p class="tree-legend">
|
||||
每代一行,一代里按「户」排开;<b>一对夫妻之间有一条短横线</b>,
|
||||
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上),
|
||||
再由横梁分到每个孩子。
|
||||
每代一行;<b>一对夫妻之间有一条短横线</b>,
|
||||
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上)。
|
||||
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
|
||||
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
|
||||
<br><span class="muted">注:本图由 family-chart 绘制。免费版只画「同一位祖先的血脉后代 + 各人配偶」,
|
||||
妻子那侧的岳父母、舅子等姻亲不在图内(在名册与成员详情页仍可看到)。</span>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
/*****************************************************************
|
||||
* 家谱布局:区间法(tidy tree)+ 先摆节点、后按节点画线
|
||||
*
|
||||
* 参考过 gitee.com/coku2333/FamilyTreeView(用户 2026-10-02 指定的
|
||||
* 「亲友+」效果),但它的游标铺法在我们的"以我为中心、含姻亲旁支"的
|
||||
* 图上要叠四五层防重叠/重排修补才能不叠卡,每层又互相破坏 —— 最终
|
||||
* 整棵树漂移(用户:"错的更离谱了,这次连位置都不对了")。
|
||||
* 现在的规则(详见下方第 1 节注释):
|
||||
* ① 每代一行;一对夫妻 = 相邻两张卡;
|
||||
* ② 树递归用「区间法」分配互不重叠的 x 区间,父户居中于子女跨度
|
||||
* —— 天然不叠卡,长幼序天然"兄姐在左、弟妹在右";
|
||||
* ③ 连线画在卡片背后,**先把所有节点坐标摆定,再统一按节点画线**。
|
||||
*****************************************************************/
|
||||
(function () {
|
||||
var root = document.getElementById('kinChart');
|
||||
if (!root || typeof d3 === 'undefined') { return; }
|
||||
if (!root || typeof window.f3 === 'undefined') { return; }
|
||||
|
||||
var data;
|
||||
var raw;
|
||||
try {
|
||||
data = JSON.parse(root.getAttribute('data-kin') || '{}');
|
||||
} catch (err) { data = {}; }
|
||||
var units = (data.units || []);
|
||||
if (!units.length) { return; }
|
||||
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
|
||||
} catch (err) {
|
||||
raw = [];
|
||||
}
|
||||
if (!raw.length) { return; }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
@@ -216,412 +175,91 @@ $payloadJson = json_encode(
|
||||
.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; // 画布留白
|
||||
var chart = f3.createChart('#kinChart', raw)
|
||||
.setTransitionTime(600)
|
||||
.setOrientationVertical()
|
||||
.setShowSiblingsOfMain(true)
|
||||
.setAncestryDepth(2)
|
||||
// 树根是最高祖先;progeny_depth 要覆盖 "儿子 / 女儿" 这一代,
|
||||
// 所以要比 "我" 到树根的世代再多一代。
|
||||
.setProgenyDepth(3)
|
||||
.setCardXSpacing(180)
|
||||
.setCardYSpacing(180);
|
||||
|
||||
/* ---------- 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); }
|
||||
}
|
||||
chart.setCardHtml()
|
||||
.setStyle('rect')
|
||||
.setCardDim({ w: 140, h: 120, mini_tree: false })
|
||||
.setCardInnerHtmlCreator(function (d) {
|
||||
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='
|
||||
+ encodeURIComponent(id) + '">'
|
||||
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
|
||||
+ '<span class="kin-nm">' + esc(p.name) + '</span>'
|
||||
+ '<span class="kin-lb">' + esc(p.label) + '</span>'
|
||||
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
|
||||
+ '</a>';
|
||||
});
|
||||
});
|
||||
|
||||
// ⚠️⚠️ 关键:`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; }
|
||||
});
|
||||
});
|
||||
chart.editTree().setCanAdd(false);
|
||||
chart.setSingleParentEmptyCard(false);
|
||||
|
||||
// 我所在的户
|
||||
var meUi = 0;
|
||||
units.forEach(function (u, ui) {
|
||||
u.cards.forEach(function (c) { if (c.me) { meUi = ui; } });
|
||||
});
|
||||
chart.updateTree({ initial: true, tree_position: 'fit' });
|
||||
window.kinChart = chart;
|
||||
|
||||
// 户的"血亲位"(哪张卡是本家的人):优先取"有父户"的那张
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
/* ① 需求宽度:自底向上 */
|
||||
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>';
|
||||
});
|
||||
});
|
||||
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 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
|
||||
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
|
||||
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
|
||||
// 区间法布局里同一父户的子女在它的区间内相邻铺开,横梁自然是短的。
|
||||
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 applyTransform(t) {
|
||||
var z = zoomObj(), d3 = window.d3;
|
||||
if (!z || !d3 || !f3Canvas) return;
|
||||
d3.select(f3Canvas).call(z.transform, t);
|
||||
}
|
||||
// 把镜头挪到指定人:用库算出的树坐标,固定比例 0.9,避免「看全部」时缩得太小
|
||||
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));
|
||||
var d3 = window.d3;
|
||||
if (!d3) return;
|
||||
var tree = chart.store.getTree();
|
||||
if (!tree || !tree.data) return;
|
||||
var node = tree.data.find(function (n) { return n.data && n.data.id === id; });
|
||||
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;
|
||||
|
||||
var meId = root.getAttribute('data-me') || '';
|
||||
fit();
|
||||
// 进来先把镜头对准「我」
|
||||
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); }
|
||||
// fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
|
||||
if (meId) {
|
||||
setTimeout(function () { centerOn(meId); }, 900);
|
||||
// 万一 zoom 对象还没初始化好,再补一次
|
||||
setTimeout(function () { centerOn(meId); }, 1400);
|
||||
}
|
||||
|
||||
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); }); }
|
||||
if (btnFit) {
|
||||
btnFit.addEventListener('click', function () {
|
||||
chart.updateTree({ tree_position: 'fit' });
|
||||
});
|
||||
}
|
||||
if (btnMe && meId) {
|
||||
btnMe.addEventListener('click', function () { centerOn(meId); });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user