diff --git a/templates/kin/tree.php b/templates/kin/tree.php index bd64f88..899976a 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -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,22 +614,119 @@ $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); } } })(); @@ -604,7 +734,8 @@ $payloadJson = json_encode( 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', '
' + html + '
'); - /* ---------- 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 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 + }; + }); - // ② 有子女 → 从夫妻横线**中点**往下垂到"横梁" - 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); - } + // 每一代的行 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',