v20: 家谱布局推倒重写为区间法(tidy tree)—— 一次成型,无多轮推挤修补

用户反馈:"错的更离谱了,这次连位置都不对了"(v19 实测"我"跑到离父母 800px 的最右侧)。

根因:v18/v19 是「游标铺放 → 防重叠 → 同代重排 → 再防重叠 → 再硬推」
叠了四五层修补,每一层都在破坏上一层的"父居中于子女",多轮互相拉扯后整棵树漂移。

区间法规则(一次成型,天然不叠卡):
  ① 每棵户子树自底向上算需求宽度 needW = max(自身宽, Σ子女宽 + 间距);
  ② 自顶向下分配互不重叠的 x 区间,子女依次铺在父区间内;
  ③ 父户居中于子女的实际跨度。
跨树边(岳父母户也把妻子列为子女)只走「主父户」进树递归
(主位卡是我方血亲的那个父户,服务端给每张卡标 blood),
其余父户只留给画线段 —— 岳父母的子女横梁照样连到妻子,布局不重复铺人。

验证(20 人夹具 + 真实渲染):
  · 版面五判据 A-E 全过(8/8):不重叠 / 线在卡后 / 下垂线从夫妻横线中点出 /
    同代有空隙 / 横线在两卡缝隙;行分布:兄姐弟妹紧贴我、堂表在伯父下方、
    岳父母贴妻子右上、外甥在舅子下方;
  · 连线完整性:该连上一代的卡 0 缺线、悬空竖线 0 段;
  · 反向验证 3/3:注入坏法(下垂线从左卡出 / needW 砍半 / 中点取左卡)全部变红。
