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}`, });