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 动画
This commit is contained in:
+94
-156
@@ -17,7 +17,7 @@ import {
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users, Network, Loader2, Maximize2, FileText } from "lucide-react"
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useState, useRef, useEffect, useCallback, useLayoutEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { MemberNameWithStatus } from "@/components/member-name-with-status"
|
||||
import { RelationshipPathDisplay } from "@/components/relationship-path-display"
|
||||
@@ -119,72 +119,67 @@ export default function TreePage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const d3ChartRef = useRef<D3OrgChartRef>(null)
|
||||
|
||||
// 传统族谱初始定位到始祖并自适应缩放(如果有高亮成员则跳过)
|
||||
useEffect(() => {
|
||||
// 传统族谱初始定位到始祖并自适应缩放(使用 useLayoutEffect + rAF 替代 600ms setTimeout)
|
||||
useLayoutEffect(() => {
|
||||
if (viewMode !== 'traditional' || hasInitialPositioned || !treeData.rootId) return
|
||||
// 如果有高亮成员,跳过初始定位,让高亮定位逻辑处理
|
||||
if (highlightedMemberId) {
|
||||
setHasInitialPositioned(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 找到始祖成员:优先使用标记为 isFounder 的成员,否则使用 rootId 对应的成员
|
||||
const founderMember = Object.values(treeData.members).find(m => m.isFounder === true)
|
||||
|| treeData.members[treeData.rootId]
|
||||
if (!founderMember) return
|
||||
|
||||
// 延迟执行,等待 DOM 渲染完成
|
||||
const timer = setTimeout(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// 查找树内容容器(包含 TreeLayout 的 div)
|
||||
const treeContent = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement
|
||||
if (!treeContent) return
|
||||
|
||||
// 查找始祖节点的 DOM 元素
|
||||
const founderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement
|
||||
if (!founderNode) return
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const treeRect = treeContent.getBoundingClientRect()
|
||||
|
||||
// 计算树的实际尺寸(当前缩放为1)
|
||||
const treeWidth = treeRect.width
|
||||
const treeHeight = treeRect.height
|
||||
|
||||
// 计算适合容器的缩放比例,留出一些边距
|
||||
const padding = 40
|
||||
const scaleX = (containerRect.width - padding * 2) / treeWidth
|
||||
const scaleY = (containerRect.height - padding * 2) / treeHeight
|
||||
const fitScale = Math.min(scaleX, scaleY, 1) // 最大不超过1
|
||||
const newScale = Math.max(fitScale, 0.2) // 最小0.2
|
||||
|
||||
// 先设置缩放
|
||||
setScale(newScale)
|
||||
|
||||
// 延迟计算位置(等待缩放生效)
|
||||
setTimeout(() => {
|
||||
// 双 rAF 确保浏览器完成布局后再计算(~32ms vs 之前 600ms)
|
||||
let raf2 = 0
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const updatedFounderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement
|
||||
if (!updatedFounderNode) return
|
||||
const treeContent = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement
|
||||
if (!treeContent) return
|
||||
|
||||
const updatedContainerRect = containerRef.current.getBoundingClientRect()
|
||||
const updatedNodeRect = updatedFounderNode.getBoundingClientRect()
|
||||
const founderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement
|
||||
if (!founderNode) return
|
||||
|
||||
// 计算始祖节点相对于容器中心的偏移
|
||||
const containerCenterX = updatedContainerRect.width / 2
|
||||
const nodeCenterX = updatedNodeRect.left - updatedContainerRect.left + updatedNodeRect.width / 2
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const treeRect = treeContent.getBoundingClientRect()
|
||||
|
||||
// 计算需要的 x 偏移量使始祖居中
|
||||
const offsetX = containerCenterX - nodeCenterX
|
||||
const treeWidth = treeRect.width
|
||||
const treeHeight = treeRect.height
|
||||
|
||||
setPosition({ x: offsetX, y: 0 })
|
||||
setHasInitialPositioned(true)
|
||||
}, 100)
|
||||
}, 500)
|
||||
const padding = 40
|
||||
const scaleX = (containerRect.width - padding * 2) / treeWidth
|
||||
const scaleY = (containerRect.height - padding * 2) / treeHeight
|
||||
const fitScale = Math.min(scaleX, scaleY, 1)
|
||||
const newScale = Math.max(fitScale, 0.15)
|
||||
|
||||
setScale(newScale)
|
||||
|
||||
// 下一帧计算居中位置
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return
|
||||
const updatedFounderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement
|
||||
if (!updatedFounderNode) return
|
||||
|
||||
const updatedContainerRect = containerRef.current.getBoundingClientRect()
|
||||
const updatedNodeRect = updatedFounderNode.getBoundingClientRect()
|
||||
|
||||
const containerCenterX = updatedContainerRect.width / 2
|
||||
const nodeCenterX = updatedNodeRect.left - updatedContainerRect.left + updatedNodeRect.width / 2
|
||||
const offsetX = containerCenterX - nodeCenterX
|
||||
|
||||
setPosition({ x: offsetX, y: 0 })
|
||||
setHasInitialPositioned(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
if (raf2) cancelAnimationFrame(raf2)
|
||||
}
|
||||
}, [viewMode, treeData.rootId, treeData.members, hasInitialPositioned, highlightedMemberId])
|
||||
|
||||
// 切换视图模式时重置初始定位状态和缩放位置
|
||||
@@ -194,51 +189,43 @@ export default function TreePage() {
|
||||
setPosition({ x: 0, y: 0 })
|
||||
}, [viewMode])
|
||||
|
||||
// 当有高亮成员时,定位到该成员并居中显示,设置 scale 为 1.0
|
||||
useEffect(() => {
|
||||
// 当有高亮成员时,定位到该成员并居中显示(使用 rAF 替代 1000ms setTimeout)
|
||||
useLayoutEffect(() => {
|
||||
if (viewMode !== 'traditional' || !highlightedMemberId) return
|
||||
|
||||
// 第一步:重置位置和缩放为初始状态
|
||||
setScale(1)
|
||||
setPosition({ x: 0, y: 0 })
|
||||
|
||||
// 延迟执行,等待 DOM 完全渲染(需要足够长的时间让树完全展开)
|
||||
const timer = setTimeout(() => {
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// 查找高亮成员的 DOM 元素
|
||||
const highlightedNode = containerRef.current.querySelector(`[data-member-id="${highlightedMemberId}"]`) as HTMLElement
|
||||
if (!highlightedNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const nodeRect = highlightedNode.getBoundingClientRect()
|
||||
|
||||
// 计算容器中心
|
||||
const containerCenterX = containerRect.width / 2
|
||||
const containerCenterY = containerRect.height / 2
|
||||
|
||||
// 此时 position 已经是 {x:0, y:0},所以节点位置就是相对于原点的位置
|
||||
// 我们需要计算让节点居中的偏移量
|
||||
const nodeCenterX = nodeRect.left - containerRect.left + nodeRect.width / 2
|
||||
const nodeCenterY = nodeRect.top - containerRect.top + nodeRect.height / 2
|
||||
|
||||
// 计算偏移量
|
||||
const offsetX = containerCenterX - nodeCenterX
|
||||
const offsetY = containerCenterY - nodeCenterY
|
||||
|
||||
setPosition({ x: offsetX, y: offsetY })
|
||||
|
||||
// 30秒后清除高亮状态
|
||||
setTimeout(() => {
|
||||
setHighlightedMemberId(null)
|
||||
}, 30000)
|
||||
}, 1000) // 增加到1秒,确保DOM完全渲染
|
||||
let raf2 = 0
|
||||
let raf3 = 0
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
raf3 = requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const highlightedNode = containerRef.current.querySelector(`[data-member-id="${highlightedMemberId}"]`) as HTMLElement
|
||||
if (!highlightedNode) return
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const nodeRect = highlightedNode.getBoundingClientRect()
|
||||
|
||||
const containerCenterX = containerRect.width / 2
|
||||
const containerCenterY = containerRect.height / 2
|
||||
const nodeCenterX = nodeRect.left - containerRect.left + nodeRect.width / 2
|
||||
const nodeCenterY = nodeRect.top - containerRect.top + nodeRect.height / 2
|
||||
|
||||
setPosition({ x: containerCenterX - nodeCenterX, y: containerCenterY - nodeCenterY })
|
||||
|
||||
setTimeout(() => setHighlightedMemberId(null), 30000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
if (raf2) cancelAnimationFrame(raf2)
|
||||
if (raf3) cancelAnimationFrame(raf3)
|
||||
}
|
||||
}, [viewMode, highlightedMemberId, setHighlightedMemberId])
|
||||
|
||||
// 传统族谱自适应视图函数
|
||||
@@ -362,7 +349,7 @@ export default function TreePage() {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
setScale((s) => Math.min(Math.max(s + delta, 0.1), 2))
|
||||
setScale((s) => Math.min(Math.max(s + delta, 0.15), 3))
|
||||
}
|
||||
|
||||
// 计算两指间距离
|
||||
@@ -401,7 +388,7 @@ export default function TreePage() {
|
||||
} else if (e.touches.length === 2 && touchStartRef.current.distance) {
|
||||
const newDistance = getTouchDistance(e.touches)
|
||||
const scaleFactor = newDistance / touchStartRef.current.distance
|
||||
setScale(s => Math.min(Math.max(s * scaleFactor, 0.1), 2))
|
||||
setScale(s => Math.min(Math.max(s * scaleFactor, 0.15), 3))
|
||||
touchStartRef.current.distance = newDistance
|
||||
}
|
||||
}
|
||||
@@ -515,74 +502,18 @@ export default function TreePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用useCallback创建稳定的回调,通过ref访问最新的relationMode
|
||||
// 使用useCallback创建稳定的回调,通过ref访问最新的relationMode(移除直接 DOM 操作)
|
||||
const handleD3MemberClick = useCallback((id: string) => {
|
||||
console.log('D3 onMemberClick triggered:', id, 'relationMode:', relationModeRef.current)
|
||||
if (relationModeRef.current) {
|
||||
console.log('Calling handleMemberClick, current selected:', selectedMembersRef.current)
|
||||
|
||||
// 直接操作 DOM 来显示选中标记
|
||||
// D3 图表的节点在 SVG 的 foreignObject 中,需要特殊查询
|
||||
// 注意:配偶没有独立的节点,需要在配偶区域显示选中标记
|
||||
const updateSelectionMark = (memberId: string, selected: boolean) => {
|
||||
// 首先尝试查找主成员节点
|
||||
let nodeElement = document.querySelector(`[data-member-id="${memberId}"]`) as HTMLElement
|
||||
let isSpouse = false
|
||||
|
||||
// 如果找不到,可能是配偶,尝试查找配偶区域
|
||||
if (!nodeElement) {
|
||||
const spouseArea = document.querySelector(`[data-spouse-id="${memberId}"]`) as HTMLElement
|
||||
if (spouseArea) {
|
||||
nodeElement = spouseArea
|
||||
isSpouse = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodeElement) {
|
||||
console.log('Node element not found for:', memberId)
|
||||
return
|
||||
}
|
||||
console.log('Updating selection mark for:', memberId, 'isSpouse:', isSpouse, 'selected:', selected)
|
||||
|
||||
if (selected) {
|
||||
// 只添加打钩标记,不改变边框和背景
|
||||
if (!nodeElement.querySelector('.selection-mark')) {
|
||||
const checkMark = document.createElement('div')
|
||||
checkMark.className = 'selection-mark'
|
||||
if (isSpouse) {
|
||||
// 配偶区域打钩位置(右边)
|
||||
checkMark.style.cssText = 'position: absolute; top: -6px; right: -6px; z-index: 20;'
|
||||
nodeElement.style.position = 'relative'
|
||||
} else {
|
||||
// 主成员打钩位置(左边)
|
||||
checkMark.style.cssText = 'position: absolute; top: 6px; left: 6px; z-index: 20;'
|
||||
}
|
||||
checkMark.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#2563eb" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10" fill="none" stroke="white" stroke-width="2.5"/></svg>`
|
||||
nodeElement.appendChild(checkMark)
|
||||
}
|
||||
} else {
|
||||
// 移除选中标记
|
||||
const mark = nodeElement.querySelector('.selection-mark')
|
||||
if (mark) mark.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// 直接处理选择逻辑
|
||||
if (selectedMembersRef.current.length === 0) {
|
||||
// 选择第一个成员
|
||||
updateSelectionMark(id, true)
|
||||
setSelectedMembers([id])
|
||||
} else if (selectedMembersRef.current.length === 1) {
|
||||
const [firstId] = selectedMembersRef.current
|
||||
if (firstId === id) {
|
||||
// 点击同一个成员,取消选择
|
||||
updateSelectionMark(id, false)
|
||||
setSelectedMembers([])
|
||||
return
|
||||
}
|
||||
|
||||
// 选择第二个成员,显示选中标记
|
||||
updateSelectionMark(id, true)
|
||||
setSelectedMembers([firstId, id])
|
||||
|
||||
const result = calculateRelationship(firstId, id)
|
||||
@@ -596,16 +527,12 @@ export default function TreePage() {
|
||||
})
|
||||
setShowRelationDialog(true)
|
||||
|
||||
// 延迟清除选中状态
|
||||
setTimeout(() => {
|
||||
// 清除所有选中标记
|
||||
document.querySelectorAll('.selection-mark').forEach(mark => mark.remove())
|
||||
setSelectedMembers([])
|
||||
setRelationMode(false)
|
||||
}, 300)
|
||||
}
|
||||
} else {
|
||||
console.log('Navigating to member detail')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
router.push(`/members/${id}?${params.toString()}`)
|
||||
}
|
||||
@@ -745,7 +672,17 @@ export default function TreePage() {
|
||||
|
||||
// 未登录时不渲染内容(已在 useEffect 中重定向)
|
||||
if (status === 'loading' || !session) {
|
||||
return null
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
|
||||
<SiteHeader />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle root not found
|
||||
@@ -838,12 +775,13 @@ export default function TreePage() {
|
||||
<div className="flex-1 relative min-h-0">
|
||||
{/* Toolbar - 移动端底部横向,桌面端左侧纵向 */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 md:bottom-auto md:top-4 md:left-4 md:translate-x-0 z-20 flex flex-row md:flex-col gap-1 md:gap-2 bg-background/90 backdrop-blur p-1.5 md:p-2 rounded-lg border shadow-lg">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.min(s + 0.1, 2))}>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.min(s + 0.1, 3))}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.max(s - 0.1, 0.1))}>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.max(s - 0.1, 0.15))}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-[10px] text-muted-foreground text-center tabular-nums select-none px-1">{Math.round(scale * 100)}%</span>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleTraditionalFitView} title="自适应视图">
|
||||
<Move className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -862,7 +800,7 @@ export default function TreePage() {
|
||||
{/* Tree Canvas */}
|
||||
<div
|
||||
ref={containerRefCallback}
|
||||
className="w-full h-full overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat select-none"
|
||||
className="w-full h-full overflow-hidden cursor-move relative touch-none bg-[url('/rice-paper.png')] bg-repeat select-none"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
|
||||
@@ -193,10 +193,8 @@ function PersonCard({
|
||||
}
|
||||
|
||||
const url = buildAddUrl(type)
|
||||
console.log('handleAddMember called:', type, url)
|
||||
// 使用 setTimeout 延迟跳转,确保菜单关闭后再执行
|
||||
setTimeout(() => {
|
||||
console.log('Navigating to:', url)
|
||||
window.location.href = url
|
||||
}, 100)
|
||||
}
|
||||
@@ -205,7 +203,6 @@ function PersonCard({
|
||||
const handleConfirmAddParent = () => {
|
||||
if (pendingRelationType) {
|
||||
const url = buildAddUrl(pendingRelationType)
|
||||
console.log('Confirmed add parent, navigating to:', url)
|
||||
setTimeout(() => {
|
||||
window.location.href = url
|
||||
}, 100)
|
||||
|
||||
+117
-203
@@ -5,7 +5,7 @@ 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 } from "react"
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, memo, useLayoutEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { permissions } from "@/lib/permissions"
|
||||
|
||||
@@ -26,6 +26,33 @@ interface Connection {
|
||||
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,
|
||||
@@ -52,9 +79,6 @@ export function TreeLayout({
|
||||
const VERTICAL_GAP = 48 // 3rem = 48px
|
||||
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
|
||||
|
||||
// 节点注册计数,用于触发连线重新计算
|
||||
const [nodeRegisteredCount, setNodeRegisteredCount] = useState(0)
|
||||
|
||||
// 折叠状态管理:存储被折叠的节点ID
|
||||
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(() => new Set())
|
||||
|
||||
@@ -80,12 +104,10 @@ export function TreeLayout({
|
||||
}
|
||||
}, [expandAll, onExpandAllChange])
|
||||
|
||||
// 处理节点 ref 注册
|
||||
// 节点 ref 注册(批量模式,不再触发 N 次 setState)
|
||||
const handleNodeRef = useCallback((memberId: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
nodeRefs.current.set(memberId, el)
|
||||
// 触发连线重新计算
|
||||
setNodeRegisteredCount(prev => prev + 1)
|
||||
} else {
|
||||
nodeRefs.current.delete(memberId)
|
||||
}
|
||||
@@ -128,161 +150,101 @@ export function TreeLayout({
|
||||
return checkHidden(nodeId)
|
||||
}, [getMember, collapsedNodes])
|
||||
|
||||
// 计算所有连线位置
|
||||
// 计算所有连线位置(使用 requestAnimationFrame 批量计算,避免 layout thrashing)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
const calculateConnections = useCallback(() => {
|
||||
const newConnections: Connection[] = []
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
if (!containerRect) {
|
||||
return
|
||||
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
|
||||
|
||||
// 检查是否有足够的节点已注册
|
||||
if (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
|
||||
|
||||
let processedCount = 0
|
||||
let skippedNoChildren = 0
|
||||
let skippedCollapsed = 0
|
||||
let skippedHidden = 0
|
||||
let skippedNotInDom = 0
|
||||
let skippedZeroSize = 0
|
||||
const parentCenterX = (parentRect.left + parentRect.width / 2 - containerRect.left) / scale
|
||||
const parentBottomY = (parentRect.bottom - containerRect.top) / scale
|
||||
|
||||
nodeRefs.current.forEach((nodeElement, nodeId) => {
|
||||
const member = getMember(nodeId)
|
||||
if (!member || !member.childrenIds || member.childrenIds.length === 0) {
|
||||
skippedNoChildren++
|
||||
return
|
||||
}
|
||||
|
||||
// 如果该节点被折叠,不绘制到子节点的连线
|
||||
if (collapsedNodes.has(nodeId)) {
|
||||
skippedCollapsed++
|
||||
return
|
||||
}
|
||||
|
||||
// 如果该节点被祖先折叠隐藏,跳过
|
||||
if (isNodeHiddenByCollapse(nodeId)) {
|
||||
skippedHidden++
|
||||
return
|
||||
}
|
||||
|
||||
// 检查元素是否仍在 DOM 中且可见
|
||||
if (!document.body.contains(nodeElement)) {
|
||||
skippedNotInDom++
|
||||
return
|
||||
}
|
||||
const parentRect = nodeElement.getBoundingClientRect()
|
||||
// 如果元素不可见(宽高为0),跳过
|
||||
if (parentRect.width === 0 || parentRect.height === 0) {
|
||||
skippedZeroSize++
|
||||
return
|
||||
}
|
||||
|
||||
processedCount++
|
||||
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 }>
|
||||
|
||||
// 考虑缩放因素:getBoundingClientRect 返回的是缩放后的尺寸
|
||||
// 需要除以 scale 来获取实际的逻辑位置
|
||||
const parentCenterX = (parentRect.left + parentRect.width / 2 - containerRect.left) / scale
|
||||
const parentBottomY = (parentRect.bottom - containerRect.top) / scale
|
||||
if (childPositions.length === 0) return
|
||||
|
||||
// 获取所有子节点的位置
|
||||
const childPositions = member.childrenIds
|
||||
.map(childId => {
|
||||
const childElement = nodeRefs.current.get(childId)
|
||||
if (!childElement) return null
|
||||
// 检查子元素是否仍在 DOM 中
|
||||
if (!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))
|
||||
|
||||
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
|
||||
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 },
|
||||
from: { x: parentCenterX, y: parentBottomY },
|
||||
to: { x: parentCenterX, y: verticalLineY },
|
||||
type: 'vertical'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setConnections(newConnections)
|
||||
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])
|
||||
|
||||
// 折叠状态变化时重新计算连线
|
||||
useEffect(() => {
|
||||
// 多次延迟计算,等待 DOM 完全更新
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
timers.push(setTimeout(calculateConnections, 50))
|
||||
timers.push(setTimeout(calculateConnections, 150))
|
||||
timers.push(setTimeout(calculateConnections, 300))
|
||||
timers.push(setTimeout(calculateConnections, 500))
|
||||
timers.push(setTimeout(calculateConnections, 1000))
|
||||
return () => timers.forEach(timer => clearTimeout(timer))
|
||||
}, [collapsedNodes, calculateConnections])
|
||||
|
||||
// 缩放变化时重新计算连线
|
||||
useEffect(() => {
|
||||
// 使用 useLayoutEffect 在 DOM 更新后立即计算连线(替代 7 个 setTimeout)
|
||||
useLayoutEffect(() => {
|
||||
calculateConnections()
|
||||
}, [scale, calculateConnections])
|
||||
// 双 rAF 确保浏览器完成布局后再计算
|
||||
const raf2 = requestAnimationFrame(() => calculateConnections())
|
||||
return () => cancelAnimationFrame(raf2)
|
||||
}, [calculateConnections, treeData, collapsedNodes])
|
||||
|
||||
// 节点注册变化时重新计算连线(防抖)
|
||||
// 使用 ResizeObserver 监听容器尺寸变化(替代 window.resize)
|
||||
useEffect(() => {
|
||||
if (nodeRegisteredCount === 0) return
|
||||
const timer = setTimeout(calculateConnections, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [nodeRegisteredCount, calculateConnections])
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const observer = new ResizeObserver(() => calculateConnections())
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [calculateConnections])
|
||||
|
||||
// 清理 rAF
|
||||
useEffect(() => {
|
||||
// 多次尝试计算,确保 DOM 完全渲染
|
||||
// 对于大型族谱(100+人),需要更长的延迟
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
timers.push(setTimeout(calculateConnections, 100))
|
||||
timers.push(setTimeout(calculateConnections, 300))
|
||||
timers.push(setTimeout(calculateConnections, 500))
|
||||
timers.push(setTimeout(calculateConnections, 1000))
|
||||
timers.push(setTimeout(calculateConnections, 2000))
|
||||
timers.push(setTimeout(calculateConnections, 3000))
|
||||
timers.push(setTimeout(calculateConnections, 5000))
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', calculateConnections)
|
||||
|
||||
return () => {
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
window.removeEventListener('resize', calculateConnections)
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
}
|
||||
}
|
||||
}, [calculateConnections, treeData])
|
||||
}, [])
|
||||
|
||||
|
||||
// 预计算排序后的子节点映射,避免每次渲染时重复计算
|
||||
@@ -310,45 +272,6 @@ export function TreeLayout({
|
||||
|
||||
if (!root) return null
|
||||
|
||||
// 姓名分解函数:将全名分解为姓和名
|
||||
const parseName = (fullName: string): { surname: string; givenName: string } => {
|
||||
const name = fullName.trim()
|
||||
if (!name) return { surname: '', givenName: '' }
|
||||
|
||||
// 常见复姓列表
|
||||
const compoundSurnames = [
|
||||
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
|
||||
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
|
||||
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
|
||||
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
|
||||
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
|
||||
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
|
||||
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
|
||||
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
|
||||
]
|
||||
|
||||
// 检查是否是复姓
|
||||
for (const compound of compoundSurnames) {
|
||||
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: '' }
|
||||
}
|
||||
|
||||
// 递归渲染树节点 - 使用 memo 优化
|
||||
const handleCreateNewRelation = (prefillName: string, relationType: RelationType) => {
|
||||
if (!relationDialog) return
|
||||
@@ -423,36 +346,27 @@ export function TreeLayout({
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
|
||||
{/* 连线层 - 使用 div 绘制 */}
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
|
||||
{/* 连线层 - 使用 SVG path 绘制(清晰且支持动画) */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: 1, width: '100%', height: '100%', overflow: 'visible' }}
|
||||
>
|
||||
{connections.map((conn, index) => {
|
||||
const isHorizontal = conn.type === 'horizontal'
|
||||
// 使用 1px 但通过 transform scale 缩小来实现细线
|
||||
const width = isHorizontal ? Math.abs(conn.to.x - conn.from.x) : 1
|
||||
const height = isHorizontal ? 1 : Math.abs(conn.to.y - conn.from.y)
|
||||
const left = Math.min(conn.from.x, conn.to.x)
|
||||
const top = Math.min(conn.from.y, conn.to.y)
|
||||
|
||||
// 跳过无效的连线(宽度或高度为0)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
|
||||
const d = `M ${conn.from.x} ${conn.from.y} L ${conn.to.x} ${conn.to.y}`
|
||||
return (
|
||||
<div
|
||||
<path
|
||||
key={index}
|
||||
className="absolute"
|
||||
d={d}
|
||||
stroke="rgba(180, 83, 9, 0.7)"
|
||||
strokeWidth={1}
|
||||
fill="none"
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: 'rgba(180, 83, 9, 0.7)',
|
||||
transform: isHorizontal ? 'scaleY(0.5)' : 'scaleX(0.5)',
|
||||
transformOrigin: 'top left',
|
||||
transition: 'stroke-dashoffset 0.3s ease-out',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</svg>
|
||||
|
||||
{/* 树节点层 */}
|
||||
<div className="relative" style={{ zIndex: 10 }} key="tree-root">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user