Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbKinGraph.ets
T

288 lines
9.8 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
*
* 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。
*
* 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼:
* · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻),
* 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`,
* 比直接算坐标画线**难得多**、也更难调;
* · 连线要**画在节点下面**(否则线会压在头像上)——
* Canvas 一次画完,天然有先后顺序。
* ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。
* 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。
*
* 布局规则(与"从上往下读家谱"的直觉一致):
* · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上);
* · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边;
* · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。
* ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**,
* 力导向会把人随机甩开,反而看不懂谁是长辈。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbKinPerson } from '../model/Types';
/** 一个节点在画布上的位置 */
interface KinNode {
p: FbKinPerson;
x: number;
y: number;
}
@ComponentV2
export struct FbKinGraphView {
/** 家谱里的人 */
@Param people: FbKinPerson[] = [];
/** 我(居中显示、加粗) */
@Param meId: number = 0;
/** 点某个节点(弹详情) */
@Event onPick: (id: number) => void = (id: number): void => {
};
/** 画布上下文(必须在组件里创建,不能跨组件共享) */
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;
}
}
})
}
/**
* 重画整张图(**先算位置、再画线、最后画节点**)。
*
* ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。
* Canvas 没有 z-index,只能靠绘制顺序。
*/
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 {
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();
}
}
}
/** 画节点(圆 + 首字 + 称谓) */
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.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';
}
}
}
/** 某代里排在最前的那个节点(用来放代数标签) */
private firstOfGen(gen: number): KinNode | undefined {
for (const n of this.nodes) {
if (n.p.gen === gen) {
return n;
}
}
return undefined;
}
/** 代数 → 层名(与家谱页的分层标题同口径) */
private genLabel(g: number): string {
if (g === 0) {
return '我这一辈';
}
if (g === -1) {
return '父母辈';
}
if (g <= -2) {
return '祖辈';
}
if (g === 1) {
return '子女辈';
}
return '孙辈';
}
}