用户 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(旧解析器在新页面上只会解析出空)
335 lines
14 KiB
PHP
335 lines
14 KiB
PHP
<?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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// ---- 版式常量(改这里就能整体调松紧)----
|
||
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; ?>
|