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:
selfrelease
2026-07-16 15:45:24 +08:00
parent b5612a3c2e
commit ce06674cba
5 changed files with 212 additions and 363 deletions
+94 -156
View File
@@ -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}