v17: 家谱连线重排 —— 整齐树布局,线不再乱
用户反馈(原话):「人数对了,但是连线什么的还是乱的,实在不行,用一个画图谱的库试试也行,
以自己为中心进行连线。」
先量清"乱"在哪(_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 项全过。
This commit is contained in:
+314
-105
@@ -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
|
||||
);
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h1>我的家谱</h1>
|
||||
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女一律从夫妻横线中点连出</span>
|
||||
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女从夫妻横线中点连出</span>
|
||||
</div>
|
||||
|
||||
<?php if (!$canChart): ?>
|
||||
@@ -133,16 +144,12 @@ $payloadJson = json_encode(
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
/* 画布。里面的内容(卡片 + svg 连线)由下面的脚本按数据算出来再落进去。
|
||||
⚠️ 尺寸不能是 0:脚本要按容器的真实宽高算初始 fit(0×0 会缩到看不清)。 */
|
||||
?>
|
||||
<div class="kin-chart" id="kinChart"
|
||||
data-kin="<?= e($payloadJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
|
||||
|
||||
<p class="tree-legend">
|
||||
灰色细线是排行与配偶关系:<b>一对夫妻之间有一条短横线</b>,
|
||||
<b>他们的子女一律从这条横线的中点连下来</b>(不管是谁登录,孩子都挂在这对夫妻上),
|
||||
每代一行,一代里按「户」排开;<b>一对夫妻之间有一条短横线</b>,
|
||||
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上),
|
||||
再由横梁分到每个孩子。
|
||||
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
|
||||
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
|
||||
@@ -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 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(
|
||||
+ '<span class="kin-lb">' + esc(c.label) + '</span>'
|
||||
+ (c.ph ? '<span class="kin-mark">待确认</span>' : '')
|
||||
+ '</a>';
|
||||
drawn[c.id] = 1;
|
||||
});
|
||||
});
|
||||
root.insertAdjacentHTML('beforeend',
|
||||
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
|
||||
|
||||
// ---- 4. 画线 ----
|
||||
// 用户的核心要求:「夫妻之间有横线;所有子女都从这条**横线**出来」。
|
||||
// 竖线起点恒为 (左卡中心 + 右卡中心)/2 —— 与"谁登录"无关,
|
||||
// 也与"孩子只登记了父亲还是只登记了母亲"无关。
|
||||
var paths = [];
|
||||
var bars = []; // 调试/断言用:每户的横线区间与中点
|
||||
var drops = []; // 调试/断言用:每户下垂线的起点 x
|
||||
//
|
||||
// 三条线(顺序即画法):
|
||||
// ① 夫妻横线:连两张卡的**中间空档**(视觉上把夫妻连起来);
|
||||
// ② 下垂线:从横线的**中点**往下(单亲户 = 本人卡的中心往下);
|
||||
// ③ 横梁 + 分叉:由横梁分到每个孩子的卡顶。
|
||||
// ⚠️ ② 的起点取"卡中间高度"会**竖着穿过卡片下半部分**;所以让竖向那段
|
||||
// 从**卡下沿**开始 —— 视觉上仍是"从夫妻那条线的中点出来",
|
||||
// 但不会压在卡上。(用户要的是"别从某一个人出来",不是"必须穿过卡面"。)
|
||||
var paths = [], bars = [], drops = [];
|
||||
|
||||
Object.keys(lay).forEach(function (k) {
|
||||
var L = lay[k];
|
||||
units.forEach(function (u) {
|
||||
var L = lay[u._i];
|
||||
if (L.hasBar) {
|
||||
paths.push('M' + L.left + ',' + L.barY + 'L' + L.right + ',' + L.barY);
|
||||
// ⚠️ 夫妻横线**只画两张卡之间的那一段缝隙**(不是从卡中心连到卡中心):
|
||||
// 从中心画会从卡片身上穿过去(实测 B 项抓到 4 处穿卡)。
|
||||
// 起止取「左卡右沿」到「右卡左沿」。
|
||||
paths.push('M' + (L.left + CW / 2) + ',' + L.barY
|
||||
+ 'L' + (L.right - CW / 2) + ',' + L.barY);
|
||||
}
|
||||
bars.push({ ui: +k, x1: L.left, x2: L.right, mid: (L.left + L.right) / 2,
|
||||
y: L.barY, has: L.hasBar });
|
||||
bars.push({ ui: u._i, x1: L.left + CW / 2, x2: L.right - CW / 2,
|
||||
mid: (L.left + L.right) / 2, y: L.barY, has: L.hasBar });
|
||||
});
|
||||
|
||||
units.forEach(function (u, ui) {
|
||||
var L = lay[ui];
|
||||
units.forEach(function (u) {
|
||||
var L = lay[u._i];
|
||||
var kids = (u.kids || []).map(function (id) { return pos[id]; })
|
||||
.filter(function (p) { return !!p; })
|
||||
.sort(function (a, b) { return a.x - b.x; });
|
||||
if (!kids.length) { return; }
|
||||
var srcX = (L.left + L.right) / 2; // ★ 夫妻横线中点(单亲时就是本人卡中心)
|
||||
var y0 = L.barY;
|
||||
var busY = L.cy + YS - CH / 2 - 30; // 子女行上方的横梁
|
||||
paths.push('M' + srcX + ',' + y0 + 'L' + srcX + ',' + busY);
|
||||
if (kids.length > 1) {
|
||||
paths.push('M' + kids[0].x + ',' + busY + 'L' + kids[kids.length - 1].x + ',' + busY);
|
||||
var srcX = (L.left + L.right) / 2; // ★ 夫妻横线中点(单亲 = 本人卡中心)
|
||||
var fromY = L.cy + CH / 2; // 从卡片下沿起(见上面的说明)
|
||||
var busY = L.cy + YS - CH / 2 - 24; // 子女行上方的横梁
|
||||
paths.push('M' + srcX + ',' + fromY + 'L' + srcX + ',' + busY);
|
||||
|
||||
// ⚠️⚠️ 横梁**不能直接连"第一个孩子到最后一个孩子"**:
|
||||
// 孩子分属不同的户(比如"我"和"姐姐"各是一户),
|
||||
// 而"我"那一户里还有配偶(妻子)。从最左孩子的卡中心连到最右孩子的卡中心,
|
||||
// 会把夹在中间的那位**配偶**也框进横梁范围 —— 看着就像"这条线也连了妻子"。
|
||||
// 正解:先按 x 排序,**逐段连接相邻两个孩子**(段间缺口处断开),
|
||||
// 每段只覆盖它两端的两张卡(从"左卡中心"到"右卡中心")。
|
||||
for (var i = 0; i + 1 < kids.length; i++) {
|
||||
var a = kids[i], b2 = kids[i + 1];
|
||||
// 只在"这两张卡之间没有别的卡片"时直连;否则断开(避免压过别人)
|
||||
var blocked = false;
|
||||
for (var j = 0; j < units.length; j++) {
|
||||
var other = pos[units[j].cards[0].id];
|
||||
if (!other || other === a || other === b2) { continue; }
|
||||
var oc = other.x;
|
||||
if (oc > a.x + 2 && oc < b2.x - 2) { blocked = true; break; }
|
||||
}
|
||||
if (blocked) { continue; }
|
||||
paths.push('M' + a.x + ',' + busY + 'L' + b2.x + ',' + busY);
|
||||
}
|
||||
kids.forEach(function (k) {
|
||||
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 + 2));
|
||||
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 - 4));
|
||||
});
|
||||
drops.push({ ui: ui, x: srcX, y0: y0, busY: busY });
|
||||
drops.push({ ui: u._i, x: srcX, y0: L.barY, fromY: fromY, busY: busY });
|
||||
});
|
||||
|
||||
root.insertAdjacentHTML('afterbegin',
|
||||
@@ -279,15 +492,12 @@ $payloadJson = json_encode(
|
||||
+ paths.map(function (d) { return '<path class="kin-link" d="' + d + '"></path>'; }).join('')
|
||||
+ '</svg>');
|
||||
|
||||
// 给勾稽用(外部断言读它)
|
||||
window.__kinLayout = { W: W, H: H, CW: CW, CH: CH, bars: bars, drops: drops,
|
||||
pos: pos, paths: paths };
|
||||
|
||||
// ---- 5. 拖拽 + 缩放(d3.zoom)----
|
||||
// ---- 5. 拖拽 + 缩放 ----
|
||||
var stage = root.querySelector('.kin-stage');
|
||||
var svg = root.querySelector('svg.kin-lines');
|
||||
var wrap = d3.select(root).append('g'); // 占位,真正的变换施加在 svg + stage 上
|
||||
wrap.remove();
|
||||
|
||||
var zoom = d3.zoom().scaleExtent([0.15, 2.5]).on('zoom', function (ev) {
|
||||
var t = ev.transform;
|
||||
@@ -313,7 +523,6 @@ $payloadJson = json_encode(
|
||||
if (!p) { return; }
|
||||
var r = root.getBoundingClientRect();
|
||||
var t = d3.zoomTransform(root);
|
||||
// 目标:把该卡挪到容器中心
|
||||
var tx = r.width / 2 - p.x * t.k;
|
||||
var ty = r.height / 2 - p.y * t.k;
|
||||
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
|
||||
|
||||
Reference in New Issue
Block a user