From 4b38c8914241e16e9f9757535c40b96105038ba0 Mon Sep 17 00:00:00 2001
From: laoyang
Date: Fri, 2 Oct 2026 12:48:33 +0800
Subject: [PATCH] =?UTF-8?q?v17:=20=E5=AE=B6=E8=B0=B1=E8=BF=9E=E7=BA=BF?=
=?UTF-8?q?=E9=87=8D=E6=8E=92=20=E2=80=94=E2=80=94=20=E6=95=B4=E9=BD=90?=
=?UTF-8?q?=E6=A0=91=E5=B8=83=E5=B1=80=EF=BC=8C=E7=BA=BF=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E4=B9=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
用户反馈(原话):「人数对了,但是连线什么的还是乱的,实在不行,用一个画图谱的库试试也行,
以自己为中心进行连线。」
先量清"乱"在哪(_tooling/_fb_kin_diag_lines.py + _fb_kin_geom.py,用真实数据):
· 同代卡片**会重叠** —— 一对夫妻宽 254px,却按固定 200px 一格摆
(实测相邻两户重叠 52px,最小间隙 -118px);
· 夫妻横线画在**卡片内部**(y = 卡中心 + 半卡高 − 6),线从卡身上穿过(实测 2~4 处);
· 子女不落在父母下方,横梁跨得老远(实测一条横梁横跨 314px,从别人的卡上方经过)。
改法(不看"库"了 —— 实测第三方库只画得出 20 人里的 9 人,换库解决不了"画谁"):
① **同代按「户宽」逐个排开**(不再用固定格距)+ 防重叠兜底;
② 同一父户的子女**排成紧挨的一整块**,块中心对到父户中心 —— 横梁因此只覆盖
自己那几个孩子,绝不跨到别人头上;
③ 夫妻横线**只画两张卡之间的那一段缝隙**(不再从卡中心连到卡中心);
④ 子女下垂线仍从**夫妻横线中点**往下(这条是用户点名的要求,保持不变);
⑤ 子女下垂线的竖向段从**卡下沿**起,不再穿过卡片下半部分。
验证(就这一个模块):
· `_fb_kin_geom.py`(新)—— 20 人夹具 **8 项全过**:
A 任意两张卡不重叠 B 没有连线穿过卡片 C 有子女的户下垂线全从横线中点出
D 同代相邻卡片留有空隙 E 夫妻横线恰好落在两卡缝隙上
(⚠️ 该脚本用 svg.getScreenCTM() 把连线转成屏幕坐标再与卡片比 ——
第一版直接拿 SVG 用户坐标比屏幕坐标,B/D 两条恒真)
· `_fb_kin_geom_falsify.py`(新)—— 反向验证 **3/3 抓住**:
注入「横线从卡中心连卡中心」→ B 变红
注入「下垂线从某一张卡出来」→ C 变红
注入「拆掉防重叠」→ A 变红
· `_fb_kin_check_v17.py`(替代旧库版)11 项全过;e2e 550 条全过;家谱功能专项 22 项全过
顺带修掉的两个**假绿**(都不是本轮引入,是既有断言失效):
· `_fb_btn_check.py` 的「危险按钮不是品牌实心」写死了 `--brand` 常量,
而 v14 起模块主题色会覆盖 --brand → 注入 bug 后按钮变紫仍被判"正常"。
改成**现场读** `getComputedStyle(document.body)['--brand']`。
同时补上「页面上得先有危险按钮」这条前提断言(原先 danger 为空时整段是空跑)。
· `_fb_static_dry2.sh`(部署脚本静态断言的本地空跑脚本)是**手工快照**,
部署脚本改过后它还是旧的 → 拿旧断言量新代码、报出已经修好的失败。
改成每次从 `familybook_deploy.sh` **现抽**那段再跑。
另外:`_fb_geom_mobile.py` 的家谱选择器/尺寸断言随结构从旧库改为 v17
(.kin-card / svg.kin-lines / path.kin-link / 118:92),窄屏几何 520 项全过。
---
templates/kin/tree.php | 423 ++++++++++++++++++++++++++++++-----------
1 file changed, 316 insertions(+), 107 deletions(-)
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', '