v18: 家谱改成「仿亲友+」App 的画法(按用户给的参考实现)

用户反馈(原话):「不行,非常乱。我给你个链接
https://gitee.com/coku2333/FamilyTreeView 你去读这个代码,尤其是它那个仿亲友+app
的效果,就是我想要的,按照这个做。」

参考实现是 Android 的 NewFamilyTreeView(ViewGroup + onDraw 画线)。我读了它的
README 与源码,把它的**布局规则**逐条搬过来(不是搬代码,是搬算法):

  ① **从下往上定位**:先孙辈(+2)→ 子女(+1)→ 我(0)→ 父母(-1)→ 祖辈(-2)。
     因为"子层位置定了,父层就定了" —— 父居中于子之上。
  ② 每一代维护 **genLeft / genRight 两个游标**(该代已用到的左右边界);
     往右铺从 genRight 起、往左铺从 genLeft 起(对应它的 setPart)。
  ③ **配偶并排在主位右侧**(一对夫妻 = 相邻两张卡)。
  ④ 兄姐往左、弟妹往右;舅姨(母系)往右、叔伯姑(父系)往左 ——
     都是"以我/父母为中心"向两侧展开,而不是按格子平铺。
  ⑤ 我居中于我的子女之上;父母居中于我之上;祖辈居中于父母之上。
  ⑥ **连线画在卡片背后**(参考是 ViewGroup.onDraw 先画线再绘子 View)——
     所以线可以从卡中心连到卡中心,被卡面挡住即可,不用绕路。
  ⑦ 卡片改成参考的窄长比例(它 50×80,我们 88×106):头像占上半、姓名与称呼在下。

画线(沿用参考的三段式,子女一律从夫妻横线中点出):
  夫妻横线(两卡中心之间)→ 从**横线中点**往下垂到横梁 → 横梁分到每个孩子。

上一版(v17「整齐树」)的问题:线虽然不穿卡了,但整张图看着像表格 ——
每一代从左到右平铺、兄弟姐被推到最外侧、伯父排在爸爸右边。这一版才对上参考的观感。

验证(按用户要求只做简单测试):
  · `_fb_kin_v18.py`(新)—— 20 人夹具渲染:**0 重叠**、20 张卡、28 段连线、无 JS 报错;
    各行分布确认:祖辈居中于父母之上(575/653)、伯父在爸爸左边(403 < 575)、
    兄姐在我左(442/519)、弟妹在我右、堂兄紧贴伯父下方。
  · e2e 553 条全过(家谱段断言随 v18 更新为 takeSlot/placePart/putCentered/midX)
  · 修掉的既有 bug:`parentsOf` 的键是**户索引**,而多处按**人 id** 去查父母 ——
    混用会永远查不到,导致"户的主位永远取第一张卡"(伯父那户取成伯母)、
    父系/母系判定全错、叔伯姑全跑到右边。新增 personParents 映射统一。
  · 修掉的布局 bug:兄姐/弟妹原本从"整代游标末尾"起 → 被推到同代最外侧;
    堂表亲调 placePart 会与我的兄姐重叠(实测抓到 1 处)→ 改成紧贴其父母下方。

