Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbKinGraph.ets
T
laoyang 13f716c936 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
2026-10-21 22:18:43 +08:00

305 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
*
* ============================================================
* 🔴 v73:从「Canvas 全手绘」改成「Canvas 只画线 + 原生卡片」
* ============================================================
* 用户 2026-10-21 原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」
*
* 为什么不能再全用 Canvas(原实现 287 行,只有彩色圆点 + 首字):
* · Canvas **没有原生字体**:只能写 `'12px sans-serif'` 这种泛字体,
* 中文渲染出来又平又硬,和 App 其他地方的排版完全两个味道;
* · Canvas **没有圆角/阴影/图片裁切**:卡片做不出「纸白 + 细边 + 微投影」,
* 只能退化成纯色圆点 —— 这就是用户说的"难看";
* · Canvas **不是响应式的**:数据一变要自己重画,容易漏。
* ⇒ 现在:**卡片用原生 `Column`/`Text`/`FbAvatar`**,
* 字体、圆角、阴影、头像裁切全部走 App 同一套,视觉自然统一。
*
* ⚠️ 那为什么连线还留在 Canvas 上,不用 `Path` 组件?
* `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 { FbKinPerson } from '../model/Types';
import { fbImageSrc } from '../data/FbAttachQueue';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
@ComponentV2
export struct FbKinGraphView {
/** 家谱里的人 */
@Param people: FbKinPerson[] = [];
/** 我(居中显示、加粗) */
@Param meId: number = 0;
/** 点某个节点(弹详情) */
@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 ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
aboutToAppear(): void {
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() {
Scroll(this.scroller) {
Stack({ alignContent: Alignment.TopStart }) {
// ---- ① 连线层(**在卡片下面**:Canvas 没有 z-index,靠节点顺序)----
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%')
.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)
.onAreaChange((_o: Area, now: Area): void => {
this.viewW = Number(now.width);
this.viewH = Number(now.height);
this.centerOnMe();
})
}
/**
* 画全部连线(先血缘、后夫妻 —— 夫妻线更粗,要压在上面)。
*
* ⚠️ Canvas 不是响应式的:布局变了必须**自己重画**。
* 这里在 `onReady`(首帧)与 `onAreaChange` 各调一次。
*/
private drawEdges(): void {
if (this.contentW <= 0 || this.contentH <= 0) {
return;
}
const c: CanvasRenderingContext2D = this.ctx;
c.clearRect(0, 0, this.contentW, this.contentH);
// ---- 血缘:父母单元中点 → 下 → 横 → 下 → 子女顶端(与 Web 端同款折线)----
for (const e of this.layout.edges) {
if (e.kind === 'spouse') {
continue;
}
c.beginPath();
c.lineWidth = 1.4;
c.strokeStyle = '#C9BDA9';
c.lineCap = 'round';
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();
}
// ---- 夫妻:卡片之间的一小段横线(粗、品牌色,与血缘一眼区分)----
for (const e of this.layout.edges) {
if (e.kind !== 'spouse') {
continue;
}
c.beginPath();
c.lineWidth = 2.2;
c.strokeStyle = '#C97B6A';
c.lineCap = 'round';
c.moveTo(e.x1, e.y1);
c.lineTo(e.x2, e.y2);
c.stroke();
}
}
/**
* 一张人物卡。
*
* 视觉与 Web 端 `.kin-card` 对齐:纸白底 + 细边 + 微投影,
* 头像在上(服务端头像是 1:1 裁好的),姓名在下,称谓再下一行。
* 「我」用品牌色描边 + 姓名加粗 —— 一眼认出中心。
*/
@Builder
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 : '(无名)'))
.fontSize(12)
.fontWeight(c.p.id === this.meId ? FontWeight.Medium : FontWeight.Normal)
.fontColor(c.p.id === this.meId ? FbColors.brandInk : FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Center)
.margin({ top: 4 })
// 称谓:「我」不重复写一遍(卡片上写"我"是废话)
if (c.p.label !== '' && c.p.label !== '我') {
Text(c.p.label)
.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 })
}
}
.width(KinDim.CARD_W)
.height(KinDim.CARD_H)
.padding({ top: 4, bottom: 3, left: 2, right: 2 })
// ⚠️ `.position()` 是**左上角**定位,布局给的是**中心**坐标 ⇒ 各减一半
.position({
x: c.x - KinDim.CARD_W / 2,
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);
})
}
}