v73b:家谱连线改成「每对父母一条母线」,去掉重合的横段

旧画法每个子女各画一条独立折线,在真实数据上会画出两条横段完全
重合的线(岳父母→我户 216→72 与 我父母→姐户 72→216,拐点同为
132.9),视觉上是一个「口」字框,看着像父母家和岳父母家连在一起。

新画法:父母单元中点 ──竖──→ 母线高度 ──横(只覆盖自己子女)──→
各子女 ──竖──→ 子女卡片上沿。一条线只服务一对父母,不再交叉重叠。

关键修正:子女挂点必须是**子女本人那张卡**,不是「子女所在户的中点」。
我这一户同时是两对父母的子女户(我父母→我、岳父母→妻子),
用户中点时两条竖线都落在 x=72、完全重合;改用本人卡的 x 后
妻子那条落在 105,两条线才分开。

⚠️ 这类病端点断言完全抓不到(端点都好好贴在卡片上),所以测试侧
新增「没有任何两条线段完全重合」,并在视图侧说明 bus 段是同一折线
公式的退化情形、不必单独写分支。

验证:离线 30/0、反向 13/0、真实数据+屏幕对照 11/0、
像素级 9/0、真机几何 15/0、ArkTS 守卫 905/0。
This commit is contained in:
2026-10-21 22:57:56 +08:00
parent 13f716c936
commit 8cabc35400
2 changed files with 198 additions and 29 deletions
+188 -28
View File
@@ -89,17 +89,27 @@ export class KinCardPos {
/** /**
* 一条连线(Canvas 直接照着画,不需要再算)。 * 一条连线(Canvas 直接照着画,不需要再算)。
* *
* 血缘线是**三段折线**:父母单元中点 → 往下 → 横移 → 往下 → 子女单元顶端。 * 这里把拐点算好(`midY`),视图只负责 `moveTo/lineTo`:
* 这里把拐点算好(`midY`),视图只负责 `moveTo/lineTo`。 * `(x1,y1) → (x1,midY) → (x2,midY) → (x2,y2)`
*
* 🔴 v73b:画法从「每个子女一条独立折线」改成「**每对父母一条母线**」,
* 于是 `blood` 的内涵变了 —— 它现在只画**竖直段**,横段单独是 `bus`:
*
* · `blood` 主竖线:父母单元中点 → 母线高度(`x1 === x2`,`y2 === midY`)
* · `bus` 母线横段:只覆盖这对父母**自己子女**的跨度(`y1 === y2 === midY`)
* · `blood` 子女短竖线:母线高度 → 子女卡片上沿(`x1 === x2`,`y1 === midY`)
*
* ⚠️ 三种都是上面那条折线公式的**退化情形**(要么横段长度为 0、要么竖段为 0),
* 所以视图里**不需要为 `bus` 单独写分支**,同一段代码照样画对。
*/ */
export class KinEdge { export class KinEdge {
/** 'blood' = 父母→子女;'spouse' = 夫妻 */ /** 'blood' = 父母→子女的竖直段;'bus' = 母线横段;'spouse' = 夫妻 */
kind: string = 'blood'; kind: string = 'blood';
x1: number = 0; x1: number = 0;
y1: number = 0; y1: number = 0;
x2: number = 0; x2: number = 0;
y2: number = 0; y2: number = 0;
/** 血缘线的横向拐弯 y(spouse 线不用) */ /** 折线的拐弯 y(spouse 线不用;bus/blood 都等于母线高度) */
midY: number = 0; midY: number = 0;
} }
@@ -619,23 +629,67 @@ function rowIndex(gens: number[], gen: number): number {
} }
/** /**
* 血缘连线:从「父母单元」的中点垂到「子女单元」的顶端。 * 血缘连线:**每对父母一条母线**。
* *
* ⚠️ 起点用**父母单元中点**(夫妻横线的中点),不是父亲个人 —— * ============================================================
* 这是 family-chart 的画法,也是「孩子是两个人共同的孩子」的视觉表达。 * 🔴🔴 v73b:为什么不是「每个子女一条折线」(用户 2026-10-21:「连线还是乱七八糟的」)
* 原实现从父亲个人往下画,遇到母亲在右、孩子在中间时线会斜穿。 * ============================================================
* 旧画法(v73 初版):每个子女各画一条 `父母中点 → 下 → 横移 → 下 → 子女` 的折线。
* 病在真实数据上看得最清楚:
*
* 线A「岳父母(x=216) → 我这一户(x=72)」横段 216→72,拐点 y=132.9
* 线B「我父母(x=72) → 姐这一户(x=216)」横段 72→216,拐点 y=132.9
*
* ⇒ 两条横段**完全重合**、拐点同高,画出来是一个「口」字框:
*
* 父母(72) 岳父母(216)
* | |
* +-----------------+ ← 两条线的横段叠在一起
* | |
* 我(72) 姐(216)
*
* 看上去就像"父母家和岳父母家连在一起、我和姐都挂在上面" —— 这正是用户说的
* **「乱七八糟」**。而且 6 条线**全部**用同一个 0.42 拐点比例 ⇒ 所有横线挤在
* 同一高度、糊成一片。
*
* ⚠️ 注意这类病**端点断言完全抓不到**(每个端点都好好地贴在卡片边缘上),
* 所以离线套件里专门加了一条「没有任何两条线段完全重合」的断言
* (见 `_fb_kin_layout_v73.py` ⑤b-2),否则"全绿但画面错"会一直重演。
*
* 新画法(家谱通行画法)——同一对父母的**所有子女共用一条母线**:
*
* 父母单元中点 ──竖──→ 母线高度 ──横(只覆盖自己子女跨度)──→ 各子女 ──竖──→ 子女顶端
*
* · 一条线只服务一对父母 ⇒ 不会再和别的父母的线交叉/重叠
* · 横段只跨「自己子女」的范围,不会拉到别人头顶
* · 每个子女只从母线上垂一根短竖线
*/ */
function buildBloodEdges(layout: KinLayout, people: FbKinPerson[], function buildBloodEdges(layout: KinLayout, people: FbKinPerson[],
index: PeopleIndex, unitOfPerson: number[], units: KinUnit[], index: PeopleIndex, unitOfPerson: number[], units: KinUnit[],
gens: number[]): void { gens: number[]): void {
const done: string[] = []; // ---- ① 先把「父母单元 → 它的子女(**人**)」聚起来 ----
//
// 🔴🔴 v73b 关键修正:挂点必须是**子女本人那张卡**,不是"子女所在户的中点"。
//
// 病根(真实数据实测):
// 我这一户同时是**两对父母**的子女户 ——
// · 我父母(户 -1:1)的子女:我的户、老姐的户
// · 岳父母(户 -1:4)的子女:我的户 ← 因为妻子在那户里
// 旧代码给"子女户"画竖线时用**户中点**(我这户 = 72)
// ⇒ 两对父母的竖线**都落在 x=72**、y 区间也相同(都到下一行上沿)
// ⇒ 画出来是两条**完全重合**的线,像"我父母和岳父母接到了同一个人"。
// 正确语义:妻子是岳父母的女儿,竖线该落在**妻子那张卡**(x=105);
// 我是我父母的儿子,竖线落在**我那张卡**(x=39)。
//
// ⇒ 按「人」记录:父母户 key → 子女本人 id 列表。挂点用该人的卡片 x,
// 天然把同一户里的不同血缘分开。
const kidsOf: Map<string, number[]> = new Map<string, number[]>();
const parentKeys: string[] = []; // 保序(Map 迭代序在 ArkTS 里不可依赖)
for (let i = 0; i < people.length; i++) { for (let i = 0; i < people.length; i++) {
const child: FbKinPerson = people[i]; const child: FbKinPerson = people[i];
const childCard: KinCardPos | undefined = cardOf(layout, child.id); if (cardOf(layout, child.id) === undefined) {
if (childCard === undefined) {
continue; continue;
} }
// ---- 每个子女只连一条线(父母可能有两个,共用一个"落点")----
let pid: number = 0; let pid: number = 0;
for (let k = 0; k < child.parents.length; k++) { for (let k = 0; k < child.parents.length; k++) {
if (index.find(child.parents[k]) >= 0) { if (index.find(child.parents[k]) >= 0) {
@@ -646,27 +700,133 @@ function buildBloodEdges(layout: KinLayout, people: FbKinPerson[],
if (pid === 0) { if (pid === 0) {
continue; continue;
} }
const pu: number = unitOfPerson[index.find(pid)]; const pi: number = index.find(pid);
if (pi < 0) {
continue;
}
const pu: number = unitOfPerson[pi];
const cu: number = unitOfPerson[i]; const cu: number = unitOfPerson[i];
if (pu < 0 || cu < 0) {
continue;
}
const pkey: string = units[pu].key;
if (pkey === units[cu].key) {
continue; // 同单元(夫妻互称父母)跳过
}
let list: number[] | undefined = kidsOf.get(pkey);
if (list === undefined) {
list = [];
kidsOf.set(pkey, list);
parentKeys.push(pkey);
}
if (list.indexOf(child.id) < 0) {
list.push(child.id); // 去重(同一人可能登记了多个 parent)
}
}
// ---- ② 每对父母画一组:主竖线 + 母线横段 + 各子女短竖线 ----
for (let pi = 0; pi < parentKeys.length; pi++) {
const pkey: string = parentKeys[pi];
const pu: number = indexOfUnit(units, pkey);
if (pu < 0) { if (pu < 0) {
continue; continue;
} }
const k2: string = units[pu].key + '>' + units[cu].key; // 🔴 行号必须用 `rowIndex`(gens 下标),**不能**用 gen 数值 ——
if (done.indexOf(k2) >= 0) { // 真机 bug「线没连对」就是这里(见 `rowIndex` 的注释)。
continue; // 同一对「父母单元 → 子女单元」只画一次 const pgi: number = rowIndex(gens, units[pu].gen);
} const y1: number = pgi * KinDim.ROW_H + KinDim.CARD_H / 2;
done.push(k2); const px: number = units[pu].x;
const parentY: number = rowIndex(gens, units[pu].gen) * KinDim.ROW_H; // 子女只保留在**下一代**的:脏数据可能把同代/上一代也登记成子女
const e: KinEdge = new KinEdge(); const raw: number[] | undefined = kidsOf.get(pkey);
e.kind = 'blood'; if (raw === undefined) {
e.x1 = units[pu].x; continue;
e.y1 = parentY + KinDim.CARD_H / 2; }
e.x2 = units[cu].x; const kidXs: number[] = [];
e.y2 = rowIndex(gens, units[cu].gen) * KinDim.ROW_H - KinDim.CARD_H / 2; const kidIds: number[] = [];
// 拐点在两代之间靠上 40% 处:靠近父母的下沿,视觉上「分叉」更像扇出 for (let k = 0; k < raw.length; k++) {
e.midY = e.y1 + (e.y2 - e.y1) * 0.42; const kid: number = raw[k];
layout.edges.push(e); const kidCard: KinCardPos | undefined = cardOf(layout, kid);
if (kidCard === undefined) {
continue;
}
if (rowIndex(gens, kidCard.p.gen) <= pgi) {
continue;
}
kidIds.push(kid);
kidXs.push(kidCard.x);
}
if (kidIds.length === 0) {
continue;
}
// 按卡片 x 排序(母线从左往右画,不来回折)
const order: number[] = [];
for (let k = 0; k < kidIds.length; k++) {
order.push(k);
}
order.sort((a: number, b: number): number => kidXs[a] - kidXs[b]);
let left: number = 1e9;
let right: number = -1e9;
let nearestRow: number = 1e9;
for (let k = 0; k < order.length; k++) {
const x: number = kidXs[order[k]];
if (x < left) {
left = x;
}
if (x > right) {
right = x;
}
const kidCard2: KinCardPos | undefined = cardOf(layout, kidIds[order[k]]);
if (kidCard2 !== undefined) {
const r: number = rowIndex(gens, kidCard2.p.gen);
if (r < nearestRow) {
nearestRow = r;
}
}
}
// 母线高度:落在父母下沿与**最近那代子女**上沿之间,靠子女一侧(分叉更明显)
const yChildTop: number = nearestRow * KinDim.ROW_H - KinDim.CARD_H / 2;
const busY: number = y1 + (yChildTop - y1) * 0.62;
// 主竖线:父母单元中点 → 母线高度
const stem: KinEdge = new KinEdge();
stem.kind = 'blood';
stem.x1 = px;
stem.y1 = y1;
stem.x2 = px;
stem.y2 = busY;
stem.midY = busY;
layout.edges.push(stem);
// 母线横段:只有一个子女时省掉(否则是零长线,白占一条)
if (right - left > 0.5) {
const bus: KinEdge = new KinEdge();
bus.kind = 'bus';
bus.x1 = left;
bus.y1 = busY;
bus.x2 = right;
bus.y2 = busY;
bus.midY = busY;
layout.edges.push(bus);
}
// 各子女:从母线上垂一根短竖线到**自己那张卡**的上沿
for (let k = 0; k < order.length; k++) {
const kidCard3: KinCardPos | undefined = cardOf(layout, kidIds[order[k]]);
if (kidCard3 === undefined) {
continue;
}
const drop: KinEdge = new KinEdge();
drop.kind = 'blood';
drop.x1 = kidCard3.x;
drop.y1 = busY;
drop.x2 = kidCard3.x;
drop.y2 = rowIndex(gens, kidCard3.p.gen) * KinDim.ROW_H - KinDim.CARD_H / 2;
drop.midY = busY;
layout.edges.push(drop);
}
} }
} }
+10 -1
View File
@@ -210,7 +210,16 @@ export struct FbKinGraphView {
const c: CanvasRenderingContext2D = this.ctx; const c: CanvasRenderingContext2D = this.ctx;
c.clearRect(0, 0, this.contentW, this.contentH); c.clearRect(0, 0, this.contentW, this.contentH);
// ---- 血缘:父母单元中点 → 下 → 横 → 下 → 子女顶端(与 Web 端同款折线)---- // ---- 血缘:`(x1,y1) → (x1,midY) → (x2,midY) → (x2,y2)` ----
//
// 🔴 v73b:这里**不需要**为母线(`bus`)写分支 —— 母线横段只是同一个公式的
// 退化情形(`y1 === y2 === midY` ⇒ 头和尾那两段竖线长度为 0),
// 照这个公式画出来正好是一条纯横线。三类几何都画对:
// · 主竖线 x1===x2 ⇒ 纯竖线
// · 母线横段 y1===y2===midY ⇒ 纯横线
// · 子女短竖线 x1===x2 ⇒ 纯竖线
// ⚠️ 别为了"看得更清楚"给 bus 单独写一段:两段代码会各自漂移,
// 以后改拐点或线宽就会不一致。
for (const e of this.layout.edges) { for (const e of this.layout.edges) {
if (e.kind === 'spouse') { if (e.kind === 'spouse') {
continue; continue;