v15: 家谱重写 —— 以我为中心检索全体亲属 + 自己排版画线
用户 2026-10-02 的反馈(原话):
「显示不齐全,应该显示本人的上一代,这一代,和下一代的所有人…以本人为中心去搜索,
只要有关系的,都检索出来进行展示」
「夫妻之间有一条横线…这对夫妻下面所有的子女,都应该从夫妻之间的这个横线出来,
而不是从这对夫妻的其中一个出来,这样可能会出现一个 bug,就是如果从一个出来,
例如从父亲出来,母亲登录进去竟然看不到自己的孩子」
「数据仍然不全,差很多」
根因(实测,不是猜):第三方库 family-chart **只画了 20 人里的 9 人**。
_tooling/_fb_kin_cover_probe.py 的硬数据:
画出的:祖父/祖母/父亲/母亲/我/妻子/哥哥/儿子/女儿(9)
没画的:伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥(11)
库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
**横向亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来** ——
换树根也救不了(换到最高祖先,"妻子的兄弟"照样不在树里)。
「母亲看不到孩子」还有第二层原因:孩子只登记了父亲时,母亲那一侧根本没有边。
改法(不再依赖库的树算法):
· app/Kinship::familyMap() —— 以「我」为中心的两轮贯穿检索,带 seen 防环:
父母(-1 代)/ 子女(+1 代)/ 配偶(同代)/ 每一层的配偶
/ 配偶的父母与子女(姻亲旁支由此进来)/ 兄弟姐妹的子女(侄甥)
→ 上一代、这一代、下一代的**所有有关系的人**都在名单里。
· app/Kinship::householdChildren() —— 子女按**父母所在的户**归堆,
而不是按"某个人的 children"。于是孩子只登记了一位父母时,
照样挂到这对夫妻的户上(语义:这孩子是我们俩的)。
· templates/kin/tree.php —— 自己排版:每代一行、按「户」水平排开;
夫妻两张卡之间画短横线;子女一律从**这条横线的中点**垂下来
(x = (左卡中心 + 右卡中心) / 2,与"谁登录"无关)。
· 拖拽/缩放保留(用户明确要),改用原生 d3.zoom;不再引 family-chart。
验证(只跑这一个模块,按用户要求):
· _tooling/_fb_kin_v15.py —— 22 项全过:
✔ 20/20 全员画出(== 从「我」出发可达的人数)
✔ 7 对夫妻都有横线;7 个有子女的户**下垂线全部落在横线中点**
✔ 用户点名的场景一:删掉「母亲→儿子」这条边(模拟只登记父亲)后,
下垂线**仍然**从夫妻横线中点出来,孩子仍在图上
✔ 用户点名的场景二:给母亲开账号、**用她的手机号登录**,
家谱里能看到自己的孩子,且与父亲视角画的是同一批人
✔ 截图存档(全图 / 母亲视角 / 窄屏)
· _tooling/_fb_kin_v15_falsify.py —— 反向验证 2/2 抓住:
注入「从其中一张卡出来」→「从横线中点出来」变红
注入「只走直系血亲」→「每个人都被画出来」变红
· e2e 550 条全过(家谱段改到 v15 的判据上)
顺带修的既有问题:
· app/Kinship.php 老库升级回填按实际存在的列动态拼(缺列不再 1054 炸断升级)
· _fb_e2e.py 家谱断言改到 kin_v15_nodes(旧解析器在新页面上只会解析出空)
This commit is contained in:
+150
@@ -134,6 +134,156 @@ final class Kinship
|
||||
return array_map('intval', array_keys($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* v15:**家谱图的完整数据出口**(以本人为中心,上一代 / 这一代 / 下一代全员)。
|
||||
*
|
||||
* 为什么重写(用户 2026-10-02 的原话):
|
||||
* 「显示不齐全,应该显示本人的上一代,这一代,和下一代的所有人…
|
||||
* 应该以本人为中心去搜索,只要有关系的,都检索出来进行展示」
|
||||
* 「数据仍然不全,差很多」
|
||||
*
|
||||
* 实测(_fb_kin_cover_probe.py):第三方库 family-chart **只画了 20 人里的 9 人** ——
|
||||
* 伯父/伯母/岳父母/嫂子/堂兄/堂嫂/舅子/侄子/堂侄/外甥一个都没画。
|
||||
* 库只会画「树根的祖先链 + 后代 + 树根的兄弟姐妹」,**横向的亲戚与姻亲旁支
|
||||
* 永远进不来**(换树根也救不了:换到最高祖先,"妻子的兄弟"照样不在树里)。
|
||||
* 所以家谱不再依赖库的树算法,改由这里给出**完整的名单 + 代数 + 户**,
|
||||
* 前端按「代分层、户并排」自己排版、自己画线。
|
||||
*
|
||||
* 算法(以本人为中心的两轮贯穿,带 $seen 防环):
|
||||
* ① 从「我」出发:父母(-1 代)、子女(+1 代)、配偶(同代);
|
||||
* ② 从**每一位配偶**出发:其父母与子女也要进来 —— 配偶的父母是姻亲(同 -1 代),
|
||||
* 这样「妻子的兄弟」「妻子的侄子」这些姻亲旁支才有路子进来;
|
||||
* ③ 血亲每往上一层,就把那层的**配偶们**也补进来(伯母/婶婶/姑父…);
|
||||
* ④ 血亲每往下一层,同样补配偶,并**跨层补侄甥**(兄弟姐妹的子女 = 下一代);
|
||||
* ⑤ 代数越界(超过 up/down 代)的人只到边界那一层为止,不再向外扩 ——
|
||||
* 否则"有关系"会一路串到远亲,图会无限大。
|
||||
*
|
||||
* @return array{
|
||||
* gen: array<int,int>, id => 相对代数(我 = 0)
|
||||
* units: array<int,array>, 户(夫妻单元)列表,见下
|
||||
* unit_of: array<int,int>, id => 户序号
|
||||
* me: int
|
||||
* }
|
||||
*/
|
||||
public static function familyMap(int $familyId, int $me, array $people, int $up = 2, int $down = 2): array
|
||||
{
|
||||
$g = self::graph($familyId);
|
||||
if (!isset($people[$me])) {
|
||||
return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me];
|
||||
}
|
||||
|
||||
// ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ----
|
||||
$gen = [$me => 0];
|
||||
$seen = [$me => true];
|
||||
$q = [$me];
|
||||
// 卡片能容纳的代数有限,越界就不再往外走(但边界那一层的人照样收)
|
||||
$clamp = static function (int $v) use ($up, $down): int {
|
||||
return max(-$up, min($down, $v));
|
||||
};
|
||||
while ($q) {
|
||||
$id = (int)array_shift($q);
|
||||
$lv = (int)$gen[$id];
|
||||
$push = static function (int $nid, int $nlv) use (&$gen, &$seen, &$q, $people, $clamp): void {
|
||||
$nlv = $clamp($nlv);
|
||||
if (!isset($people[$nid])) {
|
||||
return; // 名册里没有这个人
|
||||
}
|
||||
if (!isset($seen[$nid])) {
|
||||
$seen[$nid] = true;
|
||||
$gen[$nid] = $nlv;
|
||||
$q[] = $nid;
|
||||
} elseif ($nlv > $gen[$nid]) {
|
||||
// ⚠️ 同一人可能有两条路到达、算出不同代数(堂兄既是"伯父的孩子"、
|
||||
// 也可能通过某条配偶边走到)。取**离我最近**的那一代,
|
||||
// 否则他会跑到下一代的层里去。
|
||||
$gen[$nid] = $nlv;
|
||||
}
|
||||
};
|
||||
foreach (self::parentIds($g, $id) as $p) { $push((int)$p, $lv - 1); }
|
||||
foreach (self::childIds($g, $id) as $c) { $push((int)$c, $lv + 1); }
|
||||
// 配偶永远同代 —— 这一条让「配偶的父母/子女」也能顺着扩出去
|
||||
foreach (self::spouseIds($g, $id) as $s) { $push((int)$s, $lv); }
|
||||
}
|
||||
|
||||
// ---- ② 户(夫妻单元):一对互为配偶的两人合成一户 ----
|
||||
// 只在「同一代 + 互为配偶」时成户;跨代的配偶边(脏数据)各自成户,不硬凑。
|
||||
$units = [];
|
||||
$unitOf = [];
|
||||
$assigned = [];
|
||||
foreach (array_keys($gen) as $id) {
|
||||
$id = (int)$id;
|
||||
if (isset($assigned[$id])) {
|
||||
continue;
|
||||
}
|
||||
$assigned[$id] = true;
|
||||
$partner = 0;
|
||||
foreach (self::spouseIds($g, $id) as $s) {
|
||||
$s = (int)$s;
|
||||
if (isset($gen[$s]) && (int)$gen[$s] === (int)$gen[$id] && !isset($assigned[$s])) {
|
||||
$partner = $s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($partner > 0) {
|
||||
$assigned[$partner] = true;
|
||||
}
|
||||
$units[] = ['gen' => (int)$gen[$id], 'ids' => $partner > 0 ? [$id, $partner] : [$id]];
|
||||
}
|
||||
foreach ($units as $i => $u) {
|
||||
foreach ($u['ids'] as $id) {
|
||||
$unitOf[(int)$id] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me];
|
||||
}
|
||||
|
||||
/**
|
||||
* v15:把「孩子」按**父母所在的户**归堆。
|
||||
*
|
||||
* 用户的核心要求(原话):「这对夫妻下面所有的子女,都应该从夫妻之间的这个横线出来,
|
||||
* 而不是从这对夫妻的其中一个出来,这样可能会出现一个 bug,就是如果从一个出来,
|
||||
* 例如从父亲出来,母亲登录进去之后竟然看不到自己的孩子,这是非常大的问题。」
|
||||
*
|
||||
* 所以这里**不以"某个人的 children"为准** —— 那正是"从父亲一个人出来"的根源
|
||||
* (孩子只登记了父亲时,父亲有边、母亲没边,母亲登录就看不到孩子)。
|
||||
* 改成:对每个孩子取其**父母们**,再看这些父母落在哪一户,把这条子女关系
|
||||
* 挂到**那一户**上。于是:
|
||||
* · 孩子登记了父母双方 → 挂到这对夫妻的户 → 从夫妻横线中点垂线;
|
||||
* · 孩子只登记了一位父/母 → 也挂到那位父/母**所在的户**(该户若有配偶,
|
||||
* 照样从两人的横线中点出来 —— 语义上"这孩子是我们俩的")。
|
||||
*
|
||||
* @param array<int,int> $unitOf id => 户序号
|
||||
* @return array<int,int[]> 户序号 => [孩子 id, ...]
|
||||
*/
|
||||
public static function householdChildren(array $g, array $unitOf): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (array_keys($unitOf) as $child) {
|
||||
$child = (int)$child;
|
||||
$u = (int)$unitOf[$child];
|
||||
// 只认「这位孩子的父母」,且父母必须在图上
|
||||
$parentsHere = [];
|
||||
foreach (self::parentIds($g, $child) as $p) {
|
||||
$p = (int)$p;
|
||||
if (isset($unitOf[$p]) && (int)$unitOf[$p] !== $u) {
|
||||
// ⚠️ 父母的户不能是孩子自己所在的户(把配偶记成父母这类脏数据)
|
||||
$parentsHere[] = $p;
|
||||
}
|
||||
}
|
||||
if (!$parentsHere) {
|
||||
continue; // 没有父母在图上 → 不是"某户的孩子"
|
||||
}
|
||||
// 同一个孩子可能被多位父母指向不同户(脏数据):取第一户,避免重复画线
|
||||
$home = (int)$unitOf[$parentsHere[0]];
|
||||
$out[$home][] = $child;
|
||||
}
|
||||
foreach ($out as $k => $v) {
|
||||
$out[$k] = array_values(array_unique(array_map('intval', $v)));
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 祖父母那一级:往上走一步再取父母。
|
||||
* 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。
|
||||
|
||||
+39
-45
@@ -432,82 +432,76 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
}
|
||||
|
||||
/* ---------------- 家谱(以我为中心) ----------------
|
||||
v13:改由第三方库 **family-chart**(D3 生态,MIT)画,不再自研网格布局。
|
||||
v15:**自己排版、自己画线**,不再用 family-chart。
|
||||
|
||||
为什么不再自研:v12 的「整棵树一个 grid」几何上对齐了,但连线的横杆是**网格项**,
|
||||
只能横跨「从父母列到子女列」的整段列区间 —— 中间只要夹着跟这条血缘无关的人
|
||||
(最典型是配偶带进来的岳父母),横杆就会从他们卡片中间穿过去。这是布局范式的死结,
|
||||
不是调样式能救的;换成库的树算法之后这层结构本身就不存在了。
|
||||
为什么把库卸掉(实测数据,不是口味问题):
|
||||
20 人的夹具,库只画出 **9 人** —— 伯父/伯母/岳父母/嫂子/堂兄/堂嫂/舅子/侄子/
|
||||
堂侄/外甥一个都没画。库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
|
||||
**横向亲戚与姻亲旁支永远进不来**(换树根也救不了)。用户两次反馈
|
||||
「显示不齐全」「数据仍然不全,差很多」说的就是这个。
|
||||
自己排版之后,「画谁」与「线从哪出」都变成几行代码:
|
||||
· 每代一行,按「户」(夫妻单元)水平排开;
|
||||
· 夫妻两张卡之间画一条短横线;
|
||||
· 子女一律从这条**横线的中点**垂下来 —— 与"谁登录"无关、与"孩子只登记了一位父母"无关。
|
||||
|
||||
⚠️ 库自己的 family-chart.css **没有引入**:实测库里所有规则都写在 `.f3` 作用域下,
|
||||
而运行时并不会生成任何 `.f3` 元素(卡片定位全靠 inline style)——引进来是死文件,
|
||||
白白多 16KB。所以下面这些是**库真正需要**的那几条,全部收在 `.kin-chart` 作用域里。
|
||||
拖拽/缩放继续保留(用户明确说「反正可以缩放和拖动」),用原生 d3.zoom 实现。 */
|
||||
|
||||
⚠️ 卡片/连线都不是我们生成的:卡片是库建的真实 <div>(.card_cont > .card > 我们的
|
||||
.kin-card),连线是 svg 里的 <path class="link">。改版式只能从这两个钩子下手。 */
|
||||
|
||||
/* v14:树根换成「最高祖先」之后整棵树会很大,光靠拖很累 ——
|
||||
这两个按钮把「看全部」与「回到我」分开。放在画布**外面**,
|
||||
免得被 svg 的拖拽吞掉点击(画布整块都被库接管了手势)。 */
|
||||
.kin-tools { display: flex; gap: 8px; justify-content: flex-end; margin: 0 0 10px; }
|
||||
|
||||
.kin-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* 太矮三代挤成一团、太高白占屏 */
|
||||
height: clamp(420px, 62vh, 720px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
/* 库自己接管拖拽/缩放,别让浏览器插手 */
|
||||
cursor: grab;
|
||||
/* 拖拽/缩放自己接管,别让浏览器插手滚动 */
|
||||
touch-action: none;
|
||||
}
|
||||
/* 库在容器里自己建 #f3Canvas(position:relative; width/height:100%),
|
||||
再往里插 svg.main_svg 与 #htmlSvg。这里只要保证那两层撑满。 */
|
||||
.kin-chart #f3Canvas,
|
||||
.kin-chart #htmlSvg { width: 100%; height: 100%; }
|
||||
.kin-chart:active { cursor: grabbing; }
|
||||
|
||||
/* ⚠️ 库的 `.f3 svg.main_svg{width:100%;height:100%}` 选择器**永远不会命中**
|
||||
(SVG 的直接父是 #f3Canvas,中间没有 .f3 元素)→ svg 会退回默认的 300×150,
|
||||
于是库按 300×150 算 fit,整棵树被缩到 0.4 倍、卡片只有 37px 宽。
|
||||
这里直接点名修掉。 */
|
||||
.kin-chart svg.main_svg { width: 100%; height: 100%; display: block; }
|
||||
/* 卡片层与连线层都靠 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; }
|
||||
|
||||
/* 连线:库默认 stroke="#fff"(深色主题),在浅色纸底上等于隐形 —— 必须覆盖成褐灰。
|
||||
用 !important 是因为颜色是**写在 path 的行内属性**上的(stroke="#fff"),
|
||||
普通 CSS 声明(无论特异性)都压不过行内属性;这个钩子是唯一入口。 */
|
||||
.kin-chart .link,
|
||||
.kin-chart path.link { stroke: #C9BDA9 !important; stroke-width: 1.5 !important; }
|
||||
|
||||
/* 卡片:库给 .card_cont 定位、给 .card 一律加 translate(-50%,-50%)(居中锚点),
|
||||
我们只负责卡面观感。 */
|
||||
.kin-chart .card { background: transparent; border: 0; }
|
||||
/* 连线:夫妻短横线 + 子女下垂线,全部一条笔触 */
|
||||
.kin-chart path.kin-link {
|
||||
fill: none;
|
||||
stroke: #C9BDA9;
|
||||
stroke-width: 1.6;
|
||||
vector-effect: non-scaling-stroke; /* 缩放时线不跟着变粗变细 */
|
||||
}
|
||||
|
||||
.kin-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
width: 116px; box-sizing: border-box; padding: 9px 8px 8px;
|
||||
position: absolute;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 2px;
|
||||
box-sizing: border-box; padding: 7px 6px 6px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink);
|
||||
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||
transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.kin-chart .card:hover .kin-card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-l);
|
||||
border-color: var(--brand-2);
|
||||
.kin-card:hover {
|
||||
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
||||
}
|
||||
.kin-card .kin-ph {
|
||||
width: 46px; height: 46px; border-radius: 50%; overflow: hidden;
|
||||
width: 38px; height: 38px; border-radius: 50%; 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: 13px; font-weight: 600; line-height: 1.3; }
|
||||
.kin-card .kin-lb { font-size: 11px; color: var(--brand); line-height: 1.35; }
|
||||
.kin-card .kin-nm { font-size: 13px; font-weight: 600; line-height: 1.25; }
|
||||
.kin-card .kin-lb { font-size: 11px; color: var(--brand); line-height: 1.25; }
|
||||
.kin-card .kin-mark { font-size: 10px; color: var(--faint); }
|
||||
|
||||
/* 「我」:品牌色描边 + 光圈 */
|
||||
@@ -516,11 +510,11 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
box-shadow: 0 0 0 3px rgba(168, 74, 40, .12), var(--shadow);
|
||||
}
|
||||
.kin-card.is-me .kin-lb { color: var(--brand-ink); font-weight: 600; }
|
||||
|
||||
/* 「待确认」(只填了称呼、没对应到真人):虚线 */
|
||||
.kin-card.is-ph { border-style: dashed; background: #FCFAF6; }
|
||||
|
||||
.tree-legend { font-size: 12px; color: var(--muted); margin: 14px 2px 0; }
|
||||
|
||||
/* 详情页里的迷你家谱 */
|
||||
.mini-tree { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mini-gen { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
@@ -499,11 +499,11 @@ switch ($r) {
|
||||
$me = (int)Auth::personId();
|
||||
$people = Person::map($familyId);
|
||||
$graph = Kinship::graph($familyId);
|
||||
$tree = Kinship::tree($graph, $me, $people, 3, 3);
|
||||
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
|
||||
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
|
||||
|
||||
View::render('kin/tree', [
|
||||
'title' => '我的家谱',
|
||||
'tree' => $tree,
|
||||
'meId' => $me,
|
||||
'people' => $people,
|
||||
'graph' => $graph,
|
||||
|
||||
+253
-246
@@ -1,327 +1,334 @@
|
||||
<?php
|
||||
/**
|
||||
* 我的家谱:以我为中心,向上向下各三代。
|
||||
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
|
||||
*
|
||||
* **v13 —— 改用成熟的第三方库 family-chart(D3 生态,MIT)来画。**
|
||||
* ============================ v15 为什么又重写渲染 ============================
|
||||
*
|
||||
* 为什么放弃自研(v12 的「单一网格」也救不回来):
|
||||
* v11「每代各一个网格」的列宽由内容决定,各代对不齐;v12 收成「整棵树一个 grid」,
|
||||
* 几何上确实对齐了,但仍有一个**结构性死结**:连线的横杆是**网格项**,只能横跨
|
||||
* 「从父母那一列到子女那一列」的整段列区间。于是只要中间夹着**跟这条血缘无关**的人
|
||||
* (最典型的是配偶带进来的岳父母),横杆就会**从他们卡片中间穿过去**。
|
||||
* 这不是调样式能修的 —— 横杆的跨度由列位决定,而列位是为「同代并排」服务的,
|
||||
* 两套诉求在同一张网格里没法同时满足。
|
||||
* v13/v14 用第三方库 family-chart 画,结果**数据一直不全**(用户两次反馈:
|
||||
* 「显示不齐全」「数据仍然不全,差很多」)。实测(_tooling/_fb_kin_cover_probe.py)
|
||||
* 给出了硬证据:20 人的夹具,库**只画了 9 人** ——
|
||||
* 伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥**一个都没画**。
|
||||
* 原因在库的算法本身:它只画「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
|
||||
* **横向的亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来**。
|
||||
* 换树根也救不了 —— 换成最高祖先,"妻子的兄弟"照样不在树里。
|
||||
*
|
||||
* 换成库之后:
|
||||
* · 布局(谁放哪、怎么折线、怎么避让)全部交给库的树算法,不再由我们用网格硬凑;
|
||||
* · 库只画**本家血脉 + 配偶**,不画配偶的父母 —— 这正是「横杆穿卡片」的根源被消掉的原因,
|
||||
* 语义上也算合理(姻亲本来就不是本家的血亲,v11 起就单独标着「姻亲」两个字)。
|
||||
* · 自带拖拽平移 / 滚轮缩放,家里人多了不用我们做横向滚动。
|
||||
* 所以这一版**不再依赖库的树算法**:
|
||||
* · `Kinship::familyMap()` 以「我」为中心按代数检索**所有人**
|
||||
* (父母、子女、配偶、配偶的父母与子女、每层的配偶、兄弟姐妹的子女…);
|
||||
* · 前端按「**代分层、户并排**」自己排版、自己画线(见下面的 JS)。
|
||||
*
|
||||
* ⚠️ 接入时踩过的三个坑(都写进注释,别再踩):
|
||||
* ① 库把**数据数组里的第一个人**当默认"主人物"(createChart 里 `main_id = data[0].id`),
|
||||
* 所以「我」必须排在数组最前面,否则整棵树会以别人为中心。
|
||||
* ② 卡片回调拿到的是 `TreeDatum`,它 `.data` 是**整个 Datum**({id,data,rels}),
|
||||
* 我们自己塞的字段在 **`d.data.data.xxx`**(双层)。写一层就得到空白卡片。
|
||||
* ③ 库的 family-chart.css **不必引入**:实测库里那些规则**全部挂在 `.f3` 下**,
|
||||
* 而运行时并不会生成任何 `.f3` 元素(卡片的定位全靠 inline style:
|
||||
* `.card_cont{position:absolute;transform:translate(...)}`、
|
||||
* `.cards_view{transform:scale(...)}`)—— 引进来也是死文件,反而多了 16KB。
|
||||
* 真正需要的三条(svg 撑满、连线颜色、卡片观感)我们自己写,见 app.css 的 `.kin-chart`。
|
||||
* 用户点名的两条(都已兑现):
|
||||
* ① 「显示本人的上一代、这一代、下一代的所有人」→ 见 familyMap 的两轮贯穿;
|
||||
* ② 「子女必须从**夫妻之间的横线**出来,不能从夫妻其中一个出来
|
||||
* (否则母亲登录看不到自己的孩子)」→ 子女挂在**户**上,
|
||||
* 竖线起点 = 夫妻横线的**中点**(`x = (左卡中心 + 右卡中心) / 2`),
|
||||
* 与"是父亲登录还是母亲登录"完全无关。
|
||||
*
|
||||
* 称呼、头像、「本人」高亮、待确认这些语义都由 App\Kinship::label() / person 记录决定,一字未改。
|
||||
* ⚠️ 为什么不再用库:库把布局写死在它的树算法里,我们既改不了"画谁"(横向亲戚不进树),
|
||||
* 也改不了"线从哪出"。自己排版之后这两件事都变成几行代码。
|
||||
* ⚠️ 拖拽/缩放仍要保留(用户明确说「反正可以缩放和拖动」):用原生 d3.zoom,
|
||||
* 不再需要库(d3.min.js 还在 vendor 里,可继续复用)。
|
||||
*/
|
||||
$nodes = $tree['nodes'];
|
||||
$me = $nodes[$meId] ?? null;
|
||||
|
||||
// ---- v14:先圈定「上 / 本 / 下三代里所有有关系的人」----
|
||||
//
|
||||
// ⚠️ 这是本轮重写的核心(用户第 5 条:「显示不齐全…以本人为中心去搜索,只要有关系的
|
||||
// 都检索出来进行展示」)。旧写法把**全名册**塞给库,看着多,其实库只画
|
||||
// 「从 main_id 出发的层次结构」,横向的堂表亲、配偶那边的旁支照样进不来,
|
||||
// 而树上又挂了一堆根本没关系的人(看着像"家谱里混进了外人")。
|
||||
// 现在先自己按**代数**筛选(focusMap),再交给库 —— 库拿到的人就是该出现的人。
|
||||
$focusGen = \App\Kinship::focusMap($graph, (int)$meId, $people, 2, 2);
|
||||
if (!isset($focusGen[(int)$meId])) {
|
||||
$focusGen[(int)$meId] = 0;
|
||||
}
|
||||
$focusIds = array_map('intval', array_keys($focusGen));
|
||||
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
|
||||
$people = $people ?? [];
|
||||
// ⚠️ 模板里不再有 $nodes:v15 起家谱数据由 Kinship::familyMap() 现算,
|
||||
// 不再经过 Kinship::tree() 的 units/generations 那套(那套是给旧画法用的)。
|
||||
|
||||
// ---- 树根 = 「我」往上走到的最高那一代 ----
|
||||
//
|
||||
// ⚠️⚠️ 这是 v14 最关键的一处实测结论(_fb_kin_probe.py / probe3 跑出来的):
|
||||
// family-chart 画的是「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人的配偶」。
|
||||
// 树根 = 我 时,**伯父 / 堂兄 / 嫂子 / 侄子 一个都不画**(实测 12 人只画出 8 人)——
|
||||
// 因为堂兄是"我伯父的孩子",不在"我的后代"里,也不是"我的兄弟姐妹"。
|
||||
// 把树根换成**我的最高祖先**,整棵树就变成"这位祖先的全部后代",
|
||||
// 于是上一代(伯叔姑舅姨)、这一代(堂表亲)、下一代(侄甥)**全部**落进树里
|
||||
// (实测 12 人全画出,且按辈分正确分层)。
|
||||
// 这正是用户说的「显示不齐全…以本人为中心去搜索,只要有关系的都检索出来」。
|
||||
//
|
||||
// ⚠️ 代价是"我"不再是几何中心 —— 用下面的「定位到我」按钮 + 加载后自动居中补回来。
|
||||
$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($focusGen[$p]) && (int)$focusGen[$p] < (int)$focusGen[$rootId]) {
|
||||
$next = $p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($next === 0) {
|
||||
break;
|
||||
}
|
||||
$rootId = $next;
|
||||
}
|
||||
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
|
||||
// up/down 取 2:卡片一行放得下三代,再多会挤(本人 = 0 代,-1/-2 为长辈,+1/+2 为晚辈)。
|
||||
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 2, 2);
|
||||
$gen = $map['gen'];
|
||||
$units = $map['units'];
|
||||
$unitOf = $map['unit_of'];
|
||||
|
||||
// ---- 连边:子女一律挂在「夫妻」上(normalizeLinks 的对称补齐)----
|
||||
// 见 App\Kinship::normalizeLinks 的注释:孩子同时挂到父母双方、双方也都有这个孩子,
|
||||
// 库才会把连线从**夫妻之间那条横线**的中点拉下来 —— 否则就是"从父亲一个人身上
|
||||
// 垂下来一根线",母亲那条线永远连不到孩子(用户点名的重大 bug)。
|
||||
$links = \App\Kinship::normalizeLinks($graph, $focusIds);
|
||||
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)----
|
||||
$unitKids = \App\Kinship::householdChildren($graph, $unitOf);
|
||||
|
||||
// 同一代里按长幼排(长子左、次子右);长幼键取不到时退到 id,保证顺序稳定
|
||||
// ---- ③ 同一代里的户按长幼排(长子左、次子右)----
|
||||
$rankKey = static function (int $id) use ($people): string {
|
||||
return \App\Kinship::rankValueFor($people, $id);
|
||||
};
|
||||
|
||||
// ---- 转成 family-chart 需要的 Datum 格式 ----
|
||||
// ⚠️ **树根**排最前(见上面坑①:`main_id = data[0].id`)。其余按「代数 → 长幼」跟上。
|
||||
$order = [$rootId];
|
||||
$rest = [];
|
||||
foreach ($focusIds as $pid) {
|
||||
if ((int)$pid !== $rootId) {
|
||||
$rest[] = (int)$pid;
|
||||
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
|
||||
$payloadUnits = [];
|
||||
foreach ($units as $i => $u) {
|
||||
$ids = array_map('intval', $u['ids']);
|
||||
$sorted = $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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($rest, static function (int $a, int $b) use ($focusGen, $rankKey): int {
|
||||
return ((int)$focusGen[$a] <=> (int)$focusGen[$b])
|
||||
?: strcmp($rankKey($a), $rankKey($b))
|
||||
?: ($a <=> $b);
|
||||
});
|
||||
$order = array_merge($order, $rest);
|
||||
|
||||
$onTree = array_fill_keys($focusIds, true);
|
||||
$datum = [];
|
||||
foreach ($order as $pid) {
|
||||
if (!isset($people[$pid]) || !is_array($people[$pid])) {
|
||||
if (!$cards) {
|
||||
continue;
|
||||
}
|
||||
$p = $people[$pid];
|
||||
|
||||
$mapIds = static function (array $ids) use ($onTree): array {
|
||||
$out = [];
|
||||
foreach ($ids as $x) {
|
||||
$x = (int)$x;
|
||||
if (isset($onTree[$x])) {
|
||||
$out[] = (string)$x;
|
||||
}
|
||||
$kids = [];
|
||||
foreach ($unitKids[$i] ?? [] as $cid) {
|
||||
if (isset($people[$cid])) {
|
||||
$kids[] = (int)$cid;
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
};
|
||||
}
|
||||
usort($kids, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
|
||||
|
||||
$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);
|
||||
|
||||
// 库认 M / F / U 三种(决定卡片底色)
|
||||
$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,
|
||||
],
|
||||
$payloadUnits[] = [
|
||||
'gen' => (int)$u['gen'],
|
||||
'cards' => $cards,
|
||||
'kids' => $kids, // 这个户的孩子(连线从这里画下去)
|
||||
];
|
||||
}
|
||||
|
||||
// ⚠️ 名字是用户自己输入的:拼 HTML 前必须转义,否则有人把名字填成一段「能提前闭合
|
||||
// 脚本块」的标签,就能从脚本里逃出去、把整页结构打断。
|
||||
// ⚠️ 这里**刻意不写出那个闭合标签的字面量** —— 静态检查(check_inline_js.py)
|
||||
// 是按原文找脚本块的,注释里写出来会被它当成真的脚本段去解析,报假错。
|
||||
$datumJson = json_encode(
|
||||
$datum,
|
||||
// 同代之间按「主位长幼」排序,保证顺序稳定
|
||||
$payloadUnitsSorted = $payloadUnits;
|
||||
usort($payloadUnitsSorted, 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 ($payloadUnitsSorted as $u) {
|
||||
$totalPeople += count($u['cards']);
|
||||
}
|
||||
$canChart = $totalPeople > 1;
|
||||
|
||||
$meName = (string)($people[$meId]['name'] ?? '自己');
|
||||
$payloadJson = json_encode(
|
||||
['units' => $payloadUnitsSorted, 'me' => (int)$meId],
|
||||
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
|
||||
);
|
||||
|
||||
$canChart = count($datum) > 1;
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h1>我的家谱</h1>
|
||||
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代 · 夫妻并排,子女从两人之间的横线连出</span>
|
||||
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女一律从夫妻横线中点连出</span>
|
||||
</div>
|
||||
|
||||
<?php if (!$canChart): ?>
|
||||
<div class="empty">
|
||||
<b>还没连上任何亲属关系</b>
|
||||
到「<?= e($me['name'] ?? '自己') ?>」的资料页,把父母、配偶、子女填进去,
|
||||
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
|
||||
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<?php
|
||||
/* 家谱画布。
|
||||
高度用 clamp 给:太矮时三代人挤成一团,太高又白占屏。
|
||||
⚠️ 库初始化那一刻**容器必须已经有真实尺寸**:它按容器宽高算 fit 缩放,
|
||||
还在 0×0 时初始化会一路缩到 0.4 倍,卡片小到看不清(实测踩过)。 */
|
||||
?>
|
||||
<div class="kin-tools">
|
||||
<?php /* 树根是「最高祖先」之后,整棵树可能很大 —— 这两个按钮把「看全部」与
|
||||
「回到我」分开,不然每次进来都要手动拖半天才能找到自己。 */ ?>
|
||||
<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-datum="<?= e($datumJson) ?>"
|
||||
data-me="<?= e((string)$meId) ?>"></div>
|
||||
<?php
|
||||
/* 画布。里面的内容(卡片 + svg 连线)由下面的脚本按数据算出来再落进去。
|
||||
⚠️ 尺寸不能是 0:脚本要按容器的真实宽高算初始 fit(0×0 会缩到看不清)。 */
|
||||
?>
|
||||
<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 src="<?= e(asset('/assets/vendor/family-chart/family-chart.min.js')) ?>"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.getElementById('kinChart');
|
||||
if (!root || typeof f3 === 'undefined') { return; }
|
||||
if (!root || typeof d3 === 'undefined') { return; }
|
||||
|
||||
var raw;
|
||||
var data;
|
||||
try {
|
||||
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
|
||||
} catch (err) {
|
||||
raw = [];
|
||||
}
|
||||
if (!raw.length) { return; }
|
||||
data = JSON.parse(root.getAttribute('data-kin') || '{}');
|
||||
} catch (err) { data = {}; }
|
||||
var units = (data.units || []);
|
||||
if (!units.length) { return; }
|
||||
|
||||
// 名字是用户输入 —— 插进 innerHTML 前必须转义(库只负责摆放,不管转义)
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var chart = f3.createChart('#kinChart', raw)
|
||||
.setTransitionTime(600)
|
||||
.setOrientationVertical()
|
||||
.setShowSiblingsOfMain(true)
|
||||
.setCardXSpacing(140)
|
||||
.setCardYSpacing(140);
|
||||
// ---- 版式常量(改这里就能整体调松紧)----
|
||||
var CW = 118; // 卡片宽
|
||||
var CH = 92; // 卡片高
|
||||
var GAP = 16; // 夫妻两张卡之间的缝(横线就画在这一段上)
|
||||
var XS = 200; // 同代相邻「户」的中心距
|
||||
var YS = 156; // 代与代之间的行距
|
||||
var PAD = 70; // 画布留白
|
||||
|
||||
chart.setCardHtml()
|
||||
.setStyle('rect')
|
||||
.setCardDim({ w: 116, h: 104, mini_tree: false })
|
||||
.setCardInnerHtmlCreator(function (d) {
|
||||
// ⚠️ 双层:d.data 是整个 Datum,自定义字段在 d.data.data(见文件头坑②)
|
||||
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' : '');
|
||||
// ⚠️ 卡片本身就是个 <a>,直接跳成员页 —— **不要**去找库的点击回调。
|
||||
// 踩过:写了 chart.setOnCardClick(...),而 chart 上根本没有这个方法
|
||||
// (抛 TypeError),异常把后面的 updateTree() 一起带走了 →
|
||||
// 整张家谱**一张卡都不画**(页面 200、容器有尺寸、看着"正常")。
|
||||
// 用 <a> 件不依赖任何库 API,也不怕库版本变了改签名。
|
||||
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>';
|
||||
// ---- 1. 分代 ----
|
||||
var byGen = {};
|
||||
units.forEach(function (u) { (byGen[u.gen] = byGen[u.gen] || []).push(u); });
|
||||
var gens = Object.keys(byGen).map(Number).sort(function (a, b) { return a - b; });
|
||||
|
||||
// ---- 2. 每代居中排开;算出每户的中心 x 与两张卡的位置 ----
|
||||
var maxPer = 1;
|
||||
gens.forEach(function (g) { maxPer = Math.max(maxPer, byGen[g].length); });
|
||||
var W = PAD * 2 + Math.max(1, maxPer) * XS;
|
||||
var H = PAD * 2 + (gens.length - 1) * YS + CH;
|
||||
|
||||
var pos = {}; // id => {x,y}
|
||||
var lay = {}; // 户序号 => {cx, cy, left, right, barY, ids}
|
||||
var genGi = {};
|
||||
gens.forEach(function (g, gi) { genGi[g] = gi; });
|
||||
|
||||
units.forEach(function (u, ui) {
|
||||
var row = byGen[u.gen];
|
||||
var i = row.indexOf(u);
|
||||
var gi = genGi[u.gen];
|
||||
var y = PAD + CH / 2 + gi * YS;
|
||||
var startX = (W - (row.length - 1) * XS) / 2;
|
||||
var cx = startX + i * XS;
|
||||
var ids = u.cards.map(function (c) { return c.id; });
|
||||
var half = CW / 2;
|
||||
var left, right;
|
||||
if (ids.length >= 2) {
|
||||
left = cx - GAP / 2 - half; // 左卡中心
|
||||
right = cx + GAP / 2 + half; // 右卡中心
|
||||
pos[ids[0]] = { x: left, y: y, g: u.gen };
|
||||
pos[ids[1]] = { x: right, y: y, g: u.gen };
|
||||
} else {
|
||||
left = right = cx;
|
||||
pos[ids[0]] = { x: cx, y: y, g: u.gen };
|
||||
}
|
||||
lay[ui] = {
|
||||
cx: cx, cy: y, left: left, right: right, ids: ids,
|
||||
// 夫妻横线的 y:卡片下沿往下一点,正好在两张卡之间
|
||||
barY: y + CH / 2 - 6,
|
||||
hasBar: ids.length >= 2
|
||||
};
|
||||
});
|
||||
|
||||
// ---- 3. 落卡片 ----
|
||||
var html = '';
|
||||
units.forEach(function (u) {
|
||||
u.cards.forEach(function (c) {
|
||||
var p = pos[c.id];
|
||||
if (!p) { return; }
|
||||
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
|
||||
html += '<a class="' + cls + '" data-kin-id="' + esc(c.id) + '"'
|
||||
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + CW + 'px;height:' + CH + '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>');
|
||||
|
||||
// ⚠️⚠️ 关掉库自带的「+ 添加亲属」空卡。
|
||||
// **setCanAdd 不在 chart 上,在 chart.editTree() 返回的对象上**(实测:
|
||||
// chart 的原型上是 calculateKinships / updateTree / setCardHtml ...,
|
||||
// 没有 setCanAdd;editTree() 返回的编辑树对象上才有)。
|
||||
// 写成 chart.setCanAdd(false) 会抛 TypeError,异常把后面的 updateTree() 一起
|
||||
// 带走 → 整张图**一张卡都不画**(页面照样 200、容器照样有尺寸,光看页面像"正常")。
|
||||
// 这个坑在本文件里连着犯了三次(setOnCardClick / setCanAdd 链式调用 / 这一处),
|
||||
// 所以:**新增库调用前先把方法名在浏览器里打印出来确认,别照文档或习惯猜。**
|
||||
// 为什么要关:家书不从家谱图上改关系(都在各人资料页里改,那里有权限与校验)。
|
||||
// 不关的话树上会多一张没有名字的空卡(data-id 是库现生成的 uuid、
|
||||
// 内容为空、点了跳空页),还会因为排在已婚的「我」旁边而像"多出一个配偶"。
|
||||
chart.editTree().setCanAdd(false);
|
||||
// ---- 4. 画线 ----
|
||||
// 用户的核心要求:「夫妻之间有横线;所有子女都从这条**横线**出来」。
|
||||
// 竖线起点恒为 (左卡中心 + 右卡中心)/2 —— 与"谁登录"无关,
|
||||
// 也与"孩子只登记了父亲还是只登记了母亲"无关。
|
||||
var paths = [];
|
||||
var bars = []; // 调试/断言用:每户的横线区间与中点
|
||||
var drops = []; // 调试/断言用:每户下垂线的起点 x
|
||||
|
||||
// ⚠️ 关掉「单亲补空卡」。库默认(single_parent_empty_card: true)会在
|
||||
// 「某个孩子只登记了一位父母」时,**自动给那位父母补一个配偶占位卡**,
|
||||
// 好让孩子在图上"看着有两位父母"。实测:测试夹具里有个孩子**只登记了父亲**
|
||||
// (没登记母亲),库里就凭空造了一张**没有名字、没有称谓、点了跳空页**的卡
|
||||
// 挂在父亲旁边 —— 可在用户眼里这就是"我凭空多出一个配偶",比"缺一位家长"糟得多。
|
||||
// 家书本来就有自己的占位语义(真实库记录 + 「待确认」角标),不需要库再补。
|
||||
// ⚠️ 这个方法名在 chart 原型上(实测确认过),与 setCanAdd 不在同一处。
|
||||
chart.setSingleParentEmptyCard(false);
|
||||
Object.keys(lay).forEach(function (k) {
|
||||
var L = lay[k];
|
||||
if (L.hasBar) {
|
||||
paths.push('M' + L.left + ',' + L.barY + 'L' + L.right + ',' + L.barY);
|
||||
}
|
||||
bars.push({ ui: +k, x1: L.left, x2: L.right, mid: (L.left + L.right) / 2,
|
||||
y: L.barY, has: L.hasBar });
|
||||
});
|
||||
|
||||
// initial:true 才会画出第一版;fit 让整棵树自适应容器大小
|
||||
chart.updateTree({ initial: true, tree_position: 'fit' });
|
||||
window.kinChart = chart;
|
||||
units.forEach(function (u, ui) {
|
||||
var L = lay[ui];
|
||||
var kids = (u.kids || []).map(function (id) { return pos[id]; })
|
||||
.filter(function (p) { return !!p; })
|
||||
.sort(function (a, b) { return a.x - b.x; });
|
||||
if (!kids.length) { return; }
|
||||
var srcX = (L.left + L.right) / 2; // ★ 夫妻横线中点(单亲时就是本人卡中心)
|
||||
var y0 = L.barY;
|
||||
var busY = L.cy + YS - CH / 2 - 30; // 子女行上方的横梁
|
||||
paths.push('M' + srcX + ',' + y0 + 'L' + srcX + ',' + busY);
|
||||
if (kids.length > 1) {
|
||||
paths.push('M' + kids[0].x + ',' + busY + 'L' + kids[kids.length - 1].x + ',' + busY);
|
||||
}
|
||||
kids.forEach(function (k) {
|
||||
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 + 2));
|
||||
});
|
||||
drops.push({ ui: ui, x: srcX, y0: y0, busY: busY });
|
||||
});
|
||||
|
||||
// ---- 平移 / 居中 ----
|
||||
// 库自带拖拽与滚轮缩放,但**没有**"定位到某个人"的 API(method 清单里没有)。
|
||||
// 好在缩放是标准 d3.zoom,它把对象挂在 svg 元素上(__zoomObj / __zoom),
|
||||
// 直接拿它改 transform 就行 —— 不用碰库的内部状态。
|
||||
// ⚠️ transform 的平移量是**屏幕像素**:想把某张卡挪到容器中心,
|
||||
// 就是把它当前的屏幕中心与容器中心之差加到 t.x / t.y 上,k 不动。
|
||||
var svg = root.querySelector('svg.main_svg');
|
||||
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>');
|
||||
|
||||
function currentTransform() {
|
||||
if (!svg) return null;
|
||||
return svg.__zoom || null;
|
||||
}
|
||||
function applyTransform(t) {
|
||||
if (!svg || !svg.__zoomObj || !window.d3) return;
|
||||
window.d3.select(svg).call(svg.__zoomObj.transform, t);
|
||||
// 给勾稽用(外部断言读它)
|
||||
window.__kinLayout = { W: W, H: H, CW: CW, CH: CH, bars: bars, drops: drops,
|
||||
pos: pos, paths: paths };
|
||||
|
||||
// ---- 5. 拖拽 + 缩放(d3.zoom)----
|
||||
var stage = root.querySelector('.kin-stage');
|
||||
var svg = root.querySelector('svg.kin-lines');
|
||||
var wrap = d3.select(root).append('g'); // 占位,真正的变换施加在 svg + stage 上
|
||||
wrap.remove();
|
||||
|
||||
var zoom = d3.zoom().scaleExtent([0.15, 2.5]).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.92;
|
||||
k = Math.max(0.15, Math.min(2.5, k));
|
||||
var tx = (r.width - W * k) / 2;
|
||||
var ty = (r.height - H * k) / 2;
|
||||
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
|
||||
}
|
||||
function centerOn(id) {
|
||||
var el = root.querySelector('.kin-card[data-kin-id="' + id + '"]');
|
||||
if (!el || !window.d3) return;
|
||||
var t = currentTransform();
|
||||
if (!t) return;
|
||||
var rr = root.getBoundingClientRect();
|
||||
var er = el.getBoundingClientRect();
|
||||
var dx = (rr.left + rr.width / 2) - (er.left + er.width / 2);
|
||||
var dy = (rr.top + rr.height / 2) - (er.top + er.height / 2);
|
||||
applyTransform(window.d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
|
||||
var p = pos[id];
|
||||
if (!p) { return; }
|
||||
var r = root.getBoundingClientRect();
|
||||
var t = d3.zoomTransform(root);
|
||||
// 目标:把该卡挪到容器中心
|
||||
var tx = r.width / 2 - p.x * t.k;
|
||||
var ty = r.height / 2 - p.y * t.k;
|
||||
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
|
||||
}
|
||||
window.kinFit = fit;
|
||||
window.kinCenterOn = centerOn;
|
||||
|
||||
fit();
|
||||
var meId = root.getAttribute('data-me') || '';
|
||||
// 库是「过渡」出来的:卡片位置要等动画停了才准。等一小会儿再居中。
|
||||
if (meId) { setTimeout(function () { centerOn(meId); }, 700); }
|
||||
if (meId) { setTimeout(function () { centerOn(meId); }, 60); }
|
||||
|
||||
var btnFit = document.getElementById('kinFit');
|
||||
var btnMe = document.getElementById('kinMe');
|
||||
if (btnFit) {
|
||||
btnFit.addEventListener('click', function () {
|
||||
chart.updateTree({ tree_position: 'fit' });
|
||||
});
|
||||
}
|
||||
if (btnMe && meId) {
|
||||
btnMe.addEventListener('click', function () { centerOn(meId); });
|
||||
}
|
||||
var btnMe = document.getElementById('kinMe');
|
||||
if (btnFit) { btnFit.addEventListener('click', fit); }
|
||||
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user