Files
chinese-family-tree-2/components/tree/d3-org-chart-flow.tsx
T
freedakgmail e7167f4340 0.6.2.0
2025-12-10 13:03:10 +08:00

928 lines
35 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, UserPlus, Users, Baby } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import jsPDF from "jspdf"
import { useRouter } from "next/navigation"
import { useFamily } from "@/context/family-context"
// 全局类型声明
declare global {
interface Window {
d3ChartContextMenu?: (event: MouseEvent, memberId: string) => void
d3ChartSpouseClick?: (event: Event, spouseId: string) => void
}
}
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 SpouseData {
id: string
name: string
avatarUrl: string | null | undefined
gender: string
birthYear: string
deathYear: string
ageText: string
}
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
spouseAvatarUrl?: string | null
spouseGender?: string | null
spouseDeathYear?: string | null
spouseBirthYear?: string | null
spouseAgeText?: string
// 多配偶支持
spouses: SpouseData[]
spouseCount: number
hasChildren: boolean
ageText: string
isFounder?: boolean
}
// 计算年龄
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 [contextMenuNode, setContextMenuNode] = useState<FamilyMember | null>(null)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const router = useRouter()
const { currentTree } = useFamily()
// 构建新增成员的 URL
const buildAddUrl = (type: 'father' | 'mother' | 'spouse' | 'child', member: FamilyMember) => {
const params = new URLSearchParams()
if (currentTree?.id) {
params.set('treeId', currentTree.id)
}
switch (type) {
case 'father':
params.set('childId', member.id)
params.set('gender', 'MALE')
params.set('generation', (member.generation - 1).toString())
// 如果已有母亲,设为配偶
if (member.motherId) {
params.set('spouseId', member.motherId)
}
break
case 'mother':
params.set('childId', member.id)
params.set('gender', 'FEMALE')
params.set('generation', (member.generation - 1).toString())
// 如果已有父亲,设为配偶
if (member.fatherId) {
params.set('spouseId', member.fatherId)
}
break
case 'spouse':
params.set('spouseId', member.id)
params.set('generation', member.generation.toString())
// 配偶性别与当前成员相反
params.set('gender', member.gender === 'MALE' ? 'FEMALE' : 'MALE')
// 如果当前成员有子女,将他们也作为新配偶的子女
if (member.childrenIds && member.childrenIds.length > 0) {
// 传递所有子女ID
member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
break
case 'child':
// 根据当前成员性别设置父/母
if (member.gender === 'MALE') {
params.set('fatherId', member.id)
// 如果父亲有配偶,设置配偶为母亲
if (member.spouseIds && member.spouseIds.length > 0) {
params.set('motherId', member.spouseIds[0])
}
} else {
params.set('motherId', member.id)
// 如果母亲有配偶,设置配偶为父亲
if (member.spouseIds && member.spouseIds.length > 0) {
params.set('fatherId', member.spouseIds[0])
}
}
params.set('generation', (member.generation + 1).toString())
break
}
return `/members/new?${params.toString()}`
}
const handleAddMember = (type: 'father' | 'mother' | 'spouse' | 'child', member: FamilyMember) => {
const url = buildAddUrl(type, member)
console.log('D3 handleAddMember called:', type, url)
// 使用 setTimeout 延迟跳转,确保菜单关闭后再执行
setTimeout(() => {
console.log('Navigating to:', url)
window.location.href = url
}, 100)
}
// 判断可以添加的关系
const canAddFather = (member: FamilyMember) => !member.fatherId
const canAddMother = (member: FamilyMember) => !member.motherId
const canAddSpouse = (member: FamilyMember) => true // 配偶总是可以添加
const canAddChild = (member: FamilyMember) => true // 子女总是可以添加
// 转换数据格式
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
// 重要:parentId 必须指向已经在 visited 集合中的节点,否则 d3-org-chart 会报 missing 错误
let parentId: string | null = null
if (currentId !== rootId) {
// 根据姓氏判断主线,优先选择同姓的父母
// 同时确保父母已经被访问过(在 visited 中)
const father = member.fatherId && visited.has(member.fatherId) ? members[member.fatherId] : null
const mother = member.motherId && visited.has(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}`
: ''
// 处理多配偶数据
const spousesData = spouses.map(sp => ({
id: sp.id,
name: sp.fullName,
avatarUrl: sp.avatarUrl,
gender: sp.gender === 'MALE' ? '男' : '女',
birthYear: sp.birthDate ? new Date(sp.birthDate).getFullYear().toString() : '',
deathYear: sp.deathDate ? new Date(sp.deathDate).getFullYear().toString() : '',
ageText: (() => {
const age = calculateAge(sp.birthDate, sp.deathDate)
return age !== null ? (sp.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,
spouseAvatarUrl: spouses.length > 0 ? spouses[0].avatarUrl : null,
spouseGender: spouses.length > 0 ? (spouses[0].gender === 'MALE' ? '男' : '女') : null,
spouseDeathYear: spouses.length > 0 ? (spouses[0].deathDate ? new Date(spouses[0].deathDate).getFullYear().toString() : '') : null,
spouseBirthYear: spouses.length > 0 ? (spouses[0].birthDate ? new Date(spouses[0].birthDate).getFullYear().toString() : '') : null,
spouseAgeText: spouses.length > 0 ? (() => {
const sp = spouses[0]
const age = calculateAge(sp.birthDate, sp.deathDate)
return age !== null ? (sp.deathDate ? `享年${age}` : `${age}`) : ''
})() : '',
// 新增多配偶数组
spouses: spousesData,
spouseCount: spouses.length,
hasChildren: Object.values(members).some(m => m.fatherId === member.id || m.motherId === member.id),
ageText: ageText,
isFounder: member.isFounder === true
})
// 添加子节点到队列
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((d: any) => {
const spouseCount = d.data.spouseCount || 0
return spouseCount === 0 ? 170 : spouseCount === 1 ? 300 : 170 + spouseCount * 110
}) // 根据配偶数量动态调整宽度
.nodeHeight(() => 175)
.childrenMargin(() => 50)
.compactMarginBetween(() => 20) // 减少间距
.compactMarginPair(() => 20)
.neighbourMargin(() => 20)
.siblingsMargin(() => 20)
.linkUpdate(function (d: any, i: any, arr: any) {
// 现代风格连线 - 使用柔和的灰蓝色
d3.select(arr[i])
.attr('stroke', '#94a3b8')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '')
.style('opacity', 0.6)
})
.buttonContent(({ node }: any) => {
return `<div style="
color: #64748b;
border-radius: 20px;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
margin: auto;
background: white;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
" onmouseover="this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'" onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.08)'">
<span style="font-size: 10px;">${node.children ? '▲' : '▼'}</span>
<span>${node.data._directSubordinates || 0}</span>
</div>`
})
.nodeContent((d: any) => {
const node = d.data
const isMale = node.gender === '男'
const isDead = !!node.deathYear
const hasSpouse = !!node.spouseId
const isFounder = node.isFounder === true
// 现代简约风格配色
const topBarGradient = isFounder
? 'linear-gradient(to right, #f59e0b, #ea580c, #f59e0b)'
: isDead
? 'linear-gradient(to right, #78716c, #57534e, #78716c)'
: isMale
? 'linear-gradient(to right, #38bdf8, #3b82f6, #38bdf8)'
: 'linear-gradient(to right, #fb7185, #ec4899, #fb7185)'
const bgGradient = isFounder
? 'linear-gradient(135deg, #fffbeb, #fef3c7, #fef9c3)'
: isDead
? 'linear-gradient(135deg, #f5f5f4, #e7e5e4, #f5f5f4)'
: isMale
? 'linear-gradient(135deg, #f0f9ff, #e0f2fe, #ecfeff)'
: 'linear-gradient(135deg, #fff1f2, #fce7f3, #fdf2f8)'
const avatarBorder = isFounder
? '#f59e0b'
: isDead ? '#a8a29e' : isMale ? '#38bdf8' : '#fb7185'
const avatarGlow = isFounder
? 'rgba(245, 158, 11, 0.4)'
: isDead ? 'rgba(168, 162, 158, 0.3)' : isMale ? 'rgba(56, 189, 248, 0.4)' : 'rgba(251, 113, 133, 0.4)'
const textColor = '#000000' // 纯黑色文字
const badgeGradient = isFounder
? 'linear-gradient(to right, #f59e0b, #ea580c)'
: isDead
? 'linear-gradient(to right, #78716c, #57534e)'
: isMale
? 'linear-gradient(to right, #38bdf8, #3b82f6)'
: 'linear-gradient(to right, #fb7185, #ec4899)'
// 获取头像URL
const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null
// 检查是否被选中
const isSelected = selectedMembers.includes(node.id)
// 多配偶数据
const spousesArray = node.spouses || []
const spouseCount = spousesArray.length
// 动态宽度:根据配偶数量调整
const width = spouseCount === 0 ? 150 : spouseCount === 1 ? 280 : 150 + spouseCount * 100
return `
<div
data-member-id="${node.id}"
style="
position: relative;
width: ${width}px;
height: 160px;
box-sizing: border-box;
background: ${bgGradient};
border-radius: 16px;
padding: 16px 12px 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
cursor: pointer;
transition: all 0.3s ease;
"
onmouseover="this.style.boxShadow='0 12px 40px rgba(0,0,0,0.15)'; this.style.transform='translateY(-4px) scale(1.02)'"
onmouseout="this.style.boxShadow='0 4px 20px rgba(0,0,0,0.08)'; this.style.transform='translateY(0) scale(1)'"
>
<!-- 顶部装饰条 -->
<div style="
position: absolute;
top: 0;
left: 16px;
right: 16px;
height: 4px;
background: ${topBarGradient};
border-radius: 0 0 4px 4px;
"></div>
<!-- 世代标签 - 胶囊风格 -->
<div style="
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
background: ${badgeGradient};
color: white;
font-size: 10px;
font-weight: 500;
padding: 3px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 10;
letter-spacing: 1px;
">
${node.generation}
</div>
<!-- 选中标记 -->
${isSelected ? `
<div style="position: absolute; top: 8px; left: 8px; z-index: 20;">
<div style="width: 20px; height: 20px; border-radius: 50%; background: #3b82f6; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(59,130,246,0.4);">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
</div>
` : ''}
<div style="display: flex; justify-content: center; gap: 16px; margin-top: 8px;">
<!-- 主成员 -->
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; flex: 1;">
<!-- 头像带光晕 -->
<div style="position: relative;">
<div style="
position: absolute;
inset: -2px;
border-radius: 50%;
background: ${avatarGlow};
filter: blur(8px);
"></div>
<div style="
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 3px solid ${avatarBorder};
box-shadow: 0 4px 12px rgba(0,0,0,0.1), inset 0 0 0 2px white;
">
${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="28" height="28" viewBox="0 0 24 24" fill="none" stroke="${isDead ? '#a8a29e' : '#ec4899'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/><circle cx="12" cy="12" r="10"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="${isDead ? '#a8a29e' : '#3b82f6'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>`
}
</div>
<!-- 状态指示器 -->
<div style="
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${isDead ? '#a8a29e' : '#22c55e'};
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
"></div>
</div>
<!-- 姓名 -->
<div style="
font-size: 15px;
font-weight: 600;
color: ${textColor};
letter-spacing: 0.5px;
white-space: nowrap;
font-family: serif;
">
${node.name}
</div>
<!-- 生卒信息 -->
<div style="text-align: center;">
${node.birthYear ? `
<div style="
font-size: 10px;
color: #000000;
font-family: serif;
background: ${isDead ? 'rgba(168,162,158,0.3)' : 'rgba(107,114,128,0.2)'};
padding: 2px 8px;
border-radius: 10px;
display: inline-block;
font-weight: 500;
">
${node.birthYear}${node.deathYear ? `${node.deathYear}` : ' —'}
</div>
${node.ageText ? `<div style="font-size: 10px; color: #000000; margin-top: 2px; font-weight: 500; font-family: serif;">${node.ageText}</div>` : ''}
` : ''}
</div>
</div>
<!-- 配偶们 -->
${spousesArray.map((sp: any, idx: number) => {
const spIsMale = sp.gender === '男'
const spIsDead = !!sp.deathYear
const spAvatarBorder = spIsDead ? '#a8a29e' : spIsMale ? '#38bdf8' : '#fb7185'
const spAvatarGlow = spIsDead ? 'rgba(168, 162, 158, 0.3)' : spIsMale ? 'rgba(56, 189, 248, 0.4)' : 'rgba(251, 113, 133, 0.4)'
const spAvatarUrl = sp.avatarUrl ? avatarCache.get(sp.avatarUrl) : null
return `
<div
class="spouse-area"
data-spouse-id="${sp.id}"
style="display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; position: relative; min-width: 80px;"
onclick="event.stopPropagation(); window.d3ChartSpouseClick && window.d3ChartSpouseClick(event, '${sp.id}')"
>
${idx === 0 ? `
<!-- 连接线 -->
<div style="
position: absolute;
left: -8px;
top: 22px;
width: 16px;
height: 2px;
background: linear-gradient(to right, ${avatarBorder}, ${spAvatarBorder});
border-radius: 1px;
"></div>
` : ''}
<!-- 头像带光晕 -->
<div style="position: relative;">
<div style="
position: absolute;
inset: -2px;
border-radius: 50%;
background: ${spAvatarGlow};
filter: blur(6px);
"></div>
<div style="
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px solid ${spAvatarBorder};
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
">
${spAvatarUrl
? `<img src="${spAvatarUrl}" alt="${sp.name}" style="width: 100%; height: 100%; object-fit: cover;" />`
: spIsMale
? `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${spIsDead ? '#a8a29e' : '#3b82f6'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${spIsDead ? '#a8a29e' : '#ec4899'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/><circle cx="12" cy="12" r="10"/></svg>`
}
</div>
<!-- 状态指示器 -->
<div style="
position: absolute;
bottom: -1px;
right: -1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: ${spIsDead ? '#a8a29e' : '#22c55e'};
border: 2px solid white;
"></div>
</div>
<!-- 姓名 -->
<div style="
font-size: 12px;
font-weight: 600;
color: #000000;
white-space: nowrap;
font-family: serif;
">
${sp.name}
</div>
<!-- 生卒信息 -->
${sp.birthYear ? `
<div style="
font-size: 9px;
color: #000000;
font-family: serif;
background: ${spIsDead ? 'rgba(168,162,158,0.3)' : 'rgba(107,114,128,0.2)'};
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
">
${sp.birthYear}${sp.deathYear ? `-${sp.deathYear}` : '-'}
</div>
` : ''}
</div>
`
}).join('')}
</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() // 初始化时展开所有节点
.fit() // 自适应全图显示
// 注册全局配偶点击处理函数
window.d3ChartSpouseClick = (event: Event, spouseId: string) => {
event.stopPropagation()
if (spouseId && onMemberClick) {
onMemberClick(spouseId)
}
}
// 添加右键菜单支持
setTimeout(() => {
const nodes = chartRef.current?.querySelectorAll('[data-member-id]')
console.log('Found nodes for context menu:', nodes?.length)
nodes?.forEach((node) => {
const memberId = (node as HTMLElement).getAttribute('data-member-id')
if (memberId) {
node.addEventListener('contextmenu', (e) => {
e.preventDefault()
const mouseEvent = e as MouseEvent
const member = members[memberId]
if (member && !relationMode) {
console.log('Right click on member:', member.fullName, 'at', mouseEvent.clientX, mouseEvent.clientY)
setContextMenuNode(member)
setContextMenuPosition({ x: mouseEvent.clientX, y: mouseEvent.clientY })
}
})
}
})
}, 200)
chartInstanceRef.current = chart
setIsReady(true)
return () => {
// 清理
if (chartRef.current) {
chartRef.current.innerHTML = ''
}
// 清理全局函数
if (window.d3ChartContextMenu) {
delete window.d3ChartContextMenu
}
if (window.d3ChartSpouseClick) {
delete window.d3ChartSpouseClick
}
}
}, [members, rootId, onMemberClick, avatarCache])
// 监听 selectedMembers 变化,通过 DOM 操作更新选中标记
useEffect(() => {
console.log('Selection useEffect triggered, selectedMembers:', selectedMembers, 'isReady:', isReady)
if (!chartRef.current || !isReady) return
// 移除所有现有的选中标记
const existingMarks = chartRef.current.querySelectorAll('.selection-mark')
existingMarks.forEach(mark => mark.remove())
// 为选中的成员添加选中标记(只打钩,不改变边框和背景)
selectedMembers.forEach(memberId => {
console.log('Marking member as selected:', memberId)
// 先尝试查找主成员节点
const nodeElement = chartRef.current?.querySelector(`[data-member-id="${memberId}"]`)
// 再尝试查找配偶区域
const spouseElement = chartRef.current?.querySelector(`[data-spouse-id="${memberId}"]`)
if (nodeElement) {
// 主成员:只添加打钩标记(左边)
const checkMark = document.createElement('div')
checkMark.className = 'selection-mark'
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" stroke-linecap="round" stroke-linejoin="round"><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 if (spouseElement) {
// 配偶:只添加打钩标记
const checkMark = document.createElement('div')
checkMark.className = 'selection-mark'
checkMark.style.cssText = 'position: absolute; top: -5px; right: -5px; 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" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10" fill="none" stroke="white" stroke-width="2.5"/></svg>`
spouseElement.appendChild(checkMark)
}
})
}, [selectedMembers, isReady])
// 导出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-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat relative" key={`d3-chart-${rootId}`}>
{/* 图表容器 */}
<div
ref={chartRef}
className="w-full h-full"
style={{ overflow: 'hidden' }}
/>
{/* 自定义右键菜单 */}
{contextMenuNode && (
<div
className="fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{
left: contextMenuPosition.x,
top: contextMenuPosition.y,
pointerEvents: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="px-2 py-1.5 text-sm text-muted-foreground">
{contextMenuNode.fullName}
</div>
<div className="h-px bg-border my-1" />
{canAddFather(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('father', contextMenuNode)
setContextMenuNode(null)
}}
>
<UserPlus className="h-4 w-4 text-blue-500" />
</div>
)}
{canAddMother(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('mother', contextMenuNode)
setContextMenuNode(null)
}}
>
<UserPlus className="h-4 w-4 text-pink-500" />
</div>
)}
{(canAddFather(contextMenuNode) || canAddMother(contextMenuNode)) && (canAddSpouse(contextMenuNode) || canAddChild(contextMenuNode)) && (
<div className="h-px bg-border my-1" />
)}
{canAddSpouse(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('spouse', contextMenuNode)
setContextMenuNode(null)
}}
>
<Users className="h-4 w-4 text-red-500" />
</div>
)}
{canAddChild(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('child', contextMenuNode)
setContextMenuNode(null)
}}
>
<Baby className="h-4 w-4 text-green-500" />
</div>
)}
</div>
)}
{/* 点击其他地方关闭菜单 */}
{contextMenuNode && (
<div
className="fixed inset-0 z-40"
onClick={() => setContextMenuNode(null)}
/>
)}
</div>
)
})
D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow'
// 使用memo优化,避免不必要的重新渲染
export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => {
return prevProps.rootId === nextProps.rootId &&
prevProps.members === nextProps.members
})