v19: 家谱画线重写 —— 先定节点坐标、再按节点画线(用户建议的思路)

用户反馈(原话):「还是有问题,主要是线的问题,很多线画的不到位,要么是连的不到位,
要么就是和下一代的节点没连上。你是用 convas 画的吗?应该把每个节点的位置都摆好之后,
再根据节点进行画线」。

我按这个思路把画线整段重写了,并用脚本把「哪张卡没连上」**量成数字**(新工具
`_tooling/_fb_kin_linecheck.py`)。量出来的真问题与修法:

1. **两套坐标混着用**(这是"连不到位"的根因):
   横梁端点用了 `placed[k].uc`(**户中心**),而卡片真实中心是 `pos[id].x`;
   「子女接父母」的竖线起点取"卡顶",横梁 y 却按"父户那一代的 y"推 —— 不在同一坐标系。
   → 现在统一:**先落定所有 pos[],再由 pos 反推每户的画线锚点**(left/right/mid/top/bottom),
     画线只引用这些锚点(新 `anchor[]`,模板里显式标注了这条契约)。

2. **横梁 y 用错了代**:原用 `nextGenY(我的代数)` —— 但**叔伯的子女与我同代**(不是下一代),
   于是横梁画到别处、孩子接不上。
   → 改成由**孩子自己的卡顶**算:`busY = min(孩子卡顶) - SP/2`。

3. **「接入父母横梁」用错了锚**:原是"本代卡顶 - SP/2"(只在相邻两代才成立),
   姻亲旁支一错位就接不上(实测 6 张卡"有父母却没有线连上去")。
   → 改成查**父母那一户的 `busY`**。

4. **同胞被别家孩子插在中间**("线乱"的真正病根):
   实测我这一代原为 哥哥(161) 嫂子(275) **堂兄(389) 堂嫂(503)** 老杨(617) ——
   堂兄夹在哥哥和我之间,父母连子女的横梁被迫横跨,看着不像一家人。
   → 新增 `groupSiblings()`:同代按「父户的中心 x」重排,让同胞挨在一起;
     重排后再跑防重叠 + 父户居中(收敛)。

5. **户宽算错 26px**(防重叠一直推不开的原因):
   边界用 `n * SLOT`,而单人户实际是 `IW`、双人户是 `2*IW+SP` —— 处处偏大。
   → 全部改成真实宽度(新增 `halfW()`),并加 `hardAntiOverlap()` 按最终坐标硬推、循环收敛。

**战绩**(`_fb_kin_linecheck.py` 是这轮新写的诊断工具,它自己也被修了三次判据 ——
"端点恰好等于卡边"→"区间覆盖"→"半个槽位",前两版都误报):
  · 20 人夹具:「应连上一代却无线」= **无**;「接不上的竖线」= **0 段**;重叠 0 对
  · 版面质量 8 项全过(含 A 不重叠 / D 有空隙 / C 下垂线从中点出)
  · **真实线上数据(11 人)**:重叠 0 对、19 段连线、无 JS 报错
  · e2e 553 条全过

