Files
chinese-family-tree-2/components/tree/minimap.tsx
T
freedakgmail 3489a10807 0.8.1.0
2025-12-21 19:24:52 +08:00

381 lines
12 KiB
TypeScript

"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<HTMLDivElement | null>
scale?: number
position?: { x: number; y: number }
onPositionChange?: (position: { x: number; y: number }) => void
// D3族谱需要的属性
d3ChartRef?: React.RefObject<any>
// 通用属性
viewMode: 'traditional' | 'd3-org-chart'
}
export function Minimap({
containerRef,
scale = 1,
position = { x: 0, y: 0 },
onPositionChange,
d3ChartRef,
viewMode
}: MinimapProps) {
const minimapRef = useRef<HTMLCanvasElement>(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 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()
// 计算所有节点的边界
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 relativeX = nodeRect.left - containerRect.left
const relativeY = nodeRect.top - containerRect.top
const nodeWidth = nodeRect.width
const nodeHeight = nodeRect.height
minX = Math.min(minX, relativeX)
minY = Math.min(minY, relativeY)
maxX = Math.max(maxX, relativeX + nodeWidth)
maxY = Math.max(maxY, relativeY + nodeHeight)
nodePositions.push({ x: relativeX, y: relativeY, w: nodeWidth, h: nodeHeight })
})
if (minX === Infinity) return
// 添加边距
const padding = 50
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)
})
// 绘制视口框(当前可见区域)
const viewportX = (0 - minX) * minimapScale + 5
const viewportY = (0 - minY) * minimapScale + 5
const viewportW = containerRect.width * minimapScale
const viewportH = containerRect.height * 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 svg: SVGSVGElement | null = null
// 找到包含 foreignObject 的 SVG(这是 d3-org-chart 的特征)
allSvgs.forEach((s) => {
if (s.querySelector('foreignObject') && s.querySelector('g')) {
svg = s as SVGSVGElement
}
})
if (!svg) {
ctx.fillStyle = '#9ca3af'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('加载中...', canvas.width / 2, canvas.height / 2)
return
}
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<HTMLCanvasElement>) => {
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
// 计算点击位置对应的树坐标
const targetX = (clickX - 10) / minimapScale + treeOffset.x
const targetY = (clickY - 10) / minimapScale + treeOffset.y
// 计算新的位置(使点击位置居中)
const newX = -(targetX * scale - containerRect.width / 2)
const newY = -(targetY * scale - containerRect.height / 2)
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<HTMLCanvasElement>) => {
setIsDragging(true)
handleMinimapClick(e)
}, [handleMinimapClick])
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) {
handleMinimapClick(e)
}
}, [isDragging, handleMinimapClick])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
if (!isExpanded) {
return (
<div className="absolute bottom-20 right-4 z-20 md:bottom-4">
<Button
variant="outline"
size="icon"
className="h-10 w-10 bg-background/90 backdrop-blur shadow-lg"
onClick={() => setIsExpanded(true)}
title="显示缩略图"
>
<Map className="h-4 w-4" />
</Button>
</div>
)
}
return (
<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>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsExpanded(false)}
title="收起"
>
<Minimize2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 缩略图画布 */}
<canvas
ref={minimapRef}
width={minimapSize.width}
height={minimapSize.height}
className="cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ display: 'block' }}
/>
</div>
)
}