v73:家谱自绘重做(Canvas 画线 + 原生卡片),去掉列表只留图谱
用户原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」 改法:不再全 Canvas 手绘(只有彩色圆点 + 首字,中文又平又硬、做不出圆角阴影), 改成「Canvas 只画线 + 原生 Column/Text/FbAvatar 绝对定位」。 不能改用 Path 组件:Path.commands 单位是 px,而 .position() 是 vp,混用会缩 1/3; Canvas 绘制坐标系本身是 vp,与 .position() 自洽。 布局算法抽成纯函数 common/KinLayout.ets(可单独验证,不再和"画"揉在一起): - 横向单位是「夫妻单元」而不是「人」,夫妻并排紧邻 - 子女挂在夫妻单元中点下方(family-chart 语义) - 迭代松弛 24 轮容纳冲突约束(家谱不是树:姻亲的"挂父母下方"与"夫妻并排"互相打架) - 全程确定性(排序带 minId 兜底),避免每次重画卡片抖位置 真机抓到并修掉两个真 bug: 1. 户号 0 被当成"没分户":服务端 unit_of 从 0 编号,端上判 > 0 ⇒ 我与妻子被拆两户(真机 Δx=680px)。修:布局层 >= 0,解析层缺失给 -1, 不把任何数值当哨兵。 2. 线没连对(用户原话「主要是线没连对」):buildBloodEdges 用 gen 数值当行号, minGen=-1 ⇒ 长辈线从 y=-148 起(屏幕外),整组线串到上一代卡片。 修:新增 rowIndex(gens, gen),用走标换算而不是数值。 验证: - 离线布局 29/0(含户号 0、脏数据一户三人、镜像常量与端上源码一致) - 反向验证 11/0(9 处注入,含"户号 0 当没分户"与"用 gen 数值当行号"两条真 bug 复现) - 真机几何 15/0、真实数据+屏幕对照 9/0(反解密度 3.125x,残差 0.23/0.32px; 12 个血缘线端点全部贴在卡片上下沿) - ArkTS 静态守卫 905/0
This commit is contained in:
@@ -0,0 +1,719 @@
|
|||||||
|
/**
|
||||||
|
* 家书 FamilyBook · 家谱布局算法(鸿蒙端)
|
||||||
|
*
|
||||||
|
* 🔴 为什么把布局从视图里**抽出来**(v73):
|
||||||
|
* 原来 `FbKinGraph.ets` 里「算位置」和「画」揉在一起(287 行),
|
||||||
|
* 结果位置算法没法单独验证 —— 只能靠肉眼看截图,改一处不知道碰坏了哪。
|
||||||
|
* 现在布局是**纯函数**(输入人 → 输出坐标 + 连线),视图只管照着画。
|
||||||
|
*
|
||||||
|
* ============================================================
|
||||||
|
* 布局语义(与 Web 端 `family-chart` 对齐)
|
||||||
|
* ============================================================
|
||||||
|
* · **横向的单位是「夫妻单元」**,不是「人」——
|
||||||
|
* 这是与原实现最大的差别。原实现按 `gen` 均分横向位置,
|
||||||
|
* 完全没看 `unit`(户号)⇒ **夫妻被拆散**(丈夫和妻子之间隔着别人),
|
||||||
|
* 连线只能斜着穿过去,看起来一团乱。
|
||||||
|
* · **子女挂在「父母单元」的中点下方**(不是挂在父亲或母亲个人下方)——
|
||||||
|
* 这也是 family-chart 的做法:连线从夫妻横线中点垂下来。
|
||||||
|
* · **同代不重叠**:同代 unit 之间至少留 `UNIT_GAP`。
|
||||||
|
*
|
||||||
|
* ============================================================
|
||||||
|
* 为什么用「迭代松弛」而不是一次性算完的 tidy tree
|
||||||
|
* ============================================================
|
||||||
|
* 标准的 tidy tree(Reingold–Tilford)要求结构**是树**。
|
||||||
|
* 但家谱不是树:岳父岳母这一类**姻亲**,他们的子女(妻子)
|
||||||
|
* 已经作为配偶落在「我」的单元里 ——
|
||||||
|
* · 子女要挂父母下方 ⇒ 妻子应该在我岳父岳母下面
|
||||||
|
* · 夫妻要并排 ⇒ 妻子应该在我右边
|
||||||
|
* 这两条**互相打架**,树结构表达不了。
|
||||||
|
*
|
||||||
|
* 松弛(迭代逼近)能容纳冲突约束:两边各让一步,收敛到折中位置。
|
||||||
|
* 11 个节点 × 24 轮,开销可以忽略。
|
||||||
|
*
|
||||||
|
* ⚠️ 迭代必须**确定性**:同样的输入必须给同样的输出,
|
||||||
|
* 否则每次重画(`onAreaChange` 会触发)图就抖一下。
|
||||||
|
* ⇒ 排序的比较函数里永远带上「unit 内最小 id」当最后一级 tie-break,
|
||||||
|
* 绝不依赖 `Array.sort` 的稳定性或遍历顺序。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FbKinPerson } from '../model/Types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片与间距尺寸(vp)。
|
||||||
|
*
|
||||||
|
* ⚠️ 这些数字与 `FbKinGraph.ets` 里画的卡片**必须一致**:
|
||||||
|
* 布局按它们算间距与连线落点,视图按它们画卡片。
|
||||||
|
* 两边不一致 ⇒ 连线与卡片错位(用户 2026-10-03 在 Web 端踩过这个)。
|
||||||
|
*/
|
||||||
|
export class KinDim {
|
||||||
|
/**
|
||||||
|
* 卡片宽(v73 定稿 62)。
|
||||||
|
*
|
||||||
|
* 🔴 为什么是 62 而不是 70:真机实测(1080px / 3.0x ⇒ 360vp 屏,
|
||||||
|
* 页面左右各 16vp padding ⇒ **可用只有 328vp**)。
|
||||||
|
* 线上这个家庭 4 列(父母辈 4 人 / 我这一辈 4 人),
|
||||||
|
* `70 宽 + 26 缝` 排出来 **358vp** ⇒ 最右边那一列(岳父、妻子)
|
||||||
|
* 被裁到屏幕外,一进家谱就看不全 —— 用户第一眼看到的就是"不完整"。
|
||||||
|
* 收成 `62 宽 + 16 缝` 后 4 列 = 296vp,稳稳落在 328vp 内。
|
||||||
|
* ⚠️ 别为了"再宽松点"把这两个数调回去:一调就又会裁边。
|
||||||
|
*/
|
||||||
|
static readonly CARD_W: number = 62;
|
||||||
|
/** 卡片高(头像 42 + 名字 + 称谓) */
|
||||||
|
static readonly CARD_H: number = 84;
|
||||||
|
/** 夫妻之间的缝(比代间距小得多 ⇒ 一眼看出"这俩是一对") */
|
||||||
|
static readonly SPOUSE_GAP: number = 4;
|
||||||
|
/** 同代不同 unit 之间的缝 */
|
||||||
|
static readonly UNIT_GAP: number = 16;
|
||||||
|
/** 每代的行高(卡片高 + 纵向留白,留给连线拐弯) */
|
||||||
|
static readonly ROW_H: number = 148;
|
||||||
|
/** 内容四周留白 */
|
||||||
|
static readonly PAD_X: number = 8;
|
||||||
|
static readonly PAD_Y: number = 22;
|
||||||
|
/** 迭代轮数(收敛后再多也没变化,24 轮对 11 人绰绰有余) */
|
||||||
|
static readonly ITERS: number = 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一张卡的位置(**中心**坐标,视图层自己减半宽半高) */
|
||||||
|
export class KinCardPos {
|
||||||
|
p: FbKinPerson;
|
||||||
|
/** 中心 x */
|
||||||
|
x: number = 0;
|
||||||
|
/** 中心 y */
|
||||||
|
y: number = 0;
|
||||||
|
|
||||||
|
constructor(p: FbKinPerson) {
|
||||||
|
this.p = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条连线(Canvas 直接照着画,不需要再算)。
|
||||||
|
*
|
||||||
|
* 血缘线是**三段折线**:父母单元中点 → 往下 → 横移 → 往下 → 子女单元顶端。
|
||||||
|
* 这里把拐点算好(`midY`),视图只负责 `moveTo/lineTo`。
|
||||||
|
*/
|
||||||
|
export class KinEdge {
|
||||||
|
/** 'blood' = 父母→子女;'spouse' = 夫妻 */
|
||||||
|
kind: string = 'blood';
|
||||||
|
x1: number = 0;
|
||||||
|
y1: number = 0;
|
||||||
|
x2: number = 0;
|
||||||
|
y2: number = 0;
|
||||||
|
/** 血缘线的横向拐弯 y(spouse 线不用) */
|
||||||
|
midY: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 布局结果 */
|
||||||
|
export class KinLayout {
|
||||||
|
cards: KinCardPos[] = [];
|
||||||
|
edges: KinEdge[] = [];
|
||||||
|
/** 内容包围盒(不含 PAD),视图据此算缩放与初始偏移 */
|
||||||
|
width: number = 0;
|
||||||
|
height: number = 0;
|
||||||
|
/**
|
||||||
|
* 「我」在画布上的位置(已归一化)。
|
||||||
|
*
|
||||||
|
* ⚠️ v73:家谱一打开就要把镜头对准我 —— 但「我」**不一定是几何中心**
|
||||||
|
* (我这一辈里我可能是第二个,上面父母辈还铺开了 4 列)。
|
||||||
|
* 视图拿这个坐标去 `scrollTo`,否则一进家谱看到的是左上角。
|
||||||
|
*/
|
||||||
|
meX: number = -1;
|
||||||
|
meY: number = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个「夫妻单元」(同代内 `unit` 相同的人;`unit` 无效时按单人成组) */
|
||||||
|
class KinUnit {
|
||||||
|
key: string = '';
|
||||||
|
gen: number = 0;
|
||||||
|
members: FbKinPerson[] = [];
|
||||||
|
/** 本单元总宽(成员并排 + 夫妻缝) */
|
||||||
|
width: number = 0;
|
||||||
|
/** 中心 x */
|
||||||
|
x: number = 0;
|
||||||
|
/** 本轮想去的中心 x */
|
||||||
|
target: number = 0;
|
||||||
|
/** 父母所在 unit 的 key(空 = 上方没有本图内的人) */
|
||||||
|
parentKey: string = '';
|
||||||
|
/** 单元内最小 id —— 排序的**最后一级 tie-break**,保证确定性 */
|
||||||
|
minId: number = 0;
|
||||||
|
|
||||||
|
constructor(key: string, gen: number) {
|
||||||
|
this.key = key;
|
||||||
|
this.gen = gen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** id → 那个人(数组下标查找,避免 ArkTS 里 Map 的泛型噪音) */
|
||||||
|
class PeopleIndex {
|
||||||
|
private ids: number[] = [];
|
||||||
|
private idx: number[] = [];
|
||||||
|
|
||||||
|
build(people: FbKinPerson[]): void {
|
||||||
|
this.ids = [];
|
||||||
|
this.idx = [];
|
||||||
|
for (let i = 0; i < people.length; i++) {
|
||||||
|
this.ids.push(people[i].id);
|
||||||
|
this.idx.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回下标;找不到 -1 */
|
||||||
|
find(id: number): number {
|
||||||
|
for (let i = 0; i < this.ids.length; i++) {
|
||||||
|
if (this.ids[i] === id) {
|
||||||
|
return this.idx[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主入口:算出一整张家谱的位置与连线。
|
||||||
|
*
|
||||||
|
* @param people 服务端 `/api/v1/kin` 给的人(已带 gen / unit / parents / children / spouses)
|
||||||
|
* @param meId 我(标出中心;也是「父母优先连我自己的父母」的依据)
|
||||||
|
*/
|
||||||
|
export function layoutKin(people: FbKinPerson[], meId: number): KinLayout {
|
||||||
|
const out: KinLayout = new KinLayout();
|
||||||
|
if (people.length === 0) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index: PeopleIndex = new PeopleIndex();
|
||||||
|
index.build(people);
|
||||||
|
|
||||||
|
// ---- ① 按「代数 + 夫妻单元」分组 ----
|
||||||
|
const units: KinUnit[] = [];
|
||||||
|
const unitOfPerson: number[] = []; // 与 people 等长:每人属于哪个 unit 下标
|
||||||
|
for (let i = 0; i < people.length; i++) {
|
||||||
|
unitOfPerson.push(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < people.length; i++) {
|
||||||
|
const p: FbKinPerson = people[i];
|
||||||
|
/* 🔴🔴 v73 真机抓到的真 bug:**户 0 被当成"没分户"** ⇒ 我与妻子被拆成两户。
|
||||||
|
*
|
||||||
|
* 原来这里是 `p.unit > 0 ? 按户 : 单人`,理由是"0 表示没分户"。
|
||||||
|
* 但服务端 `Kinship::familyMap()` 的 `unit_of` 是
|
||||||
|
* `foreach ($units as $i => $u)` **从 0 开始**编号的 ——
|
||||||
|
* 户 0 是**真实存在的一户**(线上实测:户 0 = 杨永全#1 + 洪博为#3,正是我和妻子)。
|
||||||
|
* ⇒ 户 0 被走 `solo` 分支拆成两个单人单元,
|
||||||
|
* 真机上表现为「我」与妻子之间横插了姐姐一家(Δx = 680px 而不是 198px),
|
||||||
|
* 而**离线测试完全看不出来**(Python 镜像也抄了同样的错判)。
|
||||||
|
*
|
||||||
|
* 修法:**不把任何数值当成哨兵**。缺字段/无户号由**解析层**统一成 -1
|
||||||
|
* (见 `FbParse.parseKin`:`unit` 缺失或 null 时给 -1),
|
||||||
|
* 这里只判 `>= 0` 即"有一户"。这样服务端怎么编号都不会再错。
|
||||||
|
* ⚠️ 别改回 `> 0`:那等于把"第 0 户"这颗雷重新埋上。
|
||||||
|
* ⚠️ 也别用 `!== undefined`:`-1` 会漏过去,把全家人并成一户。
|
||||||
|
*/
|
||||||
|
const key: string = p.unit >= 0 ? (p.gen.toString() + ':' + p.unit.toString())
|
||||||
|
: (p.gen.toString() + ':solo:' + p.id.toString());
|
||||||
|
|
||||||
|
let hit: number = -1;
|
||||||
|
for (let u = 0; u < units.length; u++) {
|
||||||
|
if (units[u].key === key) {
|
||||||
|
hit = u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit < 0) {
|
||||||
|
const nu: KinUnit = new KinUnit(key, p.gen);
|
||||||
|
nu.minId = p.id;
|
||||||
|
units.push(nu);
|
||||||
|
hit = units.length - 1;
|
||||||
|
} else if (p.id < units[hit].minId) {
|
||||||
|
units[hit].minId = p.id;
|
||||||
|
}
|
||||||
|
units[hit].members.push(p);
|
||||||
|
unitOfPerson[i] = hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元内排序:把成员按**配偶链**串成一排。
|
||||||
|
*
|
||||||
|
* 🔴 为什么不是简单"我在最前、其余按 id"(v73 第一版就是那样,本地验出来了):
|
||||||
|
* 服务端的 `unit`(夫妻单元号)是算出来的,正常一户 2 人;
|
||||||
|
* 但数据脏的时候(重复录入、先录了"妻子"又录了"配偶")**一户可能 3 人**。
|
||||||
|
* 那时按 id 排会让"我"排在最左、真配偶被挤到第三位,
|
||||||
|
* 中间隔着另一个人 ⇒ **夫妻不再相邻**,连线横穿第三个人。
|
||||||
|
* 按配偶链排就能保证:只要两人登记了夫妻关系,他们在卡片上一定挨着。
|
||||||
|
*
|
||||||
|
* 起点选择:优先从「我」开始;否则从链头(没有本户内配偶的那个人)开始。
|
||||||
|
*/
|
||||||
|
for (let u = 0; u < units.length; u++) {
|
||||||
|
const ms: FbKinPerson[] = units[u].members;
|
||||||
|
units[u].members = orderBySpouseChain(ms, meId);
|
||||||
|
const ordered: FbKinPerson[] = units[u].members;
|
||||||
|
units[u].width = ordered.length * KinDim.CARD_W + (ordered.length - 1) * KinDim.SPOUSE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ② 找每个单元的「父母单元」----
|
||||||
|
//
|
||||||
|
// ⚠️ 一个单元可能有多个人、每个人的父母又可能在不同单元
|
||||||
|
// (「我」的父母 vs 「妻子」的父母)。
|
||||||
|
// 规则:**「我」所在的单元优先认我自己的父母** ——
|
||||||
|
// 否则妻子那侧的岳父母会把主树带偏(整张图看起来像"围着岳父家转")。
|
||||||
|
for (let u = 0; u < units.length; u++) {
|
||||||
|
units[u].parentKey = '';
|
||||||
|
}
|
||||||
|
for (let u = 0; u < units.length; u++) {
|
||||||
|
const unit: KinUnit = units[u];
|
||||||
|
const votes: string[] = [];
|
||||||
|
let hasMe: boolean = false;
|
||||||
|
|
||||||
|
for (let m = 0; m < unit.members.length; m++) {
|
||||||
|
const p: FbKinPerson = unit.members[m];
|
||||||
|
if (p.id === meId) {
|
||||||
|
hasMe = true;
|
||||||
|
}
|
||||||
|
for (let k = 0; k < p.parents.length; k++) {
|
||||||
|
const pi: number = index.find(p.parents[k]);
|
||||||
|
if (pi < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pu: number = unitOfPerson[pi];
|
||||||
|
if (pu === u) {
|
||||||
|
continue; // 同单元(夫妻互称父母)跳过
|
||||||
|
}
|
||||||
|
const puGen: number = units[pu].gen;
|
||||||
|
if (puGen >= unit.gen) {
|
||||||
|
continue; // 只认**上一代**,防止把子孙当父母
|
||||||
|
}
|
||||||
|
votes.push(units[pu].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMe) {
|
||||||
|
// 「我」在的单元:只认我自己的父母(前面已经混进配偶父母了,这里重算)
|
||||||
|
let own: string = '';
|
||||||
|
const meIdx: number = index.find(meId);
|
||||||
|
if (meIdx >= 0) {
|
||||||
|
const me: FbKinPerson = people[meIdx];
|
||||||
|
for (let k = 0; k < me.parents.length; k++) {
|
||||||
|
const pi: number = index.find(me.parents[k]);
|
||||||
|
if (pi < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pu: number = unitOfPerson[pi];
|
||||||
|
if (pu >= 0 && units[pu].gen < unit.gen) {
|
||||||
|
own = units[pu].key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.parentKey = own;
|
||||||
|
} else if (votes.length > 0) {
|
||||||
|
// 其余单元:取出现次数最多的(平票时取 key 字典序最小 —— 确定性)
|
||||||
|
votes.sort((a: string, b: string): number => a < b ? -1 : (a > b ? 1 : 0));
|
||||||
|
let best: string = votes[0];
|
||||||
|
let bestN: number = 0;
|
||||||
|
let i: number = 0;
|
||||||
|
while (i < votes.length) {
|
||||||
|
let j: number = i;
|
||||||
|
while (j < votes.length && votes[j] === votes[i]) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
const cnt: number = j - i;
|
||||||
|
if (cnt > bestN) {
|
||||||
|
bestN = cnt;
|
||||||
|
best = votes[i];
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
unit.parentKey = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ③ 初始排布:每代内按 (单元序号) 均分 ----
|
||||||
|
const gens: number[] = [];
|
||||||
|
for (let u = 0; u < units.length; u++) {
|
||||||
|
if (gens.indexOf(units[u].gen) < 0) {
|
||||||
|
gens.push(units[u].gen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gens.sort((a: number, b: number): number => a - b);
|
||||||
|
|
||||||
|
for (let g = 0; g < gens.length; g++) {
|
||||||
|
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||||
|
let total: number = 0;
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
total += row[i].width;
|
||||||
|
}
|
||||||
|
total += (row.length - 1) * KinDim.UNIT_GAP;
|
||||||
|
let cursor: number = -total / 2;
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
row[i].x = cursor + row[i].width / 2;
|
||||||
|
row[i].target = row[i].x;
|
||||||
|
cursor += row[i].width + KinDim.UNIT_GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ④ 迭代松弛 ----
|
||||||
|
//
|
||||||
|
// 每一轮做两件事:
|
||||||
|
// ⓐ 自顶向下:把子女单元拉到「父母单元中点」下方(父母定,子女跟)
|
||||||
|
// ⓑ 自底向上:把父母单元拉到「子女们的平均位置」(子女定,父母跟)
|
||||||
|
// 交替进行,让冲突约束(夫妻并排 vs 子女挂父母下方)互相让步。
|
||||||
|
//
|
||||||
|
// ⚠️ 最后一轮**必须自顶向下**:用户看家谱最在意「谁在谁下面」,
|
||||||
|
// 以子女位置收尾才保证「子女确实落在父母正下方」。
|
||||||
|
for (let it = 0; it < KinDim.ITERS; it++) {
|
||||||
|
relaxDown(units, gens);
|
||||||
|
relaxUp(units, gens);
|
||||||
|
}
|
||||||
|
relaxDown(units, gens);
|
||||||
|
|
||||||
|
// ---- ⑤ 转成卡片坐标(纵向按代数排行)----
|
||||||
|
const layout: KinLayout = new KinLayout();
|
||||||
|
for (let g = 0; g < gens.length; g++) {
|
||||||
|
const cy: number = g * KinDim.ROW_H;
|
||||||
|
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||||
|
for (let u = 0; u < row.length; u++) {
|
||||||
|
const unit: KinUnit = row[u];
|
||||||
|
let cx: number = unit.x - unit.width / 2 + KinDim.CARD_W / 2;
|
||||||
|
for (let m = 0; m < unit.members.length; m++) {
|
||||||
|
const pos: KinCardPos = new KinCardPos(unit.members[m]);
|
||||||
|
pos.x = cx;
|
||||||
|
pos.y = cy;
|
||||||
|
layout.cards.push(pos);
|
||||||
|
cx += KinDim.CARD_W + KinDim.SPOUSE_GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ⑥ 连线 ----
|
||||||
|
// ⚠️ 顺序:先算血缘(配偶线在视图里画得更粗,不该被血缘盖住)
|
||||||
|
buildBloodEdges(layout, people, index, unitOfPerson, units, gens);
|
||||||
|
buildSpouseEdges(layout, people, index);
|
||||||
|
|
||||||
|
// ---- ⑦ 包围盒(居中用)----
|
||||||
|
let minX: number = 1e9;
|
||||||
|
let maxX: number = -1e9;
|
||||||
|
let minY: number = 1e9;
|
||||||
|
let maxY: number = -1e9;
|
||||||
|
for (let i = 0; i < layout.cards.length; i++) {
|
||||||
|
const c: KinCardPos = layout.cards[i];
|
||||||
|
if (c.x < minX) {
|
||||||
|
minX = c.x;
|
||||||
|
}
|
||||||
|
if (c.x > maxX) {
|
||||||
|
maxX = c.x;
|
||||||
|
}
|
||||||
|
if (c.y < minY) {
|
||||||
|
minY = c.y;
|
||||||
|
}
|
||||||
|
if (c.y > maxY) {
|
||||||
|
maxY = c.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout.width = maxX - minX + KinDim.CARD_W;
|
||||||
|
layout.height = maxY - minY + KinDim.CARD_H;
|
||||||
|
|
||||||
|
// 平移成「左上角为 (PAD, PAD)」的坐标,视图不用再关心负坐标
|
||||||
|
const dx: number = -minX + KinDim.CARD_W / 2 + KinDim.PAD_X;
|
||||||
|
const dy: number = -minY + KinDim.CARD_H / 2 + KinDim.PAD_Y;
|
||||||
|
for (let i = 0; i < layout.cards.length; i++) {
|
||||||
|
layout.cards[i].x += dx;
|
||||||
|
layout.cards[i].y += dy;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < layout.edges.length; i++) {
|
||||||
|
layout.edges[i].x1 += dx;
|
||||||
|
layout.edges[i].y1 += dy;
|
||||||
|
layout.edges[i].x2 += dx;
|
||||||
|
layout.edges[i].y2 += dy;
|
||||||
|
layout.edges[i].midY += dy;
|
||||||
|
}
|
||||||
|
layout.width += KinDim.PAD_X * 2;
|
||||||
|
layout.height += KinDim.PAD_Y * 2;
|
||||||
|
|
||||||
|
// 「我」的坐标(视图据此把镜头对准我)
|
||||||
|
const mePos: KinCardPos | undefined = cardOf(layout, meId);
|
||||||
|
if (mePos !== undefined) {
|
||||||
|
layout.meX = mePos.x;
|
||||||
|
layout.meY = mePos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把同一个「户」里的人按**配偶链**串成一排。
|
||||||
|
*
|
||||||
|
* 规则:从起点出发,每次挑「与已排到最后的那个人是配偶」的成员;
|
||||||
|
* 挑不到就挑「与已排到第一个人是配偶」的(往左延伸);
|
||||||
|
* 都没有就按 id 取下一个(不相干的人)。
|
||||||
|
*
|
||||||
|
* ⚠️ 起点优先「我」:我在最左、配偶在右,符合「从自己往右读」的直觉。
|
||||||
|
* ⚠️ 全程用 `indexOf` 判重、按 id 定序 —— 保证**确定性**
|
||||||
|
* (同样的输入每次给同样的排列,否则重画时卡片会跳位置)。
|
||||||
|
*/
|
||||||
|
function orderBySpouseChain(members: FbKinPerson[], meId: number): FbKinPerson[] {
|
||||||
|
const rest: FbKinPerson[] = [];
|
||||||
|
for (let i = 0; i < members.length; i++) {
|
||||||
|
rest.push(members[i]);
|
||||||
|
}
|
||||||
|
// 起点:我 → 否则 id 最小的
|
||||||
|
rest.sort((a: FbKinPerson, b: FbKinPerson): number => {
|
||||||
|
if (a.id === meId) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (b.id === meId) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
const out: FbKinPerson[] = [];
|
||||||
|
if (rest.length === 0) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.push(rest[0]);
|
||||||
|
rest.shift();
|
||||||
|
|
||||||
|
while (rest.length > 0) {
|
||||||
|
const tail: FbKinPerson = out[out.length - 1];
|
||||||
|
let pick: number = -1;
|
||||||
|
// ① 先找 tail 的配偶
|
||||||
|
for (let i = 0; i < rest.length; i++) {
|
||||||
|
if (tail.spouses.indexOf(rest[i].id) >= 0) {
|
||||||
|
pick = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ② 再找 head 的配偶(往左边继续串)
|
||||||
|
if (pick < 0) {
|
||||||
|
const head: FbKinPerson = out[0];
|
||||||
|
for (let i = 0; i < rest.length; i++) {
|
||||||
|
if (head.spouses.indexOf(rest[i].id) >= 0) {
|
||||||
|
pick = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pick >= 0) {
|
||||||
|
out.unshift(rest[pick]);
|
||||||
|
rest.splice(pick, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ③ 都不是配偶 ⇒ 按 id 取下一个(数据里不相干的人)
|
||||||
|
if (pick < 0) {
|
||||||
|
pick = 0;
|
||||||
|
}
|
||||||
|
out.push(rest[pick]);
|
||||||
|
rest.splice(pick, 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取某代的所有单元 */
|
||||||
|
function rowOf(units: KinUnit[], gen: number): KinUnit[] {
|
||||||
|
const out: KinUnit[] = [];
|
||||||
|
for (let i = 0; i < units.length; i++) {
|
||||||
|
if (units[i].gen === gen) {
|
||||||
|
out.push(units[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 (target, minId) 排序 —— minId 是**确定性**的最后一级 */
|
||||||
|
function sortByTarget(row: KinUnit[]): void {
|
||||||
|
row.sort((a: KinUnit, b: KinUnit): number => {
|
||||||
|
if (a.target !== b.target) {
|
||||||
|
return a.target - b.target;
|
||||||
|
}
|
||||||
|
return a.minId - b.minId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贪心排布:按顺序尽量靠近各自 `target`,但**不许重叠**。
|
||||||
|
*
|
||||||
|
* ⚠️ 不用"按 target 直接赋值":那会让同代的单元叠在一起
|
||||||
|
* (家谱里同代人多时,父母中心往往挤在很小的一段)。
|
||||||
|
*/
|
||||||
|
function arrange(row: KinUnit[]): void {
|
||||||
|
let cursor: number = -1e9;
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
const u: KinUnit = row[i];
|
||||||
|
const wantLeft: number = u.target - u.width / 2;
|
||||||
|
const left: number = wantLeft > cursor ? wantLeft : cursor;
|
||||||
|
u.x = left + u.width / 2;
|
||||||
|
cursor = left + u.width + KinDim.UNIT_GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自顶向下:子女单元跟着父母单元的中点走 */
|
||||||
|
function relaxDown(units: KinUnit[], gens: number[]): void {
|
||||||
|
for (let g = 0; g < gens.length; g++) {
|
||||||
|
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
const u: KinUnit = row[i];
|
||||||
|
if (u.parentKey !== '') {
|
||||||
|
const pi: number = indexOfUnit(units, u.parentKey);
|
||||||
|
if (pi >= 0) {
|
||||||
|
u.target = units[pi].x;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有上一代(如岳父岳母):贴到「自己子女那侧」的位置。
|
||||||
|
// ⚠️ 不能简单保持不动 —— 否则它和主树的关系会随时间漂到莫名其妙的地方。
|
||||||
|
u.target = u.x;
|
||||||
|
}
|
||||||
|
sortByTarget(row);
|
||||||
|
arrange(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自底向上:父母单元跟着子女单元的平均位置走 */
|
||||||
|
function relaxUp(units: KinUnit[], gens: number[]): void {
|
||||||
|
for (let g = gens.length - 1; g >= 0; g--) {
|
||||||
|
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
const u: KinUnit = row[i];
|
||||||
|
let sum: number = 0;
|
||||||
|
let n: number = 0;
|
||||||
|
for (let k = 0; k < units.length; k++) {
|
||||||
|
if (units[k].parentKey === u.key) {
|
||||||
|
sum += units[k].x;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.target = n > 0 ? sum / n : u.x;
|
||||||
|
}
|
||||||
|
sortByTarget(row);
|
||||||
|
arrange(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexOfUnit(units: KinUnit[], key: string): number {
|
||||||
|
for (let i = 0; i < units.length; i++) {
|
||||||
|
if (units[i].key === key) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `gen` 的**数值** → 卡片所在的**行下标**。
|
||||||
|
*
|
||||||
|
* 🔴🔴 v73 真机 bug:**线没连对**(用户 2026-10-21:「主要是线没连对」)。
|
||||||
|
*
|
||||||
|
* 卡片 y 用的是 `第几行`(`g = 0,1,2…`,见 ⑤ 里 `const cy = g * KinDim.ROW_H`),
|
||||||
|
* 而这里原来直接用 `gen` 的**数值**当行号(`units[pu].gen * KinDim.ROW_H`)。
|
||||||
|
* `gen` 的数值是相对我的代数(-1 = 长辈、0 = 我、+1 = 晚辈),
|
||||||
|
* 它**不等于**行下标 —— 只有当 `minGen === 0` 时两者才碰巧相等。
|
||||||
|
* 本家 `minGen = -1` ⇒ 连线的 y 整体**少算一行**:
|
||||||
|
* 长辈线从 y=−148 起(在屏幕外),子女线的落点落在"我这一辈"卡片上,
|
||||||
|
* 结果是线全部串到上一个人身上、与卡片错位。
|
||||||
|
* ⇒ 唯一正确的换算是 `gens.indexOf(gen)`。
|
||||||
|
*
|
||||||
|
* ⚠️ 别用 `gen - minGen` 代替:`gens` 是**去重排序后的实际存在的行**,
|
||||||
|
* 缺代(比如没有 -2 那一代)时 `gen - minGen` 会算出空行、线还是错。
|
||||||
|
*/
|
||||||
|
function rowIndex(gens: number[], gen: number): number {
|
||||||
|
const i: number = gens.indexOf(gen);
|
||||||
|
return i < 0 ? 0 : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 血缘连线:从「父母单元」的中点垂到「子女单元」的顶端。
|
||||||
|
*
|
||||||
|
* ⚠️ 起点用**父母单元中点**(夫妻横线的中点),不是父亲个人 ——
|
||||||
|
* 这是 family-chart 的画法,也是「孩子是两个人共同的孩子」的视觉表达。
|
||||||
|
* 原实现从父亲个人往下画,遇到母亲在右、孩子在中间时线会斜穿。
|
||||||
|
*/
|
||||||
|
function buildBloodEdges(layout: KinLayout, people: FbKinPerson[],
|
||||||
|
index: PeopleIndex, unitOfPerson: number[], units: KinUnit[],
|
||||||
|
gens: number[]): void {
|
||||||
|
const done: string[] = [];
|
||||||
|
for (let i = 0; i < people.length; i++) {
|
||||||
|
const child: FbKinPerson = people[i];
|
||||||
|
const childCard: KinCardPos | undefined = cardOf(layout, child.id);
|
||||||
|
if (childCard === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ---- 每个子女只连一条线(父母可能有两个,共用一个"落点")----
|
||||||
|
let pid: number = 0;
|
||||||
|
for (let k = 0; k < child.parents.length; k++) {
|
||||||
|
if (index.find(child.parents[k]) >= 0) {
|
||||||
|
pid = child.parents[k];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pid === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pu: number = unitOfPerson[index.find(pid)];
|
||||||
|
const cu: number = unitOfPerson[i];
|
||||||
|
if (pu < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const k2: string = units[pu].key + '>' + units[cu].key;
|
||||||
|
if (done.indexOf(k2) >= 0) {
|
||||||
|
continue; // 同一对「父母单元 → 子女单元」只画一次
|
||||||
|
}
|
||||||
|
done.push(k2);
|
||||||
|
|
||||||
|
const parentY: number = rowIndex(gens, units[pu].gen) * KinDim.ROW_H;
|
||||||
|
const e: KinEdge = new KinEdge();
|
||||||
|
e.kind = 'blood';
|
||||||
|
e.x1 = units[pu].x;
|
||||||
|
e.y1 = parentY + KinDim.CARD_H / 2;
|
||||||
|
e.x2 = units[cu].x;
|
||||||
|
e.y2 = rowIndex(gens, units[cu].gen) * KinDim.ROW_H - KinDim.CARD_H / 2;
|
||||||
|
// 拐点在两代之间靠上 40% 处:靠近父母的下沿,视觉上「分叉」更像扇出
|
||||||
|
e.midY = e.y1 + (e.y2 - e.y1) * 0.42;
|
||||||
|
layout.edges.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 夫妻连线:两人卡片之间的一小段横线 */
|
||||||
|
function buildSpouseEdges(layout: KinLayout, people: FbKinPerson[], index: PeopleIndex): void {
|
||||||
|
for (let i = 0; i < people.length; i++) {
|
||||||
|
const p: FbKinPerson = people[i];
|
||||||
|
for (let k = 0; k < p.spouses.length; k++) {
|
||||||
|
const sid: number = p.spouses[k];
|
||||||
|
// ⚠️ 只画一次(否则同一对画两条,线宽叠加看起来发黑)
|
||||||
|
if (sid < p.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (index.find(sid) < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const a: KinCardPos | undefined = cardOf(layout, p.id);
|
||||||
|
const b: KinCardPos | undefined = cardOf(layout, sid);
|
||||||
|
if (a === undefined || b === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const e: KinEdge = new KinEdge();
|
||||||
|
e.kind = 'spouse';
|
||||||
|
// 左的人在左、右的人在右(夫妻并排时 a 可能已经偏右)
|
||||||
|
if (a.x <= b.x) {
|
||||||
|
e.x1 = a.x + KinDim.CARD_W / 2;
|
||||||
|
e.y1 = a.y;
|
||||||
|
e.x2 = b.x - KinDim.CARD_W / 2;
|
||||||
|
e.y2 = b.y;
|
||||||
|
} else {
|
||||||
|
e.x1 = b.x + KinDim.CARD_W / 2;
|
||||||
|
e.y1 = b.y;
|
||||||
|
e.x2 = a.x - KinDim.CARD_W / 2;
|
||||||
|
e.y2 = a.y;
|
||||||
|
}
|
||||||
|
e.midY = e.y1;
|
||||||
|
layout.edges.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 id 找卡片位置 */
|
||||||
|
export function cardOf(layout: KinLayout, id: number): KinCardPos | undefined {
|
||||||
|
for (let i = 0; i < layout.cards.length; i++) {
|
||||||
|
if (layout.cards[i].p.id === id) {
|
||||||
|
return layout.cards[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,34 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
|
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
|
||||||
*
|
*
|
||||||
* 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。
|
* ============================================================
|
||||||
|
* 🔴 v73:从「Canvas 全手绘」改成「Canvas 只画线 + 原生卡片」
|
||||||
|
* ============================================================
|
||||||
|
* 用户 2026-10-21 原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」
|
||||||
*
|
*
|
||||||
* 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼:
|
* 为什么不能再全用 Canvas(原实现 287 行,只有彩色圆点 + 首字):
|
||||||
* · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻),
|
* · Canvas **没有原生字体**:只能写 `'12px sans-serif'` 这种泛字体,
|
||||||
* 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`,
|
* 中文渲染出来又平又硬,和 App 其他地方的排版完全两个味道;
|
||||||
* 比直接算坐标画线**难得多**、也更难调;
|
* · Canvas **没有圆角/阴影/图片裁切**:卡片做不出「纸白 + 细边 + 微投影」,
|
||||||
* · 连线要**画在节点下面**(否则线会压在头像上)——
|
* 只能退化成纯色圆点 —— 这就是用户说的"难看";
|
||||||
* Canvas 一次画完,天然有先后顺序。
|
* · Canvas **不是响应式的**:数据一变要自己重画,容易漏。
|
||||||
* ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。
|
* ⇒ 现在:**卡片用原生 `Column`/`Text`/`FbAvatar`**,
|
||||||
* 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。
|
* 字体、圆角、阴影、头像裁切全部走 App 同一套,视觉自然统一。
|
||||||
*
|
*
|
||||||
* 布局规则(与"从上往下读家谱"的直觉一致):
|
* ⚠️ 那为什么连线还留在 Canvas 上,不用 `Path` 组件?
|
||||||
* · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上);
|
* `Path.commands` 的坐标单位是 **px**(官方文档明确写明),
|
||||||
* · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边;
|
* 而卡片定位的 `.position()` 单位是 **vp**。
|
||||||
* · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。
|
* 两套单位混用,在高分屏(本机 3.0x)上线会缩到 1/3、与卡片完全错位。
|
||||||
* ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**,
|
* Canvas 的绘制坐标系**也是 vp**(与 `.position()` 一致,原实现已验证),
|
||||||
* 力导向会把人随机甩开,反而看不懂谁是长辈。
|
* 所以「Canvas 画线 + 原生卡片绝对定位」是唯一单位自洽的组合。
|
||||||
|
*
|
||||||
|
* 布局算法**不在这个文件里** —— 见 `common/KinLayout.ets`(纯函数、可单独验证)。
|
||||||
|
*
|
||||||
|
* ⚠️ 不做双指缩放:`scale()` 是位图缩放,卡片里的中文会糊;
|
||||||
|
* 内容超出一屏时用 `Scroll` 双向滚动 —— 更符合手机的肌肉记忆。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { FbAvatar } from './Widgets';
|
||||||
|
import { KinCardPos, KinDim, KinEdge, KinLayout, layoutKin } from '../common/KinLayout';
|
||||||
import { FbColors, FbMetric } from '../common/Theme';
|
import { FbColors, FbMetric } from '../common/Theme';
|
||||||
import { FbKinPerson } from '../model/Types';
|
import { FbKinPerson } from '../model/Types';
|
||||||
|
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||||
/** 一个节点在画布上的位置 */
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||||
interface KinNode {
|
|
||||||
p: FbKinPerson;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ComponentV2
|
@ComponentV2
|
||||||
export struct FbKinGraphView {
|
export struct FbKinGraphView {
|
||||||
@@ -40,249 +45,261 @@ export struct FbKinGraphView {
|
|||||||
@Event onPick: (id: number) => void = (id: number): void => {
|
@Event onPick: (id: number) => void = (id: number): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 画布上下文(必须在组件里创建,不能跨组件共享) */
|
/** 算好的布局 */
|
||||||
|
@Local layout: KinLayout = new KinLayout();
|
||||||
|
/** 内容尺寸(`Scroll` 里的子层要有确定宽高,否则滚不动) */
|
||||||
|
@Local contentW: number = 0;
|
||||||
|
@Local contentH: number = 0;
|
||||||
|
/** 视口宽度(内容比视口窄时居中) */
|
||||||
|
@Local viewW: number = 0;
|
||||||
|
/** 视口高度(判断"要不要滚") */
|
||||||
|
@Local viewH: number = 0;
|
||||||
|
/** 内容几何哈希:布局一变就要**重画 Canvas**(见 `drawEdges` 的说明) */
|
||||||
|
@Local geoStamp: string = '';
|
||||||
|
|
||||||
|
/** 滚动容器(要能"一进来就对准我") */
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
/** 已经自动对准过一次(避免用户手动滚动后被拉回去) */
|
||||||
|
private centered: boolean = false;
|
||||||
|
|
||||||
|
/** Canvas 上下文(**必须在组件里创建**,不能跨组件共享) */
|
||||||
private settings: RenderingContextSettings = new RenderingContextSettings(true);
|
private settings: RenderingContextSettings = new RenderingContextSettings(true);
|
||||||
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
|
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
|
||||||
/** 画布尺寸(onAreaChange 拿到) */
|
|
||||||
@Local cw: number = 0;
|
|
||||||
@Local ch: number = 0;
|
|
||||||
/** 算好的节点位置(画与点击都用它) */
|
|
||||||
private nodes: KinNode[] = [];
|
|
||||||
|
|
||||||
/** 节点半径(vp) */
|
aboutToAppear(): void {
|
||||||
private readonly R: number = 22;
|
this.rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@Param` 变化时重算。
|
||||||
|
*
|
||||||
|
* ⚠️ 用 `@Monitor` 而**不是**在 `build()` 里算:`build()` 每次重渲染都跑,
|
||||||
|
* 在那里算布局等于每帧做一遍「11 人 × 24 轮松弛」。
|
||||||
|
*/
|
||||||
|
@Monitor('people', 'meId')
|
||||||
|
onDataChange(): void {
|
||||||
|
// 数据换了 ⇒ 允许重新对准我(否则同步刷新后镜头还停在上一个人的位置)
|
||||||
|
this.centered = false;
|
||||||
|
this.rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuild(): void {
|
||||||
|
this.layout = layoutKin(this.people, this.meId);
|
||||||
|
// `+ 2`:盖住描边的半像素溢出,否则最右侧卡片的边会被裁掉一条
|
||||||
|
this.contentW = this.layout.width + 2;
|
||||||
|
this.contentH = this.layout.height + 2;
|
||||||
|
// 🔴 几何指纹:Canvas **不是响应式的**,布局变了必须自己重画。
|
||||||
|
// 只靠 `onReady`(首帧一次)是不够的 —— 首帧时 `people` 往往还是空的,
|
||||||
|
// 等数据到了布局才变,那时连线会**留在旧位置**(卡片动了线没动)。
|
||||||
|
// 这里把每张卡的坐标拼成一个串,变了就触发重画。
|
||||||
|
this.geoStamp = this.stampOf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容几何指纹(卡片坐标 + 线数) */
|
||||||
|
private stampOf(): string {
|
||||||
|
let s: string = '';
|
||||||
|
for (const c of this.layout.cards) {
|
||||||
|
s += c.p.id.toString() + ':' + c.x.toFixed(1) + ',' + c.y.toFixed(1) + ';';
|
||||||
|
}
|
||||||
|
return s + '|' + this.layout.edges.length.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把镜头对准「我」。
|
||||||
|
*
|
||||||
|
* ⚠️ 只在**第一次**做,且内容超出视口才需要滚 ——
|
||||||
|
* 内容比视口窄时已经居中(见 `offsetX`),再滚反而会滚出空白。
|
||||||
|
*/
|
||||||
|
private centerOnMe(): void {
|
||||||
|
if (this.centered || this.viewW <= 0 || this.viewH <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.layout.meX < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.centered = true;
|
||||||
|
const needX: boolean = this.contentW > this.viewW;
|
||||||
|
const needY: boolean = this.contentH > this.viewH;
|
||||||
|
if (!needX && !needY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scroller.scrollTo({
|
||||||
|
xOffset: needX ? Math.max(0, this.layout.meX - this.viewW / 2) : 0,
|
||||||
|
yOffset: needY ? Math.max(0, this.layout.meY - this.viewH / 2) : 0,
|
||||||
|
animation: { duration: 260, curve: Curve.EaseOut }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容比视口窄 ⇒ 左右居中;否则从左边开始(可滚) */
|
||||||
|
private offsetX(): number {
|
||||||
|
if (this.contentW >= this.viewW) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (this.viewW - this.contentW) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 几何变了就补一次重画。
|
||||||
|
*
|
||||||
|
* 🔴 为什么光有 `onAreaChange` 不够:它只在**宽高变化**时触发。
|
||||||
|
* 而布局变了、总宽高却没变的情况是真实存在的 ——
|
||||||
|
* 比如两个人互换了位置、或者某个单元整体挪了 20vp。
|
||||||
|
* 那时 Canvas 里留的是**旧坐标的线**,卡片却在**新位置** ⇒ 线穿到卡片外面。
|
||||||
|
* `geoStamp` 是坐标指纹,它变就一定重画。
|
||||||
|
*/
|
||||||
|
@Monitor('geoStamp')
|
||||||
|
onGeoChange(): void {
|
||||||
|
// 推到下一个 tick:此时 `contentW/contentH` 与 Canvas 的布局都已生效
|
||||||
|
setTimeout((): void => {
|
||||||
|
this.drawEdges();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Stack({ alignContent: Alignment.TopStart }) {
|
||||||
|
// ---- ① 连线层(**在卡片下面**:Canvas 没有 z-index,靠节点顺序)----
|
||||||
Canvas(this.ctx)
|
Canvas(this.ctx)
|
||||||
|
.width(this.contentW)
|
||||||
|
.height(this.contentH)
|
||||||
|
.onReady((): void => {
|
||||||
|
this.drawEdges();
|
||||||
|
this.centerOnMe();
|
||||||
|
})
|
||||||
|
.onAreaChange((_o: Area, _n: Area): void => {
|
||||||
|
this.drawEdges();
|
||||||
|
})
|
||||||
|
// 连线不参与命中:点卡片边缘时不该点到它下面的线
|
||||||
|
.hitTestBehavior(HitTestMode.None)
|
||||||
|
|
||||||
|
// ---- ② 卡片层 ----
|
||||||
|
ForEach(this.layout.cards, (c: KinCardPos) => {
|
||||||
|
this.personCard(c)
|
||||||
|
}, (c: KinCardPos): string => 'kc_' + c.p.id.toString())
|
||||||
|
}
|
||||||
|
.width(this.contentW)
|
||||||
|
.height(this.contentH)
|
||||||
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
|
// ⚠️ `Free`(可双向)不是 `Both` —— `Both` 在 API 9 就废弃了,
|
||||||
|
// SDK 的 `ScrollDirection` 现在只有 Vertical / Horizontal / Free / None。
|
||||||
|
.scrollable(ScrollDirection.Free)
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
// 内容比视口窄时居中:`Scroll` 没法「居中内容」,用对称 padding 顶到中间
|
||||||
|
.padding({ left: this.offsetX(), right: this.offsetX() })
|
||||||
.backgroundColor(FbColors.paper)
|
.backgroundColor(FbColors.paper)
|
||||||
// ⚠️ `onReady` 只在**首帧**触发一次;后续尺寸变化要靠 `onAreaChange`。
|
.onAreaChange((_o: Area, now: Area): void => {
|
||||||
// 只用 onReady 的话,切到家谱页(宽高这时才定下来)会画不出东西。
|
this.viewW = Number(now.width);
|
||||||
.onReady((): void => {
|
this.viewH = Number(now.height);
|
||||||
this.redraw();
|
this.centerOnMe();
|
||||||
})
|
|
||||||
.onAreaChange((_old: Area, now: Area): void => {
|
|
||||||
this.cw = Number(now.width);
|
|
||||||
this.ch = Number(now.height);
|
|
||||||
this.redraw();
|
|
||||||
})
|
|
||||||
// 🔴 `Canvas` **没有**"点到了哪个图形"的概念 —— 命中检测要自己算。
|
|
||||||
// 画的位置由 `layout()` 定,点击时拿同一份 `this.nodes` 比距离即可。
|
|
||||||
.onClick((ev: ClickEvent): void => {
|
|
||||||
const x: number = ev.x;
|
|
||||||
const y: number = ev.y;
|
|
||||||
// 命中半径放宽一点(手指比鼠标粗):用 1.35×R 更"跟手"
|
|
||||||
const hit: number = this.R * 1.35;
|
|
||||||
for (const n of this.nodes) {
|
|
||||||
const dx: number = x - n.x;
|
|
||||||
const dy: number = y - n.y;
|
|
||||||
if (dx * dx + dy * dy <= hit * hit) {
|
|
||||||
this.onPick(n.p.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重画整张图(**先算位置、再画线、最后画节点**)。
|
* 画全部连线(先血缘、后夫妻 —— 夫妻线更粗,要压在上面)。
|
||||||
*
|
*
|
||||||
* ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。
|
* ⚠️ Canvas 不是响应式的:布局变了必须**自己重画**。
|
||||||
* Canvas 没有 z-index,只能靠绘制顺序。
|
* 这里在 `onReady`(首帧)与 `onAreaChange` 各调一次。
|
||||||
*/
|
*/
|
||||||
private redraw(): void {
|
|
||||||
if (this.cw <= 0 || this.ch <= 0 || this.people.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.layout();
|
|
||||||
this.drawEdges();
|
|
||||||
this.drawNodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 算节点位置(按代数分层)。
|
|
||||||
*
|
|
||||||
* ⚠️ 结果存进 `this.nodes` —— 点击命中检测要用**同一份坐标**,
|
|
||||||
* 重算一遍容易出现"看到的和点到的不一样"。
|
|
||||||
*/
|
|
||||||
private layout(): void {
|
|
||||||
// ---- ① 按代数分组 ----
|
|
||||||
const byGen: Map<number, FbKinPerson[]> = new Map<number, FbKinPerson[]>();
|
|
||||||
for (const p of this.people) {
|
|
||||||
const cur: FbKinPerson[] | undefined = byGen.get(p.gen);
|
|
||||||
if (cur !== undefined) {
|
|
||||||
cur.push(p);
|
|
||||||
} else {
|
|
||||||
byGen.set(p.gen, [p]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const gens: number[] = [];
|
|
||||||
byGen.forEach((_v: FbKinPerson[], k: number): void => {
|
|
||||||
gens.push(k);
|
|
||||||
});
|
|
||||||
// ⚠️ 必须按**数值**排(Map 的键直接 sort 会按字符串:-1,-2,0,1)
|
|
||||||
gens.sort((a: number, b: number): number => a - b);
|
|
||||||
|
|
||||||
// ---- ② 纵向:每代一条水平带,均分高度 ----
|
|
||||||
const padTop: number = 46; // 给顶部代数标签留位置
|
|
||||||
const padBottom: number = 26;
|
|
||||||
const bandH: number = (this.ch - padTop - padBottom) / Math.max(1, gens.length);
|
|
||||||
|
|
||||||
const nodes: KinNode[] = [];
|
|
||||||
for (let gi: number = 0; gi < gens.length; gi++) {
|
|
||||||
const g: number = gens[gi];
|
|
||||||
const ps: FbKinPerson[] = byGen.get(g) !== undefined ? (byGen.get(g) as FbKinPerson[]) : [];
|
|
||||||
const y: number = padTop + bandH * gi + bandH / 2;
|
|
||||||
|
|
||||||
// ---- ③ 横向:同代均分 ----
|
|
||||||
// ⚠️ 左右各留 `R + 8`,否则第一个/最后一个节点会贴边、头像被切掉。
|
|
||||||
const padX: number = this.R + 10;
|
|
||||||
const usable: number = this.cw - padX * 2;
|
|
||||||
const step: number = ps.length > 1 ? usable / (ps.length - 1) : 0;
|
|
||||||
// ⚠️ 只有一个人时放**正中间**(step=0 会让它贴在左边)
|
|
||||||
const startX: number = ps.length > 1 ? padX : this.cw / 2;
|
|
||||||
|
|
||||||
for (let i: number = 0; i < ps.length; i++) {
|
|
||||||
nodes.push({ p: ps[i], x: startX + step * i, y: y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.nodes = nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 id 找节点 */
|
|
||||||
private nodeOf(id: number): KinNode | undefined {
|
|
||||||
for (const n of this.nodes) {
|
|
||||||
if (n.p.id === id) {
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 画连线 */
|
|
||||||
private drawEdges(): void {
|
private drawEdges(): void {
|
||||||
|
if (this.contentW <= 0 || this.contentH <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const c: CanvasRenderingContext2D = this.ctx;
|
const c: CanvasRenderingContext2D = this.ctx;
|
||||||
c.lineWidth = 1.6;
|
c.clearRect(0, 0, this.contentW, this.contentH);
|
||||||
c.strokeStyle = '#B9A896';
|
|
||||||
|
|
||||||
for (const n of this.nodes) {
|
// ---- 血缘:父母单元中点 → 下 → 横 → 下 → 子女顶端(与 Web 端同款折线)----
|
||||||
// ---- 父母 → 子女(折线:下-横-下)----
|
for (const e of this.layout.edges) {
|
||||||
const parents: number[] = n.p.parents;
|
if (e.kind === 'spouse') {
|
||||||
for (const pid of parents) {
|
|
||||||
const par: KinNode | undefined = this.nodeOf(pid);
|
|
||||||
if (par === undefined) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 折线中点取两者的横向中点,纵向上 1/3 处拐
|
|
||||||
const midY: number = par.y + (n.y - par.y) * 0.55;
|
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.moveTo(par.x, par.y + this.R);
|
c.lineWidth = 1.4;
|
||||||
c.lineTo(par.x, midY);
|
c.strokeStyle = '#C9BDA9';
|
||||||
c.lineTo(n.x, midY);
|
c.lineCap = 'round';
|
||||||
c.lineTo(n.x, n.y - this.R);
|
c.lineJoin = 'round';
|
||||||
|
c.moveTo(e.x1, e.y1);
|
||||||
|
c.lineTo(e.x1, e.midY);
|
||||||
|
c.lineTo(e.x2, e.midY);
|
||||||
|
c.lineTo(e.x2, e.y2);
|
||||||
c.stroke();
|
c.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 夫妻(水平短线,**画粗一点**,与血缘线区分)----
|
// ---- 夫妻:卡片之间的一小段横线(粗、品牌色,与血缘一眼区分)----
|
||||||
for (const sid of n.p.spouses) {
|
for (const e of this.layout.edges) {
|
||||||
// 只画一次(按 id 大小去重,否则同一对会画两条)
|
if (e.kind !== 'spouse') {
|
||||||
if (sid < n.p.id) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const sp: KinNode | undefined = this.nodeOf(sid);
|
c.beginPath();
|
||||||
if (sp === undefined) {
|
c.lineWidth = 2.2;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
c.save();
|
|
||||||
c.lineWidth = 3;
|
|
||||||
c.strokeStyle = '#C97B6A';
|
c.strokeStyle = '#C97B6A';
|
||||||
c.beginPath();
|
c.lineCap = 'round';
|
||||||
c.moveTo(n.x + this.R, n.y);
|
c.moveTo(e.x1, e.y1);
|
||||||
c.lineTo(sp.x - this.R, sp.y);
|
c.lineTo(e.x2, e.y2);
|
||||||
c.stroke();
|
|
||||||
c.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 画节点(圆 + 首字 + 称谓) */
|
|
||||||
private drawNodes(): void {
|
|
||||||
const c: CanvasRenderingContext2D = this.ctx;
|
|
||||||
for (const n of this.nodes) {
|
|
||||||
const isMe: boolean = n.p.id === this.meId;
|
|
||||||
|
|
||||||
// 圆底(用头像色)
|
|
||||||
c.beginPath();
|
|
||||||
c.arc(n.x, n.y, this.R, 0, Math.PI * 2);
|
|
||||||
c.fillStyle = n.p.avatarColor !== '' ? n.p.avatarColor : '#8A6B4B';
|
|
||||||
c.fill();
|
|
||||||
// 「我」加一圈描边,一眼认出中心
|
|
||||||
if (isMe) {
|
|
||||||
c.lineWidth = 2.5;
|
|
||||||
c.strokeStyle = '#A84A28';
|
|
||||||
c.stroke();
|
c.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 名字首字
|
|
||||||
c.font = '16px sans-serif';
|
|
||||||
c.textAlign = 'center';
|
|
||||||
c.textBaseline = 'middle';
|
|
||||||
c.fillStyle = '#FFFFFF';
|
|
||||||
const nm: string = n.p.shown !== '' ? n.p.shown : n.p.name;
|
|
||||||
c.fillText(nm.length > 0 ? nm.substring(0, 1) : '?', n.x, n.y);
|
|
||||||
|
|
||||||
// 名字(圆下方)
|
|
||||||
c.font = isMe ? 'bold 12px sans-serif' : '12px sans-serif';
|
|
||||||
c.fillStyle = isMe ? '#A84A28' : '#2C231D';
|
|
||||||
// ⚠️ 名字可能很长("青岛西海岸电影城"那种)—— 截断,别糊成一片
|
|
||||||
const label: string = nm.length > 5 ? nm.substring(0, 5) + '…' : nm;
|
|
||||||
c.fillText(label, n.x, n.y + this.R + 11);
|
|
||||||
|
|
||||||
// 称谓(名字下面,小字灰色)
|
|
||||||
if (n.p.label !== '' && n.p.label !== '我') {
|
|
||||||
c.font = '10px sans-serif';
|
|
||||||
c.fillStyle = '#8A7A6A';
|
|
||||||
c.fillText(n.p.label, n.x, n.y + this.R + 24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 代数标签(每层左边)----
|
/**
|
||||||
// ⚠️ 只在每层**第一个**节点旁写,避免重复画
|
* 一张人物卡。
|
||||||
const first: KinNode | undefined = this.firstOfGen(n.p.gen);
|
*
|
||||||
if (first !== undefined && first.p.id === n.p.id) {
|
* 视觉与 Web 端 `.kin-card` 对齐:纸白底 + 细边 + 微投影,
|
||||||
c.font = '11px sans-serif';
|
* 头像在上(服务端头像是 1:1 裁好的),姓名在下,称谓再下一行。
|
||||||
c.textAlign = 'left';
|
* 「我」用品牌色描边 + 姓名加粗 —— 一眼认出中心。
|
||||||
c.fillStyle = '#8A7A6A';
|
*/
|
||||||
c.fillText(this.genLabel(n.p.gen), 8, n.y - this.R - 8);
|
@Builder
|
||||||
c.textAlign = 'center';
|
personCard(c: KinCardPos) {
|
||||||
}
|
Column({ space: 0 }) {
|
||||||
}
|
FbAvatar({
|
||||||
}
|
name: c.p.shown !== '' ? c.p.shown : c.p.name,
|
||||||
|
color: c.p.avatarColor !== '' ? c.p.avatarColor : '#8A6B4B',
|
||||||
|
// 卡宽 62 ⇒ 头像 40,左右各留 11(含 3 内边距)= 不贴边、也不挤
|
||||||
|
dia: 40,
|
||||||
|
fontSizeVp: 15,
|
||||||
|
imageUrl: fbImageSrc(c.p.avatar, FB_TOKEN_STORE.get())
|
||||||
|
})
|
||||||
|
|
||||||
/** 某代里排在最前的那个节点(用来放代数标签) */
|
Text(c.p.shown !== '' ? c.p.shown : (c.p.name !== '' ? c.p.name : '(无名)'))
|
||||||
private firstOfGen(gen: number): KinNode | undefined {
|
.fontSize(12)
|
||||||
for (const n of this.nodes) {
|
.fontWeight(c.p.id === this.meId ? FontWeight.Medium : FontWeight.Normal)
|
||||||
if (n.p.gen === gen) {
|
.fontColor(c.p.id === this.meId ? FbColors.brandInk : FbColors.ink)
|
||||||
return n;
|
.maxLines(1)
|
||||||
}
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
}
|
.width('100%')
|
||||||
return undefined;
|
.textAlign(TextAlign.Center)
|
||||||
}
|
.margin({ top: 4 })
|
||||||
|
|
||||||
/** 代数 → 层名(与家谱页的分层标题同口径) */
|
// 称谓:「我」不重复写一遍(卡片上写"我"是废话)
|
||||||
private genLabel(g: number): string {
|
if (c.p.label !== '' && c.p.label !== '我') {
|
||||||
if (g === 0) {
|
Text(c.p.label)
|
||||||
return '我这一辈';
|
.fontSize(10)
|
||||||
|
.fontColor(c.p.id === this.meId ? FbColors.brand : FbColors.brand2)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.margin({ top: 1 })
|
||||||
}
|
}
|
||||||
if (g === -1) {
|
|
||||||
return '父母辈';
|
|
||||||
}
|
}
|
||||||
if (g <= -2) {
|
.width(KinDim.CARD_W)
|
||||||
return '祖辈';
|
.height(KinDim.CARD_H)
|
||||||
}
|
.padding({ top: 4, bottom: 3, left: 2, right: 2 })
|
||||||
if (g === 1) {
|
// ⚠️ `.position()` 是**左上角**定位,布局给的是**中心**坐标 ⇒ 各减一半
|
||||||
return '子女辈';
|
.position({
|
||||||
}
|
x: c.x - KinDim.CARD_W / 2,
|
||||||
return '孙辈';
|
y: c.y - KinDim.CARD_H / 2
|
||||||
|
})
|
||||||
|
.backgroundColor(FbColors.surface)
|
||||||
|
.borderRadius(FbMetric.radiusS)
|
||||||
|
.border({
|
||||||
|
width: c.p.id === this.meId ? 1.5 : 1,
|
||||||
|
color: c.p.id === this.meId ? FbColors.brand : FbColors.line
|
||||||
|
})
|
||||||
|
.shadow({ radius: 3, color: '#1A2B2620', offsetX: 0, offsetY: 1 })
|
||||||
|
.onClick((): void => {
|
||||||
|
this.onPick(c.p.id);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,15 @@ export function parseKin(json: string): FbKinGraph {
|
|||||||
label: s(r['label'] as string),
|
label: s(r['label'] as string),
|
||||||
gen: n(r['gen'] as number),
|
gen: n(r['gen'] as number),
|
||||||
avatarColor: s(r['avatar_color'] as string),
|
avatarColor: s(r['avatar_color'] as string),
|
||||||
unit: n(r['unit'] as number),
|
// v73:家谱卡片要显示头像(服务端一直返回,以前画圆点没解析)
|
||||||
|
avatar: s(r['avatar'] as string),
|
||||||
|
/* 🔴 v73:**不能**用 `n()` —— 它把 null/undefined 折成 0,
|
||||||
|
而服务端的户号 `unit_of` 是**从 0 开始**编号的(户 0 是真实的一户)。
|
||||||
|
⇒ 用 `n()` 会让"字段缺失"与"第 0 户"变成同一个值,
|
||||||
|
布局层就无法区分,线上实测表现为「我与妻子被拆成两户」。
|
||||||
|
这里统一成 -1 表示"没有户号",布局层只判 `>= 0`。
|
||||||
|
⚠️ 别改回 `n(...)`,也别在布局层用 `> 0`。 */
|
||||||
|
unit: r['unit'] !== null && r['unit'] !== undefined ? n(r['unit'] as number) : -1,
|
||||||
// ⚠️ 从 `Record<string, Object>` 里取值必须**显式断言**成目标类型 ——
|
// ⚠️ 从 `Record<string, Object>` 里取值必须**显式断言**成目标类型 ——
|
||||||
// ArkTS 不会自动把 `Object` 收窄成 `number[]`(编译期报
|
// ArkTS 不会自动把 `Object` 收窄成 `number[]`(编译期报
|
||||||
// "Argument of type 'Object' is not assignable to 'number[]'")。
|
// "Argument of type 'Object' is not assignable to 'number[]'")。
|
||||||
|
|||||||
@@ -349,6 +349,14 @@ export interface FbKinPerson {
|
|||||||
/** 代数:0 = 我,负 = 长辈,正 = 晚辈 */
|
/** 代数:0 = 我,负 = 长辈,正 = 晚辈 */
|
||||||
gen: number;
|
gen: number;
|
||||||
avatarColor: string;
|
avatarColor: string;
|
||||||
|
/**
|
||||||
|
* 头像(服务端 OSS 相对路径,如 `avatars/xxx.jpg`;空 = 这个人没传过头像)。
|
||||||
|
*
|
||||||
|
* ⚠️ v73 加:家谱卡片改成"头像在上"之后要用真图。
|
||||||
|
* 服务端 `/api/v1/kin` 一直就返回这个字段,只是端上以前画圆点用不着、没解析。
|
||||||
|
* 与 `FbRosterItem.avatar` **同一个含义**,拼地址同样走 `fbImageSrc()`。
|
||||||
|
*/
|
||||||
|
avatar: string;
|
||||||
/** 户(夫妻单元)号 */
|
/** 户(夫妻单元)号 */
|
||||||
unit: number;
|
unit: number;
|
||||||
parents: number[];
|
parents: number[];
|
||||||
|
|||||||
@@ -168,21 +168,10 @@ function isSubsequence(q: string, s: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* 家谱里的一代(一层)。
|
* 家谱里的一代(一层)。
|
||||||
*
|
*
|
||||||
* ⚠️ 由 `FbKinRing`(旧的"三圈"演示结构)改成"按代数分层" ——
|
* ⚠️ v73:这个接口连带「列表」视图一起撤掉了 —— 家谱现在只有图一种形态,
|
||||||
* 因为真实接口 `/api/v1/kin` 给的就是**按代数**的人(`gen` 字段),
|
* 代数分层由**图谱的纵向位置**表达(长辈在上、晚辈在下),
|
||||||
* 而"三圈"(直系/其他亲属)是当初照着演示数据设计的分法,
|
* 不再需要「这一代叫什么名字」这种中间结构。
|
||||||
* 与真实数据的形状对不上。
|
|
||||||
*/
|
*/
|
||||||
interface FbKinGen {
|
|
||||||
/** 代数:0 = 我,负 = 长辈,正 = 晚辈 */
|
|
||||||
gen: number;
|
|
||||||
/** 层名,如「父母辈」 */
|
|
||||||
title: string;
|
|
||||||
/** 说明,如「父辈与母辈」 */
|
|
||||||
sub: string;
|
|
||||||
/** 这一代的人 */
|
|
||||||
people: FbKinPerson[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@ComponentV2
|
@ComponentV2
|
||||||
export struct FbPeoplePage {
|
export struct FbPeoplePage {
|
||||||
@@ -322,12 +311,10 @@ export struct FbPeoplePage {
|
|||||||
/** 家谱的同步提示(与名册分开:两批数据可能一个成功一个失败) */
|
/** 家谱的同步提示(与名册分开:两批数据可能一个成功一个失败) */
|
||||||
@Local kinSyncNote: string = '';
|
@Local kinSyncNote: string = '';
|
||||||
/**
|
/**
|
||||||
* 家谱的显示方式:'graph'(知识图谱,默认)/ 'list'(分层列表)。
|
* ⚠️ v73:原来这里有 `kinMode`('graph' / 'list' 两种视图)。
|
||||||
*
|
* 用户 2026-10-21 要求去掉列表、只留图谱 ⇒ 状态本身也不用了。
|
||||||
* 用户要求把它做成知识图谱那样的图,所以默认给图谱。
|
* 「列表」那套是按代数分组的通讯录,名册页已经有同样的能力。
|
||||||
* 但保留列表:小屏上人多了图会挤,列表能看清每个人的关系摘要。
|
|
||||||
*/
|
*/
|
||||||
@Local kinMode: string = 'graph';
|
|
||||||
/** 我在家谱里的 id(图谱里要标出「我」) */
|
/** 我在家谱里的 id(图谱里要标出「我」) */
|
||||||
@Local kinMeId: number = 0;
|
@Local kinMeId: number = 0;
|
||||||
private repo: FbPersonRepo = FbPersonRepo.inst();
|
private repo: FbPersonRepo = FbPersonRepo.inst();
|
||||||
@@ -568,8 +555,8 @@ export struct FbPeoplePage {
|
|||||||
*/
|
*/
|
||||||
private confirmDelete(): void {
|
private confirmDelete(): void {
|
||||||
/* 🔴 v59e:删除入口从"简要卡"挪到了"详情卡底部" ⇒ 目标人先取 `detailPerson`。
|
/* 🔴 v59e:删除入口从"简要卡"挪到了"详情卡底部" ⇒ 目标人先取 `detailPerson`。
|
||||||
⚠️ 不能只取 `quickSource()`:详情有可能是**从家谱那行直接打开**的
|
⚠️ 不能只取 `quickSource()`:详情有可能是**从家谱图上直接点开**的
|
||||||
(`kinRow` 的 onClick 直接写 `detailPerson`,`quickPerson` 一直是 null)
|
(点卡片直接写 `detailPerson`,`quickPerson` 一直是 null)
|
||||||
⇒ 那样点删除会**静默什么都不发生**(`who === null` 就 return 了)。
|
⇒ 那样点删除会**静默什么都不发生**(`who === null` 就 return 了)。
|
||||||
这正是"按钮点了没反应"最难查的一类:没有报错、没有日志。 */
|
这正是"按钮点了没反应"最难查的一类:没有报错、没有日志。 */
|
||||||
const who: FbRosterPerson | null =
|
const who: FbRosterPerson | null =
|
||||||
@@ -1385,17 +1372,55 @@ export struct FbPeoplePage {
|
|||||||
* ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。
|
* ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。
|
||||||
*/
|
*/
|
||||||
private openDetail(id: number): void {
|
private openDetail(id: number): void {
|
||||||
const raw: FbRosterPerson | undefined = this.rawById.get(id);
|
/* 🔴🔴 v73 真 bug:点家谱卡片**静默没反应**。
|
||||||
if (raw === undefined) {
|
根因:这里原来只从 `rawById` 取,而 `rawById` 来自 `repo.list()`
|
||||||
return;
|
—— 那条 SQL 带 `JOIN person_roster`,**只包含"我挑进名册的人"**。
|
||||||
}
|
家谱里的人(父母、岳父母、配偶)**不一定在我的名册里** ⇒ 查不到 ⇒ 直接 return。
|
||||||
this.detailPerson = raw;
|
字面上就是"点了没反应",而且不打日志的话 hilog 一个字都抓不到。
|
||||||
const token: string = FB_TOKEN_STORE.get();
|
|
||||||
if (token === '') {
|
修法分两段(与 `byId()` 的注释同一条思路):
|
||||||
return;
|
① 名册里有 ⇒ 立刻用本地那份开浮层(不等网络,点开就有内容);
|
||||||
}
|
② 名册里没有 ⇒ 先空着把浮层**打开**(`detailPerson` 用一个占位),
|
||||||
|
再异步 `byId()` / 网络详情补上 —— **不能**因为本地没缓存就不响应点击。
|
||||||
|
⚠️ 注意 `detailPerson` 是 `FbRosterPerson`,不能塞 `null` 当作"只开框",
|
||||||
|
所以第 ② 种情况必须先构造一个最小可渲染对象(只有 id + 姓名)。
|
||||||
|
*/
|
||||||
|
const cached: FbRosterPerson | undefined = this.rawById.get(id);
|
||||||
|
hilog.info(DOMAIN, TAG, 'openDetail(%{public}d):名册命中=%{public}s(名册共 %{public}d 人)',
|
||||||
|
id, (cached !== undefined ? 'yes' : 'no'), this.rawById.size);
|
||||||
|
|
||||||
this.detailToken += 1;
|
this.detailToken += 1;
|
||||||
const my: number = this.detailToken;
|
const my: number = this.detailToken;
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
this.detailPerson = cached;
|
||||||
|
} else {
|
||||||
|
// ② 名册里没有 —— 先用家谱那份拼一个最小对象把浮层打开
|
||||||
|
const kin: FbKinPerson | undefined = this.kinPeople.find(
|
||||||
|
(p: FbKinPerson): boolean => p.id === id);
|
||||||
|
if (kin === undefined) {
|
||||||
|
hilog.warn(DOMAIN, TAG, 'openDetail(%{public}d):名册与家谱都没有这个人', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.detailPerson = placeholderPerson(kin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token: string = FB_TOKEN_STORE.get();
|
||||||
|
if (token === '') {
|
||||||
|
hilog.warn(DOMAIN, TAG, 'openDetail(%{public}d):令牌为空,浮层已开但不取详情', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 本地库补一份(家谱那边的人往往不 JOIN roster,这里能补到)
|
||||||
|
this.repo.byId(id).then((local: FbRosterPerson | null): void => {
|
||||||
|
if (local === null || my !== this.detailToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.detailPerson !== null && this.detailPerson.id === id) {
|
||||||
|
this.detailPerson = local;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.repo.fetchDetail(id, token).then((full: FbRosterPerson | null): void => {
|
this.repo.fetchDetail(id, token).then((full: FbRosterPerson | null): void => {
|
||||||
// ① 期间用户已经关了浮层、或又点开别人 ⇒ 丢弃这次结果
|
// ① 期间用户已经关了浮层、或又点开别人 ⇒ 丢弃这次结果
|
||||||
if (full === null || my !== this.detailToken) {
|
if (full === null || my !== this.detailToken) {
|
||||||
@@ -1482,7 +1507,7 @@ export struct FbPeoplePage {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 家谱的说明条("以我为中心" + 图谱/列表切换)。
|
* 家谱的说明条(「以我为中心」+ 人数)。
|
||||||
*
|
*
|
||||||
* 🔴 v68:这里原来还有一颗「补关系」按钮,**从来没有 `onTap`** ——
|
* 🔴 v68:这里原来还有一颗「补关系」按钮,**从来没有 `onTap`** ——
|
||||||
* 点了一点反应都没有(全仓库扫过,只剩这一颗)。
|
* 点了一点反应都没有(全仓库扫过,只剩这一颗)。
|
||||||
@@ -1491,9 +1516,11 @@ export struct FbPeoplePage {
|
|||||||
* ⇒ 与其留一个永远点不动的按钮,先撤掉(免得被当成"坏了")。
|
* ⇒ 与其留一个永远点不动的按钮,先撤掉(免得被当成"坏了")。
|
||||||
* 要做得先加 `POST /api/v1/persons/<id>/relations`,那是独立一批活。
|
* 要做得先加 `POST /api/v1/persons/<id>/relations`,那是独立一批活。
|
||||||
*
|
*
|
||||||
* ⚠️ 从 `kinView` 里抽出来的原因:**列表模式**要把它**一起放进滚动的 Scroll**
|
* 🔴 v73(用户 2026-10-21):右侧的「图谱 / 列表」切换**整块撤掉**。
|
||||||
* (这样它才跟着内容滚走),而**图谱模式**它是固定的一层。
|
* 原话是"手机端这个家谱实在是很难看",而那个"列表"视图
|
||||||
* 抽成一个 Builder,两处引用同一份实现,不会改一处漏一处。
|
* 本质就是个按代数分组的通讯录 —— 名册页早就有同样的能力
|
||||||
|
* (还能搜索、筛选标签),留着它只会让人以为家谱有两种画法。
|
||||||
|
* 现在家谱只有一种形态:**图**。
|
||||||
*/
|
*/
|
||||||
@Builder
|
@Builder
|
||||||
kinHeader() {
|
kinHeader() {
|
||||||
@@ -1509,15 +1536,6 @@ export struct FbPeoplePage {
|
|||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
|
||||||
// 图谱 / 列表 切换
|
|
||||||
Row({ space: 0 }) {
|
|
||||||
this.kinModeBtn('图谱', 'graph')
|
|
||||||
this.kinModeBtn('列表', 'list')
|
|
||||||
}
|
|
||||||
.padding(2)
|
|
||||||
.backgroundColor(FbColors.inputBg)
|
|
||||||
.borderRadius(FbMetric.radiusPill)
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
|
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
|
||||||
@@ -1527,13 +1545,8 @@ export struct FbPeoplePage {
|
|||||||
@Builder
|
@Builder
|
||||||
kinView() {
|
kinView() {
|
||||||
Column() {
|
Column() {
|
||||||
// 🔴 图谱模式**不跟滚**(它是 Canvas 画的一整屏图,本就没有"滚动内容"可言)
|
|
||||||
// ⇒ 本模式顶栏仍是固定的一层。
|
|
||||||
// 「列表」模式则把页头移进内层 `Scroll` 一起滚(见下面的分支)。
|
|
||||||
if (this.kinMode === 'graph') {
|
|
||||||
this.pageHeaderItem()
|
this.pageHeaderItem()
|
||||||
this.kinHeader()
|
this.kinHeader()
|
||||||
}
|
|
||||||
|
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
Text('正在读取本地数据…')
|
Text('正在读取本地数据…')
|
||||||
@@ -1549,8 +1562,8 @@ export struct FbPeoplePage {
|
|||||||
.textAlign(TextAlign.Center)
|
.textAlign(TextAlign.Center)
|
||||||
.fontSize(FbMetric.fontSmall)
|
.fontSize(FbMetric.fontSmall)
|
||||||
.fontColor(FbColors.faint)
|
.fontColor(FbColors.faint)
|
||||||
} else if (this.kinMode === 'graph') {
|
} else {
|
||||||
// ---- 知识图谱(Canvas 画:圆节点 + 连线)----
|
// ---- 家谱图(Canvas 画线 + 原生卡片,见 components/FbKinGraph.ets)----
|
||||||
Column() {
|
Column() {
|
||||||
FbKinGraphView({
|
FbKinGraphView({
|
||||||
people: this.kinPeople,
|
people: this.kinPeople,
|
||||||
@@ -1563,43 +1576,6 @@ export struct FbPeoplePage {
|
|||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: FB_DOCK_RESERVE - 24 })
|
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: FB_DOCK_RESERVE - 24 })
|
||||||
} else {
|
|
||||||
// ---- 分层列表 ----
|
|
||||||
Scroll() {
|
|
||||||
Column({ space: 0 }) {
|
|
||||||
// 🔴 **顶栏 + 页头 + 说明条**都放进这个滚动的 Scroll 里
|
|
||||||
// ⇒ 上滑时一起滚走(用户 2026-10-15 要求本页也按首页那套)。
|
|
||||||
// ⚠️ 图谱模式**不能**这样放(它不滚动,见上面的分支)。
|
|
||||||
this.pageHeaderItem()
|
|
||||||
this.kinHeader()
|
|
||||||
|
|
||||||
ForEach(this.kinGens(), (g: FbKinGen) => {
|
|
||||||
this.genBlock(g)
|
|
||||||
}, (g: FbKinGen): string => 'kg_' + g.gen.toString())
|
|
||||||
|
|
||||||
Text('只显示以我为中心、上下两代以内的亲属(手机上再往外排就看不下了)。')
|
|
||||||
.fontSize(FbMetric.fontTiny)
|
|
||||||
.fontColor(FbColors.faint)
|
|
||||||
.width('100%')
|
|
||||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 8, bottom: 20 })
|
|
||||||
|
|
||||||
// 🔴 **末尾占位**:与名册列表同理 —— 悬浮栏留白必须做成**内容的一部分**,
|
|
||||||
// 不能做成 Scroll 的 `padding(bottom)`(那会把可视底边往上收,
|
|
||||||
// 看着就是"内容没滚到屏幕最底、下面空一块")。
|
|
||||||
this.dockSpacerItem()
|
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
}
|
|
||||||
.layoutWeight(1)
|
|
||||||
.width('100%')
|
|
||||||
.scrollBar(BarState.Off)
|
|
||||||
.edgeEffect(EdgeEffect.Spring)
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
|
|
||||||
.onDidScroll(fbOnScroll)
|
|
||||||
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
|
|
||||||
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
|
|
||||||
.onTouch(fbOnTouch)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
@@ -1609,97 +1585,11 @@ export struct FbPeoplePage {
|
|||||||
/**
|
/**
|
||||||
* 一代人(家谱里的一层)。
|
* 一代人(家谱里的一层)。
|
||||||
*
|
*
|
||||||
* ⚠️ 自己居中、长辈在上、晚辈在下 —— 与"从上往下读"的直觉一致。
|
* ⚠️ v73:这个 Builder 与 `kinRow` 一起**已删除** —— 它们只服务
|
||||||
* 标题写清"这是哪一代"(父母辈 / 我这一辈 / 子女辈),
|
* 「列表」视图,而用户 2026-10-21 要求去掉列表、只留图谱。
|
||||||
* 否则用户看到一堆名字不知道是什么关系。
|
* 原来这里按代数分组、每组一个白卡片 + 行分隔线,
|
||||||
|
* 本质就是个通讯录;名册页已经有同样的能力(还能搜索/筛标签)。
|
||||||
*/
|
*/
|
||||||
@Builder
|
|
||||||
genBlock(g: FbKinGen) {
|
|
||||||
Column({ space: 0 }) {
|
|
||||||
// 代数标题
|
|
||||||
Row({ space: 8 }) {
|
|
||||||
Text(g.title)
|
|
||||||
.fontSize(FbMetric.fontSmall)
|
|
||||||
.fontWeight(FontWeight.Medium)
|
|
||||||
.fontColor(FbColors.ink)
|
|
||||||
Text(g.sub)
|
|
||||||
.fontSize(FbMetric.fontTiny)
|
|
||||||
.fontColor(FbColors.faint)
|
|
||||||
Blank()
|
|
||||||
Text(g.people.length.toString() + ' 人')
|
|
||||||
.fontSize(FbMetric.fontTiny)
|
|
||||||
.fontColor(FbColors.faint)
|
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
.padding({ left: 14, right: 14, top: 10, bottom: 6 })
|
|
||||||
.backgroundColor(FbColors.surface)
|
|
||||||
|
|
||||||
// 这一代的人
|
|
||||||
ForEach(g.people, (p: FbKinPerson, idx: number) => {
|
|
||||||
Column({ space: 0 }) {
|
|
||||||
this.kinRow(p)
|
|
||||||
if (idx < g.people.length - 1) {
|
|
||||||
Divider()
|
|
||||||
.strokeWidth(0.5)
|
|
||||||
.color(FbColors.line)
|
|
||||||
.margin({ left: 66 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
}, (p: FbKinPerson): string => 'kp_' + p.id.toString())
|
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 10 })
|
|
||||||
.backgroundColor(FbColors.surface)
|
|
||||||
.borderRadius(FbMetric.radius)
|
|
||||||
.clip(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 家谱里的一行(头像 + 称呼 + 称谓 + 关系摘要) */
|
|
||||||
@Builder
|
|
||||||
kinRow(p: FbKinPerson) {
|
|
||||||
Row({ space: 12 }) {
|
|
||||||
FbAvatar({
|
|
||||||
name: p.shown,
|
|
||||||
color: p.avatarColor !== '' ? p.avatarColor : '#8A6B4B',
|
|
||||||
dia: 40,
|
|
||||||
fontSizeVp: 16
|
|
||||||
})
|
|
||||||
Column({ space: 3 }) {
|
|
||||||
Row({ space: 6 }) {
|
|
||||||
Text(p.shown)
|
|
||||||
.fontSize(FbMetric.fontBody)
|
|
||||||
.fontColor(FbColors.ink)
|
|
||||||
.fontWeight(p.gen === 0 && p.label === '我' ? FontWeight.Bold : FontWeight.Medium)
|
|
||||||
.maxLines(1)
|
|
||||||
if (p.label !== '') {
|
|
||||||
FbTag({ text: p.label, tone: 'muted' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alignItems(VerticalAlign.Center)
|
|
||||||
|
|
||||||
// 关系摘要(有几个父母/子女/配偶)—— 让人一眼看出"这人有几个孩子"
|
|
||||||
Text(this.relSummary(p))
|
|
||||||
.fontSize(10)
|
|
||||||
.fontColor(FbColors.faint)
|
|
||||||
.maxLines(1)
|
|
||||||
}
|
|
||||||
.alignItems(HorizontalAlign.Start)
|
|
||||||
.layoutWeight(1)
|
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
.padding({ left: 14, right: 14, top: 11, bottom: 11 })
|
|
||||||
.alignItems(VerticalAlign.Center)
|
|
||||||
.onClick((): void => {
|
|
||||||
// 点家谱里的人 ⇒ 复用名册的详情浮层(同一个人、同一份数据源)
|
|
||||||
const raw: FbRosterPerson | undefined = this.rawById.get(p.id);
|
|
||||||
if (raw !== undefined) {
|
|
||||||
this.detailPerson = raw;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 一圈亲属卡 */
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 共用小件
|
// 共用小件
|
||||||
@@ -1770,99 +1660,10 @@ export struct FbPeoplePage {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 三圈:我 / 直系 / 其他亲属 */
|
/* ⚠️ v73:`kinModeBtn` / `kinGens` / `genTitle` / `genSub` / `relSummary`
|
||||||
/** 家谱视图切换按钮(图谱 / 列表) */
|
五个方法一起删了 —— 它们只为「列表」视图服务,而那个视图已撤。
|
||||||
@Builder
|
代数分层的信息现在由**图谱本身的纵向位置**表达(长辈在上、晚辈在下),
|
||||||
kinModeBtn(label: string, key: string) {
|
不再需要「父母辈 / 我这一辈」这种文字小标题。 */
|
||||||
Text(label)
|
|
||||||
.fontSize(FbMetric.fontTiny)
|
|
||||||
.fontColor(this.kinMode === key ? Color.White : FbColors.muted)
|
|
||||||
.backgroundColor(this.kinMode === key ? '#4A7C82' : Color.Transparent)
|
|
||||||
.borderRadius(FbMetric.radiusPill)
|
|
||||||
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
|
|
||||||
.onClick((): void => {
|
|
||||||
this.kinMode = key;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 家谱:按代数分组(服务端已按 gen 排序) */
|
|
||||||
private kinGens(): FbKinGen[] {
|
|
||||||
const map: Map<number, FbKinPerson[]> = new Map<number, FbKinPerson[]>();
|
|
||||||
for (const p of this.kinPeople) {
|
|
||||||
const cur: FbKinPerson[] | undefined = map.get(p.gen);
|
|
||||||
if (cur !== undefined) {
|
|
||||||
cur.push(p);
|
|
||||||
} else {
|
|
||||||
map.set(p.gen, [p]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ⚠️ Map 的键是数字,直接 sort 会按**字符串**排(-1, 0, 1, -2 → -1,-2,0,1)
|
|
||||||
// ⇒ 先转成数组、按数值排。
|
|
||||||
const gens: number[] = [];
|
|
||||||
map.forEach((_v: FbKinPerson[], k: number): void => {
|
|
||||||
gens.push(k);
|
|
||||||
});
|
|
||||||
gens.sort((a: number, b: number): number => a - b);
|
|
||||||
|
|
||||||
const out: FbKinGen[] = [];
|
|
||||||
for (const g of gens) {
|
|
||||||
const ps: FbKinPerson[] = map.get(g) !== undefined ? (map.get(g) as FbKinPerson[]) : [];
|
|
||||||
const it: FbKinGen = {
|
|
||||||
gen: g,
|
|
||||||
title: this.genTitle(g),
|
|
||||||
sub: this.genSub(g),
|
|
||||||
people: ps,
|
|
||||||
};
|
|
||||||
out.push(it);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 代数 → 中文层名 */
|
|
||||||
private genTitle(g: number): string {
|
|
||||||
if (g === 0) {
|
|
||||||
return '我这一辈';
|
|
||||||
}
|
|
||||||
if (g === -1) {
|
|
||||||
return '父母辈';
|
|
||||||
}
|
|
||||||
if (g <= -2) {
|
|
||||||
return '祖辈(上 ' + (-g).toString() + ' 代)';
|
|
||||||
}
|
|
||||||
if (g === 1) {
|
|
||||||
return '子女辈';
|
|
||||||
}
|
|
||||||
return '孙辈(下 ' + g.toString() + ' 代)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 代数说明 */
|
|
||||||
private genSub(g: number): string {
|
|
||||||
if (g === 0) {
|
|
||||||
return '我与配偶、兄弟姐妹';
|
|
||||||
}
|
|
||||||
if (g < 0) {
|
|
||||||
return '父辈与母辈';
|
|
||||||
}
|
|
||||||
return '孩子与侄甥';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 「N 位父母 · N 位子女」这类摘要 */
|
|
||||||
private relSummary(p: FbKinPerson): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (p.parents.length > 0) {
|
|
||||||
parts.push('父母 ' + p.parents.length.toString());
|
|
||||||
}
|
|
||||||
if (p.spouses.length > 0) {
|
|
||||||
parts.push('配偶 ' + p.spouses.length.toString());
|
|
||||||
}
|
|
||||||
if (p.siblings.length > 0) {
|
|
||||||
parts.push('兄弟姐妹 ' + p.siblings.length.toString());
|
|
||||||
}
|
|
||||||
if (p.children.length > 0) {
|
|
||||||
parts.push('子女 ' + p.children.length.toString());
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? parts.join(' · ') : '暂无亲属关系';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 家谱顶部的人数说明 */
|
/** 家谱顶部的人数说明 */
|
||||||
private kinTotalText(): string {
|
private kinTotalText(): string {
|
||||||
@@ -1878,3 +1679,57 @@ export struct FbPeoplePage {
|
|||||||
return hit !== undefined ? hit.avatarColor : '#A84A28';
|
return hit !== undefined ? hit.avatarColor : '#A84A28';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用「家谱里的那个人」拼一个**最小可渲染**的 `FbRosterPerson`。
|
||||||
|
*
|
||||||
|
* 🔴 为什么需要(v73 真 bug):家谱里的人**不一定在我名册里**
|
||||||
|
* (`FbPersonRepo.list()` 带 `JOIN person_roster`,只含"我挑进名册的人"),
|
||||||
|
* 那时点卡片以前是**静默 return ⇒ 点了没反应**。
|
||||||
|
* 这里先拼一个只够画浮层头部的对象,**让浮层立刻打开**,
|
||||||
|
* 随后 `repo.byId()` / `fetchDetail()` 到了再原地升级。
|
||||||
|
*
|
||||||
|
* ⚠️ 只填家谱真有的那几项,其余给空值 —— 不要瞎猜(填错比空着更糟:
|
||||||
|
* 用户会以为"系统里有这个资料")。本地库/接口补上来后会被整份替换掉。
|
||||||
|
*/
|
||||||
|
function placeholderPerson(k: FbKinPerson): FbRosterPerson {
|
||||||
|
const p: FbRosterPerson = {
|
||||||
|
id: k.id,
|
||||||
|
name: k.name,
|
||||||
|
shown: k.shown !== '' ? k.shown : k.name,
|
||||||
|
// 家谱那份的称谓是服务端算好的,这里能用
|
||||||
|
label: k.label,
|
||||||
|
tags: [],
|
||||||
|
avatarColor: k.avatarColor,
|
||||||
|
avatar: k.avatar,
|
||||||
|
birthdaySolar: '',
|
||||||
|
birthdayFull: '',
|
||||||
|
birthdayLunar: '',
|
||||||
|
phone: '',
|
||||||
|
phones: [],
|
||||||
|
initials: '',
|
||||||
|
org: '',
|
||||||
|
title: '',
|
||||||
|
slogan: '',
|
||||||
|
email: '',
|
||||||
|
addrCity: '',
|
||||||
|
gender: '',
|
||||||
|
note: '',
|
||||||
|
isAdmin: false,
|
||||||
|
addrCountry: '',
|
||||||
|
addrProvince: '',
|
||||||
|
addrDistrict: '',
|
||||||
|
address: '',
|
||||||
|
birthRank: '',
|
||||||
|
birthdayLunarLeap: false,
|
||||||
|
birthdayLunarRaw: '',
|
||||||
|
contacts: { phone: [], email: [], address: [], url: [] },
|
||||||
|
jobs: [],
|
||||||
|
myNote: '',
|
||||||
|
myNickname: '',
|
||||||
|
idCardMasked: '',
|
||||||
|
canEditAll: false,
|
||||||
|
isSelf: false
|
||||||
|
};
|
||||||
|
return p;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user