⚠️ 顺带发现的数据问题(**不是代码问题**,已告知用户):
真实数据里 **爸爸与妈妈之间没有登记夫妻关系**(妈妈是单独一户),
所以图上他们是并排但不算一对;补上「配偶」后会自动合并成一户。
This commit is contained in:
2026-10-02 15:49:50 +08:00
parent 8cfcb8d978
commit 65378ff48e
+268 -51
View File
@@ -309,6 +309,39 @@ $payloadJson = json_encode(
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;
@@ -317,7 +350,7 @@ $payloadJson = json_encode(
if (!p || p.gen !== gen) { return; }
any = true;
lo = Math.min(lo, p.x0);
hi = Math.max(hi, p.x0 + p.n * SLOT);
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; }
@@ -581,30 +614,128 @@ $payloadJson = json_encode(
/* ---------- 4. 同代防重叠(保险):按 x 排序后依次推开,子树跟着走 ---------- */
(function antiOverlap() {
for (var g = gmin; g <= gmax; g++) {
for (var g = gmin; g <= gmax; g++) { antiOverlapRow(g); }
})();
/* ---------- 4b. 同代重排:让**同一对父母的同胞**挨在一起 ----------
*
* ⚠️⚠️ 这是"线乱"的真正病根。原来的顺序会让别家的孩子插进同胞之间:
* 实测我这一代是 哥哥(161) 嫂子(275) **堂兄(389) 堂嫂(503)** 老杨(617) …
* —— 堂兄(伯父家的)夹在哥哥和我之间,于是父母连子女的横梁要横跨老远,
* 看着就不像"一家人"。
* 参考实现是靠"按父辈的游标顺序递归摆放"天然做到这一点的;
* 我们的布局是"先按代平铺、再对齐父母",所以少了这一步,这里补上:
* 按「父户的中心 x」把同代的户重新排序(没有父户的按原相对位置排末尾),
* 然后**横向依次重排**(保持每户宽度与间距),再让父户重新居中。
*/
(function groupSiblings() {
for (var g2 = gmin; g2 <= gmax; g2++) {
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);
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。
* 所以这里在**所有**定位/重排步骤之后,再统一收一遍。
*/
(function hardAntiOverlap() {
// ⚠️⚠️ 兜底段(放姻亲旁支,如"舅子")与 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() {
// ⚠️ 跑**两遍**:单遍不足以收敛 —— 推开一户会连带它的子树一起移,
// 移完可能又与更右的一户贴上(实测"堂嫂 → 舅子"只差 39px,
// 单遍推开后它右边的"老杨"又被挤到,第二遍才稳)。
for (var round2 = 0; round2 < 3; round2++) {
for (var gf = gmin; gf <= gmax; gf++) { antiOverlapRow(gf); }
}
})();
/* ---------- 5. 落最终坐标 ---------- */
var xs = [];
units.forEach(function (u, ui) {
var p = placed[ui];
if (p) { xs.push(p.x0, p.x0 + p.n * SLOT); }
// ⚠️ 户的实际宽度:单人 IW、双人 2*IW+SP —— **不是** n*SLOT
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 W = (maxX - minX) + PAD * 2;
@@ -648,50 +779,136 @@ $payloadJson = json_encode(
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
/* ---------- 7. 连线(画在卡片背后,所以可以从卡中心连到卡中心)---------- */
/* ---------- 7. 连线 ----------
*
* ⚠️⚠️ v19:**先把所有节点坐标摆定,再统一按节点坐标画线**(用户 2026-10-02 的建议原话:
* 「应该把每个节点的位置都摆好之后,再根据节点进行画线」)。
* 上一版是"边摆放边画线",于是两套坐标混着用:
* · 横梁端点用了 `placed[k].uc`(**户中心**),而卡片的真实中心是 `pos[id].x` ——
* 户中心与卡中心只在"单人户"下相等,双人户差半个槽位;
* · 「子女接父母」的竖线起点取"卡顶",而横梁的 y 是按"父户那一代的 y"推的,
* 两者不在同一个坐标系里。
* 结果:实测 5 张卡「有父母却没有线连上去」、17 段线两端都不碰任何卡片(悬空)。
*
* 现在统一成三步:
* ① 每个"人"的坐标已经在 pos[] 里定死(那是唯一的真值);
* ② 每"户"从它的卡片坐标推出:左卡中心、右卡中心、横线中点、卡顶/卡底 y;
* ③ 画线只用这些**由节点坐标推出的值**,不再引用放置阶段的游标数据。
*
* 线的三段(与参考实现一致):
* A. 夫妻横线:两张卡中心之间(y = 卡中心高度)
* B. 下垂线:从**横线中点**往下 → 到"子女代的横梁"(y = 下一代卡顶 - SP/2)
* C. 横梁 + 分叉:横梁连到每个孩子的**卡顶中点**,再各自往下一点点接上卡
* 另外每人若有父母,从卡顶往上画一小段接到横梁(保证"和下一代连上"是闭合的)。
*/
var paths = [], bars = [], drops = [];
function line(x1, y1, x2, y2) {
if (Math.abs(x1 - x2) < 0.05 && Math.abs(y1 - y2) < 0.05) { return; }
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
}
// ---- ② 由「人」的坐标推出「户」的画线锚点 ----
// anchor[ui] = {gen, y, left, right, mid, top, bottom, hasBar, cardIds}
var anchor = {};
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 });
}
// ② 有子女 → 从夫妻横线**中点**往下垂到"横梁"
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 });
}
// ④ 每个人若有父母 → 从卡顶往上画一小段(接到上一代的横梁)
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);
}
var ids = u.cards.map(function (c) { return String(c.id); })
.filter(function (id) { return !!pos[id]; });
if (!ids.length) { return; }
var ys = ids.map(function (id) { return pos[id].y; });
var y = ys[0]; // 同户必然同一行
var cx = ids.map(function (id) { return pos[id].x; });
var left = Math.min.apply(null, cx), right = Math.max.apply(null, cx);
anchor[ui] = {
gen: u.gen, y: y, yRow: y,
left: left, right: right,
mid: (left + right) / 2, // ★ 夫妻横线中点(单亲 = 卡中心)
top: y - IH / 2, bottom: y + IH / 2,
hasBar: ids.length >= 2,
ids: ids
};
});
// 每一代的行 y(用于算"横梁"的位置:下一代卡顶再往上一点)
var genY = {};
Object.keys(anchor).forEach(function (ui) {
var a = anchor[ui];
if (genY[a.gen] === undefined) { genY[a.gen] = a.y; }
});
function nextGenY(gen) {
var best = null;
Object.keys(genY).forEach(function (g) {
var gg = +g;
if (gg > gen && (best === null || gg < best)) { best = gg; }
});
return best === null ? null : genY[best];
}
// ---- ③ 画线:只用 anchor 与 pos ----
// A. 夫妻横线
units.forEach(function (u, ui) {
var a = anchor[ui];
if (!a) { return; }
if (a.hasBar) { line(a.left, a.y, a.right, a.y); }
bars.push({ ui: ui, x1: a.left, x2: a.right, y: a.y, mid: a.mid, has: a.hasBar });
});
// B/C. 从"我"这一户往下连子女(横线中点 → 横梁 → 每个孩子的卡顶)
//
// ⚠️ 横梁 y 取「**子女那一代的卡片顶** 再往上 SP/2」——
// 上一版取的是"父户的 y + IH/2 + SP/2",当两代之间还有别的行时就会错位。
Object.keys(anchor).forEach(function (k) {
var ui = +k, a = anchor[ui];
var kids = (kidsOf[ui] || []).filter(function (kk) { return anchor[kk]; });
if (!kids.length) { return; }
// ⚠️⚠️ 横梁 y 必须由**孩子自己的卡顶**算,不能用"我这一代的下一代":
// 叔伯的子女与我同代(不是下一代),舅姨的子女也是同代 ——
// 用 nextGenY() 会把横梁画到别处,孩子的竖线于是接不上(实测 18 段悬空)。
var kidTops = kids.map(function (kk) { return anchor[kk].top; });
var busY = Math.min.apply(null, kidTops) - SP / 2; // 横梁:孩子卡顶再往上 SP/2
// 下垂线:从**卡底**(不是卡中心)→ 横梁,这样不会压在卡片身上
line(a.mid, a.bottom, a.mid, busY);
drops.push({ ui: ui, x: a.mid, y0: a.bottom, busY: busY });
// 横梁:从最左孩子连到最右孩子。
//
// ⚠️ 曾经担心"长横梁横穿全图",但那不是真正的问题 ——
// 横梁的 y 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
// 那个已经在下面的 `groupSiblings()` 里修掉了(同父的子女会被排到一起)。
// 所以这里恢复成"最左 → 最右"的整条横梁,看起来才是一家人。
var ks = kids.map(function (kk) { return anchor[kk].mid; })
.sort(function (x, y2) { return x - y2; });
if (ks.length > 1) { line(ks[0], busY, ks[ks.length - 1], busY); }
// 每个孩子:从横梁往下接到**卡顶**
kids.forEach(function (kk) {
var c = anchor[kk];
line(c.mid, busY, c.mid, c.top);
});
});
// D. 每个"有父母"的人:**卡顶往上接到父母那一户的横梁**
//
// ⚠️ 这里必须用「**父母那一户**的下垂线终点 busY」,
// 而不是"本代卡顶 - SP/2" —— 后者只在"父母与我在相邻两代"时才相等,
// 姻亲/旁支一错位就接不上(实测 6 张卡"有父母却没有线连上去")。
// 上面 B/C 段已经把每户的 busY 算好放进 drops[],这里直接查。
var busOfUnit = {};
drops.forEach(function (d) { busOfUnit[d.ui] = d.busY; });
Object.keys(anchor).forEach(function (k) {
var ui = +k, a = anchor[ui];
var ps = parentsOf[String(ui)] || [];
if (!ps.length) { return; }
// 取"负责连他的那一户"(parentsOf 里的第一个)
var pu = ps[0];
var busY = busOfUnit[pu];
if (busY === undefined) {
// 父母那一户没有子女横梁(数据异常):退化到"本代卡顶 - SP/2"
busY = a.top - SP / 2;
}
line(a.mid, busY, a.mid, a.top);
});
root.insertAdjacentHTML('afterbegin',