Files
FamilyBook/templates/kin/tree.php
T
laoyang 7f00d3e553 feat(kin,movies): 家谱改用第三方库 family-chart;观影设置按钮改纯图标并统一页头度量
家谱(v13):
- 自研布局是死结,不是样式问题:连线的横杆是网格项,只能横跨「父母列→子女列」
  整段区间,中间夹着与血缘无关的人(如媳妇那侧的岳父母)时必然从卡片上穿过。
  换成 family-chart(D3 生态,MIT)后,排布只走本家血脉 + 配偶,姻亲不进画布,
  这个死结在构造上就不存在了。
- 接入踩到并修掉的坑:main_id = data[0].id(「我」必须排最前);
  卡片回调里自定义字段在 d.data.data(双层);库的 family-chart.css 是死文件
  (全挂在 .f3 作用域,运行时没有 .f3 元素);库的 svg 撑满选择器永不命中
  (必须自己补 .kin-chart svg.main_svg);setCanAdd/setOnCardClick 不在 chart 上
  (写错会抛异常,把 updateTree 一起带走 → 整张图空白,页面照样 200)。
- 关掉 setSingleParentEmptyCard:库默认给孩子只登记一位父母的人自动补一张无名
  配偶卡,看着像「凭空多出一个配偶」。
- 卡片用原生 <a> 链成员页,不依赖库的点击回调。

观影按钮(用户本轮原话):
- 「记一场电影」与「设置」高度仍不一致 —— 根因是文字按钮高度由 padding+line-height
  自然推出(43.8px),而 .btn-icon 写死了 40px。抽出 --act-* / --act-h 变量,两边同源。
- 设置按钮去掉「设置」二字,只留图标,图标换成三条横线(☰)。
- 设置按钮在所有页面排最右(.movie-set 用 margin-left:auto 顶到行尾)。
- 除设置外其他按钮都有颜色,与设置区分(主按钮实心 / 次要浅底 / 危险淡红)。
  顺带修掉一个特异性 bug:删除按钮被 .btn 品牌底盖成实心红,与「编辑」难分辨。
- 设置下拉四个条目文字前都加图标(行内 SVG,不引外部字体)。

测试与部署:
- 新增 _fb_kin_check.py(桌面,12 条)+ _fb_kin_falsify.py(6/6)证明
  「整张家谱空白」这类只靠通用几何抓不到的坏法能被抓住。
- _fb_geom_mobile.py 家谱段重写为窄屏语义(库按容器 fit 缩放是正常行为,
  改为断「比例没被压扁 / 缩放不至于看不清」),并加 _fb_kin_mobile_falsify.py(4/4)。
- _fb_btn_check.py(59 条)+ _fb_btn_falsify.py(3/3)覆盖观影页头按钮五条需求。
- familybook_deploy.sh / familybook_smoke.sh 的家谱断言改为第三方库形态,
  设置按钮断言改为 btn-icon 纯图标 + 三条横线 + 条目图标。
- 新增 .gitattributes:vendor 库与 .min.js 标 -text,原样入库不被改写行尾。
2026-10-01 19:35:48 +08:00

