ow
This commit is contained in:
1 parent
d2278826fb
commit
57201f2860
3 files changed
+472
-36
No files matched your search
@@ -8,9 +8,10 @@
|
||||
<button class="ftc-zoom-btn" title="放大" @click="zoomBy(ZOOM_STEP)">+</button>
|
||||
<button class="ftc-zoom-btn" title="缩小" @click="zoomBy(-ZOOM_STEP)">-</button>
|
||||
<button class="ftc-zoom-btn" title="适应画布" @click="fitGraph">⤢</button>
|
||||
<button class="ftc-zoom-btn" :title="dirTitle" @click="toggleDirection">{{ layoutDir === "h" ? "⇄" : "⇅" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else-if="!loading" description="暂无全族数据" />
|
||||
<a-empty v-else-if="!loading" :description="loadError ? `无法展示:${loadError}` : '暂无全族数据'" />
|
||||
|
||||
<template #footer>
|
||||
<a-button type="primary" @click="onCancel">关闭</a-button>
|
||||
@@ -65,9 +66,13 @@ const isMale = (g?: string) => {
|
||||
|
||||
const loading = ref(false);
|
||||
const data = ref<FamilyTreeData | null>(null);
|
||||
/** 加载失败 / 无数据的原因,显示在空状态里,便于定位问题 */
|
||||
const loadError = ref("");
|
||||
/** 接口没返回 CurrentPerson.FamilyPerson 时使用的兜底根节点(数据中的始祖) */
|
||||
const fallbackRoot = ref("");
|
||||
|
||||
const hasPerson = computed(() => !!data.value?.CurrentPerson?.FamilyPerson?.PersonId);
|
||||
const rootPersonId = computed(() => data.value?.CurrentPerson?.FamilyPerson?.PersonId ?? "");
|
||||
const hasPerson = computed(() => !!(data.value?.CurrentPerson?.FamilyPerson?.PersonId || fallbackRoot.value));
|
||||
const rootPersonId = computed(() => data.value?.CurrentPerson?.FamilyPerson?.PersonId || fallbackRoot.value || "");
|
||||
|
||||
/** 全族索引:人员信息 / 夫妻关系(双向)/ 亲子关系 / 亲子反向索引 */
|
||||
let personMap = new Map<string, any>();
|
||||
@@ -264,8 +269,8 @@ const primaryParentOf = (personId: string): string => {
|
||||
return (adopted ?? parents[0]).parentId;
|
||||
};
|
||||
|
||||
/** 构建图数据:递归一次性展示所有人员,祖先为根,配偶同行右侧,孩子列在配偶正下方 */
|
||||
const buildGraphData = () => {
|
||||
/** 构建横向布局图数据:递归一次性展示所有人员,祖先为根,配偶同行右侧,孩子列在配偶正下方 */
|
||||
const buildHorizontalData = () => {
|
||||
const nodes: any[] = [];
|
||||
const edges: any[] = [];
|
||||
const root = rootPersonId.value;
|
||||
@@ -394,6 +399,228 @@ const buildGraphData = () => {
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建纵向布局图数据:本人与配偶同行并排,孩子排在下一行并居中于各自父节点。
|
||||
* 仅改变排列方式,卡片内容与样式不变。
|
||||
*/
|
||||
const buildVerticalData = () => {
|
||||
const nodes: any[] = [];
|
||||
const edges: any[] = [];
|
||||
const root = rootPersonId.value;
|
||||
if (!root) return { nodes, edges };
|
||||
|
||||
const CARD_WIDTH = 220;
|
||||
const CARD_HEIGHT = 120;
|
||||
const SPOUSE_GAP = 24; // 配偶紧贴本人
|
||||
const H_GAP = 40; // 兄弟子树水平间隔
|
||||
const Y_STEP = CARD_HEIGHT + 40; // 上下层间隔
|
||||
|
||||
/** 布局槽位:一张卡片(本人或配偶)+ 同行配偶 + 下一行孩子 */
|
||||
interface VSlot {
|
||||
node: any;
|
||||
/** 与左邻槽位的水平间隔 */
|
||||
gap: number;
|
||||
/** 同行的配偶槽位 */
|
||||
mates: VSlot[];
|
||||
/** 下一行的孩子槽位 */
|
||||
children: VSlot[];
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const createSlot = (node: any, gap: number): VSlot => ({ node, gap, mates: [], children: [] });
|
||||
|
||||
/** 普通配偶卡片(本人无独立子树,或子树已在其他分支展开时使用) */
|
||||
const createSpouseSlot = (s: any, sid: string): VSlot =>
|
||||
createSlot(
|
||||
{
|
||||
id: sid,
|
||||
style: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: "spouse",
|
||||
personId: s.id,
|
||||
tag: "配偶",
|
||||
name: s.name,
|
||||
commonName: s.commonName,
|
||||
gender: s.gender ?? personMap.get(s.id)?.Gender,
|
||||
avatarUrl: personMap.get(s.id)?.AvatarUrl ?? "",
|
||||
loaded: true,
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
},
|
||||
},
|
||||
SPOUSE_GAP
|
||||
);
|
||||
|
||||
const buildSlot = (personId: string, gap: number, isRoot = false): VSlot | null => {
|
||||
if (visited.has(personId)) return null;
|
||||
visited.add(personId);
|
||||
|
||||
const info = personMap.get(personId) ?? {};
|
||||
const spouses = [...(spousesOf.get(personId) ?? [])].sort((a, b) => (a.orderby ?? 0) - (b.orderby ?? 0));
|
||||
const children = [...(childrenOf.get(personId) ?? [])].sort((a, b) => (a.orderby ?? 0) - (b.orderby ?? 0));
|
||||
const slot = createSlot(
|
||||
{
|
||||
id: "n-" + personId,
|
||||
style: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: isRoot ? "self" : "member",
|
||||
personId,
|
||||
tag: isRoot ? "始祖" : "族员",
|
||||
name: (info.Surname ?? "") + (info.GenealogyName ?? ""),
|
||||
commonName: info.CommonName,
|
||||
gender: info.Gender,
|
||||
avatarUrl: info.AvatarUrl ?? "",
|
||||
hasChildren: children.length > 0,
|
||||
expanded: expandedIds.value.has(personId),
|
||||
loaded: true,
|
||||
},
|
||||
},
|
||||
gap
|
||||
);
|
||||
|
||||
// 配偶与本人同行并排;有子女的配偶作为独立槽位展开其子树
|
||||
spouses.forEach((s: any, i: number) => {
|
||||
const hasSub = (childrenOf.get(s.id) ?? []).length > 0;
|
||||
const sid = "s-" + personId + "-" + i;
|
||||
if (hasSub) {
|
||||
const sub = buildSlot(s.id, SPOUSE_GAP);
|
||||
// 该人员已在其他分支展开过:跳过,避免重复卡片与「指向不存在节点的连线」
|
||||
if (!sub) return;
|
||||
slot.mates.push(sub);
|
||||
edges.push({ source: "n-" + personId, target: sub.node.id, data: { kind: "spouse" } });
|
||||
} else {
|
||||
slot.mates.push(createSpouseSlot(s, sid));
|
||||
edges.push({ source: "n-" + personId, target: sid, data: { kind: "spouse" } });
|
||||
}
|
||||
});
|
||||
|
||||
if (expandedIds.value.has(personId)) {
|
||||
children.forEach((c: any) => {
|
||||
// 一个孩子可能同时是多方之子(生父 / 过继父):由“承祧 / 过继”的一方承担其后代,
|
||||
// 其余父节点只显示一张引用卡片(不展开其后代)。
|
||||
if (primaryParentOf(c.id) === personId) {
|
||||
// 亲子连线统一在 place() 阶段按实际坐标生成
|
||||
const sub = buildSlot(c.id, H_GAP);
|
||||
if (sub) slot.children.push(sub);
|
||||
} else {
|
||||
const childInfo = personMap.get(c.id) ?? {};
|
||||
const refId = "r-" + personId + "-" + c.id;
|
||||
slot.children.push(
|
||||
createSlot(
|
||||
{
|
||||
id: refId,
|
||||
style: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: "member",
|
||||
personId: c.id,
|
||||
tag: "族员",
|
||||
name:
|
||||
(childInfo.Surname ?? c.ChildrenSurname ?? "") +
|
||||
(childInfo.GenealogyName ?? c.ChildrenGenealogyName ?? ""),
|
||||
commonName: childInfo.CommonName ?? c.ChildrenCommonName,
|
||||
gender: childInfo.Gender ?? c.gender,
|
||||
avatarUrl: childInfo.AvatarUrl ?? "",
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
loaded: true,
|
||||
},
|
||||
},
|
||||
H_GAP
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return slot;
|
||||
};
|
||||
|
||||
/** 本人所在行(本人 + 同行配偶)的宽度 */
|
||||
const rowWidth = (s: VSlot, depth = 0): number => {
|
||||
if (depth > 40) return CARD_WIDTH;
|
||||
return CARD_WIDTH + s.mates.reduce((a, m) => a + m.gap + slotWidth(m, depth + 1), 0);
|
||||
};
|
||||
|
||||
/** 槽位总宽 = max(本人所在行宽度, 其孩子一行总宽) */
|
||||
const slotWidth = (s: VSlot, depth = 0): number => {
|
||||
if (depth > 40) return CARD_WIDTH;
|
||||
const childrenWidth = s.children.reduce((a, c) => a + c.gap + slotWidth(c, depth + 1), 0);
|
||||
return Math.max(rowWidth(s, depth), childrenWidth);
|
||||
};
|
||||
|
||||
const place = (s: VSlot, depth: number, left: number) => {
|
||||
if (depth > 40) return;
|
||||
const width = slotWidth(s);
|
||||
const y = depth * Y_STEP;
|
||||
// 本人 + 同行配偶整体在本子树内居中
|
||||
const row = rowWidth(s);
|
||||
const rowLeft = left + (width - row) / 2;
|
||||
const selfX = rowLeft + CARD_WIDTH / 2;
|
||||
s.node.style.x = selfX;
|
||||
s.node.style.y = y;
|
||||
nodes.push(s.node);
|
||||
|
||||
// 配偶与本人同行并排;每个配偶槽位都会真正放置,避免出现指向不存在节点的连线
|
||||
let mx = rowLeft + CARD_WIDTH;
|
||||
s.mates.forEach((m) => {
|
||||
mx += m.gap;
|
||||
place(m, depth, mx);
|
||||
mx += slotWidth(m);
|
||||
});
|
||||
|
||||
const childrenWidth = s.children.reduce((a, c) => a + c.gap + slotWidth(c), 0);
|
||||
let cx = left + (width - childrenWidth) / 2;
|
||||
// 父子连线的横向干线高度:本行底部与下一行顶部之间的中线
|
||||
const railY = (depth + 0.5) * Y_STEP;
|
||||
s.children.forEach((c) => {
|
||||
cx += c.gap;
|
||||
place(c, depth + 1, cx);
|
||||
edges.push({
|
||||
source: s.node.id,
|
||||
target: c.node.id,
|
||||
// 折点:父卡底部中心下方 → 横向干线 → 子卡顶部中心上方
|
||||
data: { kind: "parent", points: [[selfX, railY], [c.node.style.x, railY]] },
|
||||
});
|
||||
cx += slotWidth(c);
|
||||
});
|
||||
};
|
||||
|
||||
const rootSlot = buildSlot(root, 0, true);
|
||||
if (!rootSlot) return { nodes, edges };
|
||||
|
||||
place(rootSlot, 0, 0);
|
||||
let right = slotWidth(rootSlot);
|
||||
|
||||
// 兜底:主树之外仍未被渲染、但名下还有孩子的人员(例如亲子记录只挂在配偶名下),
|
||||
// 在主树右侧单独成列展示,保证全族人员一次性展示
|
||||
personMap.forEach((_, pid) => {
|
||||
if (visited.has(pid) || pid === root) return;
|
||||
if ((childrenOf.get(pid) ?? []).length === 0) return;
|
||||
const extra = buildSlot(pid, 0);
|
||||
if (!extra) return;
|
||||
right += H_GAP * 2;
|
||||
place(extra, 0, right);
|
||||
right += slotWidth(extra);
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
/** 布局方向:h=横向(深度向右),v=纵向(深度向下) */
|
||||
const layoutDir = ref<"h" | "v">("h");
|
||||
|
||||
/** 按当前方向构建图数据 */
|
||||
const buildGraphData = () => (layoutDir.value === "v" ? buildVerticalData() : buildHorizontalData());
|
||||
|
||||
/** 方向按钮提示(按钮上的箭头表示当前方向) */
|
||||
const dirTitle = computed(() => (layoutDir.value === "h" ? "当前横向排列,点击切换为纵向" : "当前纵向排列,点击切换为横向"));
|
||||
|
||||
/** 横向 / 纵向排列切换 */
|
||||
const toggleDirection = () => {
|
||||
layoutDir.value = layoutDir.value === "h" ? "v" : "h";
|
||||
initGraph();
|
||||
};
|
||||
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
let graph: any = null;
|
||||
|
||||
@@ -410,12 +637,13 @@ const zoomBy = (delta: number) => {
|
||||
graph.zoomTo(Math.min(Math.max(next, MIN_ZOOM), 10));
|
||||
};
|
||||
|
||||
/** 还原为适配整个画布 */
|
||||
/** 还原为适配整个画布(缩到最小比例以下的需重新居中,否则整图可能落在视口之外) */
|
||||
const fitGraph = async () => {
|
||||
if (!graph) return;
|
||||
await graph.fitView();
|
||||
if (graph.getZoom() < MIN_ZOOM) {
|
||||
await graph.zoomTo(MIN_ZOOM);
|
||||
if (typeof graph.fitCenter === "function") await graph.fitCenter();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -452,14 +680,23 @@ const initGraph = async () => {
|
||||
},
|
||||
edge: {
|
||||
type: (d: any) => (d?.data?.kind === "spouse" ? "line" : "polyline"),
|
||||
style: (d: any) => ({
|
||||
stroke: d?.data?.kind === "spouse" ? "#722ed1" : "#a0a0a0",
|
||||
lineWidth: 1.5,
|
||||
lineDash: d?.data?.kind === "spouse" ? [4, 4] : undefined,
|
||||
router: d?.data?.kind === "spouse" ? undefined : { type: "orth" },
|
||||
}),
|
||||
style: (d: any) => {
|
||||
const isSpouse = d?.data?.kind === "spouse";
|
||||
return {
|
||||
stroke: isSpouse ? "#722ed1" : "#a0a0a0",
|
||||
lineWidth: 1.5,
|
||||
lineDash: isSpouse ? [4, 4] : undefined,
|
||||
// 纵向布局:用布局阶段算好的折点(父卡底部中心 → 横向干线 → 子卡顶部中心)
|
||||
// 横向布局:沿用正交路由
|
||||
...(isSpouse
|
||||
? {}
|
||||
: layoutDir.value === "v"
|
||||
? { controlPoints: d?.data?.points ?? [] }
|
||||
: { router: { type: "orth" } }),
|
||||
};
|
||||
},
|
||||
},
|
||||
behaviors: ["drag-canvas", "zoom-canvas", "drag-element"],
|
||||
behaviors: ["drag-canvas", "zoom-canvas"],
|
||||
});
|
||||
graph.on("node:click", (event: any) => {
|
||||
const id = event.target?.id ?? event.item?.getID?.();
|
||||
@@ -470,9 +707,10 @@ const initGraph = async () => {
|
||||
toggleExpanded(personId);
|
||||
});
|
||||
await graph.render();
|
||||
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例,保证卡片可读
|
||||
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例并重新居中,保证卡片可见可读
|
||||
if (graph.getZoom() < MIN_ZOOM) {
|
||||
await graph.zoomTo(MIN_ZOOM);
|
||||
if (typeof graph.fitCenter === "function") await graph.fitCenter();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -486,13 +724,33 @@ const toggleExpanded = (personId: string) => {
|
||||
|
||||
const loadTree = async () => {
|
||||
const tid = props.treeId?.trim();
|
||||
if (!tid) return;
|
||||
if (!tid) {
|
||||
loadError.value = "未取到族谱 Id";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
fallbackRoot.value = "";
|
||||
try {
|
||||
const resp = await familyTreeService.FindAllByTreeId(tid);
|
||||
if (resp?.CurrentPerson?.FamilyPerson?.PersonId) {
|
||||
data.value = resp;
|
||||
// 后端未返回 CurrentPerson.FamilyPerson 时退化为「以始祖为根」,避免整张图什么都展示不出来
|
||||
const currentId = resp?.CurrentPerson?.FamilyPerson?.PersonId;
|
||||
data.value = resp ?? null;
|
||||
if (resp) {
|
||||
buildIndex(resp);
|
||||
if (currentId) {
|
||||
fallbackRoot.value = currentId;
|
||||
} else {
|
||||
// 始祖:不是任何人子女的人员;找不到则取索引中的第一位
|
||||
let top = "";
|
||||
personMap.forEach((_, pid) => {
|
||||
if (top || (parentsOf.get(pid) ?? []).length > 0) return;
|
||||
top = pid;
|
||||
});
|
||||
fallbackRoot.value = top || [...personMap.keys()][0] || "";
|
||||
}
|
||||
}
|
||||
if (hasPerson.value) {
|
||||
// 递归一次性展示所有人员:有孩子的节点默认全部展开
|
||||
const set = new Set<string>();
|
||||
childrenOf.forEach((children, pid) => {
|
||||
@@ -500,8 +758,11 @@ const loadTree = async () => {
|
||||
});
|
||||
expandedIds.value = set;
|
||||
await initGraph();
|
||||
} else {
|
||||
loadError.value = resp ? "数据中没有可展示的人员" : "接口未返回数据";
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
loadError.value = error?.message ?? String(error ?? "");
|
||||
console.log(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<button class="ftc-zoom-btn" title="放大" @click="zoomBy(ZOOM_STEP)">+</button>
|
||||
<button class="ftc-zoom-btn" title="缩小" @click="zoomBy(-ZOOM_STEP)">-</button>
|
||||
<button class="ftc-zoom-btn" title="适应画布" @click="fitGraph">⤢</button>
|
||||
<button class="ftc-zoom-btn" :title="dirTitle" @click="toggleDirection">{{ layoutDir === "h" ? "⇄" : "⇅" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无当前族员数据" />
|
||||
@@ -161,8 +162,8 @@ const describePerson = (personId: string): any => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 构建图数据:树形竖向排列,本人默认展开孩子,点击孩子可递归加载子树 */
|
||||
const buildGraphData = () => {
|
||||
/** 构建横向布局图数据:深度向右排列,本人默认展开孩子,点击孩子可递归加载子树 */
|
||||
const buildHorizontalData = () => {
|
||||
const nodes: any[] = [];
|
||||
const edges: any[] = [];
|
||||
const root = rootPersonId.value;
|
||||
@@ -263,6 +264,166 @@ const buildGraphData = () => {
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建纵向布局图数据:每层一行,孩子横向铺开并居中于父节点,配偶与本人并排。
|
||||
* 仅改变排列方式,卡片内容与样式不变。
|
||||
*/
|
||||
const buildVerticalData = () => {
|
||||
const nodes: any[] = [];
|
||||
const edges: any[] = [];
|
||||
const root = rootPersonId.value;
|
||||
if (!root) return { nodes, edges };
|
||||
|
||||
const CARD_WIDTH = 360;
|
||||
const CARD_HEIGHT = 220;
|
||||
const SPOUSE_GAP = 30; // 配偶紧贴本人
|
||||
const H_GAP = 60; // 兄弟子树水平间隔
|
||||
const Y_STEP = CARD_HEIGHT + 40; // 上下层间隔
|
||||
|
||||
/** 布局单元:本人 + 同行配偶,孩子排在下一行 */
|
||||
interface VUnit {
|
||||
personId: string;
|
||||
desc: any;
|
||||
/** 需要展开的孩子(未展开则为空) */
|
||||
children: any[];
|
||||
spouses: any[];
|
||||
}
|
||||
|
||||
const unitOf = (personId: string): VUnit | null => {
|
||||
const desc = describePerson(personId);
|
||||
if (!desc) return null;
|
||||
return {
|
||||
personId,
|
||||
desc,
|
||||
children: expandedIds.value.has(personId) ? desc.children : [],
|
||||
spouses: desc.spouses ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
/** 本人 + 配偶并排一行的总宽 */
|
||||
const unitWidth = (u: VUnit) => CARD_WIDTH + u.spouses.length * (CARD_WIDTH + SPOUSE_GAP);
|
||||
|
||||
const measuring = new Set<string>();
|
||||
/** 子树宽度 = max(本行宽度, 孩子一行总宽) */
|
||||
const measure = (u: VUnit): number => {
|
||||
if (measuring.has(u.personId)) return CARD_WIDTH;
|
||||
measuring.add(u.personId);
|
||||
let childrenWidth = 0;
|
||||
u.children.forEach((c: any, i: number) => {
|
||||
const child = unitOf(c.ChildrenId);
|
||||
childrenWidth += (child ? measure(child) : CARD_WIDTH) + (i > 0 ? H_GAP : 0);
|
||||
});
|
||||
measuring.delete(u.personId);
|
||||
return Math.max(unitWidth(u), childrenWidth);
|
||||
};
|
||||
|
||||
const place = (u: VUnit, depth: number, xLeft: number) => {
|
||||
if (depth > 40) return;
|
||||
const width = measure(u);
|
||||
const p = u.desc.person;
|
||||
const isRoot = u.personId === root;
|
||||
const y = depth * Y_STEP;
|
||||
// 本人 + 配偶并排在本行,整体居中于本子树
|
||||
const unitLeft = xLeft + (width - unitWidth(u)) / 2;
|
||||
const selfX = unitLeft + CARD_WIDTH / 2;
|
||||
// 父子连线的横向干线高度:本行底部与下一行顶部之间的中线
|
||||
const railY = (depth + 0.5) * Y_STEP;
|
||||
|
||||
// 孩子排在下一行,整体居中于本子树
|
||||
const children = u.children.map((c: any) => ({ c, unit: unitOf(c.ChildrenId) }));
|
||||
const childWidths = children.map((x) => (x.unit ? measure(x.unit) : CARD_WIDTH));
|
||||
const childrenWidth =
|
||||
childWidths.reduce((a, b) => a + b, 0) + H_GAP * Math.max(0, childWidths.length - 1);
|
||||
let cx = xLeft + (width - childrenWidth) / 2;
|
||||
children.forEach(({ c, unit }, i) => {
|
||||
const childX = cx + CARD_WIDTH / 2;
|
||||
if (unit) {
|
||||
place(unit, depth + 1, cx);
|
||||
} else {
|
||||
nodes.push({
|
||||
id: "n-" + c.ChildrenId,
|
||||
style: { x: childX, y: (depth + 1) * Y_STEP },
|
||||
data: {
|
||||
kind: "child",
|
||||
personId: c.ChildrenId,
|
||||
tag: c.RelationType ? relationLabel(c.RelationType) : "子女",
|
||||
name: (c.ChildrenSurname ?? "") + (c.ChildrenGenealogyName ?? ""),
|
||||
commonName: c.ChildrenCommonName,
|
||||
gender: c.Gender,
|
||||
avatarUrl: avatarMap.value[c.ChildrenId] ?? "",
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
loaded: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
edges.push({
|
||||
source: "n-" + u.personId,
|
||||
target: "n-" + c.ChildrenId,
|
||||
// 折点:父卡底部中心下方 → 横向干线 → 子卡顶部中心上方
|
||||
data: { kind: "parent", points: [[selfX, railY], [childX, railY]] },
|
||||
});
|
||||
cx += childWidths[i] + H_GAP;
|
||||
});
|
||||
|
||||
nodes.push({
|
||||
id: "n-" + u.personId,
|
||||
style: { x: selfX, y },
|
||||
data: {
|
||||
kind: isRoot ? "self" : "member",
|
||||
personId: u.personId,
|
||||
tag: isRoot ? "本人" : "族员",
|
||||
name: (p?.Surname ?? "") + (p?.GenealogyName ?? ""),
|
||||
commonName: p?.CommonName,
|
||||
gender: p?.Gender,
|
||||
avatarUrl: p?.AvatarUrl ?? avatarMap.value[u.personId] ?? "",
|
||||
hasChildren: u.desc.children.length > 0,
|
||||
expanded: expandedIds.value.has(u.personId),
|
||||
loaded: u.desc.loaded,
|
||||
},
|
||||
});
|
||||
|
||||
u.spouses.forEach((s: any, i: number) => {
|
||||
const sid = "s-" + u.personId + "-" + i;
|
||||
nodes.push({
|
||||
id: sid,
|
||||
style: { x: selfX + (i + 1) * (CARD_WIDTH + SPOUSE_GAP), y },
|
||||
data: {
|
||||
kind: "spouse",
|
||||
personId: s.SpouseId,
|
||||
tag: "配偶",
|
||||
name: (s.SpouseSurname ?? "") + (s.SpouseGenealogyName ?? ""),
|
||||
gender: s.Gender,
|
||||
avatarUrl: avatarMap.value[s.SpouseId] ?? "",
|
||||
loaded: true,
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
},
|
||||
});
|
||||
edges.push({ source: "n-" + u.personId, target: sid, data: { kind: "spouse" } });
|
||||
});
|
||||
};
|
||||
|
||||
const rootUnit = unitOf(root);
|
||||
if (rootUnit) place(rootUnit, 0, 0);
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
/** 布局方向:h=横向(深度向右),v=纵向(深度向下) */
|
||||
const layoutDir = ref<"h" | "v">("h");
|
||||
|
||||
/** 按当前方向构建图数据 */
|
||||
const buildGraphData = () => (layoutDir.value === "v" ? buildVerticalData() : buildHorizontalData());
|
||||
|
||||
/** 方向按钮提示(按钮上的箭头表示当前方向) */
|
||||
const dirTitle = computed(() => (layoutDir.value === "h" ? "当前横向排列,点击切换为纵向" : "当前纵向排列,点击切换为横向"));
|
||||
|
||||
/** 横向 / 纵向排列切换 */
|
||||
const toggleDirection = () => {
|
||||
layoutDir.value = layoutDir.value === "h" ? "v" : "h";
|
||||
initGraph();
|
||||
};
|
||||
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
let graph: any = null;
|
||||
|
||||
@@ -279,12 +440,13 @@ const zoomBy = (delta: number) => {
|
||||
graph.zoomTo(Math.min(Math.max(next, MIN_ZOOM), 10));
|
||||
};
|
||||
|
||||
/** 还原为适配整个画布 */
|
||||
/** 还原为适配整个画布(缩到最小比例以下的需重新居中,否则整图可能落在视口之外) */
|
||||
const fitGraph = async () => {
|
||||
if (!graph) return;
|
||||
await graph.fitView();
|
||||
if (graph.getZoom() < MIN_ZOOM) {
|
||||
await graph.zoomTo(MIN_ZOOM);
|
||||
if (typeof graph.fitCenter === "function") await graph.fitCenter();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -323,15 +485,24 @@ const initGraph = async () => {
|
||||
},
|
||||
edge: {
|
||||
type: (d: any) => (d?.data?.kind === "spouse" ? "line" : "polyline"),
|
||||
style: (d: any) => ({
|
||||
// 夫妻关系=紫色虚线,亲子关系=灰色实线
|
||||
stroke: d?.data?.kind === "spouse" ? "#722ed1" : "#a0a0a0",
|
||||
lineWidth: 1.5,
|
||||
lineDash: d?.data?.kind === "spouse" ? [4, 4] : undefined,
|
||||
router: d?.data?.kind === "spouse" ? undefined : { type: "orth" },
|
||||
}),
|
||||
style: (d: any) => {
|
||||
const isSpouse = d?.data?.kind === "spouse";
|
||||
return {
|
||||
// 夫妻关系=紫色虚线,亲子关系=灰色实线
|
||||
stroke: isSpouse ? "#722ed1" : "#a0a0a0",
|
||||
lineWidth: 1.5,
|
||||
lineDash: isSpouse ? [4, 4] : undefined,
|
||||
// 纵向布局:用布局阶段算好的折点(父卡底部中心 → 横向干线 → 子卡顶部中心)
|
||||
// 横向布局:沿用正交路由
|
||||
...(isSpouse
|
||||
? {}
|
||||
: layoutDir.value === "v"
|
||||
? { controlPoints: d?.data?.points ?? [] }
|
||||
: { router: { type: "orth" } }),
|
||||
};
|
||||
},
|
||||
},
|
||||
behaviors: ["drag-canvas", "zoom-canvas", "drag-element"],
|
||||
behaviors: ["drag-canvas", "zoom-canvas"],
|
||||
});
|
||||
graph.on("node:click", (event: any) => {
|
||||
const id = event.target?.id ?? event.item?.getID?.();
|
||||
@@ -352,9 +523,10 @@ const initGraph = async () => {
|
||||
}
|
||||
});
|
||||
await graph.render();
|
||||
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例,保证卡片可读
|
||||
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例并重新居中,保证卡片可见可读
|
||||
if (graph.getZoom() < MIN_ZOOM) {
|
||||
await graph.zoomTo(MIN_ZOOM);
|
||||
if (typeof graph.fitCenter === "function") await graph.fitCenter();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user