"use client" import { useEffect, useRef, useState, useCallback } from "react" import { Map, Minimize2 } from "lucide-react" import { Button } from "@/components/ui/button" interface MinimapProps { // 传统族谱需要的属性 containerRef?: React.RefObject scale?: number position?: { x: number; y: number } onPositionChange?: (position: { x: number; y: number }) => void // D3族谱需要的属性 d3ChartRef?: React.RefObject // 通用属性 viewMode: 'traditional' | 'd3-org-chart' } export function Minimap({ containerRef, scale = 1, position = { x: 0, y: 0 }, onPositionChange, d3ChartRef, viewMode }: MinimapProps) { const minimapRef = useRef(null) const [isExpanded, setIsExpanded] = useState(true) const [isDragging, setIsDragging] = useState(false) const [minimapSize] = useState({ width: 200, height: 150 }) const minimapScaleRef = useRef(1) const treeOffsetRef = useRef({ x: 0, y: 0 }) const treeBoundsRef = useRef({ minX: 0, minY: 0, maxX: 0, maxY: 0 }) const lastMousePosRef = useRef({ x: 0, y: 0 }) // 更新缩略图 const updateMinimap = useCallback(() => { if (!minimapRef.current) return const canvas = minimapRef.current const ctx = canvas.getContext('2d') if (!ctx) return // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height) // 绘制背景 ctx.fillStyle = '#fafafa' ctx.fillRect(0, 0, canvas.width, canvas.height) // 绘制边框 ctx.strokeStyle = '#e5e7eb' ctx.lineWidth = 1 ctx.strokeRect(0, 0, canvas.width, canvas.height) if (viewMode === 'traditional' && containerRef?.current) { // 传统族谱模式 const container = containerRef.current // 获取所有节点 const nodes = container.querySelectorAll('[data-member-id]') if (nodes.length === 0) { ctx.fillStyle = '#9ca3af' ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.fillText('加载中...', canvas.width / 2, canvas.height / 2) return } const containerRect = container.getBoundingClientRect() // 计算所有节点在原始坐标系中的边界 // 屏幕坐标 = 原始坐标 * scale + position // 原始坐标 = (屏幕坐标 - position) / scale let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity const nodePositions: { x: number; y: number; w: number; h: number }[] = [] nodes.forEach((node) => { const nodeRect = (node as HTMLElement).getBoundingClientRect() // 计算相对于容器的屏幕位置 const screenX = nodeRect.left - containerRect.left const screenY = nodeRect.top - containerRect.top // 转换为原始坐标系(不受 position 和 scale 影响) const originalX = (screenX - position.x) / scale const originalY = (screenY - position.y) / scale const originalW = nodeRect.width / scale const originalH = nodeRect.height / scale minX = Math.min(minX, originalX) minY = Math.min(minY, originalY) maxX = Math.max(maxX, originalX + originalW) maxY = Math.max(maxY, originalY + originalH) nodePositions.push({ x: originalX, y: originalY, w: originalW, h: originalH }) }) if (minX === Infinity) return // 添加边距 const padding = 30 minX -= padding minY -= padding maxX += padding maxY += padding const treeWidth = maxX - minX const treeHeight = maxY - minY // 保存边界信息 treeBoundsRef.current = { minX, minY, maxX, maxY } // 计算缩略图缩放 const scaleX = (minimapSize.width - 10) / treeWidth const scaleY = (minimapSize.height - 10) / treeHeight const minimapScale = Math.min(scaleX, scaleY) minimapScaleRef.current = minimapScale treeOffsetRef.current = { x: minX, y: minY } // 绘制节点(使用原始坐标) ctx.fillStyle = '#3b82f6' nodePositions.forEach(({ x, y, w, h }) => { const drawX = (x - minX) * minimapScale + 5 const drawY = (y - minY) * minimapScale + 5 const drawW = Math.max(w * minimapScale, 4) const drawH = Math.max(h * minimapScale, 3) ctx.beginPath() ctx.roundRect(drawX, drawY, drawW, drawH, 2) ctx.fill() }) // 绘制视口框(当前可见区域) // 视口在原始坐标系中的位置 const viewportOriginalX = -position.x / scale const viewportOriginalY = -position.y / scale const viewportOriginalW = containerRect.width / scale const viewportOriginalH = containerRect.height / scale // 转换到 minimap 坐标 const viewportX = (viewportOriginalX - minX) * minimapScale + 5 const viewportY = (viewportOriginalY - minY) * minimapScale + 5 const viewportW = viewportOriginalW * minimapScale const viewportH = viewportOriginalH * minimapScale ctx.strokeStyle = '#ef4444' ctx.lineWidth = 2 ctx.strokeRect(viewportX, viewportY, viewportW, viewportH) ctx.fillStyle = 'rgba(239, 68, 68, 0.15)' ctx.fillRect(viewportX, viewportY, viewportW, viewportH) } else if (viewMode === 'd3-org-chart') { // D3族谱模式 - 查找 SVG(在整个文档中查找) // D3 图表的 SVG 在 chartRef 容器内 const allSvgs = document.querySelectorAll('svg') let foundSvg: SVGSVGElement | null = null // 找到包含 foreignObject 的 SVG(这是 d3-org-chart 的特征) allSvgs.forEach((s) => { if (s.querySelector('foreignObject') && s.querySelector('g')) { foundSvg = s as SVGSVGElement } }) if (!foundSvg) { ctx.fillStyle = '#9ca3af' ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.fillText('加载中...', canvas.width / 2, canvas.height / 2) return } const svg = foundSvg as SVGSVGElement const gElement = svg.querySelector('g') if (!gElement) return // 获取所有 foreignObject(节点) const nodes = svg.querySelectorAll('foreignObject') if (nodes.length === 0) { ctx.fillStyle = '#9ca3af' ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.fillText('无节点', canvas.width / 2, canvas.height / 2) return } // 计算所有节点的边界 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity const nodePositions: { x: number; y: number; w: number; h: number }[] = [] nodes.forEach((node) => { const x = parseFloat(node.getAttribute('x') || '0') const y = parseFloat(node.getAttribute('y') || '0') const w = parseFloat(node.getAttribute('width') || '0') const h = parseFloat(node.getAttribute('height') || '0') if (w > 10 && h > 10) { // 过滤掉太小的元素 minX = Math.min(minX, x) minY = Math.min(minY, y) maxX = Math.max(maxX, x + w) maxY = Math.max(maxY, y + h) nodePositions.push({ x, y, w, h }) } }) if (minX === Infinity || nodePositions.length === 0) { ctx.fillStyle = '#9ca3af' ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.fillText('计算中...', canvas.width / 2, canvas.height / 2) return } // 添加边距 const padding = 100 minX -= padding minY -= padding maxX += padding maxY += padding const treeWidth = maxX - minX const treeHeight = maxY - minY // 保存边界信息 treeBoundsRef.current = { minX, minY, maxX, maxY } // 计算缩略图缩放 const scaleX = (minimapSize.width - 10) / treeWidth const scaleY = (minimapSize.height - 10) / treeHeight const minimapScale = Math.min(scaleX, scaleY) minimapScaleRef.current = minimapScale treeOffsetRef.current = { x: minX, y: minY } // 绘制节点 ctx.fillStyle = '#3b82f6' nodePositions.forEach(({ x, y, w, h }) => { const drawX = (x - minX) * minimapScale + 5 const drawY = (y - minY) * minimapScale + 5 const drawW = Math.max(w * minimapScale, 3) const drawH = Math.max(h * minimapScale, 2) ctx.fillRect(drawX, drawY, drawW, drawH) }) // 解析当前 transform 获取视口位置 const transform = gElement.getAttribute('transform') if (transform) { // 解析 translate 和 scale - 支持多种格式 let tx = 0, ty = 0, s = 1 const translateMatch = transform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/) if (translateMatch) { tx = parseFloat(translateMatch[1]) ty = parseFloat(translateMatch[2]) } const scaleMatch = transform.match(/scale\(\s*([^)\s,]+)/) if (scaleMatch) { s = parseFloat(scaleMatch[1]) } const svgRect = svg.getBoundingClientRect() // 计算视口在树坐标系中的位置 const viewportTreeX = -tx / s const viewportTreeY = -ty / s const viewportTreeW = svgRect.width / s const viewportTreeH = svgRect.height / s const viewportX = (viewportTreeX - minX) * minimapScale + 5 const viewportY = (viewportTreeY - minY) * minimapScale + 5 const viewportW = viewportTreeW * minimapScale const viewportH = viewportTreeH * minimapScale ctx.strokeStyle = '#ef4444' ctx.lineWidth = 2 ctx.strokeRect(viewportX, viewportY, viewportW, viewportH) ctx.fillStyle = 'rgba(239, 68, 68, 0.15)' ctx.fillRect(viewportX, viewportY, viewportW, viewportH) } } }, [viewMode, containerRef, scale, position, minimapSize]) // 定时更新缩略图 useEffect(() => { if (!isExpanded) return // 初始延迟,等待 DOM 渲染 const initialTimer = setTimeout(updateMinimap, 500) // 定时更新 const interval = setInterval(updateMinimap, 200) return () => { clearTimeout(initialTimer) clearInterval(interval) } }, [isExpanded, updateMinimap]) // 处理缩略图点击(用于单击定位) const handleMinimapClick = useCallback((e: React.MouseEvent) => { if (!minimapRef.current) return const canvas = minimapRef.current const rect = canvas.getBoundingClientRect() const clickX = e.clientX - rect.left const clickY = e.clientY - rect.top if (viewMode === 'traditional' && containerRef?.current && onPositionChange) { const container = containerRef.current const containerRect = container.getBoundingClientRect() const minimapScale = minimapScaleRef.current const treeOffset = treeOffsetRef.current // 计算点击位置对应的原始树坐标 // minimap 坐标 = (原始坐标 - offset) * minimapScale + 5 // 原始坐标 = (minimap 坐标 - 5) / minimapScale + offset const targetOriginalX = (clickX - 5) / minimapScale + treeOffset.x const targetOriginalY = (clickY - 5) / minimapScale + treeOffset.y // 计算新的 position(使点击位置居中) // 屏幕中心 = 原始坐标 * scale + position // position = 屏幕中心 - 原始坐标 * scale const newX = containerRect.width / 2 - targetOriginalX * scale const newY = containerRect.height / 2 - targetOriginalY * scale onPositionChange({ x: newX, y: newY }) } else if (viewMode === 'd3-org-chart' && d3ChartRef?.current) { // D3 模式:调用 fitView d3ChartRef.current.fitView?.() } }, [viewMode, containerRef, d3ChartRef, scale, onPositionChange]) // 处理拖拽开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { setIsDragging(true) lastMousePosRef.current = { x: e.clientX, y: e.clientY } // 点击时也定位 handleMinimapClick(e) }, [handleMinimapClick]) // 处理拖拽移动(使用增量移动,更平滑) const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isDragging) return if (viewMode === 'traditional' && containerRef?.current && onPositionChange) { const minimapScale = minimapScaleRef.current // 计算鼠标移动的增量 const deltaX = e.clientX - lastMousePosRef.current.x const deltaY = e.clientY - lastMousePosRef.current.y // 更新上次鼠标位置 lastMousePosRef.current = { x: e.clientX, y: e.clientY } // 将 minimap 上的移动转换为主视图的移动(方向相反) // minimap 上移动 1px = 主视图移动 (1 / minimapScale * scale) px const moveRatio = scale / minimapScale const newX = position.x - deltaX * moveRatio const newY = position.y - deltaY * moveRatio onPositionChange({ x: newX, y: newY }) } }, [isDragging, viewMode, containerRef, scale, position, onPositionChange]) const handleMouseUp = useCallback(() => { setIsDragging(false) }, []) if (!isExpanded) { return (
) } return (
{/* 标题栏 */}
缩略图
{/* 缩略图画布 */}
) }