Files
FamilyBook/templates/kin/tree.php
T
laoyang 6930191ac6 家谱:修「鼠标移上去卡片会跳」+ 卡片拆成「头像进资料 / 姓名换中心」两处点击区
用户 2026-10-03 反馈两条。

一、「鼠标放在某个人的头像上,那个框会移动一下位置」——**根因是库的 CSS**:
    库的 family-chart.css:517 有一条
      `.f3 div.card:hover > div { transform: translate(0, -2px); }`
    而我们的 .kin-card 正是 `div.card` 的**直接子 div**。卡片当时用
    `transform: translate(-50%,-50%)` 居中 —— 于是鼠标一进卡片,这条规则就把
    居中 transform **整份覆盖**成 translate(0,-2px)。
    实测(1600×1000 视口、库缩放 0.9):
      卡片 translate(-59px,-66px)  →  translate(0,-2px)
      整张卡向右下跳 **53.1 × 57.6 屏幕像素**(正好半张卡),并带 0.2s 过渡,
      所以看起来就是"框移动了一下"。
    修法两条:
      ① 居中不再依赖 transform,改用 `inset: 0` + `margin: auto`
         (绝对定位 + inset 全 0 + margin auto = 在包含块里居中);
      ② 把库那条 :hover 位移归零(`.kin-chart.f3 div.card:hover > div { transform: none }`)——
         2px 的"上浮"仍在,实测 dy=-1.8px。在 app.css 里覆盖(它加载在库 CSS 之后,
         同特异性后写的赢,不需要 !important),**不改 vendor 里那份文件**
         (升级库会整份覆盖、改动静默消失)。
    ⚠️ 660 别改回 translate(-50%,-50%) 居中 —— 那正是这次的 bug。

二、「以他为中心」按钮不要了,改成把卡片分成两处点击区:
     · 头像 .kin-ph  → 进这个人的资料页(people/show&id=)
     · 姓名 .kin-nm  → 以他为中心重画家谱(?r=kin&focus=)
    两个都是 <a>(而不是一个 a + 一个 button):点名字换中心本质就是"换个 URL
    重新渲染"。换成 <a> 之后,原来那段"给按钮绑 click + keydown +
    mousedown/pointerdown/touchstart 三处阻止冒泡"的代码**整段删掉** ——
    那是"用按钮干链接的活"才需要的补丁;顺带键盘 / 中键新窗口打开都天然可用。
    两张悬停提示("资料" / "以他为中心")分挂到头像、姓名下面,只在指针悬停时显形。
    服务端那份"谁有姻亲在图外"的 branch 计算也一并删掉(渲染没了,留着是死代码)。

验证:
  · 家谱可视化检查 26 → **29** 条全过,其中新增两条正是本次的守卫:
    ⑬ 卡片不靠 transform 居中;⑭ 鼠标移到卡片上**不位移**(原来是 53×58px)。
  · 反向验证:把 CSS 改回旧写法 → ⑬⑭ 立刻变红且实测 dx=53.1 dy=57.6;
    还原后回到全绿(断言不是永远绿的)。
  · 点击区实测:点姓名 → /index.php?r=kin&focus=9;点头像 → /index.php?r=people/show&id=9。
  · e2e 773/0(四条家谱断言跟着新口径更新)· 第 5 轮专项 91/0 · 几何 428/0 ·
    移动端 684/0 · 交互 97/0 · 升级 126/0 · 部署反向验证 3/3。
  · 部署脚本新增三条静态断言(不许 transform 居中 / 必须有 inset:0 /
    必须把库的 :hover 位移归零),防止以后被改回去。
  · 线上 templates/kin/tree.php 与 assets/css/app.css 的 md5 与本地一致;
    kin-focus 已清零、r=kin&focus= 在位、CSS 里 'inset: 0' 8 处。
2026-10-03 15:57:56 +08:00

