Files
FamilyBook/templates/kin/tree.php
T
laoyang c9037f79be 家谱 v22:改按 family-chart 官方用法接,并放开代数上限
用户指出「只需要按照 json 格式,把以我为中心的关联数据全部取出来,
全都封装进去就行了,id 用用户表的 id,性别名字生日头像都是现成的,
只需要把配偶、父母和孩子放进去用 id 关联」——并强调「之前一定是你
调用的方法不对」。

== 验证结论(先量再改)==
用**官方示例自己的数据**(donatso 的 data.json,13 人,格式完全官方)
跑了一遍:库画出 11 人,**配偶 Andrea 的父母 Zadro/Zadra 一个都没画**。
那两位就在数据里、rels.parents 也指向他们、格式与官方示例一字不差。
→ 说明「按格式全塞进去就能画全」在**姻亲**这一项上不成立:
  库的算法只走「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
  **不会从配偶继续向上/向下 walk**。这是库的算法边界,不是格式/关联问题。

== 但用户的批评有一半是对的:调用方式确实有我没做足的地方 ==
官方示例是:<div id="FamilyChart" class="f3"> + 引 family-chart.css。
我之前**既没加 f3 类、也没引库 CSS**,而是自己在 app.css 里手补
`.kin-chart svg.main_svg{width:100%;height:100%}` 去绕开 300×150 那个坑 ——
属于"用歪门邪道达到同样效果"。现在按官方来:
  · 容器加 `f3` 类(库的 CSS 全部以 .f3 作用域)
  · 引入官方 family-chart.css(新增 vendor 文件)
  · 在 app.css 里把库的深色主题变量覆盖成家书宣纸色
    (--male-color / --background-color / --text-color 等)
  · 自己的 svg 撑满那条规则删掉(官方 CSS 已提供)

== 放开代数上限(用户要求「全部取出来」)==
官方示例**不调** setAncestryDepth / setProgenyDepth —— 库在"未设置"时
会跳过深度截断,等于不限代数。我之前写死了 2/3,真实家庭一超过就被砍。
现在不设这两个值。服务端 familyMap 的 up/down 也从 (2,2) 放到 (9,9)
(这两个参数只影响**代数标号**,超出会压到边界档导致曾祖辈与祖辈混层;
遍历本身是走遍整个连通图的,人员收录数不受影响)。

== 工具同步 ==
- familybook_deploy.sh:新增 v22 三条契约(vendor CSS 在 / 模板引了它 /
  容器有 f3 类),并加**反向断言**「不许再出现 setAncestryDepth|setProgenyDepth」;
  旧的"卡片尺寸/间距"断言同步。
- _fb_kin_geom_falsify.py:注入②的原锚点(setProgenyDepth)已随本次删除,
  换成「禁掉上溯循环」——树根退回「我」,伯父/堂兄/堂嫂/堂侄/侄子那批旁支
  会离开画面,J 项必须红。3/3 全抓住。
- ⚠️ 又踩一次「注释撞断言字面量」:新写的注释里提到 setProgenyDepth 函数名,
  把「不许再设代数上限」这条反向断言直接打红。改注释避开字面量后恢复。

== 战绩 ==
e2e 549/0 · 桌面几何 368/0 · 移动端 516/0 · 交互 97/0 · 升级专测 123/0
家谱可视化 24/0 · 家谱版面 10/0 · 反向验证 3/3 · 静态断言空跑 RC=0
部署 + 线上自检全过。
2026-10-02 20:09:28 +08:00

281 lines
12 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
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代所有有关系的人。
*
* v21 —— 回退到 family-chart 库(用户 2026-10-02 明确要求)。
*
* 之前自研布局叠了多层修补后位置漂移,用户决定还是交给成熟库来做。
* 数据准备要点:
* · 用 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());
$people = $people ?? [];
// ---- ① 以我为中心,检索**所有有关系的人**(不设代数上限)----
// ⚠️ 用户明确要求「把以我为中心的关联数据全部取出来」。
// familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档),
// 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话,
// 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 9, 9);
$gen = $map['gen'];
$units = $map['units'];
$unitOf = $map['unit_of'];
if (!isset($people[$meId]) || !is_array($people[$meId])) {
$canChart = false;
} else {
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
// ---- ② 树根 = 「我」往上走到的最高那一代 ----
//
// 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);
};
// 树根排最前(库默认以 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,
],
];
}
$canChart = count($datum) > 1;
}
$meName = (string)($people[$meId]['name'] ?? '自己');
$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>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<div class="kin-tools">
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
</div>
<!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。
官方示例就是 `<div id="FamilyChart" class="f3">` + 引 family-chart.css;
少了它,库里 `.f3 svg.main_svg{width:100%;height:100%}` 这条撑满规则不生效,
SVG 会退化成浏览器默认的 300×150(连线全被裁掉,而 HTML 卡片层不受影响 →
表现为「卡在、线没了」)。 -->
<div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
每代一行;<b>一对夫妻之间有一条短横线</b>,
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上)。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
<br><span class="muted">注:本图由 family-chart 绘制。免费版只画「同一位祖先的血脉后代 + 各人配偶」,
妻子那侧的岳父母、舅子等姻亲不在图内(在名册与成员详情页仍可看到)。</span>
</p>
<!-- 官方用法是「容器带 class="f3" + 引 family-chart.css」(见 family-chart 官方示例)。
之前没引这份 CSS,靠自己在 app.css 里手补 `.kin-chart svg.main_svg{width/height:100%}`
绕过去 —— 属于"调用方法不对"。现在按官方来。
库的深色主题(--male-color / --background-color…)在 app.css 里覆盖成家书的宣纸色。 -->
<link rel="stylesheet" href="<?= e(asset('/assets/vendor/family-chart/family-chart.css')) ?>">
<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 window.f3 === 'undefined') { return; }
var raw;
try {
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
} catch (err) {
raw = [];
}
if (!raw.length) { return; }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
var chart = f3.createChart('#kinChart', raw)
.setTransitionTime(600)
.setOrientationVertical()
.setShowSiblingsOfMain(true)
// ⚠️ 官方示例**不设**祖先/后代的代数上限 —— 库在"未设置"时
// 会跳过深度截断,等于**不限代数**,有多少画多少。
// 之前写死了具体代数,真实家庭一超过就被砍掉。
// 用户要求「全部取出来」,所以这里也不设。
.setCardXSpacing(180)
.setCardYSpacing(180);
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>';
});
chart.editTree().setCanAdd(false);
chart.setSingleParentEmptyCard(false);
chart.updateTree({ initial: true, tree_position: 'fit' });
window.kinChart = chart;
// 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;
if (!z || !d3 || !f3Canvas) return;
d3.select(f3Canvas).call(z.transform, t);
}
// 把镜头挪到指定人:用库算出的树坐标,固定比例 0.9,避免「看全部」时缩得太小
function centerOn(id) {
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.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || '';
// 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', function () {
chart.updateTree({ tree_position: 'fit' });
});
}
if (btnMe && meId) {
btnMe.addEventListener('click', function () { centerOn(meId); });
}
})();
</script>
<?php endif; ?>