This commit is contained in:
2026-10-02 17:35:27 +08:00
parent 65378ff48e
commit e22202271a
+142 -481
View File
@@ -45,6 +45,24 @@ $rankKey = static function (int $id) use ($people): string {
}; };
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)---- // ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
// 「我」的血亲集合:只走 parent/child 边(不含配偶)能从「我」到达的人。
// 用途:给每张卡标 blood —— 前端据此判定一户的「主父户」
// (我的户同时被父母户与岳父母户认作子女,树递归只能走本家那条,
// 否则树变图、布局重复铺人;判定标准就是「父户的主位是不是我方血亲」)。
$blood = [$meId => true];
$bq = [$meId];
while ($bq) {
$bid = (int)array_shift($bq);
foreach (array_merge($graph['parents'][$bid] ?? [], $graph['children'][$bid] ?? []) as $nb) {
$nb = (int)$nb;
if (!isset($blood[$nb])) {
$blood[$nb] = true;
$bq[] = $nb;
}
}
}
$payloadUnits = []; $payloadUnits = [];
foreach ($units as $i => $u) { foreach ($units as $i => $u) {
$sorted = array_map('intval', $u['ids']); $sorted = array_map('intval', $u['ids']);
@@ -66,6 +84,8 @@ foreach ($units as $i => $u) {
'me' => $id === (int)$meId, 'me' => $id === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1, 'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'), 'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'),
// ⚠️ 本家血亲标记:前端用它判定一户的「主父户」(见上方 $blood 的说明)
'blood' => isset($blood[$id]),
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。 // ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor —— // 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。 // 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
@@ -82,21 +102,8 @@ foreach ($units as $i => $u) {
// 我排在她右边"(实测线上就是这个症状:红围脖@639 在我的左边)。 // 我排在她右边"(实测线上就是这个症状:红围脖@639 在我的左边)。
// //
// 正解:按「**与"我"的血缘距离**」判定 —— 这一户里离「我」更近的那一位才是主位。 // 正解:按「**与"我"的血缘距离**」判定 —— 这一户里离「我」更近的那一位才是主位。
// 我用一次不带配偶边的 BFS 算出血亲集合(Kinship::bloodRelatives 语义), // 血亲集合 $blood 已在循环外算好(只走 parent/child 边的 BFS),
// 两边都在血亲集合里时,退到「与我的步数」比大小。 // 两边都在血亲集合里时,退到「与我的步数」比大小。
$blood = [];
$bq = [$meId];
$blood[$meId] = true;
while ($bq) {
$bid = array_shift($bq);
foreach (array_merge($graph['parents'][$bid] ?? [], $graph['children'][$bid] ?? []) as $nb) {
$nb = (int)$nb;
if (!isset($blood[$nb])) {
$blood[$nb] = true;
$bq[] = $nb;
}
}
}
$head = null; $head = null;
$headScore = null; $headScore = null;
foreach ($cards as $c) { foreach ($cards as $c) {
@@ -180,17 +187,17 @@ $payloadJson = json_encode(
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script> <script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script> <script>
/***************************************************************** /*****************************************************************
* 家谱布局:仿「亲友+」App 的画法 * 家谱布局:区间法(tidy tree)+ 先摆节点、后按节点画线
* *
* 参考实现:gitee.com/coku2333/FamilyTreeView 的 NewFamilyTreeView * 参考过 gitee.com/coku2333/FamilyTreeView(用户 2026-10-02 指定的
* (用户 2026-10-02 指定「按照这个做」)。核心规则: * 「亲友+」效果),但它的游标铺法在我们的"以我为中心、含姻亲旁支"的
* ① 从下往上定位:孙辈 → 子女 → 我 → 父母 → 祖辈 * 图上要叠四五层防重叠/重排修补才能不叠卡,每层又互相破坏 —— 最终
* (子层位置定了,父层就定了 —— 父居中于子之上) * 整棵树漂移(用户:"错的更离谱了,这次连位置都不对了")。
* ② 每一代维护 genLeft / genRight 两个游标;往右铺从 genRight 起、 * 现在的规则(详见下方第 1 节注释):
* 往左铺从 genLeft 起 * ① 每代一行;一对夫妻 = 相邻两张卡;
* ③ 配偶并排在主位右侧(一对夫妻 = 相邻两张卡) * ② 树递归用「区间法」分配互不重叠的 x 区间,父户居中于子女跨度
* ④ 年长的兄弟姐妹往左、年幼的往右;舅姨往右、叔伯姑往左 * —— 天然不叠卡,长幼序天然"兄姐在左、弟妹在右";
* ⑤ 连线画在**卡片背后**(可以从卡中心连到卡中心,被卡面挡住) * ③ 连线画在卡片背后,**先把所有节点坐标摆定,再统一按节点画线**。
*****************************************************************/ *****************************************************************/
(function () { (function () {
var root = document.getElementById('kinChart'); var root = document.getElementById('kinChart');
@@ -271,471 +278,126 @@ $payloadJson = json_encode(
u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank); u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank);
}); });
/* ---------- 1. 游标(每一代的左右边界)---------- */ /* ---------- 1. 布局:区间法(tidy tree,一次成型) ----------
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;
}
/** 一"户"的实际横向半宽(用于防重叠;**不是** n*SLOT/2 ——
* 单人是 IW/2、双人是 SLOT —— 用 n*SLOT 会把它算宽 26px)。 */
function halfW(ui) {
// ⚠️ 一户的**实际**半宽:单人 IW/2 = 44、双人 (2*IW+SP)/2 = 101。
// 以前写成 (n===2 ? SLOT : IW)/2 = 57 / 44 —— 双人户被算**窄**了 44px,
// 于是防重叠判据偏松、贴着也算"够宽"(实测"堂嫂/舅子"重叠 32px 一直推不开)。
var n = units[ui].cards.length >= 2 ? 2 : 1;
return (n === 2 ? (2 * IW + SP) : IW) / 2;
}
/** 同代防重叠:按中心排序,依次保证"前一个右沿 + SP ≤ 后一个左沿"。
* ⚠️ 统一成这一个函数,三处调用 —— 原来三处各写一遍、边界算法还不一样,
* 于是"儿子/女儿"这种同代相邻的单人户会漏掉(实测重叠 11px)。 */
function antiOverlapRow(gen) {
var row = [];
units.forEach(function (u, ui) {
if (placed[ui] && placed[ui].gen === gen) { row.push(ui); }
});
if (row.length < 2) { return; }
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var i = 1; i < row.length; i++) {
var pv = row[i - 1], cu = row[i];
var need = placed[pv].uc + halfW(pv) + SP + halfW(cu); // 后一个的最小中心
if (placed[cu].uc < need) {
var d = need - placed[cu].uc;
(function shiftSub(x, dd) {
if (placed[x]) { placed[x].uc += dd; placed[x].x0 += dd; }
(kidsOf[x] || []).forEach(function (k) { if (placed[k]) { shiftSub(k, dd); } });
})(cu, d);
}
}
}
/* 一代的边界用"已放的户"刷新(对应参考的 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 === 2 ? 2 * IW + SP : IW));
});
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(往右) —— 结果舅子跑到离我很远的最右侧,
// 还和同代的人撞上(实测 D 项最小间隙 -36px)。
var ps3 = parentsOf[String(ui)] || [];
var panchor = null;
ps3.forEach(function (p3) { if (placed[p3] && panchor === null) { panchor = p3; } });
if (panchor !== null) {
// 放在父母那一户的右侧紧邻处(父母的其它子女已经在旁边了)
var pn = units[panchor].cards.length >= 2 ? 2 : 1;
var pw = pn * slotW;
var want2 = placed[panchor].uc + pw / 2 + SP + slotW / 2;
// 与同代已有的人避让
var row2 = [];
units.forEach(function (u3, ui3) {
if (placed[ui3] && placed[ui3].gen === g && ui3 !== ui) { row2.push(ui3); }
});
row2.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var rj = 0; rj < row2.length; rj++) {
var rp2 = placed[row2[rj]];
var rn2 = units[row2[rj]].cards.length >= 2 ? 2 : 1;
if (Math.abs(want2 - rp2.uc) < (slotW / 2 + rn2 * slotW / 2)) {
want2 = rp2.uc + rn2 * slotW / 2 + SP + slotW / 2;
}
}
putCentered(ui, g, want2);
} 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++) { antiOverlapRow(g); }
})();
/* ---------- 4b. 同代重排:让**同一对父母的同胞**挨在一起 ----------
* *
* ⚠️⚠️ 这是"线乱"的真正病根。原来的顺序会让别家的孩子插进同胞之间: * 为什么整个推倒重写(用户 2026-10-02:"错的更离谱了,这次连位置都不对了"):
* 实测我这一代是 哥哥(161) 嫂子(275) **堂兄(389) 堂嫂(503)** 老杨(617) … * 之前的版本是「游标铺放 → 防重叠 → 同代重排 → 再防重叠 → 再硬推」
* —— 堂兄(伯父家的)夹在哥哥和我之间,于是父母连子女的横梁要横跨老远, * 叠了四五层修补,每一层都在破坏上一层的"父居中于子女",
* 看着就不像"一家人"。 * 多轮互相拉扯后整棵树漂移(实测"我"跑到离父母 800px 的最右侧)。
* 参考实现是靠"按父辈的游标顺序递归摆放"天然做到这一点的;
* 我们的布局是"先按代平铺、再对齐父母",所以少了这一步,这里补上:
* 按「父户的中心 x」把同代的户重新排序(没有父户的按原相对位置排末尾),
* 然后**横向依次重排**(保持每户宽度与间距),再让父户重新居中。
*/
(function groupSiblings() {
for (var g2 = gmin; g2 <= gmax; g2++) {
var row = [];
units.forEach(function (u, ui) { if (placed[ui] && placed[ui].gen === g2) { row.push(ui); } });
if (row.length < 2) { continue; }
// 排序键:父户的中心 x(没有父户的用"当前 x",保证相对次序稳定)
function pkey(ui) {
var ps = parentsOf[String(ui)] || [];
var best = null;
ps.forEach(function (p) {
if (placed[p] && (best === null || placed[p].uc < best)) { best = placed[p].uc; }
});
return best === null ? (placed[ui].uc + 1e6) : best; // 无父户的排到最后
}
row.sort(function (a, b) { return pkey(a) - pkey(b) || placed[a].uc - placed[b].uc; });
// 依次重排(保持每户宽度、户间 SP)
var total = 0;
row.forEach(function (ui, i) {
var n = units[ui].cards.length >= 2 ? 2 : 1;
total += (n === 2 ? 2 * IW + SP : IW) + (i ? SP : 0);
});
var cur = placed[row[0]].x0; // 从原来的最左端起排
row.forEach(function (ui) {
var n = units[ui].cards.length >= 2 ? 2 : 1;
var w = (n === 2 ? 2 * IW + SP : IW); // ⚠️ 真实宽度,不是 n*SLOT
var d = cur - placed[ui].x0;
if (d) {
(function shift4(x, dd) {
if (placed[x]) { placed[x].uc += dd; placed[x].x0 += dd; }
(kidsOf[x] || []).forEach(function (k) { if (placed[k]) { shift4(k, dd); } });
})(ui, d);
}
cur += w + SP;
});
}
// ⚠️ 重排会打乱原有的横向间距 → 必须再跑一轮防重叠(用统一的 antiOverlapRow)
for (var g4 = gmin; g4 <= gmax; g4++) { antiOverlapRow(g4); }
// 再让父户居中于子女之上(防重叠可能又推歪了;一趟就够)
for (var g5 = gmax; g5 >= gmin; g5--) {
Object.keys(placed).forEach(function (kk) {
var ui = +kk;
if (placed[ui].gen !== g5 - 1) { return; }
var ks3 = (kidsOf[ui] || []).filter(function (x) { return placed[x]; });
if (!ks3.length) { return; }
var xs3 = ks3.map(function (x) { return placed[x].uc; }).sort(function (a, b) { return a - b; });
var want3 = (xs3[0] + xs3[xs3.length - 1]) / 2;
var d4 = want3 - placed[ui].uc;
if (Math.abs(d4) > 0.5) { placed[ui].uc += d4; placed[ui].x0 += d4; }
});
}
// 最后一遍防重叠(顺序:重排 → 防重叠 → 父居中 → 防重叠,收敛)
for (var g6 = gmin; g6 <= gmax; g6++) { antiOverlapRow(g6); }
})();
/* ---------- 4c. 收尾防重叠(最后一道,必须放在**所有**定位步骤之后) ----------
* *
* ⚠️⚠️ 排序很重要:`groupSiblings()` 会**重排**同代(打乱原有间距), * 现在换成树布局的标准解法(区间法),只有三条规则,一次成型:
* 而它若跑在防重叠**之后**,被它挪动过的户就再没人兜底了 —— * ① 每棵「户子树」自底向上算**需求宽度**:
* 实测"舅子"(姻亲旁支,由兜底段放在最后)就与"堂嫂"叠了 35px。 * needW = max( 自身宽, Σ子女needW + SP×(子女数−1) )
* 所以这里在**所有**定位/重排步骤之后,再统一收一遍。 * ② 自顶向下分配**互不重叠的 x 区间**:子女依次铺在父区间内;
* ③ 父户居中于子女的实际跨度。
* 区间天然不重叠 ⇒ 任何两张卡绝不重叠,**不需要任何后续推挤修补**。
*
* 跨树边(岳父母户也把妻子列为子女):每户只认**主父户**进树递归
* (主位卡是"我方血亲"的那个父户,服务端已给每张卡标了 blood),
* 其余父户只在**画线**时用 —— 岳父母的子女横梁照样连到妻子,
* 但布局上岳父母树不会把"我的户"再铺一遍。
*/ */
(function hardAntiOverlap() { var rawKids = kidsOf; // 原始子女关系(画线用;跨树边保留)
// ⚠️⚠️ 兜底段(放姻亲旁支,如"舅子")与 groupSiblings() 都会**事后**挪动户,
// 而它们的挪动不走 antiOverlapRow,于是同代仍可能贴上
// (实测"堂嫂 289-347"与"舅子 315-372"重叠 32px,且多遍 antiOverlapRow 都推不开)。
// 这里改成**直接按最终坐标硬推**:同代按中心排序,谁跟前一个贴太近就整支右移,
// 循环几遍直到全部满足 —— 不依赖任何前置状态。
for (var hr = 0; hr < 6; hr++) {
var moved = false;
for (var hg = gmin; hg <= gmax; hg++) {
var hrow = [];
units.forEach(function (u, ui) {
if (placed[ui] && placed[ui].gen === hg) { hrow.push(ui); }
});
if (hrow.length < 2) { continue; }
hrow.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
for (var hi = 1; hi < hrow.length; hi++) {
var hp = hrow[hi - 1], hc = hrow[hi];
var hneed = placed[hp].uc + halfW(hp) + SP + halfW(hc);
if (placed[hc].uc < hneed - 0.5) {
var hd = hneed - placed[hc].uc;
(function hs(x, dd) {
if (placed[x]) { placed[x].uc += dd; placed[x].x0 += dd; }
(kidsOf[x] || []).forEach(function (k) { if (placed[k]) { hs(k, dd); } });
})(hc, hd);
moved = true;
}
}
}
if (!moved) { break; }
}
})();
(function finalAntiOverlap() { var mainParent = {}; // ui -> 主父户 ui
// ⚠️ 跑**两遍**:单遍不足以收敛 —— 推开一户会连带它的子树一起移, var kidList = {}; // ui -> [子女户 ui,...](只含"把我当主父户"的)
// 移完可能又与更右的一户贴上(实测"堂嫂 → 舅子"只差 39px, units.forEach(function (u, ui) { u._blood = !!(u.cards[0] && u.cards[0].blood); });
// 单遍推开后它右边的"老杨"又被挤到,第二遍才稳)。 units.forEach(function (u, ki) {
for (var round2 = 0; round2 < 3; round2++) { var ps = parentsOf[ki] || [];
for (var gf = gmin; gf <= gmax; gf++) { antiOverlapRow(gf); } if (!ps.length) { return; }
var main = null;
ps.forEach(function (p) { if (main === null && units[p]._blood) { main = p; } });
if (main === null) { main = ps[0]; }
mainParent[ki] = main;
// kidList 沿 kidsOf 的遍历顺序 push —— 服务端的长幼序原样保留
(kidList[main] = kidList[main] || []).push(ki);
});
/** 一户的实际宽度:单人 IW、夫妻两张 2*IW+SP(不是 n*SLOT) */
function ownW(ui) {
return units[ui].cards.length >= 2 ? 2 * IW + SP : IW;
}
/* ① 需求宽度:自底向上 */
var needW = {};
function calcNeed(ui) {
if (needW[ui] !== undefined) { return needW[ui]; }
var total = 0;
(kidList[ui] || []).forEach(function (k, i) {
total += calcNeed(k) + (i ? SP : 0);
});
needW[ui] = Math.max(ownW(ui), total);
return needW[ui];
}
/* ②③ 分配区间:自顶向下;父户居中于子女的实际跨度 */
var placed = {}; // ui -> {gen, x0}
function assign(ui, x0) {
var own = ownW(ui);
var ks = kidList[ui] || [];
var total = 0;
ks.forEach(function (k, i) { total += needW[k] + (i ? SP : 0); });
var kx = x0 + (needW[ui] - total) / 2; // 子女在父区间内居中起铺
ks.forEach(function (k) { assign(k, kx); kx += needW[k] + SP; });
// 居中锚:所有子女(**含跨树子女**——他们已在别的树里放好)的实际跨度中点
var lo = null, hi = null;
(rawKids[ui] || []).forEach(function (k) {
if (!placed[k]) { return; }
var c = placed[k].x0 + ownW(k) / 2;
if (lo === null || c < lo) { lo = c; }
if (hi === null || c > hi) { hi = c; }
});
var want = (lo === null) ? x0 + (needW[ui] - own) / 2 : (lo + hi) / 2 - own / 2;
// 父户不得越出自己被分配的区间(越出会撞上左邻/右邻子树)
placed[ui] = {
gen: units[ui].gen,
x0: Math.max(x0, Math.min(want, x0 + needW[ui] - own))
};
}
/* 森林:无主父户的户是树根;含「我」的树先放,其余树(姻亲)依次排在右侧 */
function treeHasMe(root) {
var st = [root], seen = {};
while (st.length) {
var x = st.pop();
if (seen[x]) { continue; }
seen[x] = true;
if (x === meUi) { return true; }
(kidList[x] || []).forEach(function (k) { st.push(k); });
} }
})(); return false;
}
var roots = [];
units.forEach(function (u, ui) { if (mainParent[ui] === undefined) { roots.push(ui); } });
roots.sort(function (a, b) {
var ma = treeHasMe(a), mb = treeHasMe(b);
return (ma === mb) ? ((units[a].gen - units[b].gen) || (a - b)) : (ma ? -1 : 1);
});
function subtreeRight(root) {
var hi = -Infinity;
(function walk(x) {
var p = placed[x];
if (p) { hi = Math.max(hi, p.x0 + ownW(x)); }
(kidList[x] || []).forEach(walk);
})(root);
return hi;
}
var cursorX = 0;
var TREE_GAP = SP; // 树与树之间的留白(姻亲树贴着主树放,
// 让岳父母尽量靠近妻子;区间法保证不重叠)
roots.forEach(function (r) {
calcNeed(r);
assign(r, cursorX);
cursorX = subtreeRight(r) + TREE_GAP;
});
/* ---------- 5. 落最终坐标 ---------- */ /* ---------- 5. 落最终坐标 ---------- */
var gmin = 0, gmax = 0;
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
var xs = []; var xs = [];
units.forEach(function (u, ui) { units.forEach(function (u, ui) {
var p = placed[ui]; var p = placed[ui];
// ⚠️ 户的实际宽度:单人 IW、双人 2*IW+SP —— **不是** n*SLOT if (p) { xs.push(p.x0, p.x0 + ownW(ui)); }
if (p) { xs.push(p.x0, p.x0 + (p.n === 2 ? 2 * IW + SP : IW)); }
}); });
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs); var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
var W = (maxX - minX) + PAD * 2; var W = (maxX - minX) + PAD * 2;
@@ -876,8 +538,7 @@ $payloadJson = json_encode(
// 横梁的 y 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。 // 横梁的 y 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间 // 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。 // (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
// 那个已经在下面的 `groupSiblings()` 里修掉了(同父的子女会被排到一起)。 // 区间法布局里同一父户的子女在它的区间内相邻铺开,横梁自然是短的。
// 所以这里恢复成"最左 → 最右"的整条横梁,看起来才是一家人。
var ks = kids.map(function (kk) { return anchor[kk].mid; }) var ks = kids.map(function (kk) { return anchor[kk].mid; })
.sort(function (x, y2) { return x - y2; }); .sort(function (x, y2) { return x - y2; });
if (ks.length > 1) { line(ks[0], busY, ks[ks.length - 1], busY); } if (ks.length > 1) { line(ks[0], busY, ks[ks.length - 1], busY); }