1355 lines
52 KiB
TypeScript
1355 lines
52 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo, useCallback } 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"
|
||
import { GenerationWarningDialog } from "@/components/tree/generation-warning-dialog"
|
||
import { shouldShowGenerationWarning } from "@/lib/generation-utils"
|
||
|
||
// 全局类型声明
|
||
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()
|
||
|
||
// 代数重新计算警告对话框状态
|
||
const [showWarningDialog, setShowWarningDialog] = useState(false)
|
||
const [pendingAddMember, setPendingAddMember] = useState<{ type: 'father' | 'mother', member: FamilyMember } | null>(null)
|
||
|
||
// 构建新增成员的 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')
|
||
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
|
||
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
|
||
// 如果已有母亲,设为配偶
|
||
if (member.motherId) {
|
||
params.set('spouseId', member.motherId)
|
||
}
|
||
break
|
||
case 'mother':
|
||
params.set('childId', member.id)
|
||
params.set('gender', 'FEMALE')
|
||
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
|
||
params.set('generation', member.generation === 1 ? '1' : (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) => {
|
||
// 检查是否需要显示代数重新计算警告
|
||
if ((type === 'father' || type === 'mother') && shouldShowGenerationWarning(member, type)) {
|
||
setPendingAddMember({ type, member })
|
||
setShowWarningDialog(true)
|
||
return
|
||
}
|
||
|
||
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 handleConfirmAddParent = () => {
|
||
if (pendingAddMember) {
|
||
const url = buildAddUrl(pendingAddMember.type, pendingAddMember.member)
|
||
console.log('Confirmed add parent, navigating to:', url)
|
||
setTimeout(() => {
|
||
window.location.href = url
|
||
}, 100)
|
||
setPendingAddMember(null)
|
||
}
|
||
}
|
||
|
||
// 判断可以添加的关系
|
||
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.id === rootId // 使用 rootId 判断始祖
|
||
})
|
||
|
||
// 添加子节点到队列
|
||
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 = ''
|
||
}
|
||
|
||
// iOS/iPadOS 检测(包括 Safari、Chrome 等所有浏览器)
|
||
// iPad 上所有浏览器都使用 WebKit 引擎
|
||
const container = chartRef.current
|
||
const isTouchDevice = navigator.maxTouchPoints > 1
|
||
const isWebKit = /AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
|
||
const isIOSChrome = /CriOS/.test(navigator.userAgent)
|
||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||
(navigator.platform === 'MacIntel' && isTouchDevice) ||
|
||
isIOSChrome
|
||
|
||
console.log('Device detection:', { isIOS, isTouchDevice, isWebKit, isIOSChrome, userAgent: navigator.userAgent })
|
||
|
||
// 对于 iOS,设置明确的像素高度
|
||
if (isIOS) {
|
||
const headerHeight = 120
|
||
const calculatedHeight = window.innerHeight - headerHeight
|
||
container.style.height = `${calculatedHeight}px`
|
||
}
|
||
|
||
const data = convertToChartData()
|
||
|
||
// 获取容器实际尺寸 - iOS Safari 可能返回 0,所以使用 window 尺寸作为后备
|
||
const containerRect = chartRef.current.getBoundingClientRect()
|
||
let containerWidth = containerRect.width
|
||
let containerHeight = containerRect.height
|
||
|
||
// 如果尺寸无效,使用 window 尺寸
|
||
if (!containerWidth || containerWidth < 100) {
|
||
containerWidth = window.innerWidth
|
||
}
|
||
if (!containerHeight || containerHeight < 100) {
|
||
containerHeight = window.innerHeight - 120
|
||
}
|
||
|
||
console.log('D3 Chart container size:', { containerWidth, containerHeight, isIOS })
|
||
|
||
// 初始化图表
|
||
const chart = new OrgChart()
|
||
.container(chartRef.current)
|
||
.data(data)
|
||
.svgWidth(containerWidth)
|
||
.svgHeight(containerHeight)
|
||
.scaleExtent([0.1, 2]) // 缩放范围
|
||
.initialZoom(0.5) // 初始缩放级别
|
||
.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;
|
||
-webkit-transform: translateZ(0);
|
||
transform: translateZ(0);
|
||
"
|
||
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='translateZ(0)'"
|
||
>
|
||
<!-- 顶部装饰条 -->
|
||
<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>
|
||
${sp.ageText ? `<div style="font-size: 9px; color: #000000; margin-top: 2px; font-weight: 500; font-family: serif;">${sp.ageText}</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() // 自适应全图显示
|
||
|
||
// 延迟再次 fit
|
||
setTimeout(() => chart.fit(), 100)
|
||
setTimeout(() => chart.fit(), 300)
|
||
|
||
// 所有触摸设备都需要特殊处理 - iOS foreignObject bug 修复
|
||
if (isTouchDevice) {
|
||
setTimeout(() => {
|
||
const w = window.innerWidth
|
||
const h = window.innerHeight - 120
|
||
|
||
if (chartRef.current) {
|
||
chartRef.current.style.height = `${h}px`
|
||
chartRef.current.style.width = `${w}px`
|
||
|
||
// iOS foreignObject bug 修复:强制重绘 SVG
|
||
const svg = chartRef.current.querySelector('svg')
|
||
if (svg) {
|
||
// 触发重绘
|
||
svg.style.display = 'none'
|
||
void (svg as any).getBBox() // 强制回流
|
||
svg.style.display = ''
|
||
}
|
||
}
|
||
|
||
// 重新设置 SVG 尺寸并渲染
|
||
chart.svgWidth(w).svgHeight(h).render().expandAll().fit()
|
||
console.log('Touch device delayed render:', { w, h })
|
||
}, 500)
|
||
|
||
// 再次强制重绘
|
||
setTimeout(() => {
|
||
if (chartRef.current) {
|
||
const svg = chartRef.current.querySelector('svg')
|
||
if (svg) {
|
||
svg.style.transform = 'translateZ(0)'
|
||
// 强制所有 foreignObject 重新定位
|
||
const foreignObjects = svg.querySelectorAll('foreignObject')
|
||
foreignObjects.forEach((fo: Element) => {
|
||
(fo as HTMLElement).style.transform = 'translateZ(0)'
|
||
})
|
||
}
|
||
}
|
||
chart.fit()
|
||
}, 1000)
|
||
|
||
setTimeout(() => chart.fit(), 1500)
|
||
}
|
||
|
||
// 注册全局配偶点击处理函数
|
||
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])
|
||
|
||
// 监听窗口大小变化,重新适应视图
|
||
useEffect(() => {
|
||
const handleResize = () => {
|
||
if (chartInstanceRef.current) {
|
||
// 延迟执行 fit,等待布局稳定
|
||
setTimeout(() => {
|
||
chartInstanceRef.current?.fit()
|
||
}, 100)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('resize', handleResize)
|
||
// 监听屏幕方向变化(iPad 等设备)
|
||
window.addEventListener('orientationchange', handleResize)
|
||
|
||
return () => {
|
||
window.removeEventListener('resize', handleResize)
|
||
window.removeEventListener('orientationchange', handleResize)
|
||
}
|
||
}, [])
|
||
|
||
// 监听 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()
|
||
}
|
||
|
||
// Minimap 状态
|
||
const minimapRef = useRef<HTMLCanvasElement>(null)
|
||
const [minimapExpanded, setMinimapExpanded] = useState(true)
|
||
const minimapDataRef = useRef<{
|
||
bbox: { x: number; y: number; width: number; height: number }
|
||
mmScale: number
|
||
offsetX: number
|
||
offsetY: number
|
||
} | null>(null)
|
||
const isDraggingMinimapRef = useRef(false)
|
||
|
||
// 更新 minimap(使用 Canvas 绘制)
|
||
const updateMinimap = useCallback(() => {
|
||
if (!minimapRef.current || !chartRef.current) return
|
||
|
||
const canvas = minimapRef.current
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
|
||
const mainSvg = chartRef.current.querySelector('svg')
|
||
if (!mainSvg) return
|
||
|
||
const mmW = 180, mmH = 130
|
||
canvas.width = mmW
|
||
canvas.height = mmH
|
||
|
||
// 清空画布
|
||
ctx.fillStyle = '#fafafa'
|
||
ctx.fillRect(0, 0, mmW, mmH)
|
||
|
||
// 直接从 SVG 中查找所有 foreignObject,不管它们在哪个 g 元素中
|
||
const foreignObjects = mainSvg.querySelectorAll('foreignObject')
|
||
if (foreignObjects.length === 0) return
|
||
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||
|
||
// 收集节点位置数据 - 需要考虑父元素的 transform
|
||
const nodePositions: { x: number; y: number; w: number; h: number }[] = []
|
||
|
||
foreignObjects.forEach((fo) => {
|
||
// 获取 foreignObject 的属性
|
||
let x = parseFloat(fo.getAttribute('x') || '0')
|
||
let y = parseFloat(fo.getAttribute('y') || '0')
|
||
const w = parseFloat(fo.getAttribute('width') || '0')
|
||
const h = parseFloat(fo.getAttribute('height') || '0')
|
||
|
||
// 检查父元素是否有 transform(d3-org-chart 的节点通常在有 transform 的 g 元素中)
|
||
const parentG = fo.parentElement
|
||
if (parentG && parentG.tagName === 'g') {
|
||
const parentTransform = parentG.getAttribute('transform')
|
||
if (parentTransform) {
|
||
const translateMatch = parentTransform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/)
|
||
if (translateMatch) {
|
||
x += parseFloat(translateMatch[1])
|
||
y += parseFloat(translateMatch[2])
|
||
}
|
||
}
|
||
}
|
||
|
||
if (w > 0 && h > 0) {
|
||
nodePositions.push({ x, y, w, h })
|
||
minX = Math.min(minX, x)
|
||
minY = Math.min(minY, y)
|
||
maxX = Math.max(maxX, x + w)
|
||
maxY = Math.max(maxY, y + h)
|
||
}
|
||
})
|
||
|
||
if (minX === Infinity || minY === Infinity || nodePositions.length === 0) return
|
||
|
||
// 添加边距
|
||
const padding = 20
|
||
const contentX = minX - padding
|
||
const contentY = minY - padding
|
||
const contentW = (maxX - minX) + padding * 2
|
||
const contentH = (maxY - minY) + padding * 2
|
||
|
||
// 计算缩放比例,确保内容完全显示在 minimap 中
|
||
const scaleX = (mmW - 8) / contentW
|
||
const scaleY = (mmH - 8) / contentH
|
||
const mmScale = Math.min(scaleX, scaleY)
|
||
|
||
// 计算偏移使内容居中
|
||
const offsetX = (mmW - contentW * mmScale) / 2 - contentX * mmScale
|
||
const offsetY = (mmH - contentH * mmScale) / 2 - contentY * mmScale
|
||
|
||
// 保存数据供点击使用(使用原始坐标系)
|
||
minimapDataRef.current = {
|
||
bbox: { x: contentX, y: contentY, width: contentW, height: contentH },
|
||
mmScale,
|
||
offsetX,
|
||
offsetY
|
||
}
|
||
|
||
// 绘制连线(先画连线,再画节点)
|
||
ctx.strokeStyle = '#cbd5e1'
|
||
ctx.lineWidth = 1
|
||
mainSvg.querySelectorAll('path').forEach((path) => {
|
||
const d = path.getAttribute('d')
|
||
// 跳过没有 d 属性或者是其他类型的 path
|
||
if (!d || d.length < 5) return
|
||
|
||
const path2D = new Path2D()
|
||
const commands = d.match(/[MLHVCSQTAZ][^MLHVCSQTAZ]*/gi) || []
|
||
commands.forEach(cmd => {
|
||
const type = cmd[0].toUpperCase()
|
||
const nums = cmd.slice(1).trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n))
|
||
|
||
if (type === 'M' && nums.length >= 2) {
|
||
path2D.moveTo(nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY)
|
||
} else if (type === 'L' && nums.length >= 2) {
|
||
path2D.lineTo(nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY)
|
||
} else if (type === 'C' && nums.length >= 6) {
|
||
path2D.bezierCurveTo(
|
||
nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY,
|
||
nums[2] * mmScale + offsetX, nums[3] * mmScale + offsetY,
|
||
nums[4] * mmScale + offsetX, nums[5] * mmScale + offsetY
|
||
)
|
||
}
|
||
})
|
||
ctx.stroke(path2D)
|
||
})
|
||
|
||
// 绘制节点
|
||
ctx.fillStyle = '#3b82f6'
|
||
nodePositions.forEach(({ x, y, w, h }) => {
|
||
const drawX = x * mmScale + offsetX
|
||
const drawY = y * mmScale + offsetY
|
||
const drawW = Math.max(w * mmScale, 4)
|
||
const drawH = Math.max(h * mmScale, 3)
|
||
|
||
ctx.beginPath()
|
||
ctx.roundRect(drawX, drawY, drawW, drawH, 2)
|
||
ctx.fill()
|
||
})
|
||
|
||
// 获取当前 transform - d3-org-chart 的 transform 在第一个 g 元素上
|
||
const mainG = mainSvg.querySelector('g') as SVGGElement
|
||
const transform = mainG?.getAttribute('transform') || ''
|
||
const translateMatch = transform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/)
|
||
const scaleMatch = transform.match(/scale\(\s*([^)\s]+)\s*\)/)
|
||
|
||
const tx = translateMatch ? parseFloat(translateMatch[1]) : 0
|
||
const ty = translateMatch ? parseFloat(translateMatch[2]) : 0
|
||
const k = scaleMatch ? parseFloat(scaleMatch[1]) : 1
|
||
|
||
// 计算视口在原始坐标系中的位置
|
||
const svgRect = mainSvg.getBoundingClientRect()
|
||
const viewX = -tx / k
|
||
const viewY = -ty / k
|
||
const viewW = svgRect.width / k
|
||
const viewH = svgRect.height / k
|
||
|
||
// 绘制视口框
|
||
const vpDrawX = viewX * mmScale + offsetX
|
||
const vpDrawY = viewY * mmScale + offsetY
|
||
const vpDrawW = viewW * mmScale
|
||
const vpDrawH = viewH * mmScale
|
||
|
||
ctx.strokeStyle = '#ef4444'
|
||
ctx.lineWidth = 2
|
||
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'
|
||
ctx.beginPath()
|
||
ctx.rect(vpDrawX, vpDrawY, vpDrawW, vpDrawH)
|
||
ctx.fill()
|
||
ctx.stroke()
|
||
|
||
}, [])
|
||
|
||
// 定时更新 minimap
|
||
useEffect(() => {
|
||
if (!minimapExpanded || !isReady) return
|
||
|
||
const timer = setTimeout(updateMinimap, 500)
|
||
const interval = setInterval(updateMinimap, 200)
|
||
|
||
return () => {
|
||
clearTimeout(timer)
|
||
clearInterval(interval)
|
||
}
|
||
}, [minimapExpanded, isReady, updateMinimap])
|
||
|
||
// 点击/拖拽 minimap 定位到具体位置
|
||
const handleMinimapInteraction = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
if (!minimapDataRef.current || !chartRef.current || !chartInstanceRef.current) return
|
||
|
||
const { mmScale, offsetX, offsetY } = minimapDataRef.current
|
||
|
||
const canvas = e.currentTarget
|
||
const rect = canvas.getBoundingClientRect()
|
||
const clickX = e.clientX - rect.left
|
||
const clickY = e.clientY - rect.top
|
||
|
||
// 转换到原始坐标系
|
||
const treeX = (clickX - offsetX) / mmScale
|
||
const treeY = (clickY - offsetY) / mmScale
|
||
|
||
// 获取主图
|
||
const mainSvg = chartRef.current.querySelector('svg') as SVGSVGElement
|
||
if (!mainSvg) return
|
||
|
||
const mainG = mainSvg.querySelector('g')
|
||
if (!mainG) return
|
||
|
||
const svgRect = mainSvg.getBoundingClientRect()
|
||
|
||
// 获取当前缩放级别
|
||
const transform = mainG.getAttribute('transform') || ''
|
||
const scaleMatch = transform.match(/scale\(\s*([^)\s]+)\s*\)/)
|
||
const currentK = scaleMatch ? parseFloat(scaleMatch[1]) : 1
|
||
|
||
// 计算新的 translate,使点击位置移动到视口中心
|
||
const newTx = svgRect.width / 2 - treeX * currentK
|
||
const newTy = svgRect.height / 2 - treeY * currentK
|
||
|
||
// 直接修改 g 元素的 transform,不触发 d3-org-chart 的 zoom 事件
|
||
// 这样可以避免触发 fit() 等操作
|
||
const d3MainG = d3.select(mainG)
|
||
if (isDraggingMinimapRef.current) {
|
||
d3MainG.attr('transform', `translate(${newTx}, ${newTy}) scale(${currentK})`)
|
||
} else {
|
||
d3MainG.transition().duration(200)
|
||
.attr('transform', `translate(${newTx}, ${newTy}) scale(${currentK})`)
|
||
}
|
||
|
||
// 同步 d3.zoom 的内部状态(重要!避免下次交互时状态不一致)
|
||
const d3Svg = d3.select(mainSvg)
|
||
const newTransform = d3.zoomIdentity.translate(newTx, newTy).scale(currentK)
|
||
// @ts-ignore - 设置 zoom 的 __zoom 属性来同步状态
|
||
mainSvg.__zoom = newTransform
|
||
}, [])
|
||
|
||
const handleMinimapMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
isDraggingMinimapRef.current = true
|
||
handleMinimapInteraction(e)
|
||
}, [handleMinimapInteraction])
|
||
|
||
const handleMinimapMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
if (isDraggingMinimapRef.current) {
|
||
handleMinimapInteraction(e)
|
||
}
|
||
}, [handleMinimapInteraction])
|
||
|
||
const handleMinimapMouseUp = useCallback(() => {
|
||
isDraggingMinimapRef.current = false
|
||
}, [])
|
||
|
||
// 暴露方法给父组件
|
||
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 overflow-hidden" key={`d3-chart-${rootId}`}>
|
||
{/* 图表容器 */}
|
||
<div
|
||
ref={chartRef}
|
||
className="w-full h-full"
|
||
/>
|
||
|
||
{/* Minimap */}
|
||
{minimapExpanded ? (
|
||
<div className="absolute bottom-20 right-4 z-20 md:bottom-4 bg-background/95 backdrop-blur rounded-lg border shadow-lg overflow-hidden">
|
||
<div className="flex items-center justify-between px-2 py-1 border-b bg-muted/50">
|
||
<span className="text-xs font-medium text-muted-foreground">缩略图</span>
|
||
<button
|
||
className="h-6 w-6 flex items-center justify-center hover:bg-accent rounded"
|
||
onClick={() => setMinimapExpanded(false)}
|
||
title="收起"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="m14 10 7-7"/><path d="m3 21 7-7"/></svg>
|
||
</button>
|
||
</div>
|
||
<canvas
|
||
ref={minimapRef}
|
||
onMouseDown={handleMinimapMouseDown}
|
||
onMouseMove={handleMinimapMouseMove}
|
||
onMouseUp={handleMinimapMouseUp}
|
||
onMouseLeave={handleMinimapMouseUp}
|
||
style={{ display: 'block', cursor: 'crosshair' }}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="absolute bottom-20 right-4 z-20 md:bottom-4">
|
||
<button
|
||
className="h-10 w-10 flex items-center justify-center bg-background/90 backdrop-blur shadow-lg border rounded-lg hover:bg-accent"
|
||
onClick={() => setMinimapExpanded(true)}
|
||
title="显示缩略图"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" x2="9" y1="3" y2="18"/><line x1="15" x2="15" y1="6" y2="21"/></svg>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{/* 自定义右键菜单 */}
|
||
{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)}
|
||
/>
|
||
)}
|
||
|
||
{/* 代数重新计算警告对话框 */}
|
||
{pendingAddMember && (
|
||
<GenerationWarningDialog
|
||
open={showWarningDialog}
|
||
onOpenChange={setShowWarningDialog}
|
||
memberName={pendingAddMember.member.fullName}
|
||
relationType={pendingAddMember.type}
|
||
onConfirm={handleConfirmAddParent}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow'
|
||
|
||
// 使用memo优化,避免不必要的重新渲染
|
||
export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => {
|
||
return prevProps.rootId === nextProps.rootId &&
|
||
prevProps.members === nextProps.members
|
||
})
|