"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 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( ({ members, rootId, onMemberClick, onExport, relationMode = false, selectedMembers = [] }, ref) => { console.log('D3OrgChartFlow render, selectedMembers:', selectedMembers, 'relationMode:', relationMode) const chartRef = useRef(null) const chartInstanceRef = useRef(null) const [isReady, setIsReady] = useState(false) const [avatarCache, setAvatarCache] = useState>(new Map()) const [contextMenuNode, setContextMenuNode] = useState(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() 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() 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 `
${node.children ? '▲' : '▼'} ${node.data._directSubordinates || 0}
` }) .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 `
${node.generation}
${isSelected ? `
` : ''}
${avatarUrl ? `${node.name}` : node.gender === '女' ? `` : `` }
${node.name}
${node.birthYear ? `
${node.birthYear}${node.deathYear ? ` — ${node.deathYear}` : ' —'}
${node.ageText ? `
${node.ageText}
` : ''} ` : ''}
${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 `
${idx === 0 ? `
` : ''}
${spAvatarUrl ? `${sp.name}` : spIsMale ? `` : `` }
${sp.name}
${sp.birthYear ? `
${sp.birthYear}${sp.deathYear ? `-${sp.deathYear}` : '-'}
${sp.ageText ? `
${sp.ageText}
` : ''} ` : ''}
` }).join('')}
` }) .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 = `` 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 = `` 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(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) => { 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) => { isDraggingMinimapRef.current = true handleMinimapInteraction(e) }, [handleMinimapInteraction]) const handleMinimapMouseMove = useCallback((e: React.MouseEvent) => { 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 (
{/* 图表容器 */}
{/* Minimap */} {minimapExpanded ? (
缩略图
) : (
)} {/* 自定义右键菜单 */} {contextMenuNode && (
e.stopPropagation()} >
为 {contextMenuNode.fullName} 添加
{canAddFather(contextMenuNode) && (
{ handleAddMember('father', contextMenuNode) setContextMenuNode(null) }} > 添加父亲
)} {canAddMother(contextMenuNode) && (
{ handleAddMember('mother', contextMenuNode) setContextMenuNode(null) }} > 添加母亲
)} {(canAddFather(contextMenuNode) || canAddMother(contextMenuNode)) && (canAddSpouse(contextMenuNode) || canAddChild(contextMenuNode)) && (
)} {canAddSpouse(contextMenuNode) && (
{ handleAddMember('spouse', contextMenuNode) setContextMenuNode(null) }} > 添加配偶
)} {canAddChild(contextMenuNode) && (
{ handleAddMember('child', contextMenuNode) setContextMenuNode(null) }} > 添加子女
)}
)} {/* 点击其他地方关闭菜单 */} {contextMenuNode && (
setContextMenuNode(null)} /> )} {/* 代数重新计算警告对话框 */} {pendingAddMember && ( )}
) }) D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow' // 使用memo优化,避免不必要的重新渲染 export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => { return prevProps.rootId === nextProps.rootId && prevProps.members === nextProps.members })