Files
FamilyBook/templates/kin/tree.php
T
laoyang 80f5c4f0fc 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(旧解析器在新页面上只会解析出空)
2026-10-02 10:26:14 +08:00

335 lines
14 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
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
*
* ============================ v15 为什么又重写渲染 ============================
*
* v13/v14 用第三方库 family-chart 画,结果**数据一直不全**(用户两次反馈:
* 「显示不齐全」「数据仍然不全,差很多」)。实测(_tooling/_fb_kin_cover_probe.py)
* 给出了硬证据:20 人的夹具,库**只画了 9 人** ——
* 伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥**一个都没画**。
* 原因在库的算法本身:它只画「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
* **横向的亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来**。
* 换树根也救不了 —— 换成最高祖先,"妻子的兄弟"照样不在树里。
*
* 所以这一版**不再依赖库的树算法**:
* · `Kinship::familyMap()` 以「我」为中心按代数检索**所有人**
* (父母、子女、配偶、配偶的父母与子女、每层的配偶、兄弟姐妹的子女…);
* · 前端按「**代分层、户并排**」自己排版、自己画线(见下面的 JS)。
*
* 用户点名的两条(都已兑现):
* ① 「显示本人的上一代、这一代、下一代的所有人」→ 见 familyMap 的两轮贯穿;
* ② 「子女必须从**夫妻之间的横线**出来,不能从夫妻其中一个出来
* (否则母亲登录看不到自己的孩子)」→ 子女挂在**户**上,
* 竖线起点 = 夫妻横线的**中点**(`x = (左卡中心 + 右卡中心) / 2`),
* 与"是父亲登录还是母亲登录"完全无关。
*
* ⚠️ 为什么不再用库:库把布局写死在它的树算法里,我们既改不了"画谁"(横向亲戚不进树),
* 也改不了"线从哪出"。自己排版之后这两件事都变成几行代码。
* ⚠️ 拖拽/缩放仍要保留(用户明确说「反正可以缩放和拖动」):用原生 d3.zoom,
* 不再需要库(d3.min.js 还在 vendor 里,可继续复用)。
*/
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
$people = $people ?? [];
// ⚠️ 模板里不再有 $nodes:v15 起家谱数据由 Kinship::familyMap() 现算,
// 不再经过 Kinship::tree() 的 units/generations 那套(那套是给旧画法用的)。
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
// 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'];
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)----
$unitKids = \App\Kinship::householdChildren($graph, $unitOf);
// ---- ③ 同一代里的户按长幼排(长子左、次子右)----
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id);
};
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
$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'),
];
}
if (!$cards) {
continue;
}
$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, // 这个户的孩子(连线从这里画下去)
];
}
// 同代之间按「主位长幼」排序,保证顺序稳定
$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
);
?>
<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>
<?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>
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof d3 === 'undefined') { return; }
var data;
try {
data = JSON.parse(root.getAttribute('data-kin') || '{}');
} catch (err) { data = {}; }
var units = (data.units || []);
if (!units.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 CW = 118; // 卡片宽
var CH = 92; // 卡片高
var GAP = 16; // 夫妻两张卡之间的缝(横线就画在这一段上)
var XS = 200; // 同代相邻「户」的中心距
var YS = 156; // 代与代之间的行距
var PAD = 70; // 画布留白
// ---- 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>');
// ---- 4. 画线 ----
// 用户的核心要求:「夫妻之间有横线;所有子女都从这条**横线**出来」。
// 竖线起点恒为 (左卡中心 + 右卡中心)/2 —— 与"谁登录"无关,
// 也与"孩子只登记了父亲还是只登记了母亲"无关。
var paths = [];
var bars = []; // 调试/断言用:每户的横线区间与中点
var drops = []; // 调试/断言用:每户下垂线的起点 x
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 });
});
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 });
});
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, 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 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); }, 60); }
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); }); }
})();
</script>
<?php endif; ?>