diff --git a/templates/kin/tree.php b/templates/kin/tree.php index 08b576e..47e3866 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -2,41 +2,35 @@ /** * 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。 * - * ============================ v15 为什么又重写渲染 ============================ + * ============================ 布局算法(v16 重写) ============================ * - * v13/v14 用第三方库 family-chart 画,结果**数据一直不全**(用户两次反馈: - * 「显示不齐全」「数据仍然不全,差很多」)。实测(_tooling/_fb_kin_cover_probe.py) - * 给出了硬证据:20 人的夹具,库**只画了 9 人** —— - * 伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥**一个都没画**。 - * 原因在库的算法本身:它只画「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」, - * **横向的亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来**。 - * 换树根也救不了 —— 换成最高祖先,"妻子的兄弟"照样不在树里。 + * 历史:v13/v14 用第三方库 family-chart → 数据一直不全(实测 20 人只画出 9 人, + * 因为它只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」, + * **横向亲戚与姻亲旁支永远进不来**)。v15 改成自己排版,人数终于对了, + * 但布局是"每代按固定格子摊开",于是**线是乱的**: + * · 同代卡片**会重叠**(一对夫妻宽 254px,却按 200px 一格摆); + * · 夫妻横线画在卡片**内部**(y = 卡中心 + 半个卡高 − 6),所以线从卡上穿过去; + * · 子女不落在父母下方,横梁跨得老远、线与线互相交叉。 * - * 所以这一版**不再依赖库的树算法**: - * · `Kinship::familyMap()` 以「我」为中心按代数检索**所有人** - * (父母、子女、配偶、配偶的父母与子女、每层的配偶、兄弟姐妹的子女…); - * · 前端按「**代分层、户并排**」自己排版、自己画线(见下面的 JS)。 + * v16 换成**整齐树(tidy tree)**——这是家谱/组织图的标准排法,规则只有三条: + * ① 后序给每棵子树算「轮廓」(每一行被占用的 x 区间),按轮廓从左往右放, + * 保证**任何两张卡都不重叠**(同代不同代的都不会); + * ② 父(户)落在**其子女整体跨度的中点**上 —— 这样"子女从父母下方连出来" + * 天然成立,横梁最短、最不容易交叉; + * ③ 同代按长幼排序(长子左、次子右),兄弟姊妹相对次序稳定。 * - * 用户点名的两条(都已兑现): - * ① 「显示本人的上一代、这一代、下一代的所有人」→ 见 familyMap 的两轮贯穿; - * ② 「子女必须从**夫妻之间的横线**出来,不能从夫妻其中一个出来 - * (否则母亲登录看不到自己的孩子)」→ 子女挂在**户**上, - * 竖线起点 = 夫妻横线的**中点**(`x = (左卡中心 + 右卡中心) / 2`), - * 与"是父亲登录还是母亲登录"完全无关。 + * 连线画法(用户点名的要求,一字不改): + * · 一对夫妻之间一条短横线; + * · 他们的子女**一律从这条横线正中间垂下来**(单亲户从本人卡正下方), + * 再经横梁分到每个孩子 —— 与"谁登录""孩子登记了几位父母"都无关。 * - * ⚠️ 为什么不再用库:库把布局写死在它的树算法里,我们既改不了"画谁"(横向亲戚不进树), - * 也改不了"线从哪出"。自己排版之后这两件事都变成几行代码。 - * ⚠️ 拖拽/缩放仍要保留(用户明确说「反正可以缩放和拖动」):用原生 d3.zoom, - * 不再需要库(d3.min.js 还在 vendor 里,可继续复用)。 + * 拖拽 / 滚轮缩放保留(d3.zoom);另给「看全部 / 回到我」两个按钮。 */ $graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId()); $people = $people ?? []; -// ⚠️ 模板里不再有 $nodes:v15 起家谱数据由 Kinship::familyMap() 现算, -// 不再经过 Kinship::tree() 的 units/generations 那套(那套是给旧画法用的)。 // ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ---- -// up/down 取 2:卡片一行放得下三代,再多会挤(本人 = 0 代,-1/-2 为长辈,+1/+2 为晚辈)。 $map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 2, 2); $gen = $map['gen']; $units = $map['units']; @@ -53,9 +47,7 @@ $rankKey = static function (int $id) use ($people): string { // ---- ④ 打成前端要的数据包(代数 → 户 → 人)---- $payloadUnits = []; foreach ($units as $i => $u) { - $ids = array_map('intval', $u['ids']); - $sorted = $ids; - // 户内排序:血亲在前、配偶在后(配偶排右手边);两人都是血亲时按长幼 + $sorted = array_map('intval', $u['ids']); usort($sorted, static function (int $a, int $b) use ($rankKey): int { return strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b); }); @@ -79,6 +71,30 @@ foreach ($units as $i => $u) { if (!$cards) { continue; } + // ⚠️ 户内部:**血亲在前、配偶在后**(配偶排右手边)。 + // 只按长幼排会让"妻子排到丈夫左边",夫妻横线看着别扭。 + $head = null; + foreach ($cards as $c) { + // 血亲 = 这个户里"有父母在本图上"的那一位;都没有就取第一位 + if ($head === null) { + $head = $c; + } + foreach ($graph['parents'][(int)$c['id']] ?? [] as $par) { + if (isset($unitOf[(int)$par]) && (int)$unitOf[(int)$par] !== $i) { + $head = $c; + break 2; + } + } + } + if ($head !== null) { + $rest = []; + foreach ($cards as $c) { + if ((int)$c['id'] !== (int)$head['id']) { + $rest[] = $c; + } + } + $cards = array_merge([$head], $rest); + } $kids = []; foreach ($unitKids[$i] ?? [] as $cid) { if (isset($people[$cid])) { @@ -87,37 +103,32 @@ foreach ($units as $i => $u) { } usort($kids, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b)); - $payloadUnits[] = [ - 'gen' => (int)$u['gen'], - 'cards' => $cards, - 'kids' => $kids, // 这个户的孩子(连线从这里画下去) - ]; + $payloadUnits[] = ['gen' => (int)$u['gen'], 'cards' => $cards, 'kids' => $kids]; } // 同代之间按「主位长幼」排序,保证顺序稳定 -$payloadUnitsSorted = $payloadUnits; -usort($payloadUnitsSorted, static function (array $a, array $b) use ($rankKey): int { +usort($payloadUnits, static function (array $a, array $b) use ($rankKey): int { return ($a['gen'] <=> $b['gen']) ?: strcmp($rankKey((int)$a['cards'][0]['id']), $rankKey((int)$b['cards'][0]['id'])) ?: ((int)$a['cards'][0]['id'] <=> (int)$b['cards'][0]['id']); }); $totalPeople = 0; -foreach ($payloadUnitsSorted as $u) { +foreach ($payloadUnits as $u) { $totalPeople += count($u['cards']); } $canChart = $totalPeople > 1; $meName = (string)($people[$meId]['name'] ?? '自己'); $payloadJson = json_encode( - ['units' => $payloadUnitsSorted, 'me' => (int)$meId], + ['units' => $payloadUnits, 'me' => (int)$meId], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); ?>
- 灰色细线是排行与配偶关系:一对夫妻之间有一条短横线, - 他们的子女一律从这条横线的中点连下来(不管是谁登录,孩子都挂在这对夫妻上), + 每代一行,一代里按「户」排开;一对夫妻之间有一条短横线, + 他们的子女一律从这条横线的正中间垂下来(不管是谁登录,孩子都挂在这对夫妻上), 再由横梁分到每个孩子。 「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。 可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。 @@ -167,59 +174,240 @@ $payloadJson = json_encode( .replace(/"/g, '"').replace(/'/g, '''); } - // ---- 版式常量(改这里就能整体调松紧)---- - var CW = 118; // 卡片宽 - var CH = 92; // 卡片高 - var GAP = 16; // 夫妻两张卡之间的缝(横线就画在这一段上) - var XS = 200; // 同代相邻「户」的中心距 - var YS = 156; // 代与代之间的行距 - var PAD = 70; // 画布留白 + // ---- 版式常量 ---- + var CW = 118; // 卡片宽 + var CH = 92; // 卡片高 + var HGAP = 20; // 同代相邻「户」之间的水平间隙 + var CGAP = 14; // 夫妻两张卡之间的缝(短横线画在这一段上) + var PGAP = 16; // 夫妻两张卡与"夹在中间的竖线"之间的额外余量 + var YS = 150; // 代与代之间的行距 + var PAD = 64; // 画布留白 - // ---- 1. 分代 ---- + // ================= ========= 布局:整齐树 ================= + // + // ⚠️ 为什么不用"每代按固定格子摊开"(v15 的做法,线就是那样乱起来的): + // ① 一对夫妻占 2×CW + CGAP 宽(≈250px),单人也占 CW;按同一个固定格距摆, + // 宽的那户会**压到隔壁**(实测:相邻两户重叠了 52px); + // ② 子女不落在父母正下方,横梁要跨很远,线与线互相交叉。 + // 整齐树的做法:先按「轮廓」把子树从左往右摆开(保证不重叠), + // 再让父(户)落在**其子女跨度的中点**——于是一层一层都是居中于下一层, + // 线自然是最短的、几乎不交叉。 + + // ---- 0. 户的父子关系(哪个户是哪一户的孩子)---- + var unitOf = {}; // personId -> unitIndex + units.forEach(function (u, ui) { + u._i = ui; + u.cards.forEach(function (c) { unitOf[c.id] = ui; }); + }); + units.forEach(function (u) { u._kids = []; }); + + // 从「户 → 子女」反推「户 → 子户」:某户的孩子落在了别的户里,那个户就是子户 + var childUnits = {}; // ui -> [子户 ui,...](去重、保持顺序) + var parentUnits = {}; // 子户 ui -> [父户 ui,...](**可以有多个**) + 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 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); }); - var gens = Object.keys(byGen).map(Number).sort(function (a, b) { return a - b; }); + 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; }); + }); + } - // ---- 2. 每代居中排开;算出每户的中心 x 与两张卡的位置 ---- - var maxPer = 1; - gens.forEach(function (g) { maxPer = Math.max(maxPer, byGen[g].length); }); - var W = PAD * 2 + Math.max(1, maxPer) * XS; + // ---- 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; + }); + }); + + // ---- 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; + }); + }); + } + + // ---- 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; + } + allX.push(cxOf[u._i] - halfSpan, cxOf[u._i] + halfSpan); + }); + var minX = Math.min.apply(null, allX), maxX = Math.max.apply(null, allX); + var W = (maxX - minX) + PAD * 2; var H = PAD * 2 + (gens.length - 1) * YS + CH; - var pos = {}; // id => {x,y} - var lay = {}; // 户序号 => {cx, cy, left, right, barY, ids} - var genGi = {}; - gens.forEach(function (g, gi) { genGi[g] = gi; }); - - units.forEach(function (u, ui) { - var row = byGen[u.gen]; - var i = row.indexOf(u); - var gi = genGi[u.gen]; - var y = PAD + CH / 2 + gi * YS; - var startX = (W - (row.length - 1) * XS) / 2; - var cx = startX + i * XS; + 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 half = CW / 2; var left, right; if (ids.length >= 2) { - left = cx - GAP / 2 - half; // 左卡中心 - right = cx + GAP / 2 + half; // 右卡中心 - pos[ids[0]] = { x: left, y: y, g: u.gen }; - pos[ids[1]] = { x: right, y: y, g: u.gen }; + // 两张卡并排,中间留缝;缝的正中就是"夫妻横线"与"子女下垂线"的 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 }; } else { left = right = cx; - pos[ids[0]] = { x: cx, y: y, g: u.gen }; + pos[ids[0]] = { x: cx, y: y }; } - lay[ui] = { + lay[u._i] = { cx: cx, cy: y, left: left, right: right, ids: ids, - // 夫妻横线的 y:卡片下沿往下一点,正好在两张卡之间 - barY: y + CH / 2 - 6, + // ⚠️ 夫妻横线画在**两张卡之间的空档的正中**(纵向也在卡高的一半处), + // 视觉上就是"一条线把夫妻俩连起来"。 + // 子女的下垂线从这条线的**中点**往下走 —— 这就是用户要的形状。 + // (v15 把它画在 y + CH/2 − 6,等于画在卡片**内部**,线从卡身上穿过去;实测抓到 2 处。) + barY: y, hasBar: ids.length >= 2 }; }); - // ---- 3. 落卡片 ---- - var html = ''; + var drawn = {}, html = ''; units.forEach(function (u) { u.cards.forEach(function (c) { var p = pos[c.id]; @@ -233,45 +421,70 @@ $payloadJson = json_encode( + '' + esc(c.label) + '' + (c.ph ? '待确认' : '') + ''; + drawn[c.id] = 1; }); }); root.insertAdjacentHTML('beforeend', '