288 lines
9.8 KiB
Plaintext
288 lines
9.8 KiB
Plaintext
/**
|
||||
|
|
* 家书 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 '孙辈';
|
|||
|
|
}
|
|||
|
|
}
|