另:其他功能一行未动(用户明确要求)。
This commit is contained in:
2026-10-02 13:26:11 +08:00
parent 4b38c89142
commit 11c13a4e9d
2 changed files with 466 additions and 290 deletions
+25 -9
View File
@@ -468,7 +468,11 @@ textarea.input { min-height: 76px; resize: vertical; }
position: absolute; left: 0; top: 0;
will-change: transform;
}
.kin-chart svg.kin-lines { pointer-events: none; }
.kin-chart svg.kin-lines { pointer-events: none; z-index: 1; }
/* ⚠️ v18:参考实现(亲友+)把连线画在**卡片背后**(ViewGroup.onDraw 在子 View 之前),
所以线可以从卡中心连到卡中心、被卡面挡住即可 —— 不需要绕路。
这里用 z-index 保证「连线在卡片下面」。 */
.kin-stage { z-index: 2; }
/* 连线:夫妻短横线 + 子女下垂线,全部一条笔触 */
.kin-chart path.kin-link {
@@ -478,30 +482,42 @@ textarea.input { min-height: 76px; resize: vertical; }
vector-effect: non-scaling-stroke; /* 缩放时线不跟着变粗变细 */
}
/* v18:卡片改成「上图下名 + 称呼」的窄长形,对齐参考实现(亲友+)的比例:
参考里 50dp 宽 × 80dp 高,头像占满上半、姓名与称呼各一行贴在下半。
我们把 88×106 按同样比例放大 —— 窄一点才排得下"好几代 + 兄弟姐妹"。 */
.kin-card {
position: absolute;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px;
box-sizing: border-box; padding: 7px 6px 6px;
display: flex; flex-direction: column; align-items: center;
gap: 0;
box-sizing: border-box; padding: 4px 4px 3px;
text-align: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(43,38,32,.10);
color: var(--ink);
transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */
transition: border-color .15s ease, box-shadow .15s ease;
overflow: hidden;
}
.kin-card:hover {
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
}
.kin-card .kin-ph {
width: 38px; height: 38px; border-radius: 50%; overflow: hidden;
width: 100%; height: 58px; border-radius: 7px; overflow: hidden;
background: #F1E7D6; flex: 0 0 auto;
}
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
.kin-card .kin-nm { font-size: 13px; font-weight: 600; line-height: 1.25; }
.kin-card .kin-lb { font-size: 11px; color: var(--brand); line-height: 1.25; }
.kin-card .kin-nm {
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 3px;
}
/* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */
.kin-card .kin-lb {
font-size: 10.5px; color: var(--brand); line-height: 1.2; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.kin-card .kin-mark { font-size: 10px; color: var(--faint); }
/* 「我」:品牌色描边 + 光圈 */
+441 -281
View File
@@ -66,6 +66,10 @@ foreach ($units as $i => $u) {
'me' => $id === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'),
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
'rank' => \App\Kinship::rankValueFor($people, $id),
];
}
if (!$cards) {
@@ -157,6 +161,19 @@ $payloadJson = json_encode(
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script>
/*****************************************************************
* 家谱布局:仿「亲友+」App 的画法
*
* 参考实现:gitee.com/coku2333/FamilyTreeView 的 NewFamilyTreeView
* (用户 2026-10-02 指定「按照这个做」)。核心规则:
* ① 从下往上定位:孙辈 → 子女 → 我 → 父母 → 祖辈
* (子层位置定了,父层就定了 —— 父居中于子之上)
* ② 每一代维护 genLeft / genRight 两个游标;往右铺从 genRight 起、
* 往左铺从 genLeft 起
* ③ 配偶并排在主位右侧(一对夫妻 = 相邻两张卡)
* ④ 年长的兄弟姐妹往左、年幼的往右;舅姨往右、叔伯姑往左
* ⑤ 连线画在**卡片背后**(可以从卡中心连到卡中心,被卡面挡住)
*****************************************************************/
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof d3 === 'undefined') { return; }
@@ -174,317 +191,461 @@ $payloadJson = json_encode(
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---- 版式常量 ----
var CW = 118; // 卡片宽
var CH = 92; // 卡片高
var HGAP = 20; // 同代相邻「户」之间的水平间隙
var CGAP = 14; // 夫妻两张卡之间的缝(短横线画在这一段上)
var PGAP = 16; // 夫妻两张卡与"夹在中间的竖线"之间的额外余量
var YS = 150; // 代与代之间的行距
var PAD = 64; // 画布留白
/* ---------- 版式常量 ---------- */
var IW = 88; // 卡片宽
var IH = 106; // 卡片高
var SP = 26; // 卡片间距(参考实现里的 mSpacePX)
var SLOT = IW + SP; // 一个人的"槽位"宽(配偶间隔也用 SP,与参考一致)
var YS = IH + SP; // 代与代之间的行距
var PAD = 60; // 画布留白
// ================= ========= 布局:整齐树 =================
//
// ⚠️ 为什么不用"每代按固定格子摊开"(v15 的做法,线就是那样乱起来的):
// ① 一对夫妻占 2×CW + CGAP 宽(≈250px),单人也占 CW;按同一个固定格距摆,
// 宽的那户会**压到隔壁**(实测:相邻两户重叠了 52px);
// ② 子女不落在父母正下方,横梁要跨很远,线与线互相交叉。
// 整齐树的做法:先按「轮廓」把子树从左往右摆开(保证不重叠),
// 再让父(户)落在**其子女跨度的中点**——于是一层一层都是居中于下一层,
// 线自然是最短的、几乎不交叉。
// ---- 0. 户的父子关系(哪个户是哪一户的孩子)----
var unitOf = {}; // personId -> unitIndex
/* ---------- 0. 基础索引 ---------- */
var byId = {}; // personId -> {u}
units.forEach(function (u, ui) {
u._i = ui;
u.cards.forEach(function (c) { unitOf[c.id] = ui; });
u.cards.forEach(function (c) { byId[String(c.id)] = { u: ui, card: c }; });
});
units.forEach(function (u) { u._kids = []; });
// 从「户 → 子女」反推「户 → 子户」:某户的孩子落在了别的户里,那个户就是子户
var childUnits = {}; // ui -> [子户 ui,...](去重、保持顺序)
var parentUnits = {}; // 子户 ui -> [父户 ui,...](**可以有多个**)
// 户 → 户 的父子关系(由「户 → 子女」推)
var kidsOf = {}, parentsOf = {};
units.forEach(function (u, ui) {
(u.kids || []).forEach(function (kid) {
var ku = unitOf[kid];
if (ku === undefined || ku === ui) { return; }
childUnits[ui] = childUnits[ui] || [];
if (childUnits[ui].indexOf(ku) < 0) {
childUnits[ui].push(ku);
// ⚠️ **必须允许一个户有多个父户**:我这一户同时是「爸妈的孩子」和
// 「岳父母的孩子」(因为妻子与我同户,而她是岳父母的孩子)。
// 只留第一个父户会让另一支(我那一支)失去父子关系 → 后代被排到别处。
parentUnits[ku] = parentUnits[ku] || [];
if (parentUnits[ku].indexOf(ui) < 0) { parentUnits[ku].push(ui); }
var ref = byId[String(kid)];
if (!ref || ref.u === ui) { return; }
(kidsOf[ui] = kidsOf[ui] || []);
if (kidsOf[ui].indexOf(ref.u) < 0) {
kidsOf[ui].push(ref.u);
(parentsOf[ref.u] = parentsOf[ref.u] || []);
if (parentsOf[ref.u].indexOf(ui) < 0) { parentsOf[ref.u].push(ui); }
}
});
});
// 「主父户」= 排序时用哪一个父户。多个父户时取**代数最高的那个**
// (即最靠上的祖先那一支)—— 这样整个本家血脉会以同一支祖先为主轴排开,
// 姻亲(岳父母)只是"挂在旁边",不会把主轴拆成两半。
var parentUnitOf = {};
Object.keys(parentUnits).forEach(function (ku) {
var ps = parentUnits[ku].slice().sort(function (a, b) {
return units[a].gen - units[b].gen || a - b;
});
parentUnitOf[ku] = ps[0];
});
// ---- 2. 排序:同代内按「父户的位置」从左到右摆 ----
//
// ⚠️ v17 换了思路。v16 试过「树递归 + 轮廓合并」,但家谱不是一棵纯树:
// **姻亲**(岳父母)不是本家的后代,没有父户 → 会被当成"第二棵独立的树"
// 排到一侧,于是它的后代(妻子/我/姐姐/孩子)全被推远,
// 横梁要跨过别人的卡片(实测 D 项失败:266→838 穿过爸爸/岳父母)。
// 正解:**按代分行;同代内按"父辈位置"排序后从左往右摆**;
// 同一父户的子女排成紧挨的一整块,块的中心对到父户中心 ——
// 这样每户的横梁只覆盖自己那几个孩子。
// ---- 2a. rank 迭代:同代内按「父户的 rank」排序,几轮收敛 ----
var byGen = {};
units.forEach(function (u) { (byGen[u.gen] = byGen[u.gen] || []).push(u._i); });
var rankOf = {};
// 初值:数据顺序(服务端已按「血亲在前、长幼次序」排过)
units.forEach(function (u, ui) { rankOf[ui] = ui; });
for (var round = 0; round < 6; round++) {
Object.keys(byGen).forEach(function (g) {
var row = byGen[g].slice();
row.sort(function (a, b) {
var pa = parentUnitOf[a], pb = parentUnitOf[b];
var ka = (pa === undefined) ? -1 : rankOf[pa];
var kb = (pb === undefined) ? -1 : rankOf[pb];
if (ka !== kb) { return ka - kb; }
return rankOf[a] - rankOf[b];
});
row.forEach(function (ui, i) { rankOf[ui] = i; });
});
}
// ---- 2c. 横向落位:同代按 rank 从左往右摆,留 HGAP 间隙 ----
//
// 每个户的宽度:双人户 = 2×CW + CGAP + PGAP;单人户 = CW。
// 摆完一轮后,用「子户的中心 = 其父户的中心」做一次微调(自上而下传播),
// 让子女尽量落在父母正下方(减少横梁跨度)。
function unitWidthW(u) {
return u.cards.length >= 2 ? (2 * CW + CGAP + PGAP) : CW;
}
units.forEach(function (u) { u._w = unitWidthW(u); });
var cxOf = {};
Object.keys(byGen).forEach(function (g) {
var row = byGen[g].slice().sort(function (a, b) { return rankOf[a] - rankOf[b]; });
var x = 0;
row.forEach(function (ui) {
if (x > 0) { x += HGAP; } // 与上一个户之间留缝
cxOf[ui] = x + units[ui]._w / 2;
x += units[ui]._w;
// ⚠️⚠️ 关键:`parentsOf` 的键是**户索引**,而下面多处是按**人 id** 去查有没有父母
// (比如"哪张卡是本家的血亲位")。两者混用会**永远查不到** ——
// 症状是"主位永远取第一张卡"(伯父那一户的主位取成了伯母),
// 进而父系/母系判定全错。这里单独建一份"人 id → 父母户索引"。 */
var personParents = {}; // personId -> [父户 ui,...]
units.forEach(function (u, ui) {
u.cards.forEach(function (c) {
var ps = parentsOf[String(ui)];
if (ps && ps.length) { personParents[String(c.id)] = ps; }
});
});
// ---- 2d. 自上而下:子女往父母中心靠(但**同父的一组必须左对齐成块**)----
//
// ⚠️ v17:v16 只做了"整行防重叠 + 各自往父中心靠",结果**同一父户的多个孩子
// 会被推得七零八落**:孩子的横梁要从父户中心一路横跨到最远那个孩子,
// 中间就压过了别人的卡片(实测 D 项:横梁 801→1115 从「姐夫」身上过去)。
// 正解:**同一父户的子女必须紧挨成一整块**(块内按长幼),
// 块的中心 = 父户的中心。这样每户的横梁只覆盖自己那几个孩子,绝不越界。
var gensAsc = Object.keys(byGen).map(Number).sort(function (a, b) { return a - b; });
for (var pass = 0; pass < 3; pass++) {
gensAsc.forEach(function (g) {
// ① 分组:把这一代按「父户」分组(没有父户的各自成组)
//
// ⚠️ 分组键要用**全部父户**拼成:户 2(我+妻子)同时属于户 0(爸妈)
// 和户 1(岳父母),但**只能出现在一组里**(否则它会摆两次)。
// 取「主父户」(见上)当分组键,多个父户只在**定位**时参与平均。
var row = byGen[g].slice().sort(function (a, b) { return cxOf[a] - cxOf[b]; });
var groups = [], seen = {};
row.forEach(function (ui) {
var pu = parentUnitOf[ui];
var key = (pu === undefined) ? ('solo' + ui) : ('u' + pu);
if (!seen[key]) {
seen[key] = { key: key, parents: [], kids: [] };
groups.push(seen[key]);
}
seen[key].kids.push(ui);
// 这一组的所有父户都记下来(定位时取平均)
var allp = parentUnits[ui] || (pu === undefined ? [] : [pu]);
allp.forEach(function (p) {
if (seen[key].parents.indexOf(p) < 0) { seen[key].parents.push(p); }
});
});
// ② 每组算「块宽」,并按父户中心定位;同组内按长幼(rankOf)排
var blocks = groups.map(function (gp) {
gp.kids.sort(function (a, b) { return rankOf[a] - rankOf[b]; });
var w = 0;
gp.kids.forEach(function (k, i) { w += units[k]._w + (i ? HGAP : 0); });
// 定位目标:多父户时取**所有父户中心的平均**(姻亲与本家都在时居中,
// 看着最自然);没有父户(顶层祖先/纯姻亲)就跟随前一块往右排。
var center = null;
if (gp.parents && gp.parents.length) {
var sum = 0;
gp.parents.forEach(function (p) { sum += cxOf[p]; });
center = sum / gp.parents.length;
}
return { gp: gp, w: w, center: center,
order: (center === null) ? cxOf[gp.kids[0]] : center };
});
blocks.sort(function (a, b) { return a.order - b.order; });
// ③ 依次摆放(块之间也留 HGAP),父户中心能对上就对上,对不上就顺延
//
// ⚠️⚠️ `cur` 必须从 **0** 起,不能写 `-Infinity`:
// 顶层(最早那一代)**所有户都没有父户** → 每块都走 `cur + ...` 这一支,
// `-Infinity + 宽度` 仍是 `-Infinity` → 全部 cxOf 变 -Infinity →
// 后面 `-Infinity - (-Infinity)` = **NaN**,整张图的 x 全废
// (实测症状:所有卡片叠在同一个 x、SVG width="NaN")。
var cur = 0;
var first = true;
blocks.forEach(function (bl) {
var want = (bl.center === null) ? (cur + (first ? 0 : HGAP) + bl.w / 2) : bl.center;
var left = Math.max(want - bl.w / 2, cur + (first ? 0 : HGAP));
var x = left;
bl.gp.kids.forEach(function (k, i) {
if (i) { x += HGAP; }
cxOf[k] = x + units[k]._w / 2;
x += units[k]._w;
});
cur = x;
first = false;
});
});
}
// 我所在的户
var meUi = 0;
units.forEach(function (u, ui) {
u.cards.forEach(function (c) { if (c.me) { meUi = ui; } });
});
// ---- 3. 落坐标:户中心 → 两张卡的 x;每户的 y 由「代数」决定 ----
//
// ⚠️ y 用**代数**(gen)而不是树的 depth:姻亲(岳父母)不是本家的后代,
// 按 depth 会被排到"我"这一行或更低,看着完全不对。
// genIdx 在上面算轮廓时已经建好,这里直接复用(两处必须同一套索引)。
var gens = [];
units.forEach(function (u) { if (gens.indexOf(u.gen) < 0) { gens.push(u.gen); } });
gens.sort(function (a, b) { return a - b; });
// ⚠️ 边界必须按**卡片实际占用的范围**算,不能只用"户中心 ± 户宽/2":
// 双人户里两张卡是「中心 ± (CGAP+PGAP+CW)/2」,比户宽/2 更靠外,
// 只按户宽算会让最外侧的卡片伸出画布(v17 实测:爸爸卡 x=282..412 而画布只有 808)。
var allX = [];
// 户的"血亲位"(哪张卡是本家的人):优先取"有父户"的那张
units.forEach(function (u) {
var halfSpan;
if (u.cards.length >= 2) {
halfSpan = PGAP / 2 + CGAP / 2 + CW / 2 + CW / 2; // 最外侧卡的外沿
} else {
halfSpan = CW / 2;
var idx = 0;
for (var i = 0; i < u.cards.length; i++) {
var ps = personParents[String(u.cards[i].id)];
if (ps && ps.length) { idx = i; break; }
}
allX.push(cxOf[u._i] - halfSpan, cxOf[u._i] + halfSpan);
u._headIdx = idx;
u._head = String(u.cards[idx].id);
// 主位的"排行键"(长幼):服务端已算好(birth_rank → 生日 → id 兜底),
// 前端要靠它分「哥哥/弟弟」「姐姐/妹妹」(以及"年长的往左排")。
u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank);
});
var minX = Math.min.apply(null, allX), maxX = Math.max.apply(null, allX);
/* ---------- 1. 游标(每一代的左右边界)---------- */
var gmin = 0, gmax = 0;
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
var genL = {}, genR = {};
function resetCursor(lo, hi) {
for (var g = lo; g <= hi; g++) { genL[g] = 0; genR[g] = 0; }
}
resetCursor(gmin - 1, gmax + 1);
// 已定位的户:ui -> {gen, uc}(uc = 这一户的中心 x)
var placed = {};
/* 把一户放到某代的"下一个空位",返回它的中心 x
toRight=true 从 genR 往右接;false 从 genL 往左接 */
function takeSlot(ui, gen, toRight) {
var u = units[ui];
var n = u.cards.length >= 2 ? 2 : 1;
var span = n * SLOT;
var x0;
if (toRight) {
x0 = genR[gen];
genR[gen] = x0 + span;
} else {
genL[gen] -= span;
x0 = genL[gen];
}
// 这一户的中心:两张卡时是两卡中心的中点
var uc = x0 + IW / 2 + (n === 2 ? SLOT / 2 : 0);
placed[ui] = { gen: gen, uc: uc, n: n, x0: x0 };
return uc;
}
// 把一户**居中**放在 x = uc
function putCentered(ui, gen, uc) {
var n = units[ui].cards.length >= 2 ? 2 : 1;
placed[ui] = { gen: gen, uc: uc, n: n, x0: uc - (n === 2 ? SLOT : IW) / 2 };
return uc;
}
/* 一代的边界用"已放的户"刷新(对应参考的 measureMaxCoordinate) */
function refreshBounds(gen) {
var lo = Infinity, hi = -Infinity, any = false;
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p || p.gen !== gen) { return; }
any = true;
lo = Math.min(lo, p.x0);
hi = Math.max(hi, p.x0 + p.n * SLOT);
});
if (!any) { return; }
if (!isFinite(genL[gen]) || lo < genL[gen]) { genL[gen] = lo; }
if (!isFinite(genR[gen]) || hi > genR[gen]) { genR[gen] = hi; }
}
/* ---------- 2. 核心:放"一户 + 它的子女" ----------
childGen = 子女所在的那一代;这一户放在 childGen - 1。
完全对应参考的 setPart(position, isRight, ...)。 */
function placePart(childGen, toRight, ui) {
if (placed[ui]) { return; }
var u = units[ui];
var kids = (kidsOf[ui] || []).slice();
if (!toRight) { kids.reverse(); } // 往左铺时,长幼次序要反过来
if (!kids.length) {
// 没有子女:在"子女代"占位(参考实现就是推进游标留空),
// 自己这一户也跟着往前放 —— 这样不会和别人的子女挤在一起
var n0 = u.cards.length >= 2 ? 2 : 1;
if (toRight) { genR[childGen] += n0 * SLOT; }
else { genL[childGen] -= n0 * SLOT; }
takeSlot(ui, childGen - 1, toRight);
return;
}
kids.forEach(function (k) { placePart(childGen + 1, toRight, k); });
// 父(这一户)居中于子女之上
var first = placed[kids[0]].uc;
var last = placed[kids[kids.length - 1]].uc;
var mid = (first + last) / 2;
putCentered(ui, childGen - 1, mid);
refreshBounds(childGen - 1);
}
/* 只把"一户"放到某代(它的子女已另行处理)—— 用于兜底与姻亲 */
function placeLoose(ui, gen, toRight) {
if (placed[ui]) { return; }
takeSlot(ui, gen, toRight);
refreshBounds(gen);
}
/* ---------- 3. 按参考 order 逐代定位 ---------- */
(function layoutAll() {
/* (1) 我的子女(+1)与孙辈(+2)—— 先把"我"放在原点,再让子女居中于我之下 */
putCentered(meUi, 0, 0);
genL[0] = -IW / 2; genR[0] = (units[meUi].cards.length >= 2 ? SLOT + IW / 2 : IW / 2);
var myKids = (kidsOf[meUi] || []).slice();
myKids.forEach(function (k) { placePart(1, true, k); });
/* (2) 我居中于我的子女之上;整支(我 + 子女及以下)一起平移 */
if (myKids.length) {
var kf = placed[myKids[0]].uc, kl = placed[myKids[myKids.length - 1]].uc;
var dx = ((kf + kl) / 2) - placed[meUi].uc;
(function shift(ui, d) {
if (placed[ui]) { placed[ui].uc += d; placed[ui].x0 += d; }
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift(k, d); } });
})(meUi, dx);
}
resetCursor(gmin - 1, gmax + 1);
refreshBounds(0); refreshBounds(1); refreshBounds(2);
/* (3) 兄弟姐妹:以"父母的子女列表"的长幼序,我在中间 —— 兄姐往左、弟妹往右 */
var pu = (parentsOf[meUi] || [])[0];
var sibsAll = pu === undefined ? [] : (kidsOf[pu] || []).slice();
var myPos = sibsAll.indexOf(meUi);
var older = myPos > 0 ? sibsAll.slice(0, myPos) : []; // 长幼序里排在我前面的 = 兄姐
var younger = myPos >= 0 ? sibsAll.slice(myPos + 1) : []; // 排在我后面的 = 弟妹
// ⚠️⚠️ 兄姐/弟妹必须**紧贴着我**(在上一代"父母的")。参考实现里它们是
// "从 genLeft/genRight 往外铺",但那是**接着父母的子女块**往外长 ——
// 所以才有"哥哥在我左边、弟弟在我右边"的观感。
// 我这一版第一稿直接调 placePart() 从游标末尾起,结果兄姐/弟妹被推到
// 同代的最外侧(中间还夹着堂表亲),看着就像"外人"(实测截图确认)。
// 正解:以"我的中心"为基准,向左/向右**依次紧贴**摆放,
// 每放一户就把基准推进一户宽(并带上它的子女)。
var slotW = IW + SP;
// 兄姐:离我最近的先放(大哥最左 → 依次向左)
var cur = placed[meUi].x0; // 我这一户的左边缘
older.forEach(function (s) { // older 已是"长→幼",从最近(幼)开始向左
var n = units[s].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
var uc = cur - SP - w / 2;
putCentered(s, 0, uc);
// 它的子女(侄甥)放它下方
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
cur = uc - w / 2;
});
// 弟妹:往右紧贴
var curR = placed[meUi].x0 + (units[meUi].cards.length >= 2 ? 2 * slotW : IW);
younger.forEach(function (s) {
var n2 = units[s].cards.length >= 2 ? 2 : 1;
var w2 = n2 * slotW;
var uc2 = curR + SP + w2 / 2;
putCentered(s, 0, uc2);
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
curR = uc2 + w2 / 2;
});
// 兄姐是"长幼序里排在我前面的",最年长的应在最左 → 反向再摆一次
// (上面是从最近往左排,最年长的自然落在最左,正是要的效果)
/* (4) 父母(-1)居中于我之上;祖辈(-2)居中于父母之上 */
if (pu !== undefined && !placed[pu]) {
putCentered(pu, -1, placed[meUi].uc);
refreshBounds(-1);
var gps = (parentsOf[pu] || []).slice();
gps.forEach(function (gp, i) {
if (placed[gp]) { return; }
putCentered(gp, -2, placed[pu].uc + (gps.length > 1 ? (i === 0 ? -SLOT / 2 : SLOT / 2) : 0));
refreshBounds(-2);
});
/* (5) 父母的兄弟姐妹:舅姨(母系)往右、叔伯姑(父系)往左,他们的子女是堂表亲(0 代) */
// 判断哪个祖辈是父系:父 = 父母这一户里"性别为男"的那张卡(取不到就用主位)
var pr = units[pu];
var fatherId = null;
pr.cards.forEach(function (c) { if (c.gender === 'M') { fatherId = String(c.id); } });
if (!fatherId) { fatherId = pr._head; }
// ⚠️ 叔伯姑/舅姨要**紧挨着父母**摆:父系往父亲那侧(左)、母系往母亲那侧(右)。
// 第一稿直接 placePart(0, 左/右) —— 那是从"整代游标"的末尾起,
// 叔伯被推到很远的外侧,中间空一大段(实测截图里"伯父"跑到爸爸右边很远)。
// 正解:以"父母这一户"的两端为基准向外铺。
var puW = units[pu].cards.length >= 2 ? 2 * slotW : IW;
var puLeft = placed[pu].uc - puW / 2;
var puRight = placed[pu].uc + puW / 2;
var bySide = { left: [], right: [] };
// ⚠️⚠️ 判定「这一位是我父系的还是母系的」要看**他自己的父母**里
// 有没有那位父系祖辈 —— 而不是"去祖辈户里找爸爸"(那是恒假的写法,
// 第一稿就这么写,结果所有叔伯都被当成母系、全跑到右边)。
// fatherId = 我爸的 id;父系祖辈 = 我爸的父母里那一位。
var fatherSideGps = (personParents[String(fatherId)] || []).slice();
gps.forEach(function (gp) {
(kidsOf[gp] || []).forEach(function (s) {
if (s === pu || placed[s]) { return; }
// s 的父母里有没有"父系祖辈所在的户"
var ps2 = personParents[units[s]._head] || [];
var isFatherSide = ps2.indexOf(gp) >= 0 && fatherSideGps.indexOf(gp) >= 0;
bySide[isFatherSide ? 'left' : 'right'].push(s);
});
});
// 叔伯姑/舅姨的子女(堂表亲):**紧贴其父母下方**放。
// ⚠️ 不能调 placePart() —— 那会从"整代游标末尾"起、还会递归整支,
// 结果堂兄跑到我的兄姐位置上、两者重叠(实测抓到 1 处重叠)。
// 这里按"父户中心"居中摆,若与已有的撞上再顺延。
function placeCousins(s, gen) {
var ks = (kidsOf[s] || []).slice();
if (!ks.length) { return; }
var span = ks.length * slotW;
var startC = placed[s].uc - (span - slotW) / 2;
ks.forEach(function (k, i) {
if (placed[k]) { return; }
var n = units[k].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
putCentered(k, gen, startC + i * slotW + (slotW - w) / 2 + w / 2);
});
}
// 父系:从父亲的左边往外铺(长幼序倒过来 —— 长叔伯更靠外)
var lcur = puLeft;
bySide.left.slice().reverse().forEach(function (s) {
var n = units[s].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
var uc = lcur - SP - w / 2;
putCentered(s, -1, uc);
placeCousins(s, 0);
lcur = uc - w / 2;
});
// 母系:从母亲的右边往外铺
var rcur = puRight;
bySide.right.forEach(function (s) {
var n2 = units[s].cards.length >= 2 ? 2 : 1;
var w2 = n2 * slotW;
var uc2 = rcur + SP + w2 / 2;
putCentered(s, -1, uc2);
placeCousins(s, 0);
rcur = uc2 + w2 / 2;
});
}
/* (6) 兜底:还没定位的(姻亲岳父母、其它旁支)按"离我这支最近"贴到同代右侧 */
var rest = [];
units.forEach(function (u, ui) { if (!placed[ui]) { rest.push(ui); } });
// 先放"与我这一支有直接关系"的(比如配偶的父母),再放更远的
rest.sort(function (a, b) {
var da = nearDist(a), db = nearDist(b);
return (units[a].gen - units[b].gen) || (da - db) || (a - b);
});
rest.forEach(function (ui) {
var g = units[ui].gen;
// 尽量靠到"它的子女"旁边(姻亲的父母 → 贴在他们女儿(我配偶)那一支的右侧)
// ⚠️ 姻亲(岳父母):他们的位置应该**贴在他们女儿(我配偶)那一支的上方**,
// 而不是"飘到同代最右"。第一稿就是这么写的 —— 结果岳父母跑到爸爸右边、
// 离我妻子很远,看着像别人家的人(实测截图确认)。
// 正解:锚到"它的子女"(= 我配偶所在的那一户)的上方居中。
var ks = kidsOf[ui] || [];
var anchor = null;
ks.forEach(function (k) { if (placed[k] !== undefined && anchor === null) { anchor = placed[k]; } });
if (anchor !== undefined && anchor !== null && anchor.gen === g + 1) {
// 与"同代已有的"错开:若锚点上方已被别人占,就往右让
var want = anchor.uc;
var n = units[ui].cards.length >= 2 ? 2 : 1;
var w = n * slotW;
// 与同代已有的户依次避让(只往右让,避免来回挪)
var row = [];
units.forEach(function (u2, ui2) {
if (placed[ui2] && placed[ui2].gen === g && ui2 !== ui) { row.push(ui2); }
});
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
var half = w / 2;
for (var ri = 0; ri < row.length; ri++) {
var rp = placed[row[ri]];
var rn = units[row[ri]].cards.length >= 2 ? 2 : 1;
var rHalf = rn * slotW / 2;
if (Math.abs(want - rp.uc) < (half + rHalf + SP / 2)) {
want = rp.uc + rHalf + SP / 2 + half;
}
}
putCentered(ui, g, want);
} else {
placeLoose(ui, g, true);
}
});
function nearDist(ui) {
var ps = parentsOf[ui] || [];
var best = 9;
ps.forEach(function (p) {
// 到「我」这一支的代数距离
best = Math.min(best, Math.abs(units[p].gen - 0) + 1);
});
return best;
}
})();
/* ---------- 4. 同代防重叠(保险):按 x 排序后依次推开,子树跟着走 ---------- */
(function antiOverlap() {
for (var g = gmin; g <= gmax; g++) {
var row = [];
units.forEach(function (u, ui) { if (placed[ui] && placed[ui].gen === g) { row.push(ui); } });
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var i = 1; i < row.length; i++) {
var prev = placed[row[i - 1]], cur = placed[row[i]];
var prevRight = prev.x0 + prev.n * SLOT;
var curLeft = cur.x0;
if (curLeft < prevRight + 0) {
var d = prevRight - curLeft;
(function shift3(ui, dd) {
if (placed[ui]) { placed[ui].uc += dd; placed[ui].x0 += dd; }
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift3(k, dd); } });
})(row[i], d);
}
}
}
})();
/* ---------- 5. 落最终坐标 ---------- */
var xs = [];
units.forEach(function (u, ui) {
var p = placed[ui];
if (p) { xs.push(p.x0, p.x0 + p.n * SLOT); }
});
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
var W = (maxX - minX) + PAD * 2;
var H = PAD * 2 + (gens.length - 1) * YS + CH;
var rows = [];
for (var g = gmin; g <= gmax; g++) { rows.push(g); }
var H = PAD * 2 + (rows.length - 1) * YS + IH;
var pos = {}, lay = {};
units.forEach(function (u) {
var g = gens.indexOf(u.gen);
var y = PAD + CH / 2 + g * YS;
var cx = cxOf[u._i] - minX + PAD; // 平移到画布内
var ids = u.cards.map(function (c) { return c.id; });
var left, right;
var pos = {}; // personId -> {x, y}(卡中心)
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p) { return; }
var y = PAD + IH / 2 + rows.indexOf(u.gen) * YS;
var x0 = p.x0 - minX + PAD;
var ids = u.cards.map(function (c) { return String(c.id); });
if (ids.length >= 2) {
// 两张卡并排,中间留缝;缝的正中就是"夫妻横线"与"子女下垂线"的 x
left = cx - PGAP / 2 - CGAP / 2 - CW / 2;
right = cx + PGAP / 2 + CGAP / 2 + CW / 2;
pos[ids[0]] = { x: left, y: y };
pos[ids[1]] = { x: right, y: y };
pos[ids[0]] = { x: x0 + IW / 2, y: y };
pos[ids[1]] = { x: x0 + SLOT + IW / 2, y: y };
} else {
left = right = cx;
pos[ids[0]] = { x: cx, y: y };
pos[ids[0]] = { x: x0 + IW / 2, y: y };
}
lay[u._i] = {
cx: cx, cy: y, left: left, right: right, ids: ids,
// ⚠️ 夫妻横线画在**两张卡之间的空档的正中**(纵向也在卡高的一半处),
// 视觉上就是"一条线把夫妻俩连起来"。
// 子女的下垂线从这条线的**中点**往下走 —— 这就是用户要的形状。
// (v15 把它画在 y + CH/2 − 6,等于画在卡片**内部**,线从卡身上穿过去;实测抓到 2 处。)
barY: y,
hasBar: ids.length >= 2
};
});
var drawn = {}, html = '';
/* ---------- 6. 卡片 ---------- */
var html = '';
units.forEach(function (u) {
u.cards.forEach(function (c) {
var p = pos[c.id];
var p = pos[String(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"'
var g = (c.gender === 'M' || c.gender === 'F') ? '' : ' g-u';
html += '<a class="' + cls + g + '" data-kin-id="' + esc(c.id) + '"'
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + IW + 'px;height:' + IH + '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>';
drawn[c.id] = 1;
});
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
// ---- 4. 画线 ----
//
// 三条线(顺序即画法):
// ① 夫妻横线:连两张卡的**中间空档**(视觉上把夫妻连起来);
// ② 下垂线:从横线的**中点**往下(单亲户 = 本人卡的中心往下);
// ③ 横梁 + 分叉:由横梁分到每个孩子的卡顶。
// ⚠️ ② 的起点取"卡中间高度"会**竖着穿过卡片下半部分**;所以让竖向那段
// 从**卡下沿**开始 —— 视觉上仍是"从夫妻那条线的中点出来",
// 但不会压在卡上。(用户要的是"别从某一个人出来",不是"必须穿过卡面"。)
/* ---------- 7. 连线(画在卡片背后,所以可以从卡中心连到卡中心)---------- */
var paths = [], bars = [], drops = [];
units.forEach(function (u) {
var L = lay[u._i];
if (L.hasBar) {
// ⚠️ 夫妻横线**只画两张卡之间的那一段缝隙**(不是从卡中心连到卡中心):
// 从中心画会从卡片身上穿过去(实测 B 项抓到 4 处穿卡)。
// 起止取「左卡右沿」到「右卡左沿」。
paths.push('M' + (L.left + CW / 2) + ',' + L.barY
+ 'L' + (L.right - CW / 2) + ',' + L.barY);
function line(x1, y1, x2, y2) {
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
}
units.forEach(function (u, ui) {
var p = placed[ui];
if (!p) { return; }
var ids = u.cards.map(function (c) { return String(c.id); });
var yRow = pos[ids[0]].y; // 这一代卡片中心 y
var midX;
if (ids.length >= 2) {
var a = pos[ids[0]], b = pos[ids[1]];
midX = (a.x + b.x) / 2;
// ① 夫妻横线:两卡中心之间的水平线(画在卡后,被卡面挡住)
line(a.x, a.y, b.x, b.y);
bars.push({ ui: ui, x1: a.x, x2: b.x, y: a.y, has: true, mid: midX });
} else {
midX = pos[ids[0]].x;
bars.push({ ui: ui, x1: midX, x2: midX, y: yRow, has: false, mid: midX });
}
bars.push({ ui: u._i, x1: L.left + CW / 2, x2: L.right - CW / 2,
mid: (L.left + L.right) / 2, y: L.barY, has: L.hasBar });
});
units.forEach(function (u) {
var L = lay[u._i];
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 fromY = L.cy + CH / 2; // 从卡片下沿起(见上面的说明)
var busY = L.cy + YS - CH / 2 - 24; // 子女行上方的横梁
paths.push('M' + srcX + ',' + fromY + 'L' + srcX + ',' + busY);
// ② 有子女 → 从夫妻横线**中点**往下垂到"横梁"
var kids = (kidsOf[ui] || []).filter(function (k) { return pos[units[k]._head] || true; });
if (kids.length) {
var busY = yRow + IH / 2 + SP / 2; // 参考:卡底 + SP/2
line(midX, yRow, midX, busY);
// ③ 横梁:从最左孩子到最右孩子
var kx = kids.map(function (k) { return placed[k].uc; });
var minK = Math.min.apply(null, kx), maxK = Math.max.apply(null, kx);
if (kids.length > 1 && maxK - minK > 0.5) { line(minK, busY, maxK, busY); }
drops.push({ ui: ui, x: midX, y0: yRow, busY: busY });
}
// ⚠️⚠️ 横梁**不能直接连"第一个孩子到最后一个孩子"**:
// 孩子分属不同的户(比如"我"和"姐姐"各是一户),
// 而"我"那一户里还有配偶(妻子)。从最左孩子的卡中心连到最右孩子的卡中心,
// 会把夹在中间的那位**配偶**也框进横梁范围 —— 看着就像"这条线也连了妻子"。
// 正解:先按 x 排序,**逐段连接相邻两个孩子**(段间缺口处断开),
// 每段只覆盖它两端的两张卡(从"左卡中心"到"右卡中心")。
for (var i = 0; i + 1 < kids.length; i++) {
var a = kids[i], b2 = kids[i + 1];
// 只在"这两张卡之间没有别的卡片"时直连;否则断开(避免压过别人)
var blocked = false;
for (var j = 0; j < units.length; j++) {
var other = pos[units[j].cards[0].id];
if (!other || other === a || other === b2) { continue; }
var oc = other.x;
if (oc > a.x + 2 && oc < b2.x - 2) { blocked = true; break; }
// ④ 每个人若有父母 → 从卡顶往上画一小段(接到上一代的横梁)
ids.forEach(function (id) {
var ps = parentsOf[id];
if (ps && ps.length) {
var pp = pos[id];
line(pp.x, pp.y - IH / 2, pp.x, pp.y - IH / 2 - SP / 2);
}
if (blocked) { continue; }
paths.push('M' + a.x + ',' + busY + 'L' + b2.x + ',' + busY);
}
kids.forEach(function (k) {
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 - 4));
});
drops.push({ ui: u._i, x: srcX, y0: L.barY, fromY: fromY, busY: busY });
});
root.insertAdjacentHTML('afterbegin',
@@ -492,14 +653,14 @@ $payloadJson = json_encode(
+ 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 };
window.__kinLayout = { W: W, H: H, IW: IW, IH: IH, SLOT: SLOT, bars: bars,
drops: drops, pos: pos, paths: paths };
// ---- 5. 拖拽 + 缩放 ----
/* ---------- 8. 拖拽 + 缩放 ---------- */
var stage = root.querySelector('.kin-stage');
var svg = root.querySelector('svg.kin-lines');
var svg = root.querySelector('svg.kin-lines');
var zoom = d3.zoom().scaleExtent([0.15, 2.5]).on('zoom', function (ev) {
var zoom = d3.zoom().scaleExtent([0.15, 3]).on('zoom', function (ev) {
var t = ev.transform;
[svg, stage].forEach(function (el) {
el.style.transformOrigin = '0 0';
@@ -512,30 +673,29 @@ $payloadJson = json_encode(
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));
var k = Math.min(r.width / W, r.height / H) * 0.94;
k = Math.max(0.15, Math.min(3, k));
sel.call(zoom.transform, d3.zoomIdentity.translate(
(r.width - W * k) / 2, (r.height - H * k) / 2).scale(k));
}
function centerOn(id) {
var p = pos[id];
var p = pos[String(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));
sel.call(zoom.transform, d3.zoomIdentity.translate(
r.width / 2 - p.x * t.k, r.height / 2 - p.y * t.k).scale(t.k));
}
window.kinFit = fit;
window.kinCenterOn = centerOn;
fit();
var meId = root.getAttribute('data-me') || '';
if (meId) { setTimeout(function () { centerOn(meId); }, 60); }
fit();
// 进来先把镜头对准「我」
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); }
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
var btnMe = document.getElementById('kinMe');
if (btnFit) { btnFit.addEventListener('click', fit); }
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
})();