421 lines
14 KiB
TypeScript
421 lines
14 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 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<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
|
|
|
|
// 计算点击位置对应的原始树坐标
|
|
// 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<HTMLCanvasElement>) => {
|
|
setIsDragging(true)
|
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY }
|
|
// 点击时也定位
|
|
handleMinimapClick(e)
|
|
}, [handleMinimapClick])
|
|
|
|
// 处理拖拽移动(使用增量移动,更平滑)
|
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|