用户明确要求「还是用 family-chart 来实现吧,刚才查了这个库可以完美实现
我想要的效果,之前一定是你调用的方法不对」。自研区间法(v20)虽然
20/20 全显示且通过全部版面检查,但用户觉得不够顺滑细腻,改为交给成熟库。
== 关键修复(都是真跑出来的坑)==
1. **IIFE 首行就 return**:本地变量 `var f3 = root.querySelector(...)` 与
全局库 `f3` 重名,`typeof f3 === 'undefined'` 因变量提升恒为真 →
整段 JS 一行没执行、画布永远空白。改名 `f3Canvas`。
2. **「回到我」不动卡片层**:库的 zoom 行为绑在 `#f3Canvas` 上,不是
`svg.main_svg`。拿 svg 的 `__zoomObj` 去驱动只会动 SVG 连线层,
HTML 卡片层(`#htmlSvg .cards_view`)留在原地。改取 `#f3Canvas`。
3. **SVG 退化成 300×150**:库自带 CSS 写的是 `.f3 svg.main_svg{width:100%;
height:100%}`,而我们的容器类名是 `.kin-chart`(不是 `.f3`)、也没引
库的 CSS → SVG 根元素用浏览器默认尺寸,所有画到框外的连线被裁掉。
症状很隐蔽:卡片层是 HTML div 不受影响,于是表现为「卡在、线没了」。
补 `.kin-chart svg.main_svg{width:100%;height:100%}`。
4. **儿子/女儿这一辈不显示**:树根是最高祖先,`progeny_depth` 必须覆盖到
「我」的下一代 → 从 2 调到 3。
5. **连线是白的看不见**:库给 path 写了 inline `stroke="#fff"`,普通 CSS
覆盖不了,必须 `!important`。
== 免费版能力边界(已实测确认,非调用问题)==
库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
**不会从配偶继续向上/向下 walk**。20 人夹具里岳父/岳母/舅子/外甥
(4 位姻亲)画不出来(换树根、调 depth 都救不了)→ 图上 16/20。
服务端 data-datum 仍含全体(含姻亲),名册/称呼不受影响。
== 验证战绩 ==
- e2e 549 通过 / 0 失败
- 桌面几何 368 / 0
- 移动端几何 516 / 0(家谱断言改到 family-chart 选择器)
- 交互 97 / 0;升级专测 123 / 0
- 家谱可视化 24 / 0(含「svg 不是 300×150」这条,正是坑 3 的捕手)
- 家谱版面 10 / 0
- 反向验证 3 / 3(间距压扁→叠卡;depth=1→缺人;写坏 centerOn 平移量→偏离中心)
267 lines
11 KiB
PHP
267 lines
11 KiB
PHP
<?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 ?? [];
|
||
|
||
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
|
||
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 2, 2);
|
||
$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>
|
||
|
||
<div class="kin-chart" 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>
|
||
|
||
<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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
var chart = f3.createChart('#kinChart', raw)
|
||
.setTransitionTime(600)
|
||
.setOrientationVertical()
|
||
.setShowSiblingsOfMain(true)
|
||
.setAncestryDepth(2)
|
||
// 树根是最高祖先;progeny_depth 要覆盖 "儿子 / 女儿" 这一代,
|
||
// 所以要比 "我" 到树根的世代再多一代。
|
||
.setProgenyDepth(3)
|
||
.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; ?>
|