317 lines
16 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 只影响**代数标号**(超出就压到边界那一档),
// 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话,
// 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。
// ⚠️ v24 第 12 条:中心是 $focusId(默认=我)。点姻亲入口进来时,中心换成那个人,
// 这样 TA 那一侧的姻亲(岳父母等)才画得出来。
$focusId = (int)($focusId ?? $meId);
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
$focusId = (int)$meId;
}
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), $focusId, $people, 9, 9);
$gen = $map['gen'];
$units = $map['units'];
$unitOf = $map['unit_of'];
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
$canChart = false;
} else {
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
// ---- ② 树根 = 「我」往上走到的最高那一代 ----
//
// family-chart 画的是「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」。
// 树根 = 我 时,伯父/堂兄/嫂子/侄子 不在「我的后代」里,也不会被画出来;
// 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」,
// 上一代、这一代、下一代的血亲与旁支才能全部落进来。
// 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。
$rootId = (int)$focusId;
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),
// 「我」在图上高亮;中心那个人另有标记($focusId)
'me' => $pid === (int)$focusId,
'is_me_viewer' => $pid === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
],
'rels' => [
'parents' => $parents,
'spouses' => $spouses,
'children' => $kids,
],
];
}
// ⚠️ v24 第 12 条曾在每人数据上挂一个 `branch`("他的姻亲没画在本图上"的人名),
// 用来给卡片加一个"⇄ 姻亲"入口。用户 2026-10-03 第 7 条把那个按钮撤了
// (改成"点姓名 = 以他为中心"),所以这份计算**连同渲染一起删掉了** ——
// 留着只会是"服务端算了一个没人用的字段",日后读代码的人还得反查它干嘛的。
// (如果哪天又要"姻亲入口",从 git 历史里捞这一段即可。)
$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
);
?>
<?php /* 用户 2026-10-03 第 2 条:标题下的那句说明**去掉**;
右下角的「返回我的家谱」也去掉 —— 与页面里那个工具条重复,
会出现两个"回到我的家谱"(用户明确点名)。现在整页只有标题。 */ ?>
<div class="page-head">
<h1><?= $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?></h1>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<?php /* 用户 2026-10-03 第 2 条:「看全部 / 回到我 / 回到我的家谱」这些按钮
全部去掉。镜头由库自动 fit(updateTree 的 tree_position: 'fit'),
用户要调整时直接拖拽/滚轮即可,不需要常驻按钮。 */
/* ⚠️ 顺带说明:品牌名「家谱」已从导航条撤走,但**模块本身还在** ——
个人设置里能勾回来,且 URL 直接访问 r=kin 永远有效。
这一点写在这里是为了防止有人"顺手把 kin 路由删掉"。 */ ?>
<!-- ⚠️ `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-focus="<?= e((string)$focusId) ?>" data-me-viewer="<?= e((string)$meId) ?>"></div>
<!-- 官方用法是「容器带 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(160)
.setCardYSpacing(180);
chart.setCardHtml()
.setStyle('rect')
.setCardDim({ w: 118, h: 132, 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' : '');
/* 用户 2026-10-03(第 6 条):
① 卡片**不再整张是链接** —— 原来整卡是 <a>,右下角那个小按钮也在
<a> 里,手指/鼠标稍偏一点就先触发外层链接跳去成员页(用户反馈
"容易误触"的根因)。现在卡片是 <div>,点哪儿都不会误跳。
② 头像外框改**长方形**、宽度贴合头像本身(CSS 里 .kin-ph 定宽,
img 用 object-fit: contain 不裁切),信息在头像**下方**。
用户 2026-10-03(第 7 条):**撤掉右下角的「以 TA 为中心」按钮**,
改成把卡片分成**两个点击区**:
· 头像 → 进这个人的资料页;
· 姓名 → 以他为中心重画一张家谱。
⚠️ 两个都是 <a>(不是一个 a + 一个 button):
"点名字换中心"本质就是**换个 URL 重新渲染**,用链接最自然 ——
能中键新窗口打开、能右键复制地址、没有 JS 也能用。
原来那个 <button> 要手动绑 click + 键盘事件 + 三处 stopPropagation,
全是"用按钮做链接"的代价;换成 <a> 之后这些代码整段删掉。 */
return '<div class="' + cls + '" data-kin-id="' + esc(id) + '">'
+ '<a class="kin-ph" href="/index.php?r=people/show&id='
+ encodeURIComponent(id) + '" title="看 ' + esc(p.name) + ' 的资料">'
+ '<img src="' + esc(p.avatar) + '" alt=""></a>'
+ '<a class="kin-nm" href="/index.php?r=kin&focus='
+ encodeURIComponent(id) + '" title="以 ' + esc(p.name) + ' 为中心看家谱">'
+ esc(p.name) + '</a>'
+ '<span class="kin-lb">' + esc(p.label) + '</span>'
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
// 两张提示分挂到头像/姓名下面,**悬停时才显形**(见 CSS 的 .kin-hint):
// 这样"点哪儿去哪儿"一眼可辨,不悬停时又不占地方。
+ '<span class="kin-hint kin-hint-ph">资料</span>'
+ '<span class="kin-hint kin-hint-nm">以他为中心</span>'
+ '</div>';
});
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 focusId = root.getAttribute('data-focus') || '';
var meId = root.getAttribute('data-me-viewer') || focusId;
// ⚠️ 中心是别人时,「回到我」要跳到 focus=我 的图,而不是在图里找我的坐标
var centerTarget = root.getAttribute('data-me-viewer') || focusId;
// fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
if (meId) {
setTimeout(function () { centerOn(focusId); }, 900);
// 万一 zoom 对象还没初始化好,再补一次
setTimeout(function () { centerOn(focusId); }, 1400);
}
/* 用户 2026-10-03 第 2 条:工具条三个按钮(看全部/回到我/回到我的家谱)已撤。
镜头由库自动 fit。
用户 2026-10-03 第 7 条:「以 TA 为中心」的按钮也撤了 —— 现在**点姓名**就是
以他为中心(那个 <a> 直接指向 ?r=kin&focus=<id>,是普通的页面跳转)。
⚠️ 原来这里有一大段"给那个小按钮绑 click + keydown + mousedown/pointerdown/
touchstart 三处阻止冒泡"的代码,那是**用按钮干链接的活**才需要的补丁
(防止先触发外层链接)。换成 <a> 之后整段删掉:
少一段代码、少一类 bug,键盘与中键打开也天然可用。 */
})();
</script>
<?php endif; ?>