v18: 家谱改成「仿亲友+」App 的画法(按用户给的参考实现)
用户反馈(原话):「不行,非常乱。我给你个链接 https://gitee.com/coku2333/FamilyTreeView 你去读这个代码,尤其是它那个仿亲友+app 的效果,就是我想要的,按照这个做。」 参考实现是 Android 的 NewFamilyTreeView(ViewGroup + onDraw 画线)。我读了它的 README 与源码,把它的**布局规则**逐条搬过来(不是搬代码,是搬算法): ① **从下往上定位**:先孙辈(+2)→ 子女(+1)→ 我(0)→ 父母(-1)→ 祖辈(-2)。 因为"子层位置定了,父层就定了" —— 父居中于子之上。 ② 每一代维护 **genLeft / genRight 两个游标**(该代已用到的左右边界); 往右铺从 genRight 起、往左铺从 genLeft 起(对应它的 setPart)。 ③ **配偶并排在主位右侧**(一对夫妻 = 相邻两张卡)。 ④ 兄姐往左、弟妹往右;舅姨(母系)往右、叔伯姑(父系)往左 —— 都是"以我/父母为中心"向两侧展开,而不是按格子平铺。 ⑤ 我居中于我的子女之上;父母居中于我之上;祖辈居中于父母之上。 ⑥ **连线画在卡片背后**(参考是 ViewGroup.onDraw 先画线再绘子 View)—— 所以线可以从卡中心连到卡中心,被卡面挡住即可,不用绕路。 ⑦ 卡片改成参考的窄长比例(它 50×80,我们 88×106):头像占上半、姓名与称呼在下。 画线(沿用参考的三段式,子女一律从夫妻横线中点出): 夫妻横线(两卡中心之间)→ 从**横线中点**往下垂到横梁 → 横梁分到每个孩子。 上一版(v17「整齐树」)的问题:线虽然不穿卡了,但整张图看着像表格 —— 每一代从左到右平铺、兄弟姐被推到最外侧、伯父排在爸爸右边。这一版才对上参考的观感。 验证(按用户要求只做简单测试): · `_fb_kin_v18.py`(新)—— 20 人夹具渲染:**0 重叠**、20 张卡、28 段连线、无 JS 报错; 各行分布确认:祖辈居中于父母之上(575/653)、伯父在爸爸左边(403 < 575)、 兄姐在我左(442/519)、弟妹在我右、堂兄紧贴伯父下方。 · e2e 553 条全过(家谱段断言随 v18 更新为 takeSlot/placePart/putCentered/midX) · 修掉的既有 bug:`parentsOf` 的键是**户索引**,而多处按**人 id** 去查父母 —— 混用会永远查不到,导致"户的主位永远取第一张卡"(伯父那户取成伯母)、 父系/母系判定全错、叔伯姑全跑到右边。新增 personParents 映射统一。 · 修掉的布局 bug:兄姐/弟妹原本从"整代游标末尾"起 → 被推到同代最外侧; 堂表亲调 placePart 会与我的兄姐重叠(实测抓到 1 处)→ 改成紧贴其父母下方。 另:其他功能一行未动(用户明确要求)。
This commit is contained in:
+25
-9
@@ -468,7 +468,11 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
position: absolute; left: 0; top: 0;
|
position: absolute; left: 0; top: 0;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.kin-chart svg.kin-lines { pointer-events: none; }
|
.kin-chart svg.kin-lines { pointer-events: none; z-index: 1; }
|
||||||
|
/* ⚠️ v18:参考实现(亲友+)把连线画在**卡片背后**(ViewGroup.onDraw 在子 View 之前),
|
||||||
|
所以线可以从卡中心连到卡中心、被卡面挡住即可 —— 不需要绕路。
|
||||||
|
这里用 z-index 保证「连线在卡片下面」。 */
|
||||||
|
.kin-stage { z-index: 2; }
|
||||||
|
|
||||||
/* 连线:夫妻短横线 + 子女下垂线,全部一条笔触 */
|
/* 连线:夫妻短横线 + 子女下垂线,全部一条笔触 */
|
||||||
.kin-chart path.kin-link {
|
.kin-chart path.kin-link {
|
||||||
@@ -478,30 +482,42 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
vector-effect: non-scaling-stroke; /* 缩放时线不跟着变粗变细 */
|
vector-effect: non-scaling-stroke; /* 缩放时线不跟着变粗变细 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v18:卡片改成「上图下名 + 称呼」的窄长形,对齐参考实现(亲友+)的比例:
|
||||||
|
参考里 50dp 宽 × 80dp 高,头像占满上半、姓名与称呼各一行贴在下半。
|
||||||
|
我们把 88×106 按同样比例放大 —— 窄一点才排得下"好几代 + 兄弟姐妹"。 */
|
||||||
.kin-card {
|
.kin-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
gap: 2px;
|
gap: 0;
|
||||||
box-sizing: border-box; padding: 7px 6px 6px;
|
box-sizing: border-box; padding: 4px 4px 3px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 1px 3px rgba(43,38,32,.10);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */
|
transform: translate(-50%, -50%); /* 坐标给的是卡片中心 */
|
||||||
transition: border-color .15s ease, box-shadow .15s ease;
|
transition: border-color .15s ease, box-shadow .15s ease;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.kin-card:hover {
|
.kin-card:hover {
|
||||||
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
||||||
}
|
}
|
||||||
.kin-card .kin-ph {
|
.kin-card .kin-ph {
|
||||||
width: 38px; height: 38px; border-radius: 50%; overflow: hidden;
|
width: 100%; height: 58px; border-radius: 7px; overflow: hidden;
|
||||||
background: #F1E7D6; flex: 0 0 auto;
|
background: #F1E7D6; flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.kin-card .kin-nm { font-size: 13px; font-weight: 600; line-height: 1.25; }
|
.kin-card .kin-nm {
|
||||||
.kin-card .kin-lb { font-size: 11px; color: var(--brand); line-height: 1.25; }
|
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
/* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */
|
||||||
|
.kin-card .kin-lb {
|
||||||
|
font-size: 10.5px; color: var(--brand); line-height: 1.2; max-width: 100%;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.kin-card .kin-mark { font-size: 10px; color: var(--faint); }
|
.kin-card .kin-mark { font-size: 10px; color: var(--faint); }
|
||||||
|
|
||||||
/* 「我」:品牌色描边 + 光圈 */
|
/* 「我」:品牌色描边 + 光圈 */
|
||||||
|
|||||||
+441
-281
@@ -66,6 +66,10 @@ foreach ($units as $i => $u) {
|
|||||||
'me' => $id === (int)$meId,
|
'me' => $id === (int)$meId,
|
||||||
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
|
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
|
||||||
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'),
|
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'),
|
||||||
|
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
|
||||||
|
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
|
||||||
|
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
|
||||||
|
'rank' => \App\Kinship::rankValueFor($people, $id),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (!$cards) {
|
if (!$cards) {
|
||||||
@@ -157,6 +161,19 @@ $payloadJson = json_encode(
|
|||||||
|
|
||||||
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
|
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
|
||||||
<script>
|
<script>
|
||||||
|
/*****************************************************************
|
||||||
|
* 家谱布局:仿「亲友+」App 的画法
|
||||||
|
*
|
||||||
|
* 参考实现:gitee.com/coku2333/FamilyTreeView 的 NewFamilyTreeView
|
||||||
|
* (用户 2026-10-02 指定「按照这个做」)。核心规则:
|
||||||
|
* ① 从下往上定位:孙辈 → 子女 → 我 → 父母 → 祖辈
|
||||||
|
* (子层位置定了,父层就定了 —— 父居中于子之上)
|
||||||
|
* ② 每一代维护 genLeft / genRight 两个游标;往右铺从 genRight 起、
|
||||||
|
* 往左铺从 genLeft 起
|
||||||
|
* ③ 配偶并排在主位右侧(一对夫妻 = 相邻两张卡)
|
||||||
|
* ④ 年长的兄弟姐妹往左、年幼的往右;舅姨往右、叔伯姑往左
|
||||||
|
* ⑤ 连线画在**卡片背后**(可以从卡中心连到卡中心,被卡面挡住)
|
||||||
|
*****************************************************************/
|
||||||
(function () {
|
(function () {
|
||||||
var root = document.getElementById('kinChart');
|
var root = document.getElementById('kinChart');
|
||||||
if (!root || typeof d3 === 'undefined') { return; }
|
if (!root || typeof d3 === 'undefined') { return; }
|
||||||
@@ -174,317 +191,461 @@ $payloadJson = json_encode(
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 版式常量 ----
|
/* ---------- 版式常量 ---------- */
|
||||||
var CW = 118; // 卡片宽
|
var IW = 88; // 卡片宽
|
||||||
var CH = 92; // 卡片高
|
var IH = 106; // 卡片高
|
||||||
var HGAP = 20; // 同代相邻「户」之间的水平间隙
|
var SP = 26; // 卡片间距(参考实现里的 mSpacePX)
|
||||||
var CGAP = 14; // 夫妻两张卡之间的缝(短横线画在这一段上)
|
var SLOT = IW + SP; // 一个人的"槽位"宽(配偶间隔也用 SP,与参考一致)
|
||||||
var PGAP = 16; // 夫妻两张卡与"夹在中间的竖线"之间的额外余量
|
var YS = IH + SP; // 代与代之间的行距
|
||||||
var YS = 150; // 代与代之间的行距
|
var PAD = 60; // 画布留白
|
||||||
var PAD = 64; // 画布留白
|
|
||||||
|
|
||||||
// ================= ========= 布局:整齐树 =================
|
/* ---------- 0. 基础索引 ---------- */
|
||||||
//
|
var byId = {}; // personId -> {u}
|
||||||
// ⚠️ 为什么不用"每代按固定格子摊开"(v15 的做法,线就是那样乱起来的):
|
|
||||||
// ① 一对夫妻占 2×CW + CGAP 宽(≈250px),单人也占 CW;按同一个固定格距摆,
|
|
||||||
// 宽的那户会**压到隔壁**(实测:相邻两户重叠了 52px);
|
|
||||||
// ② 子女不落在父母正下方,横梁要跨很远,线与线互相交叉。
|
|
||||||
// 整齐树的做法:先按「轮廓」把子树从左往右摆开(保证不重叠),
|
|
||||||
// 再让父(户)落在**其子女跨度的中点**——于是一层一层都是居中于下一层,
|
|
||||||
// 线自然是最短的、几乎不交叉。
|
|
||||||
|
|
||||||
// ---- 0. 户的父子关系(哪个户是哪一户的孩子)----
|
|
||||||
var unitOf = {}; // personId -> unitIndex
|
|
||||||
units.forEach(function (u, ui) {
|
units.forEach(function (u, ui) {
|
||||||
u._i = ui;
|
u._i = ui;
|
||||||
u.cards.forEach(function (c) { unitOf[c.id] = ui; });
|
u.cards.forEach(function (c) { byId[String(c.id)] = { u: ui, card: c }; });
|
||||||
});
|
});
|
||||||
units.forEach(function (u) { u._kids = []; });
|
|
||||||
|
|
||||||
// 从「户 → 子女」反推「户 → 子户」:某户的孩子落在了别的户里,那个户就是子户
|
// 户 → 户 的父子关系(由「户 → 子女」推)
|
||||||
var childUnits = {}; // ui -> [子户 ui,...](去重、保持顺序)
|
var kidsOf = {}, parentsOf = {};
|
||||||
var parentUnits = {}; // 子户 ui -> [父户 ui,...](**可以有多个**)
|
|
||||||
units.forEach(function (u, ui) {
|
units.forEach(function (u, ui) {
|
||||||
(u.kids || []).forEach(function (kid) {
|
(u.kids || []).forEach(function (kid) {
|
||||||
var ku = unitOf[kid];
|
var ref = byId[String(kid)];
|
||||||
if (ku === undefined || ku === ui) { return; }
|
if (!ref || ref.u === ui) { return; }
|
||||||
childUnits[ui] = childUnits[ui] || [];
|
(kidsOf[ui] = kidsOf[ui] || []);
|
||||||
if (childUnits[ui].indexOf(ku) < 0) {
|
if (kidsOf[ui].indexOf(ref.u) < 0) {
|
||||||
childUnits[ui].push(ku);
|
kidsOf[ui].push(ref.u);
|
||||||
// ⚠️ **必须允许一个户有多个父户**:我这一户同时是「爸妈的孩子」和
|
(parentsOf[ref.u] = parentsOf[ref.u] || []);
|
||||||
// 「岳父母的孩子」(因为妻子与我同户,而她是岳父母的孩子)。
|
if (parentsOf[ref.u].indexOf(ui) < 0) { parentsOf[ref.u].push(ui); }
|
||||||
// 只留第一个父户会让另一支(我那一支)失去父子关系 → 后代被排到别处。
|
|
||||||
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. 排序:同代内按「父户的位置」从左到右摆 ----
|
// ⚠️⚠️ 关键:`parentsOf` 的键是**户索引**,而下面多处是按**人 id** 去查有没有父母
|
||||||
//
|
// (比如"哪张卡是本家的血亲位")。两者混用会**永远查不到** ——
|
||||||
// ⚠️ v17 换了思路。v16 试过「树递归 + 轮廓合并」,但家谱不是一棵纯树:
|
// 症状是"主位永远取第一张卡"(伯父那一户的主位取成了伯母),
|
||||||
// **姻亲**(岳父母)不是本家的后代,没有父户 → 会被当成"第二棵独立的树"
|
// 进而父系/母系判定全错。这里单独建一份"人 id → 父母户索引"。 */
|
||||||
// 排到一侧,于是它的后代(妻子/我/姐姐/孩子)全被推远,
|
var personParents = {}; // personId -> [父户 ui,...]
|
||||||
// 横梁要跨过别人的卡片(实测 D 项失败:266→838 穿过爸爸/岳父母)。
|
units.forEach(function (u, ui) {
|
||||||
// 正解:**按代分行;同代内按"父辈位置"排序后从左往右摆**;
|
u.cards.forEach(function (c) {
|
||||||
// 同一父户的子女排成紧挨的一整块,块的中心对到父户中心 ——
|
var ps = parentsOf[String(ui)];
|
||||||
// 这样每户的横梁只覆盖自己那几个孩子。
|
if (ps && ps.length) { personParents[String(c.id)] = ps; }
|
||||||
|
|
||||||
// ---- 2a. rank 迭代:同代内按「父户的 rank」排序,几轮收敛 ----
|
|
||||||
var byGen = {};
|
|
||||||
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; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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. 自上而下:子女往父母中心靠(但**同父的一组必须左对齐成块**)----
|
// 我所在的户
|
||||||
//
|
var meUi = 0;
|
||||||
// ⚠️ v17:v16 只做了"整行防重叠 + 各自往父中心靠",结果**同一父户的多个孩子
|
units.forEach(function (u, ui) {
|
||||||
// 会被推得七零八落**:孩子的横梁要从父户中心一路横跨到最远那个孩子,
|
u.cards.forEach(function (c) { if (c.me) { meUi = ui; } });
|
||||||
// 中间就压过了别人的卡片(实测 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) {
|
units.forEach(function (u) {
|
||||||
var halfSpan;
|
var idx = 0;
|
||||||
if (u.cards.length >= 2) {
|
for (var i = 0; i < u.cards.length; i++) {
|
||||||
halfSpan = PGAP / 2 + CGAP / 2 + CW / 2 + CW / 2; // 最外侧卡的外沿
|
var ps = personParents[String(u.cards[i].id)];
|
||||||
} else {
|
if (ps && ps.length) { idx = i; break; }
|
||||||
halfSpan = CW / 2;
|
|
||||||
}
|
}
|
||||||
allX.push(cxOf[u._i] - halfSpan, cxOf[u._i] + halfSpan);
|
u._headIdx = idx;
|
||||||
|
u._head = String(u.cards[idx].id);
|
||||||
|
// 主位的"排行键"(长幼):服务端已算好(birth_rank → 生日 → id 兜底),
|
||||||
|
// 前端要靠它分「哥哥/弟弟」「姐姐/妹妹」(以及"年长的往左排")。
|
||||||
|
u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank);
|
||||||
});
|
});
|
||||||
var minX = Math.min.apply(null, allX), maxX = Math.max.apply(null, allX);
|
|
||||||
|
/* ---------- 1. 游标(每一代的左右边界)---------- */
|
||||||
|
var gmin = 0, gmax = 0;
|
||||||
|
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
|
||||||
|
var genL = {}, genR = {};
|
||||||
|
function resetCursor(lo, hi) {
|
||||||
|
for (var g = lo; g <= hi; g++) { genL[g] = 0; genR[g] = 0; }
|
||||||
|
}
|
||||||
|
resetCursor(gmin - 1, gmax + 1);
|
||||||
|
|
||||||
|
// 已定位的户:ui -> {gen, uc}(uc = 这一户的中心 x)
|
||||||
|
var placed = {};
|
||||||
|
|
||||||
|
/* 把一户放到某代的"下一个空位",返回它的中心 x
|
||||||
|
toRight=true 从 genR 往右接;false 从 genL 往左接 */
|
||||||
|
function takeSlot(ui, gen, toRight) {
|
||||||
|
var u = units[ui];
|
||||||
|
var n = u.cards.length >= 2 ? 2 : 1;
|
||||||
|
var span = n * SLOT;
|
||||||
|
var x0;
|
||||||
|
if (toRight) {
|
||||||
|
x0 = genR[gen];
|
||||||
|
genR[gen] = x0 + span;
|
||||||
|
} else {
|
||||||
|
genL[gen] -= span;
|
||||||
|
x0 = genL[gen];
|
||||||
|
}
|
||||||
|
// 这一户的中心:两张卡时是两卡中心的中点
|
||||||
|
var uc = x0 + IW / 2 + (n === 2 ? SLOT / 2 : 0);
|
||||||
|
placed[ui] = { gen: gen, uc: uc, n: n, x0: x0 };
|
||||||
|
return uc;
|
||||||
|
}
|
||||||
|
// 把一户**居中**放在 x = uc
|
||||||
|
function putCentered(ui, gen, uc) {
|
||||||
|
var n = units[ui].cards.length >= 2 ? 2 : 1;
|
||||||
|
placed[ui] = { gen: gen, uc: uc, n: n, x0: uc - (n === 2 ? SLOT : IW) / 2 };
|
||||||
|
return uc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一代的边界用"已放的户"刷新(对应参考的 measureMaxCoordinate) */
|
||||||
|
function refreshBounds(gen) {
|
||||||
|
var lo = Infinity, hi = -Infinity, any = false;
|
||||||
|
units.forEach(function (u, ui) {
|
||||||
|
var p = placed[ui];
|
||||||
|
if (!p || p.gen !== gen) { return; }
|
||||||
|
any = true;
|
||||||
|
lo = Math.min(lo, p.x0);
|
||||||
|
hi = Math.max(hi, p.x0 + p.n * SLOT);
|
||||||
|
});
|
||||||
|
if (!any) { return; }
|
||||||
|
if (!isFinite(genL[gen]) || lo < genL[gen]) { genL[gen] = lo; }
|
||||||
|
if (!isFinite(genR[gen]) || hi > genR[gen]) { genR[gen] = hi; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. 核心:放"一户 + 它的子女" ----------
|
||||||
|
childGen = 子女所在的那一代;这一户放在 childGen - 1。
|
||||||
|
完全对应参考的 setPart(position, isRight, ...)。 */
|
||||||
|
function placePart(childGen, toRight, ui) {
|
||||||
|
if (placed[ui]) { return; }
|
||||||
|
var u = units[ui];
|
||||||
|
var kids = (kidsOf[ui] || []).slice();
|
||||||
|
if (!toRight) { kids.reverse(); } // 往左铺时,长幼次序要反过来
|
||||||
|
|
||||||
|
if (!kids.length) {
|
||||||
|
// 没有子女:在"子女代"占位(参考实现就是推进游标留空),
|
||||||
|
// 自己这一户也跟着往前放 —— 这样不会和别人的子女挤在一起
|
||||||
|
var n0 = u.cards.length >= 2 ? 2 : 1;
|
||||||
|
if (toRight) { genR[childGen] += n0 * SLOT; }
|
||||||
|
else { genL[childGen] -= n0 * SLOT; }
|
||||||
|
takeSlot(ui, childGen - 1, toRight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
kids.forEach(function (k) { placePart(childGen + 1, toRight, k); });
|
||||||
|
|
||||||
|
// 父(这一户)居中于子女之上
|
||||||
|
var first = placed[kids[0]].uc;
|
||||||
|
var last = placed[kids[kids.length - 1]].uc;
|
||||||
|
var mid = (first + last) / 2;
|
||||||
|
putCentered(ui, childGen - 1, mid);
|
||||||
|
refreshBounds(childGen - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 只把"一户"放到某代(它的子女已另行处理)—— 用于兜底与姻亲 */
|
||||||
|
function placeLoose(ui, gen, toRight) {
|
||||||
|
if (placed[ui]) { return; }
|
||||||
|
takeSlot(ui, gen, toRight);
|
||||||
|
refreshBounds(gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 3. 按参考 order 逐代定位 ---------- */
|
||||||
|
(function layoutAll() {
|
||||||
|
/* (1) 我的子女(+1)与孙辈(+2)—— 先把"我"放在原点,再让子女居中于我之下 */
|
||||||
|
putCentered(meUi, 0, 0);
|
||||||
|
genL[0] = -IW / 2; genR[0] = (units[meUi].cards.length >= 2 ? SLOT + IW / 2 : IW / 2);
|
||||||
|
|
||||||
|
var myKids = (kidsOf[meUi] || []).slice();
|
||||||
|
myKids.forEach(function (k) { placePart(1, true, k); });
|
||||||
|
|
||||||
|
/* (2) 我居中于我的子女之上;整支(我 + 子女及以下)一起平移 */
|
||||||
|
if (myKids.length) {
|
||||||
|
var kf = placed[myKids[0]].uc, kl = placed[myKids[myKids.length - 1]].uc;
|
||||||
|
var dx = ((kf + kl) / 2) - placed[meUi].uc;
|
||||||
|
(function shift(ui, d) {
|
||||||
|
if (placed[ui]) { placed[ui].uc += d; placed[ui].x0 += d; }
|
||||||
|
(kidsOf[ui] || []).forEach(function (k) { if (placed[k]) { shift(k, d); } });
|
||||||
|
})(meUi, dx);
|
||||||
|
}
|
||||||
|
resetCursor(gmin - 1, gmax + 1);
|
||||||
|
refreshBounds(0); refreshBounds(1); refreshBounds(2);
|
||||||
|
|
||||||
|
/* (3) 兄弟姐妹:以"父母的子女列表"的长幼序,我在中间 —— 兄姐往左、弟妹往右 */
|
||||||
|
var pu = (parentsOf[meUi] || [])[0];
|
||||||
|
var sibsAll = pu === undefined ? [] : (kidsOf[pu] || []).slice();
|
||||||
|
var myPos = sibsAll.indexOf(meUi);
|
||||||
|
var older = myPos > 0 ? sibsAll.slice(0, myPos) : []; // 长幼序里排在我前面的 = 兄姐
|
||||||
|
var younger = myPos >= 0 ? sibsAll.slice(myPos + 1) : []; // 排在我后面的 = 弟妹
|
||||||
|
|
||||||
|
// ⚠️⚠️ 兄姐/弟妹必须**紧贴着我**(在上一代"父母的")。参考实现里它们是
|
||||||
|
// "从 genLeft/genRight 往外铺",但那是**接着父母的子女块**往外长 ——
|
||||||
|
// 所以才有"哥哥在我左边、弟弟在我右边"的观感。
|
||||||
|
// 我这一版第一稿直接调 placePart() 从游标末尾起,结果兄姐/弟妹被推到
|
||||||
|
// 同代的最外侧(中间还夹着堂表亲),看着就像"外人"(实测截图确认)。
|
||||||
|
// 正解:以"我的中心"为基准,向左/向右**依次紧贴**摆放,
|
||||||
|
// 每放一户就把基准推进一户宽(并带上它的子女)。
|
||||||
|
var slotW = IW + SP;
|
||||||
|
// 兄姐:离我最近的先放(大哥最左 → 依次向左)
|
||||||
|
var cur = placed[meUi].x0; // 我这一户的左边缘
|
||||||
|
older.forEach(function (s) { // older 已是"长→幼",从最近(幼)开始向左
|
||||||
|
var n = units[s].cards.length >= 2 ? 2 : 1;
|
||||||
|
var w = n * slotW;
|
||||||
|
var uc = cur - SP - w / 2;
|
||||||
|
putCentered(s, 0, uc);
|
||||||
|
// 它的子女(侄甥)放它下方
|
||||||
|
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
|
||||||
|
cur = uc - w / 2;
|
||||||
|
});
|
||||||
|
// 弟妹:往右紧贴
|
||||||
|
var curR = placed[meUi].x0 + (units[meUi].cards.length >= 2 ? 2 * slotW : IW);
|
||||||
|
younger.forEach(function (s) {
|
||||||
|
var n2 = units[s].cards.length >= 2 ? 2 : 1;
|
||||||
|
var w2 = n2 * slotW;
|
||||||
|
var uc2 = curR + SP + w2 / 2;
|
||||||
|
putCentered(s, 0, uc2);
|
||||||
|
(kidsOf[s] || []).forEach(function (k) { placePart(1, true, k); });
|
||||||
|
curR = uc2 + w2 / 2;
|
||||||
|
});
|
||||||
|
// 兄姐是"长幼序里排在我前面的",最年长的应在最左 → 反向再摆一次
|
||||||
|
// (上面是从最近往左排,最年长的自然落在最左,正是要的效果)
|
||||||
|
|
||||||
|
/* (4) 父母(-1)居中于我之上;祖辈(-2)居中于父母之上 */
|
||||||
|
if (pu !== undefined && !placed[pu]) {
|
||||||
|
putCentered(pu, -1, placed[meUi].uc);
|
||||||
|
refreshBounds(-1);
|
||||||
|
var gps = (parentsOf[pu] || []).slice();
|
||||||
|
gps.forEach(function (gp, i) {
|
||||||
|
if (placed[gp]) { return; }
|
||||||
|
putCentered(gp, -2, placed[pu].uc + (gps.length > 1 ? (i === 0 ? -SLOT / 2 : SLOT / 2) : 0));
|
||||||
|
refreshBounds(-2);
|
||||||
|
});
|
||||||
|
/* (5) 父母的兄弟姐妹:舅姨(母系)往右、叔伯姑(父系)往左,他们的子女是堂表亲(0 代) */
|
||||||
|
// 判断哪个祖辈是父系:父 = 父母这一户里"性别为男"的那张卡(取不到就用主位)
|
||||||
|
var pr = units[pu];
|
||||||
|
var fatherId = null;
|
||||||
|
pr.cards.forEach(function (c) { if (c.gender === 'M') { fatherId = String(c.id); } });
|
||||||
|
if (!fatherId) { fatherId = pr._head; }
|
||||||
|
// ⚠️ 叔伯姑/舅姨要**紧挨着父母**摆:父系往父亲那侧(左)、母系往母亲那侧(右)。
|
||||||
|
// 第一稿直接 placePart(0, 左/右) —— 那是从"整代游标"的末尾起,
|
||||||
|
// 叔伯被推到很远的外侧,中间空一大段(实测截图里"伯父"跑到爸爸右边很远)。
|
||||||
|
// 正解:以"父母这一户"的两端为基准向外铺。
|
||||||
|
var puW = units[pu].cards.length >= 2 ? 2 * slotW : IW;
|
||||||
|
var puLeft = placed[pu].uc - puW / 2;
|
||||||
|
var puRight = placed[pu].uc + puW / 2;
|
||||||
|
var bySide = { left: [], right: [] };
|
||||||
|
// ⚠️⚠️ 判定「这一位是我父系的还是母系的」要看**他自己的父母**里
|
||||||
|
// 有没有那位父系祖辈 —— 而不是"去祖辈户里找爸爸"(那是恒假的写法,
|
||||||
|
// 第一稿就这么写,结果所有叔伯都被当成母系、全跑到右边)。
|
||||||
|
// fatherId = 我爸的 id;父系祖辈 = 我爸的父母里那一位。
|
||||||
|
var fatherSideGps = (personParents[String(fatherId)] || []).slice();
|
||||||
|
gps.forEach(function (gp) {
|
||||||
|
(kidsOf[gp] || []).forEach(function (s) {
|
||||||
|
if (s === pu || placed[s]) { return; }
|
||||||
|
// s 的父母里有没有"父系祖辈所在的户"
|
||||||
|
var ps2 = personParents[units[s]._head] || [];
|
||||||
|
var isFatherSide = ps2.indexOf(gp) >= 0 && fatherSideGps.indexOf(gp) >= 0;
|
||||||
|
bySide[isFatherSide ? 'left' : 'right'].push(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// 叔伯姑/舅姨的子女(堂表亲):**紧贴其父母下方**放。
|
||||||
|
// ⚠️ 不能调 placePart() —— 那会从"整代游标末尾"起、还会递归整支,
|
||||||
|
// 结果堂兄跑到我的兄姐位置上、两者重叠(实测抓到 1 处重叠)。
|
||||||
|
// 这里按"父户中心"居中摆,若与已有的撞上再顺延。
|
||||||
|
function placeCousins(s, gen) {
|
||||||
|
var ks = (kidsOf[s] || []).slice();
|
||||||
|
if (!ks.length) { return; }
|
||||||
|
var span = ks.length * slotW;
|
||||||
|
var startC = placed[s].uc - (span - slotW) / 2;
|
||||||
|
ks.forEach(function (k, i) {
|
||||||
|
if (placed[k]) { return; }
|
||||||
|
var n = units[k].cards.length >= 2 ? 2 : 1;
|
||||||
|
var w = n * slotW;
|
||||||
|
putCentered(k, gen, startC + i * slotW + (slotW - w) / 2 + w / 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 父系:从父亲的左边往外铺(长幼序倒过来 —— 长叔伯更靠外)
|
||||||
|
var lcur = puLeft;
|
||||||
|
bySide.left.slice().reverse().forEach(function (s) {
|
||||||
|
var n = units[s].cards.length >= 2 ? 2 : 1;
|
||||||
|
var w = n * slotW;
|
||||||
|
var uc = lcur - SP - w / 2;
|
||||||
|
putCentered(s, -1, uc);
|
||||||
|
placeCousins(s, 0);
|
||||||
|
lcur = uc - w / 2;
|
||||||
|
});
|
||||||
|
// 母系:从母亲的右边往外铺
|
||||||
|
var rcur = puRight;
|
||||||
|
bySide.right.forEach(function (s) {
|
||||||
|
var n2 = units[s].cards.length >= 2 ? 2 : 1;
|
||||||
|
var w2 = n2 * slotW;
|
||||||
|
var uc2 = rcur + SP + w2 / 2;
|
||||||
|
putCentered(s, -1, uc2);
|
||||||
|
placeCousins(s, 0);
|
||||||
|
rcur = uc2 + w2 / 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (6) 兜底:还没定位的(姻亲岳父母、其它旁支)按"离我这支最近"贴到同代右侧 */
|
||||||
|
var rest = [];
|
||||||
|
units.forEach(function (u, ui) { if (!placed[ui]) { rest.push(ui); } });
|
||||||
|
// 先放"与我这一支有直接关系"的(比如配偶的父母),再放更远的
|
||||||
|
rest.sort(function (a, b) {
|
||||||
|
var da = nearDist(a), db = nearDist(b);
|
||||||
|
return (units[a].gen - units[b].gen) || (da - db) || (a - b);
|
||||||
|
});
|
||||||
|
rest.forEach(function (ui) {
|
||||||
|
var g = units[ui].gen;
|
||||||
|
// 尽量靠到"它的子女"旁边(姻亲的父母 → 贴在他们女儿(我配偶)那一支的右侧)
|
||||||
|
// ⚠️ 姻亲(岳父母):他们的位置应该**贴在他们女儿(我配偶)那一支的上方**,
|
||||||
|
// 而不是"飘到同代最右"。第一稿就是这么写的 —— 结果岳父母跑到爸爸右边、
|
||||||
|
// 离我妻子很远,看着像别人家的人(实测截图确认)。
|
||||||
|
// 正解:锚到"它的子女"(= 我配偶所在的那一户)的上方居中。
|
||||||
|
var ks = kidsOf[ui] || [];
|
||||||
|
var anchor = null;
|
||||||
|
ks.forEach(function (k) { if (placed[k] !== undefined && anchor === null) { anchor = placed[k]; } });
|
||||||
|
if (anchor !== undefined && anchor !== null && anchor.gen === g + 1) {
|
||||||
|
// 与"同代已有的"错开:若锚点上方已被别人占,就往右让
|
||||||
|
var want = anchor.uc;
|
||||||
|
var n = units[ui].cards.length >= 2 ? 2 : 1;
|
||||||
|
var w = n * slotW;
|
||||||
|
// 与同代已有的户依次避让(只往右让,避免来回挪)
|
||||||
|
var row = [];
|
||||||
|
units.forEach(function (u2, ui2) {
|
||||||
|
if (placed[ui2] && placed[ui2].gen === g && ui2 !== ui) { row.push(ui2); }
|
||||||
|
});
|
||||||
|
row.sort(function (a, b) { return placed[a].uc - placed[b].uc; });
|
||||||
|
var half = w / 2;
|
||||||
|
for (var ri = 0; ri < row.length; ri++) {
|
||||||
|
var rp = placed[row[ri]];
|
||||||
|
var rn = units[row[ri]].cards.length >= 2 ? 2 : 1;
|
||||||
|
var rHalf = rn * slotW / 2;
|
||||||
|
if (Math.abs(want - rp.uc) < (half + rHalf + SP / 2)) {
|
||||||
|
want = rp.uc + rHalf + SP / 2 + half;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putCentered(ui, g, want);
|
||||||
|
} else {
|
||||||
|
placeLoose(ui, g, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function nearDist(ui) {
|
||||||
|
var ps = parentsOf[ui] || [];
|
||||||
|
var best = 9;
|
||||||
|
ps.forEach(function (p) {
|
||||||
|
// 到「我」这一支的代数距离
|
||||||
|
best = Math.min(best, Math.abs(units[p].gen - 0) + 1);
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------- 4. 同代防重叠(保险):按 x 排序后依次推开,子树跟着走 ---------- */
|
||||||
|
(function antiOverlap() {
|
||||||
|
for (var g = gmin; g <= gmax; g++) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------- 5. 落最终坐标 ---------- */
|
||||||
|
var xs = [];
|
||||||
|
units.forEach(function (u, ui) {
|
||||||
|
var p = placed[ui];
|
||||||
|
if (p) { xs.push(p.x0, p.x0 + p.n * SLOT); }
|
||||||
|
});
|
||||||
|
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
|
||||||
var W = (maxX - minX) + PAD * 2;
|
var W = (maxX - minX) + PAD * 2;
|
||||||
var H = PAD * 2 + (gens.length - 1) * YS + CH;
|
var rows = [];
|
||||||
|
for (var g = gmin; g <= gmax; g++) { rows.push(g); }
|
||||||
|
var H = PAD * 2 + (rows.length - 1) * YS + IH;
|
||||||
|
|
||||||
var pos = {}, lay = {};
|
var pos = {}; // personId -> {x, y}(卡中心)
|
||||||
units.forEach(function (u) {
|
units.forEach(function (u, ui) {
|
||||||
var g = gens.indexOf(u.gen);
|
var p = placed[ui];
|
||||||
var y = PAD + CH / 2 + g * YS;
|
if (!p) { return; }
|
||||||
var cx = cxOf[u._i] - minX + PAD; // 平移到画布内
|
var y = PAD + IH / 2 + rows.indexOf(u.gen) * YS;
|
||||||
var ids = u.cards.map(function (c) { return c.id; });
|
var x0 = p.x0 - minX + PAD;
|
||||||
var left, right;
|
var ids = u.cards.map(function (c) { return String(c.id); });
|
||||||
if (ids.length >= 2) {
|
if (ids.length >= 2) {
|
||||||
// 两张卡并排,中间留缝;缝的正中就是"夫妻横线"与"子女下垂线"的 x
|
pos[ids[0]] = { x: x0 + IW / 2, y: y };
|
||||||
left = cx - PGAP / 2 - CGAP / 2 - CW / 2;
|
pos[ids[1]] = { x: x0 + SLOT + IW / 2, y: y };
|
||||||
right = cx + PGAP / 2 + CGAP / 2 + CW / 2;
|
|
||||||
pos[ids[0]] = { x: left, y: y };
|
|
||||||
pos[ids[1]] = { x: right, y: y };
|
|
||||||
} else {
|
} else {
|
||||||
left = right = cx;
|
pos[ids[0]] = { x: x0 + IW / 2, y: y };
|
||||||
pos[ids[0]] = { x: cx, y: y };
|
|
||||||
}
|
}
|
||||||
lay[u._i] = {
|
|
||||||
cx: cx, cy: y, left: left, right: right, ids: ids,
|
|
||||||
// ⚠️ 夫妻横线画在**两张卡之间的空档的正中**(纵向也在卡高的一半处),
|
|
||||||
// 视觉上就是"一条线把夫妻俩连起来"。
|
|
||||||
// 子女的下垂线从这条线的**中点**往下走 —— 这就是用户要的形状。
|
|
||||||
// (v15 把它画在 y + CH/2 − 6,等于画在卡片**内部**,线从卡身上穿过去;实测抓到 2 处。)
|
|
||||||
barY: y,
|
|
||||||
hasBar: ids.length >= 2
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var drawn = {}, html = '';
|
/* ---------- 6. 卡片 ---------- */
|
||||||
|
var html = '';
|
||||||
units.forEach(function (u) {
|
units.forEach(function (u) {
|
||||||
u.cards.forEach(function (c) {
|
u.cards.forEach(function (c) {
|
||||||
var p = pos[c.id];
|
var p = pos[String(c.id)];
|
||||||
if (!p) { return; }
|
if (!p) { return; }
|
||||||
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
|
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
|
||||||
html += '<a class="' + cls + '" data-kin-id="' + esc(c.id) + '"'
|
var g = (c.gender === 'M' || c.gender === 'F') ? '' : ' g-u';
|
||||||
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + CW + 'px;height:' + CH + 'px"'
|
html += '<a class="' + cls + g + '" data-kin-id="' + esc(c.id) + '"'
|
||||||
|
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + IW + 'px;height:' + IH + 'px"'
|
||||||
+ ' href="/index.php?r=people/show&id=' + encodeURIComponent(c.id) + '">'
|
+ ' href="/index.php?r=people/show&id=' + encodeURIComponent(c.id) + '">'
|
||||||
+ '<span class="kin-ph"><img src="' + esc(c.avatar) + '" alt="" loading="lazy"></span>'
|
+ '<span class="kin-ph"><img src="' + esc(c.avatar) + '" alt="" loading="lazy"></span>'
|
||||||
+ '<span class="kin-nm">' + esc(c.name) + '</span>'
|
+ '<span class="kin-nm">' + esc(c.name) + '</span>'
|
||||||
+ '<span class="kin-lb">' + esc(c.label) + '</span>'
|
+ '<span class="kin-lb">' + esc(c.label) + '</span>'
|
||||||
+ (c.ph ? '<span class="kin-mark">待确认</span>' : '')
|
+ (c.ph ? '<span class="kin-mark">待确认</span>' : '')
|
||||||
+ '</a>';
|
+ '</a>';
|
||||||
drawn[c.id] = 1;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
root.insertAdjacentHTML('beforeend',
|
root.insertAdjacentHTML('beforeend',
|
||||||
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
|
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
|
||||||
|
|
||||||
// ---- 4. 画线 ----
|
/* ---------- 7. 连线(画在卡片背后,所以可以从卡中心连到卡中心)---------- */
|
||||||
//
|
|
||||||
// 三条线(顺序即画法):
|
|
||||||
// ① 夫妻横线:连两张卡的**中间空档**(视觉上把夫妻连起来);
|
|
||||||
// ② 下垂线:从横线的**中点**往下(单亲户 = 本人卡的中心往下);
|
|
||||||
// ③ 横梁 + 分叉:由横梁分到每个孩子的卡顶。
|
|
||||||
// ⚠️ ② 的起点取"卡中间高度"会**竖着穿过卡片下半部分**;所以让竖向那段
|
|
||||||
// 从**卡下沿**开始 —— 视觉上仍是"从夫妻那条线的中点出来",
|
|
||||||
// 但不会压在卡上。(用户要的是"别从某一个人出来",不是"必须穿过卡面"。)
|
|
||||||
var paths = [], bars = [], drops = [];
|
var paths = [], bars = [], drops = [];
|
||||||
|
|
||||||
units.forEach(function (u) {
|
function line(x1, y1, x2, y2) {
|
||||||
var L = lay[u._i];
|
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
|
||||||
if (L.hasBar) {
|
}
|
||||||
// ⚠️ 夫妻横线**只画两张卡之间的那一段缝隙**(不是从卡中心连到卡中心):
|
|
||||||
// 从中心画会从卡片身上穿过去(实测 B 项抓到 4 处穿卡)。
|
units.forEach(function (u, ui) {
|
||||||
// 起止取「左卡右沿」到「右卡左沿」。
|
var p = placed[ui];
|
||||||
paths.push('M' + (L.left + CW / 2) + ',' + L.barY
|
if (!p) { return; }
|
||||||
+ 'L' + (L.right - CW / 2) + ',' + L.barY);
|
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 });
|
||||||
}
|
}
|
||||||
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) {
|
// ② 有子女 → 从夫妻横线**中点**往下垂到"横梁"
|
||||||
var L = lay[u._i];
|
var kids = (kidsOf[ui] || []).filter(function (k) { return pos[units[k]._head] || true; });
|
||||||
var kids = (u.kids || []).map(function (id) { return pos[id]; })
|
if (kids.length) {
|
||||||
.filter(function (p) { return !!p; })
|
var busY = yRow + IH / 2 + SP / 2; // 参考:卡底 + SP/2
|
||||||
.sort(function (a, b) { return a.x - b.x; });
|
line(midX, yRow, midX, busY);
|
||||||
if (!kids.length) { return; }
|
// ③ 横梁:从最左孩子到最右孩子
|
||||||
var srcX = (L.left + L.right) / 2; // ★ 夫妻横线中点(单亲 = 本人卡中心)
|
var kx = kids.map(function (k) { return placed[k].uc; });
|
||||||
var fromY = L.cy + CH / 2; // 从卡片下沿起(见上面的说明)
|
var minK = Math.min.apply(null, kx), maxK = Math.max.apply(null, kx);
|
||||||
var busY = L.cy + YS - CH / 2 - 24; // 子女行上方的横梁
|
if (kids.length > 1 && maxK - minK > 0.5) { line(minK, busY, maxK, busY); }
|
||||||
paths.push('M' + srcX + ',' + fromY + 'L' + srcX + ',' + busY);
|
drops.push({ ui: ui, x: midX, y0: yRow, busY: busY });
|
||||||
|
}
|
||||||
|
|
||||||
// ⚠️⚠️ 横梁**不能直接连"第一个孩子到最后一个孩子"**:
|
// ④ 每个人若有父母 → 从卡顶往上画一小段(接到上一代的横梁)
|
||||||
// 孩子分属不同的户(比如"我"和"姐姐"各是一户),
|
ids.forEach(function (id) {
|
||||||
// 而"我"那一户里还有配偶(妻子)。从最左孩子的卡中心连到最右孩子的卡中心,
|
var ps = parentsOf[id];
|
||||||
// 会把夹在中间的那位**配偶**也框进横梁范围 —— 看着就像"这条线也连了妻子"。
|
if (ps && ps.length) {
|
||||||
// 正解:先按 x 排序,**逐段连接相邻两个孩子**(段间缺口处断开),
|
var pp = pos[id];
|
||||||
// 每段只覆盖它两端的两张卡(从"左卡中心"到"右卡中心")。
|
line(pp.x, pp.y - IH / 2, pp.x, pp.y - IH / 2 - SP / 2);
|
||||||
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 - 4));
|
|
||||||
});
|
});
|
||||||
drops.push({ ui: u._i, x: srcX, y0: L.barY, fromY: fromY, busY: busY });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
root.insertAdjacentHTML('afterbegin',
|
root.insertAdjacentHTML('afterbegin',
|
||||||
@@ -492,14 +653,14 @@ $payloadJson = json_encode(
|
|||||||
+ paths.map(function (d) { return '<path class="kin-link" d="' + d + '"></path>'; }).join('')
|
+ paths.map(function (d) { return '<path class="kin-link" d="' + d + '"></path>'; }).join('')
|
||||||
+ '</svg>');
|
+ '</svg>');
|
||||||
|
|
||||||
window.__kinLayout = { W: W, H: H, CW: CW, CH: CH, bars: bars, drops: drops,
|
window.__kinLayout = { W: W, H: H, IW: IW, IH: IH, SLOT: SLOT, bars: bars,
|
||||||
pos: pos, paths: paths };
|
drops: drops, pos: pos, paths: paths };
|
||||||
|
|
||||||
// ---- 5. 拖拽 + 缩放 ----
|
/* ---------- 8. 拖拽 + 缩放 ---------- */
|
||||||
var stage = root.querySelector('.kin-stage');
|
var stage = root.querySelector('.kin-stage');
|
||||||
var svg = root.querySelector('svg.kin-lines');
|
var svg = root.querySelector('svg.kin-lines');
|
||||||
|
|
||||||
var zoom = d3.zoom().scaleExtent([0.15, 2.5]).on('zoom', function (ev) {
|
var zoom = d3.zoom().scaleExtent([0.15, 3]).on('zoom', function (ev) {
|
||||||
var t = ev.transform;
|
var t = ev.transform;
|
||||||
[svg, stage].forEach(function (el) {
|
[svg, stage].forEach(function (el) {
|
||||||
el.style.transformOrigin = '0 0';
|
el.style.transformOrigin = '0 0';
|
||||||
@@ -512,30 +673,29 @@ $payloadJson = json_encode(
|
|||||||
function fit() {
|
function fit() {
|
||||||
var r = root.getBoundingClientRect();
|
var r = root.getBoundingClientRect();
|
||||||
if (!r.width || !r.height) { return; }
|
if (!r.width || !r.height) { return; }
|
||||||
var k = Math.min(r.width / W, r.height / H) * 0.92;
|
var k = Math.min(r.width / W, r.height / H) * 0.94;
|
||||||
k = Math.max(0.15, Math.min(2.5, k));
|
k = Math.max(0.15, Math.min(3, k));
|
||||||
var tx = (r.width - W * k) / 2;
|
sel.call(zoom.transform, d3.zoomIdentity.translate(
|
||||||
var ty = (r.height - H * k) / 2;
|
(r.width - W * k) / 2, (r.height - H * k) / 2).scale(k));
|
||||||
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
|
|
||||||
}
|
}
|
||||||
function centerOn(id) {
|
function centerOn(id) {
|
||||||
var p = pos[id];
|
var p = pos[String(id)];
|
||||||
if (!p) { return; }
|
if (!p) { return; }
|
||||||
var r = root.getBoundingClientRect();
|
var r = root.getBoundingClientRect();
|
||||||
var t = d3.zoomTransform(root);
|
var t = d3.zoomTransform(root);
|
||||||
var tx = r.width / 2 - p.x * t.k;
|
sel.call(zoom.transform, d3.zoomIdentity.translate(
|
||||||
var ty = r.height / 2 - p.y * t.k;
|
r.width / 2 - p.x * t.k, r.height / 2 - p.y * t.k).scale(t.k));
|
||||||
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
|
|
||||||
}
|
}
|
||||||
window.kinFit = fit;
|
window.kinFit = fit;
|
||||||
window.kinCenterOn = centerOn;
|
window.kinCenterOn = centerOn;
|
||||||
|
|
||||||
fit();
|
|
||||||
var meId = root.getAttribute('data-me') || '';
|
var meId = root.getAttribute('data-me') || '';
|
||||||
if (meId) { setTimeout(function () { centerOn(meId); }, 60); }
|
fit();
|
||||||
|
// 进来先把镜头对准「我」
|
||||||
|
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); }
|
||||||
|
|
||||||
var btnFit = document.getElementById('kinFit');
|
var btnFit = document.getElementById('kinFit');
|
||||||
var btnMe = document.getElementById('kinMe');
|
var btnMe = document.getElementById('kinMe');
|
||||||
if (btnFit) { btnFit.addEventListener('click', fit); }
|
if (btnFit) { btnFit.addEventListener('click', fit); }
|
||||||
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
|
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user