324 lines
11 KiB
JavaScript
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}`,
|
|
});
|