Files
chinese-family-tree-2/components/tree/tree-layout.tsx
T
selfrelease ce06674cba perf: 传统族谱视图全面优化
P0 性能优化:
- 连线计算用 requestAnimationFrame + useLayoutEffect 替代 7个 setTimeout 轮询
- 节点注册改为批量 ref 模式,消除 N 次 setState 重渲染
- 初始定位用 useLayoutEffect + 双 rAF 替代 600ms setTimeout
- 高亮定位用三 rAF 替代 1000ms setTimeout
- 使用 ResizeObserver 替代 window.resize 监听

P1 视觉优化:
- 连线从 div 改为 SVG path 绘制,清晰且支持动画
- 背景纹理图从 transparenttextures.com 本地化到 public/
- 添加骨架屏 loading 状态替代 return null

P2 代码质量:
- 移除 page.tsx 中 5 处 console.log
- 移除 family-node.tsx 中 3 处 console.log
- 消除 handleD3MemberClick 中的直接 DOM 操作(createElement/innerHTML)
- parseName 提取为模块级函数,避免每次渲染重建

P3 增强:
- 缩放范围从 0.1~2 调整为 0.15~3
- 工具栏显示当前缩放比例
- SVG 连线添加 transition 动画
2026-07-16 15:45:24 +08:00

