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:
@@ -1,34 +1,39 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
|
||||
*
|
||||
* 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。
|
||||
* ============================================================
|
||||
* 🔴 v73:从「Canvas 全手绘」改成「Canvas 只画线 + 原生卡片」
|
||||
* ============================================================
|
||||
* 用户 2026-10-21 原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」
|
||||
*
|
||||
* 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼:
|
||||
* · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻),
|
||||
* 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`,
|
||||
* 比直接算坐标画线**难得多**、也更难调;
|
||||
* · 连线要**画在节点下面**(否则线会压在头像上)——
|
||||
* Canvas 一次画完,天然有先后顺序。
|
||||
* ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。
|
||||
* 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。
|
||||
* 为什么不能再全用 Canvas(原实现 287 行,只有彩色圆点 + 首字):
|
||||
* · Canvas **没有原生字体**:只能写 `'12px sans-serif'` 这种泛字体,
|
||||
* 中文渲染出来又平又硬,和 App 其他地方的排版完全两个味道;
|
||||
* · Canvas **没有圆角/阴影/图片裁切**:卡片做不出「纸白 + 细边 + 微投影」,
|
||||
* 只能退化成纯色圆点 —— 这就是用户说的"难看";
|
||||
* · Canvas **不是响应式的**:数据一变要自己重画,容易漏。
|
||||
* ⇒ 现在:**卡片用原生 `Column`/`Text`/`FbAvatar`**,
|
||||
* 字体、圆角、阴影、头像裁切全部走 App 同一套,视觉自然统一。
|
||||
*
|
||||
* 布局规则(与"从上往下读家谱"的直觉一致):
|
||||
* · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上);
|
||||
* · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边;
|
||||
* · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。
|
||||
* ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**,
|
||||
* 力导向会把人随机甩开,反而看不懂谁是长辈。
|
||||
* ⚠️ 那为什么连线还留在 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';
|
||||
|
||||
/** 一个节点在画布上的位置 */
|
||||
interface KinNode {
|
||||
p: FbKinPerson;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbKinGraphView {
|
||||
@@ -40,249 +45,261 @@ export struct FbKinGraphView {
|
||||
@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);
|
||||
/** 画布尺寸(onAreaChange 拿到) */
|
||||
@Local cw: number = 0;
|
||||
@Local ch: number = 0;
|
||||
/** 算好的节点位置(画与点击都用它) */
|
||||
private nodes: KinNode[] = [];
|
||||
|
||||
/** 节点半径(vp) */
|
||||
private readonly R: number = 22;
|
||||
|
||||
build() {
|
||||
Canvas(this.ctx)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(FbColors.paper)
|
||||
// ⚠️ `onReady` 只在**首帧**触发一次;后续尺寸变化要靠 `onAreaChange`。
|
||||
// 只用 onReady 的话,切到家谱页(宽高这时才定下来)会画不出东西。
|
||||
.onReady((): void => {
|
||||
this.redraw();
|
||||
})
|
||||
.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;
|
||||
}
|
||||
}
|
||||
})
|
||||
aboutToAppear(): void {
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重画整张图(**先算位置、再画线、最后画节点**)。
|
||||
* `@Param` 变化时重算。
|
||||
*
|
||||
* ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。
|
||||
* Canvas 没有 z-index,只能靠绘制顺序。
|
||||
* ⚠️ 用 `@Monitor` 而**不是**在 `build()` 里算:`build()` 每次重渲染都跑,
|
||||
* 在那里算布局等于每帧做一遍「11 人 × 24 轮松弛」。
|
||||
*/
|
||||
private redraw(): void {
|
||||
if (this.cw <= 0 || this.ch <= 0 || this.people.length === 0) {
|
||||
@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;
|
||||
}
|
||||
this.layout();
|
||||
this.drawEdges();
|
||||
this.drawNodes();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 算节点位置(按代数分层)。
|
||||
* 几何变了就补一次重画。
|
||||
*
|
||||
* ⚠️ 结果存进 `this.nodes` —— 点击命中检测要用**同一份坐标**,
|
||||
* 重算一遍容易出现"看到的和点到的不一样"。
|
||||
* 🔴 为什么光有 `onAreaChange` 不够:它只在**宽高变化**时触发。
|
||||
* 而布局变了、总宽高却没变的情况是真实存在的 ——
|
||||
* 比如两个人互换了位置、或者某个单元整体挪了 20vp。
|
||||
* 那时 Canvas 里留的是**旧坐标的线**,卡片却在**新位置** ⇒ 线穿到卡片外面。
|
||||
* `geoStamp` 是坐标指纹,它变就一定重画。
|
||||
*/
|
||||
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;
|
||||
@Monitor('geoStamp')
|
||||
onGeoChange(): void {
|
||||
// 推到下一个 tick:此时 `contentW/contentH` 与 Canvas 的布局都已生效
|
||||
setTimeout((): void => {
|
||||
this.drawEdges();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** 按 id 找节点 */
|
||||
private nodeOf(id: number): KinNode | undefined {
|
||||
for (const n of this.nodes) {
|
||||
if (n.p.id === id) {
|
||||
return n;
|
||||
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)
|
||||
}
|
||||
return undefined;
|
||||
.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 {
|
||||
const c: CanvasRenderingContext2D = this.ctx;
|
||||
c.lineWidth = 1.6;
|
||||
c.strokeStyle = '#B9A896';
|
||||
|
||||
for (const n of this.nodes) {
|
||||
// ---- 父母 → 子女(折线:下-横-下)----
|
||||
const parents: number[] = n.p.parents;
|
||||
for (const pid of parents) {
|
||||
const par: KinNode | undefined = this.nodeOf(pid);
|
||||
if (par === undefined) {
|
||||
continue;
|
||||
}
|
||||
// 折线中点取两者的横向中点,纵向上 1/3 处拐
|
||||
const midY: number = par.y + (n.y - par.y) * 0.55;
|
||||
c.beginPath();
|
||||
c.moveTo(par.x, par.y + this.R);
|
||||
c.lineTo(par.x, midY);
|
||||
c.lineTo(n.x, midY);
|
||||
c.lineTo(n.x, n.y - this.R);
|
||||
c.stroke();
|
||||
}
|
||||
|
||||
// ---- 夫妻(水平短线,**画粗一点**,与血缘线区分)----
|
||||
for (const sid of n.p.spouses) {
|
||||
// 只画一次(按 id 大小去重,否则同一对会画两条)
|
||||
if (sid < n.p.id) {
|
||||
continue;
|
||||
}
|
||||
const sp: KinNode | undefined = this.nodeOf(sid);
|
||||
if (sp === undefined) {
|
||||
continue;
|
||||
}
|
||||
c.save();
|
||||
c.lineWidth = 3;
|
||||
c.strokeStyle = '#C97B6A';
|
||||
c.beginPath();
|
||||
c.moveTo(n.x + this.R, n.y);
|
||||
c.lineTo(sp.x - this.R, sp.y);
|
||||
c.stroke();
|
||||
c.restore();
|
||||
}
|
||||
if (this.contentW <= 0 || this.contentH <= 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 画节点(圆 + 首字 + 称谓) */
|
||||
private drawNodes(): void {
|
||||
const c: CanvasRenderingContext2D = this.ctx;
|
||||
for (const n of this.nodes) {
|
||||
const isMe: boolean = n.p.id === this.meId;
|
||||
c.clearRect(0, 0, this.contentW, this.contentH);
|
||||
|
||||
// 圆底(用头像色)
|
||||
// ---- 血缘:父母单元中点 → 下 → 横 → 下 → 子女顶端(与 Web 端同款折线)----
|
||||
for (const e of this.layout.edges) {
|
||||
if (e.kind === 'spouse') {
|
||||
continue;
|
||||
}
|
||||
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.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) {
|
||||
c.font = '11px sans-serif';
|
||||
c.textAlign = 'left';
|
||||
c.fillStyle = '#8A7A6A';
|
||||
c.fillText(this.genLabel(n.p.gen), 8, n.y - this.R - 8);
|
||||
c.textAlign = 'center';
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/** 某代里排在最前的那个节点(用来放代数标签) */
|
||||
private firstOfGen(gen: number): KinNode | undefined {
|
||||
for (const n of this.nodes) {
|
||||
if (n.p.gen === gen) {
|
||||
return n;
|
||||
/**
|
||||
* 一张人物卡。
|
||||
*
|
||||
* 视觉与 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 })
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 代数 → 层名(与家谱页的分层标题同口径) */
|
||||
private genLabel(g: number): string {
|
||||
if (g === 0) {
|
||||
return '我这一辈';
|
||||
}
|
||||
if (g === -1) {
|
||||
return '父母辈';
|
||||
}
|
||||
if (g <= -2) {
|
||||
return '祖辈';
|
||||
}
|
||||
if (g === 1) {
|
||||
return '子女辈';
|
||||
}
|
||||
return '孙辈';
|
||||
.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);
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user