Files
chinese-family-tree-2/components/tree/d3-org-chart-flow.tsx
T
freedakgmail b25f4bea02 0.0.9.0
2025-11-24 16:07:24 +08:00

431 lines
15 KiB
TypeScript

"use client"
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo } from "react"
import * as d3 from "d3"
// @ts-ignore - d3-org-chart没有TypeScript类型定义
import { OrgChart } from "d3-org-chart"
import { Download, FileText } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import jsPDF from "jspdf"
interface D3OrgChartFlowProps {
members: Record<string, FamilyMember>
rootId: string
onMemberClick?: (memberId: string) => void
onExport?: () => void
relationMode?: boolean
selectedMembers?: string[]
}
export interface D3OrgChartRef {
exportPNG: () => void
exportPDF: () => void
expandAll: () => void
collapseAll: () => void
fitView: () => void
}
interface ChartNode {
id: string
parentId: string | null
name: string
generation: string
birthYear: string
deathYear: string
birthPlace: string
gender: string
avatarUrl: string | null | undefined
spouseName: string
spouseId: string | null
hasChildren: boolean
ageText: string
}
// 计算年龄
function calculateAge(birthDate?: string, deathDate?: string): number | null {
if (!birthDate) return null
const birth = new Date(birthDate)
const end = deathDate ? new Date(deathDate) : new Date()
let age = end.getFullYear() - birth.getFullYear()
const monthDiff = end.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
age--
}
return age
}
const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
({ members, rootId, onMemberClick, onExport, relationMode = false, selectedMembers = [] }, ref) => {
console.log('D3OrgChartFlow render, selectedMembers:', selectedMembers, 'relationMode:', relationMode)
const chartRef = useRef<HTMLDivElement>(null)
const chartInstanceRef = useRef<any>(null)
const [isReady, setIsReady] = useState(false)
const [avatarCache, setAvatarCache] = useState<Map<string, string>>(new Map())
// 转换数据格式
const convertToChartData = (): ChartNode[] => {
const chartNodes: ChartNode[] = []
// 首先找到根节点(没有父节点的节点)
const rootMember = members[rootId]
if (!rootMember) return []
// 使用BFS遍历,确保只包含从rootId开始的树
const visited = new Set<string>()
const queue: string[] = [rootId]
while (queue.length > 0) {
const currentId = queue.shift()!
if (visited.has(currentId)) continue
visited.add(currentId)
const member = members[currentId]
if (!member) continue
// 获取所有配偶信息,确保配偶存在于members中
const spouses = member.spouseIds
?.map(id => members[id])
.filter(Boolean) || []
// 确定父节点ID - 只有根节点的parentId为null
let parentId: string | null = null
if (currentId !== rootId) {
// 根据姓氏判断主线,优先选择同姓的父母
const father = member.fatherId ? members[member.fatherId] : null
const mother = member.motherId ? members[member.motherId] : null
// 判断父母中谁是家族主线(同姓)
const isFatherMainLine = father && member.surname === father.surname
const isMotherMainLine = mother && member.surname === mother.surname
if (isFatherMainLine) {
parentId = member.fatherId || null
} else if (isMotherMainLine) {
parentId = member.motherId || null
} else if (father) {
// 如果都不同姓,优先选择父亲
parentId = member.fatherId || null
} else if (mother) {
parentId = member.motherId || null
}
}
// 计算年龄文本
const age = calculateAge(member.birthDate, member.deathDate)
const ageText = age !== null
? member.deathDate
? `享年${age}`
: `${age}`
: ''
chartNodes.push({
id: member.id,
parentId: parentId,
name: member.fullName,
generation: `${member.generation}`,
birthYear: member.birthDate ? new Date(member.birthDate).getFullYear().toString() : '',
deathYear: member.deathDate ? new Date(member.deathDate).getFullYear().toString() : '',
birthPlace: member.birthPlace || '',
gender: member.gender === 'MALE' ? '男' : '女',
avatarUrl: member.avatarUrl,
spouseName: spouses.length > 0 ? spouses.map(s => s.fullName).join(', ') : '',
spouseId: spouses.length > 0 ? spouses[0].id : null,
hasChildren: Object.values(members).some(m => m.fatherId === member.id || m.motherId === member.id),
ageText: ageText
})
// 添加子节点到队列
Object.values(members).forEach(m => {
if ((m.fatherId === currentId || m.motherId === currentId) && !visited.has(m.id)) {
queue.push(m.id)
}
})
}
return chartNodes
}
// 构建头像缓存(URL 直接使用)
useEffect(() => {
const cache = new Map<string, string>()
for (const member of Object.values(members)) {
if (member.avatarUrl) {
cache.set(member.avatarUrl, member.avatarUrl)
}
}
setAvatarCache(cache)
}, [members])
useEffect(() => {
if (!chartRef.current || !rootId) return
// 清理旧的图表
if (chartRef.current) {
chartRef.current.innerHTML = ''
}
const data = convertToChartData()
// 初始化图表
const chart = new OrgChart()
.container(chartRef.current)
.data(data)
.nodeWidth(() => 160)
.nodeHeight(() => 120)
.childrenMargin(() => 50)
.compactMarginBetween(() => 35)
.compactMarginPair(() => 35)
.neighbourMargin(() => 35)
.siblingsMargin(() => 35)
.linkUpdate(function (d: any, i: any, arr: any) {
// 加深连线颜色
d3.select(arr[i])
.attr('stroke', '#475569')
.attr('stroke-width', 2.5)
})
.buttonContent(({ node }: any) => {
return `<div style="color:#716E7B;border-radius:5px;padding:4px;font-size:10px;margin:auto auto;background-color:white;border: 1px solid #E4E2E9"> <span style="font-size:9px">${node.children ? `<i class="fas fa-angle-up"></i>` : `<i class="fas fa-angle-down"></i>`}</span> ${node.data._directSubordinates || 0} </div>`
})
.nodeContent((d: any) => {
const node = d.data
const color = node.gender === '男' ? '#3b82f6' : '#ec4899'
const bgColor = node.gender === '男' ? '#dbeafe' : '#fce7f3'
// 获取头像URL
const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null
// 检查是否被选中
const isSelected = selectedMembers.includes(node.id)
return `
<div style="
position: relative;
width: 160px;
height: 120px;
box-sizing: border-box;
background: ${node.deathYear ? '#fafafa' : 'white'};
border: 2px solid ${color};
border-radius: 10px;
padding: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.3s;
" onmouseover="this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)'" onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.1)'">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 5px;">
<div style="
width: 40px;
height: 40px;
border-radius: 50%;
background: ${bgColor};
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: ${color};
font-weight: bold;
flex-shrink: 0;
overflow: hidden;
border: 1px solid ${color}40;
">
${avatarUrl
? `<img src="${avatarUrl}" alt="${node.name}" style="width: 100%; height: 100%; object-fit: cover;" />`
: node.gender === '女'
? `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 24px; height: 24px;"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 24px; height: 24px;"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`
}
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; font-weight: 600; color: ${node.deathYear ? '#9ca3af' : '#1f2937'}; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; position: relative; padding-right: 12px;">
<span>${node.name}</span>
<span style="position: absolute; right: 2px; top: 4px; width: 8px; height: 8px; border-radius: 50%; background-color: ${node.deathYear ? '#9ca3af' : '#22c55e'};"></span>
${isSelected ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
</div>
<div style="font-size: 9px; color: #6b7280; padding: 1px 4px; background: #f3f4f6; border-radius: 3px; display: inline-block;">
${node.generation}
</div>
</div>
</div>
<div style="font-size: 10px; color: #6b7280; line-height: 1.3;">
${node.birthYear ? `<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1px;">
<span>${node.birthYear}${node.deathYear ? ` - ${node.deathYear}` : ''}</span>
${node.ageText ? `<span style="font-size: 9px; color: #9ca3af;">${node.ageText}</span>` : ''}
</div>` : ''}
${node.birthPlace ? `<div style="margin-bottom: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${node.birthPlace}
</div>` : ''}
</div>
${node.spouseName && node.spouseId ? `
<div
class="spouse-area"
data-spouse-id="${node.spouseId}"
style="
margin-top: 4px;
padding: 3px 5px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 9px;
color: #4b5563;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s;
"
onmouseover="this.style.borderColor='#3b82f6'; this.style.background='#eff6ff'; this.style.color='#1e40af'"
onmouseout="this.style.borderColor='#e5e7eb'; this.style.background='#f9fafb'; this.style.color='#4b5563'"
>
配偶: ${node.spouseName}
</div>
` : ''}
</div>
`
})
.onNodeClick((d: any) => {
if (onMemberClick && d) {
// d3-org-chart传递的是节点数据对象,需要提取id
const nodeId = typeof d === 'string' ? d : (d.data?.id || d.id)
if (nodeId) {
onMemberClick(nodeId)
}
}
})
.render()
.expandAll() // 初始化时展开所有节点
// 添加配偶区域点击事件监听
setTimeout(() => {
const spouseAreas = chartRef.current?.querySelectorAll('.spouse-area')
spouseAreas?.forEach((area) => {
area.addEventListener('click', (e) => {
e.stopPropagation() // 阻止事件冒泡到节点
const spouseId = (area as HTMLElement).getAttribute('data-spouse-id')
if (spouseId && onMemberClick) {
onMemberClick(spouseId)
}
})
})
}, 100)
chartInstanceRef.current = chart
setIsReady(true)
return () => {
// 清理
if (chartRef.current) {
chartRef.current.innerHTML = ''
}
}
}, [members, rootId, onMemberClick, avatarCache])
// 监听selectedMembers变化,动态显示/隐藏勾选图标
// 逻辑已移至tree/page.tsx以避免组件重新渲染
// 导出PNG
const handleExportPNG = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.exportImg({
save: true,
full: true,
onLoad: () => {
onExport?.()
}
})
}
// 导出PDF
const handleExportPDF = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.exportImg({
save: false,
full: true,
onLoad: (base64: string) => {
const pdf = new jsPDF({
orientation: 'landscape',
unit: 'px',
format: 'a4'
})
const img = new Image()
img.src = base64
img.onload = function () {
const imgWidth = img.width
const imgHeight = img.height
const pdfWidth = pdf.internal.pageSize.getWidth()
const pdfHeight = pdf.internal.pageSize.getHeight()
// 计算缩放比例以适应页面
const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight)
const scaledWidth = imgWidth * ratio
const scaledHeight = imgHeight * ratio
// 居中放置
const x = (pdfWidth - scaledWidth) / 2
const y = (pdfHeight - scaledHeight) / 2
pdf.addImage(img, 'PNG', x, y, scaledWidth, scaledHeight)
pdf.save(`家谱图-${new Date().toISOString().split('T')[0]}.pdf`)
onExport?.()
}
}
})
}
// 展开所有节点
const handleExpandAll = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.expandAll()
}
// 折叠所有节点
const handleCollapseAll = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.collapseAll()
}
// 适应视图
const handleFitView = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.fit()
}
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
exportPNG: handleExportPNG,
exportPDF: handleExportPDF,
expandAll: handleExpandAll,
collapseAll: handleCollapseAll,
fitView: handleFitView,
}))
return (
<div className="w-full h-full bg-background rounded-lg border relative" key={`d3-chart-${rootId}`}>
{/* 图表容器 */}
<div
ref={chartRef}
className="w-full h-full"
style={{ overflow: 'hidden' }}
/>
</div>
)
})
D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow'
// 使用memo优化,避免不必要的重新渲染
export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => {
return prevProps.rootId === nextProps.rootId &&
prevProps.members === nextProps.members
})