413 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import type React from "react"
import type { FamilyMember } from "@/types/family"
import { FamilyNode } from "./family-node"
import { TreeNode } from "./tree-node"
import { AddRelationDialog, type RelationType } from "./add-relation-dialog"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback, useMemo, memo, useLayoutEffect } from "react"
import { useRouter } from "next/navigation"
import { permissions } from "@/lib/permissions"
interface TreeLayoutProps {
rootId: string
onSelectMember?: (member: FamilyMember) => void
relationMode?: boolean
selectedMembers?: string[]
onMemberClick?: (memberId: string) => void
expandAll?: boolean
onExpandAllChange?: (expanded: boolean) => void
scale?: number
}
interface Connection {
from: { x: number; y: number }
to: { x: number; y: number }
type: 'vertical' | 'horizontal'
}
/** 常见复姓列表 */
const COMPOUND_SURNAMES = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
/** 将全名分解为姓和名(模块级函数,避免每次渲染重建) */
function parseName(fullName: string): { surname: string; givenName: string } {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
for (const compound of COMPOUND_SURNAMES) {
if (name.startsWith(compound) && name.length > compound.length) {
return { surname: compound, givenName: name.slice(compound.length) }
}
}
if (name.length >= 2) {
return { surname: name.charAt(0), givenName: name.slice(1) }
}
return { surname: name, givenName: '' }
}
export function TreeLayout({
rootId,
onSelectMember,
relationMode = false,
selectedMembers = [],
onMemberClick,
expandAll = false,
onExpandAllChange,
scale = 1
}: TreeLayoutProps) {
const { getMember, treeData, highlightedMemberId, currentTree } = useFamily()
// 检查用户是否有创建权限
const canEdit = permissions.canCreate(currentTree?.currentUserRole)
const root = getMember(rootId)
const router = useRouter()
const containerRef = useRef<HTMLDivElement>(null)
const [connections, setConnections] = useState<Connection[]>([])
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [relationDialog, setRelationDialog] = useState<{ member: FamilyMember; type: "child" | "spouse" } | null>(null)
// 节点间距配置
const HORIZONTAL_GAP = 16 // 1rem = 16px
const VERTICAL_GAP = 48 // 3rem = 48px
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
// 折叠状态管理:存储被折叠的节点ID
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(() => new Set())
// 切换节点折叠状态
const toggleCollapse = useCallback((memberId: string) => {
setCollapsedNodes(prev => {
const newSet = new Set(prev)
if (newSet.has(memberId)) {
newSet.delete(memberId)
} else {
newSet.add(memberId)
}
return newSet
})
}, [])
// 响应 expandAll 变化,展开所有节点
useEffect(() => {
if (expandAll) {
setCollapsedNodes(new Set())
// 通知父组件已展开
onExpandAllChange?.(false)
}
}, [expandAll, onExpandAllChange])
// 节点 ref 注册(批量模式,不再触发 N 次 setState
const handleNodeRef = useCallback((memberId: string, el: HTMLDivElement | null) => {
if (el) {
nodeRefs.current.set(memberId, el)
} else {
nodeRefs.current.delete(memberId)
}
}, [])
// 处理添加关系
const handleAddRelation = useCallback((member: FamilyMember, type: "child" | "spouse") => {
setRelationDialog({ member, type })
}, [])
// 处理节点点击
const handleNodeClick = useCallback((member: FamilyMember) => {
if (relationMode && onMemberClick) {
onMemberClick(member.id)
} else {
const params = new URLSearchParams(window.location.search)
router.push(`/members/${member.id}?${params.toString()}`)
}
}, [relationMode, onMemberClick, router])
// 检查节点是否被任何祖先折叠(使用闭包避免递归依赖问题)
// 注意:这个函数用于判断节点是否应该被隐藏,不用于连线计算
const isNodeHiddenByCollapse = useCallback((nodeId: string): boolean => {
// 如果没有折叠的节点,直接返回 false
if (collapsedNodes.size === 0) return false
const checkHidden = (id: string): boolean => {
const member = getMember(id)
if (!member) return false // 找不到成员时返回 false,不隐藏
// 检查父节点是否被折叠
const parentId = member.fatherId || member.motherId
if (parentId) {
if (collapsedNodes.has(parentId)) return true
// 递归检查祖先
return checkHidden(parentId)
}
return false
}
return checkHidden(nodeId)
}, [getMember, collapsedNodes])
// 计算所有连线位置(使用 requestAnimationFrame 批量计算,避免 layout thrashing
const rafIdRef = useRef<number | null>(null)
const calculateConnections = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null
const newConnections: Connection[] = []
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect || nodeRefs.current.size === 0) return
nodeRefs.current.forEach((nodeElement, nodeId) => {
const member = getMember(nodeId)
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
if (collapsedNodes.has(nodeId)) return
if (isNodeHiddenByCollapse(nodeId)) return
if (!document.body.contains(nodeElement)) return
const parentRect = nodeElement.getBoundingClientRect()
if (parentRect.width === 0 || parentRect.height === 0) return
const parentCenterX = (parentRect.left + parentRect.width / 2 - containerRect.left) / scale
const parentBottomY = (parentRect.bottom - containerRect.top) / scale
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement || !document.body.contains(childElement)) return null
const childRect = childElement.getBoundingClientRect()
if (childRect.width === 0 || childRect.height === 0) return null
return {
id: childId,
centerX: (childRect.left + childRect.width / 2 - containerRect.left) / scale,
topY: (childRect.top - containerRect.top) / scale
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
if (childPositions.length === 0) return
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
newConnections.push({
from: { x: leftmostX, y: verticalLineY },
to: { x: rightmostX, y: verticalLineY },
type: 'horizontal'
})
}
childPositions.forEach(child => {
newConnections.push({
from: { x: child.centerX, y: verticalLineY },
to: { x: child.centerX, y: child.topY },
type: 'vertical'
})
})
})
setConnections(newConnections)
})
}, [getMember, collapsedNodes, isNodeHiddenByCollapse, scale])
// 使用 useLayoutEffect 在 DOM 更新后立即计算连线(替代 7 个 setTimeout
useLayoutEffect(() => {
calculateConnections()
// 双 rAF 确保浏览器完成布局后再计算
const raf2 = requestAnimationFrame(() => calculateConnections())
return () => cancelAnimationFrame(raf2)
}, [calculateConnections, treeData, collapsedNodes])
// 使用 ResizeObserver 监听容器尺寸变化(替代 window.resize
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => calculateConnections())
observer.observe(container)
return () => observer.disconnect()
}, [calculateConnections])
// 清理 rAF
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [])
// 预计算排序后的子节点映射,避免每次渲染时重复计算
const sortedChildrenMap = useMemo(() => {
const map = new Map<string, string[]>()
Object.values(treeData.members).forEach(member => {
if (member.childrenIds && member.childrenIds.length > 0) {
const sorted = [...member.childrenIds].sort((aId, bId) => {
const a = treeData.members[aId]
const b = treeData.members[bId]
if (!a || !b) return 0
const aIsFounder = a.tags?.includes('始祖') || a.generation === 1
const bIsFounder = b.tags?.includes('始祖') || b.generation === 1
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
return (a.birthDate || "").localeCompare(b.birthDate || "")
})
map.set(member.id, sorted)
}
})
return map
}, [treeData.members])
if (!root) return null
// 递归渲染树节点 - 使用 memo 优化
const handleCreateNewRelation = (prefillName: string, relationType: RelationType) => {
if (!relationDialog) return
const params = new URLSearchParams()
// 分解姓名
const { surname, givenName } = parseName(prefillName)
if (surname) params.set("surname", surname)
if (givenName) params.set("givenName", givenName)
if (relationType === "child") {
params.set("generation", String(relationDialog.member.generation + 1))
if (relationDialog.member.gender === "MALE") {
params.set("fatherId", relationDialog.member.id)
if (relationDialog.member.spouseIds?.length) {
params.set("motherId", relationDialog.member.spouseIds[0])
}
} else if (relationDialog.member.gender === "FEMALE") {
params.set("motherId", relationDialog.member.id)
if (relationDialog.member.spouseIds?.length) {
params.set("fatherId", relationDialog.member.spouseIds[0])
}
}
} else if (relationType === "spouse") {
params.set("generation", String(relationDialog.member.generation))
params.set("spouseId", relationDialog.member.id)
params.set("gender", relationDialog.member.gender === "MALE" ? "FEMALE" : "MALE")
// 如果当前成员有子女,将他们也作为新配偶的子女
if (relationDialog.member.childrenIds && relationDialog.member.childrenIds.length > 0) {
relationDialog.member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
} else if (relationType === "father") {
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "MALE")
// 如果已有母亲,设为配偶
if (relationDialog.member.motherId) {
params.set("spouseId", relationDialog.member.motherId)
}
} else if (relationType === "mother") {
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "FEMALE")
// 如果已有父亲,设为配偶
if (relationDialog.member.fatherId) {
params.set("spouseId", relationDialog.member.fatherId)
}
}
const treeParams = new URLSearchParams(window.location.search)
treeParams.forEach((value, key) => {
params.set(key, value)
})
window.location.href = `/members/new?${params.toString()}`
}
// 准备根节点数据
const rootSpouses = root.spouseIds && root.spouseIds.length > 0
? root.spouseIds.map(id => getMember(id)).filter((s): s is FamilyMember => s !== undefined)
: []
const rootHasChildren = root.childrenIds && root.childrenIds.length > 0
const rootChildren = rootHasChildren
? (sortedChildrenMap.get(root.id) || [])
.map(id => getMember(id))
.filter((c): c is FamilyMember => c !== undefined)
: []
return (
<div className="relative">
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
{/* 连线层 - 使用 SVG path 绘制(清晰且支持动画) */}
<svg
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 1, width: '100%', height: '100%', overflow: 'visible' }}
>
{connections.map((conn, index) => {
const d = `M ${conn.from.x} ${conn.from.y} L ${conn.to.x} ${conn.to.y}`
return (
<path
key={index}
d={d}
stroke="rgba(180, 83, 9, 0.7)"
strokeWidth={1}
fill="none"
style={{
transition: 'stroke-dashoffset 0.3s ease-out',
}}
/>
)
})}
</svg>
{/* 树节点层 */}
<div className="relative" style={{ zIndex: 10 }} key="tree-root">
<TreeNode
member={root}
spouses={rootSpouses}
children={rootChildren}
isRoot={true}
isHighlighted={root.id === highlightedMemberId}
highlightedMemberId={highlightedMemberId}
relationMode={relationMode}
isSelected={selectedMembers.includes(root.id)}
selectedMembers={selectedMembers}
canEdit={canEdit}
hasChildren={rootHasChildren || false}
isCollapsed={collapsedNodes.has(root.id)}
collapsedNodes={collapsedNodes}
onNodeClick={handleNodeClick}
onAddRelation={handleAddRelation}
onToggleCollapse={toggleCollapse}
onNodeRef={handleNodeRef}
getMember={getMember}
sortedChildrenMap={sortedChildrenMap}
horizontalGap={HORIZONTAL_GAP}
verticalGap={VERTICAL_GAP}
/>
</div>
</div>
{relationDialog && (
<AddRelationDialog
open={!!relationDialog}
onOpenChange={(open) => {
if (!open) setRelationDialog(null)
}}
member={relationDialog.member}
relationType={relationDialog.type}
onCreateNew={handleCreateNewRelation}
/>
)}
</div>
)
}