0.8.1.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo } from "react"
|
||||
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"
|
||||
@@ -951,6 +951,253 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
|
||||
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,
|
||||
@@ -968,6 +1215,39 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
|
||||
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
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user