Files
jiapuapp/utils/tree/layout.js
T
2026-09-13 17:45:52 +08:00

324 lines
11 KiB
JavaScript

const GRID_UNIT = 5;
const NODE_HALF_WIDTH = 77;
const NODE_HALF_HEIGHT = 100;
const FAMILY_LINK_OFFSET = 18;
const MEMBER_GAP = 178;
const GENERATION_GAP = 296;
const snapToGrid = (coordinate) =>
Math.ceil(coordinate / GRID_UNIT) * GRID_UNIT;
const compareMembers = (leftMember, rightMember) =>
Number(leftMember.generation) - Number(rightMember.generation) ||
String(leftMember.id).localeCompare(String(rightMember.id));
const positionMembers = (members) => {
const memberById = new Map(
members.map((member) => [String(member.id), member]),
);
const spousesByPersonId = new Map();
members.forEach((member) => {
const partnerId = String(member.spouseOf || "");
if (partnerId && memberById.has(partnerId)) {
spousesByPersonId.set(partnerId, member);
}
});
const primaryMembers = members
.filter((member) => !member.spouseOf)
.sort(compareMembers);
const primaryById = new Map(
primaryMembers.map((member) => [String(member.id), member]),
);
const childrenByParent = new Map();
primaryMembers.forEach((member) => {
const parentId = String(member.parentId || "");
if (!parentId || !primaryById.has(parentId)) return;
const children = childrenByParent.get(parentId) || [];
children.push(member);
childrenByParent.set(parentId, children);
});
childrenByParent.forEach((children) => children.sort(compareMembers));
// 每个家庭独占一段连续横向空间,父辈始终位于该空间中央。
// 不能按世代逐行居中,否则后代较多的支系会被拉到另一支系下方。
const subtreeWidthById = new Map();
const measureSubtree = (member, ancestorIds = new Set()) => {
const memberId = String(member.id);
if (subtreeWidthById.has(memberId)) {
return subtreeWidthById.get(memberId);
}
if (ancestorIds.has(memberId)) return 1;
const nextAncestorIds = new Set(ancestorIds);
nextAncestorIds.add(memberId);
const children = childrenByParent.get(memberId) || [];
const descendantsWidth = children.reduce(
(totalWidth, child) =>
totalWidth + measureSubtree(child, nextAncestorIds),
0,
);
const ownWidth = spousesByPersonId.has(memberId) ? 2 : 1;
const subtreeWidth = Math.max(ownWidth, descendantsWidth || 1);
subtreeWidthById.set(memberId, subtreeWidth);
return subtreeWidth;
};
const rootMembers = primaryMembers.filter(
(member) => !primaryById.has(String(member.parentId || "")),
);
rootMembers.forEach((rootMember) => measureSubtree(rootMember));
const occupiedWidth = rootMembers.reduce(
(totalWidth, rootMember) =>
totalWidth + (subtreeWidthById.get(String(rootMember.id)) || 1),
0,
);
const generations = Array.from(
new Set(primaryMembers.map((member) => Number(member.generation))),
).sort((leftGeneration, rightGeneration) =>
leftGeneration - rightGeneration,
);
const generationIndexByNumber = new Map(
generations.map((generation, index) => [generation, index]),
);
const positionedMembers = [];
const positionedMemberIds = new Set();
const placeSubtree = (
member,
startUnit,
subtreeWidth,
ancestorIds = new Set(),
) => {
const memberId = String(member.id);
if (ancestorIds.has(memberId)) return;
const nextAncestorIds = new Set(ancestorIds);
nextAncestorIds.add(memberId);
const spouse = spousesByPersonId.get(memberId);
const centerUnit = startUnit + subtreeWidth / 2;
const memberUnitX = spouse ? centerUnit - 0.5 : centerUnit;
const y = snapToGrid(
200 +
(generationIndexByNumber.get(Number(member.generation)) || 0) *
GENERATION_GAP,
);
positionedMembers.push({
...member,
x: snapToGrid(80 + memberUnitX * MEMBER_GAP),
y,
});
positionedMemberIds.add(memberId);
if (spouse) {
positionedMembers.push({
...spouse,
x: snapToGrid(80 + (memberUnitX + 1) * MEMBER_GAP),
y,
});
positionedMemberIds.add(String(spouse.id));
}
const children = childrenByParent.get(memberId) || [];
const childrenWidth = children.reduce(
(totalWidth, child) =>
totalWidth + (subtreeWidthById.get(String(child.id)) || 1),
0,
);
let childStartUnit = startUnit + (subtreeWidth - childrenWidth) / 2;
children.forEach((child) => {
const childWidth = subtreeWidthById.get(String(child.id)) || 1;
placeSubtree(child, childStartUnit, childWidth, nextAncestorIds);
childStartUnit += childWidth;
});
};
let rootStartUnit = 0;
rootMembers.forEach((rootMember) => {
const rootWidth = subtreeWidthById.get(String(rootMember.id)) || 1;
placeSubtree(rootMember, rootStartUnit, rootWidth);
rootStartUnit += rootWidth;
});
members
.filter((member) => !positionedMemberIds.has(String(member.id)))
.forEach((member, index) => {
positionedMembers.push({
...member,
x: snapToGrid(80 + (occupiedWidth + index + 0.5) * MEMBER_GAP),
y: snapToGrid(
200 +
(generationIndexByNumber.get(Number(member.generation)) || 0) *
GENERATION_GAP,
),
});
});
return positionedMembers;
};
const createMetrics = (positionedMembers) => {
const maxX = Math.max(0, ...positionedMembers.map((member) => member.x));
const maxY = Math.max(0, ...positionedMembers.map((member) => member.y));
const width = Math.max(660, snapToGrid(maxX + NODE_HALF_WIDTH + 50));
const height = Math.max(640, snapToGrid(maxY + 250));
return {
width,
height,
gridUnit: GRID_UNIT,
columns: width / GRID_UNIT,
rows: height / GRID_UNIT,
};
};
const createGenerationRows = (positionedMembers) => {
const membersByGeneration = new Map();
positionedMembers.forEach((member) => {
const generationMembers = membersByGeneration.get(member.generation) || [];
generationMembers.push(member);
membersByGeneration.set(member.generation, generationMembers);
});
return Array.from(membersByGeneration.entries())
.sort(([leftGeneration], [rightGeneration]) =>
leftGeneration - rightGeneration,
)
.map(([generation, generationMembers]) => {
const y = Math.min(...generationMembers.map((member) => member.y));
return {
generation,
label: `第 ${generation} 世`,
summary: `${generationMembers.length} 位成员`,
y,
bandStyle: {
gridRow: `${Math.max(1, Math.round((y - NODE_HALF_HEIGHT) / GRID_UNIT) + 1)} / span 21`,
},
};
});
};
const createConnectors = (positionedMembers) => {
const memberById = new Map(
positionedMembers.map((member) => [String(member.id), member]),
);
const spouseByPersonId = new Map();
positionedMembers.forEach((member) => {
const partnerId = String(member.spouseOf || "");
if (partnerId && memberById.has(partnerId)) {
spouseByPersonId.set(partnerId, member);
}
});
const childrenByParent = new Map();
positionedMembers.forEach((member) => {
const parentId = String(member.parentId || "");
if (member.spouseOf || !parentId || !memberById.has(parentId)) return;
const children = childrenByParent.get(parentId) || [];
children.push(member);
childrenByParent.set(parentId, children);
});
const verticalStyle = (x, top, bottom) => ({
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
});
const horizontalStyle = (left, right, y) => ({
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
gridRow: `${Math.round(y / GRID_UNIT) + 1} / span 1`,
});
const connectors = [];
childrenByParent.forEach((children, parentId) => {
const parent = memberById.get(parentId);
const spouse = spouseByPersonId.get(parentId);
const parentAnchorX = spouse
? snapToGrid((parent.x + spouse.x) / 2)
: parent.x;
const parentBottom = parent.y + NODE_HALF_HEIGHT;
const parentAnchorY = spouse
? snapToGrid(parentBottom + FAMILY_LINK_OFFSET)
: parentBottom;
const childTop = Math.min(
...children.map((child) => child.y - NODE_HALF_HEIGHT),
);
const branchY = snapToGrid((parentAnchorY + childTop) / 2);
const minChildX = Math.min(...children.map((child) => child.x));
const maxChildX = Math.max(...children.map((child) => child.x));
if (spouse) {
connectors.push({
id: `${parentId}-${spouse.id}-family-parent`,
kind: "family",
style: verticalStyle(parent.x, parentBottom, parentAnchorY),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-spouse`,
kind: "family",
style: verticalStyle(
spouse.x,
spouse.y + NODE_HALF_HEIGHT,
parentAnchorY,
),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-bridge`,
kind: "family",
style: horizontalStyle(
Math.min(parent.x, spouse.x),
Math.max(parent.x, spouse.x),
parentAnchorY,
),
});
}
connectors.push({
id: `${parentId}-trunk`,
style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
});
connectors.push({
id: `${parentId}-branch`,
style: horizontalStyle(minChildX, maxChildX, branchY),
});
children.forEach((child) => {
connectors.push({
id: `${parentId}-${child.id}`,
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
});
});
});
positionedMembers.forEach((member) => {
if (!member.spouseOf) return;
const partner = memberById.get(String(member.spouseOf));
if (!partner || partner.generation !== member.generation) return;
if (childrenByParent.has(String(partner.id))) return;
const left = Math.min(member.x, partner.x) + NODE_HALF_WIDTH;
const right = Math.max(member.x, partner.x) - NODE_HALF_WIDTH;
if (right <= left) return;
connectors.push({
id: `${partner.id}-${member.id}-spouse`,
kind: "spouse",
style: horizontalStyle(left, right, member.y),
});
});
return connectors;
};
// 这是世系图布局的唯一公开入口。页面只消费坐标、尺寸、世代行和连线,
// 不需要了解支系宽度测量、循环关系保护或配偶桥接规则。
export const createTreeLayout = (members) => {
const positionedMembers = positionMembers(Array.isArray(members) ? members : []);
return {
members: positionedMembers,
metrics: createMetrics(positionedMembers),
generationRows: createGenerationRows(positionedMembers),
connectors: createConnectors(positionedMembers),
};
};
export const treeNodeGridStyle = (member) => ({
gridColumn: `${member.x / GRID_UNIT + 1}`,
gridRow: `${member.y / GRID_UNIT + 1}`,
});