213 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 我的家谱:以我为中心,向上向下各三代。
*
* **v13 —— 改用成熟的第三方库 family-chart(D3 生态,MIT)来画。**
*
* 为什么放弃自研(v12 的「单一网格」也救不回来):
* v11「每代各一个网格」的列宽由内容决定,各代对不齐;v12 收成「整棵树一个 grid」,
* 几何上确实对齐了,但仍有一个**结构性死结**:连线的横杆是**网格项**,只能横跨
* 「从父母那一列到子女那一列」的整段列区间。于是只要中间夹着**跟这条血缘无关**的人
* (最典型的是配偶带进来的岳父母),横杆就会**从他们卡片中间穿过去**。
* 这不是调样式能修的 —— 横杆的跨度由列位决定,而列位是为「同代并排」服务的,
* 两套诉求在同一张网格里没法同时满足。
*
* 换成库之后:
* · 布局(谁放哪、怎么折线、怎么避让)全部交给库的树算法,不再由我们用网格硬凑;
* · 库只画**本家血脉 + 配偶**,不画配偶的父母 —— 这正是「横杆穿卡片」的根源被消掉的原因,
* 语义上也算合理(姻亲本来就不是本家的血亲,v11 起就单独标着「姻亲」两个字)。
* · 自带拖拽平移 / 滚轮缩放,家里人多了不用我们做横向滚动。
*
* ⚠️ 接入时踩过的三个坑(都写进注释,别再踩):
* ① 库把**数据数组里的第一个人**当默认"主人物"(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`。
*
* 称呼、头像、「本人」高亮、待确认这些语义都由 App\Kinship::label() / person 记录决定,一字未改。
*/
$nodes = $tree['nodes'];
$me = $nodes[$meId] ?? null;
// ---- 转成 family-chart 需要的 Datum 格式 ----
// ⚠️ 「我」排最前(见上面坑①)。其余按名册顺序跟上,库自己会按辈分重新摆放。
$order = [(int)$meId];
foreach (array_keys($people) as $pid) {
if ((int)$pid !== (int)$meId) {
$order[] = (int)$pid;
}
}
$datum = [];
foreach ($order as $pid) {
if (!isset($people[$pid]) || !is_array($people[$pid])) {
continue;
}
$p = $people[$pid];
// 只连「名册里真的有的人」——树上出现一个查不到的人会画出空白卡
$parents = [];
foreach (\App\Kinship::parents($graph, $pid) as $pp) {
if ($pp !== null && isset($people[$pp])) {
$parents[] = (string)$pp;
}
}
$spouses = [];
foreach (\App\Kinship::spouseIds($graph, $pid) as $sp) {
if (isset($people[$sp])) {
$spouses[] = (string)$sp;
}
}
$kids = [];
foreach (\App\Kinship::childIds($graph, $pid) as $kd) {
if (isset($people[$kd])) {
$kids[] = (string)$kd;
}
}
// 库认 M / F / U 三种(决定卡片底色)
$g = strtolower((string)($p['gender'] ?? ''));
$gender = $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U');
$datum[] = [
'id' => (string)$pid,
'data' => [
'gender' => $gender,
'name' => (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,
],
];
}
// ⚠️ 名字是用户自己输入的:拼 HTML 前必须转义,否则有人把名字填成一段「能提前闭合
// 脚本块」的标签,就能从脚本里逃出去、把整页结构打断。
// ⚠️ 这里**刻意不写出那个闭合标签的字面量** —— 静态检查(check_inline_js.py)
// 是按原文找脚本块的,注释里写出来会被它当成真的脚本段去解析,报假错。
$datumJson = json_encode(
$datum,
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>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($me['name'] ?? '自己') ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<?php
/* 家谱画布。
高度用 clamp 给:太矮时三代人挤成一团,太高又白占屏。
⚠️ 库初始化那一刻**容器必须已经有真实尺寸**:它按容器宽高算 fit 缩放,
还在 0×0 时初始化会一路缩到 0.4 倍,卡片小到看不清(实测踩过)。 */
?>
<div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>"></div>
<p class="tree-legend">
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
图里画的是**本家血脉 + 各自的配偶**;配偶的父母(如岳父母)属姻亲,不在这张图上。
可以直接**拖拽平移**、**滚轮缩放**;点一下卡片进对应成员页。
</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; }
var raw;
try {
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
} catch (err) {
raw = [];
}
if (!raw.length) { return; }
// 名字是用户输入 —— 插进 innerHTML 前必须转义(库只负责摆放,不管转义)
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
var chart = f3.createChart('#kinChart', raw)
.setTransitionTime(600)
.setOrientationVertical()
.setShowSiblingsOfMain(true)
.setCardXSpacing(140)
.setCardYSpacing(140);
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 + '" 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>';
});
// ⚠️⚠️ 关掉库自带的「+ 添加亲属」空卡。
// **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);
// ⚠️ 关掉「单亲补空卡」。库默认(single_parent_empty_card: true)会在
// 「某个孩子只登记了一位父母」时,**自动给那位父母补一个配偶占位卡**,
// 好让孩子在图上"看着有两位父母"。实测:测试夹具里有个孩子**只登记了父亲**
// (没登记母亲),库里就凭空造了一张**没有名字、没有称谓、点了跳空页**的卡
// 挂在父亲旁边 —— 可在用户眼里这就是"我凭空多出一个配偶",比"缺一位家长"糟得多。
// 家书本来就有自己的占位语义(真实库记录 + 「待确认」角标),不需要库再补。
// ⚠️ 这个方法名在 chart 原型上(实测确认过),与 setCanAdd 不在同一处。
chart.setSingleParentEmptyCard(false);
// initial:true 才会画出第一版;fit 让整棵树自适应容器大小
chart.updateTree({ initial: true, tree_position: 'fit' });
window.kinChart = chart;
})();
</script>
<?php endif; ?>