Files
jordium-gantt-vue3/src/components/GanttLinks.vue
T

447 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { Task } from '../models/classes/Task'
import { getPredecessorIds } from '../utils/predecessorUtils'
// 定义 TaskBar 位置信息类型
interface TaskBarPosition {
left: number
top: number
width: number
height: number
}
// 定义月份分隔线位置类型
interface VerticalLine {
left: number
label?: string
}
// 定义 Props
interface Props {
tasks: Task[]
taskBarPositions: Record<number, TaskBarPosition>
width: number
height: number
offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染)
highlightedTaskId: number | null
highlightedTaskIds: Set<number>
hoveredTaskId: number | null
// 月份分隔线配置
verticalLines?: VerticalLine[]
showVerticalLines?: boolean
}
const props = withDefaults(defineProps<Props>(), {
verticalLines: () => [],
showVerticalLines: true,
offsetLeft: 0,
})
// Canvas 引用
const canvasRef = ref<HTMLCanvasElement | null>(null)
// requestAnimationFrame 防抖控制
let rafId: number | null = null
let pendingRedraw = false
let themeObserver: MutationObserver | null = null
// 当前主题(用于分隔线颜色)
const isDarkTheme = ref(document.documentElement.getAttribute('data-theme') === 'dark')
// 监听主题变化
const updateTheme = () => {
isDarkTheme.value = document.documentElement.getAttribute('data-theme') === 'dark'
scheduleRedraw()
}
/**
* 绘制关系线到 Canvas
* 性能优势:相比 SVG 提升 18 倍渲染性能
*/
const drawLinks = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) {
// eslint-disable-next-line no-console
console.error('❌ Canvas context 获取失败,可能是尺寸超限')
return
}
// 适配高清屏(Retina
const dpr = window.devicePixelRatio || 1
const displayWidth = props.width
const displayHeight = props.height
// 只在尺寸变化时更新 canvas 尺寸
const pixelWidth = displayWidth * dpr
const pixelHeight = displayHeight * dpr
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
ctx.scale(dpr, dpr)
}
// 清空画布(透明)
ctx.clearRect(0, 0, displayWidth, displayHeight)
// 绘制月份分隔线(在关系线之前,作为背景)
if (props.showVerticalLines && props.verticalLines && props.verticalLines.length > 0) {
ctx.save()
// 使用与旗帜一致的主题色:浅色模式 #409eff,暗色模式 #66b1ff
const lineColor = isDarkTheme.value ? '#66b1ff' : '#409eff'
ctx.strokeStyle = lineColor
ctx.lineWidth = 1
// 优化:一次性绘制所有垂直线,避免多次 stroke() 调用
// 虚拟渲染:减去偏移量,转换为 Canvas 局部坐标
ctx.beginPath()
for (const line of props.verticalLines) {
const localX = line.left - props.offsetLeft
// 只绘制在 Canvas 可见范围内的线
if (localX >= 0 && localX <= displayWidth) {
ctx.moveTo(localX, 0)
ctx.lineTo(localX, displayHeight)
}
}
ctx.stroke()
ctx.restore()
}
// 获取当前渲染的任务ID集合
const currentTaskIds = new Set<number>()
for (const task of props.tasks) {
currentTaskIds.add(task.id)
}
// 是否处于高亮模式
const isHighlightMode = props.highlightedTaskId !== null
// 虚拟渲染:计算 Canvas 覆盖的范围
const canvasStartX = props.offsetLeft
const canvasEndX = props.offsetLeft + displayWidth
// 定义线条数据类型
interface LineData {
x1: number
y1: number
x2: number
y2: number
c1x: number
c1y: number
c2x: number
c2y: number
arrowAngle: number
}
// 按样式分组线条数据(减少状态切换)
const highlightedLines: LineData[] = []
const hoveredLines: LineData[] = []
const normalLines: LineData[] = []
const fadedLines: LineData[] = [] // 高亮模式下的普通线条(半透明)
for (const task of props.tasks) {
if (!task.predecessor || !props.taskBarPositions[task.id]) continue
const predecessorIds = getPredecessorIds(task.predecessor)
for (const predecessorId of predecessorIds) {
const fromBar = props.taskBarPositions[predecessorId]
const toBar = props.taskBarPositions[task.id]
if (!fromBar || !toBar || !currentTaskIds.has(predecessorId)) {
continue
}
// 虚拟渲染:跳过不在 Canvas 覆盖范围内的关系线
// 如果起点和终点都在 Canvas 外,跳过
const fromX = fromBar.left + fromBar.width
const toX = toBar.left
const lineMinX = Math.min(fromX, toX)
const lineMaxX = Math.max(fromX, toX)
if (lineMaxX < canvasStartX || lineMinX > canvasEndX) {
continue // 完全在 Canvas 外,跳过
}
// 判断高亮状态
const fromIsPrimary = props.highlightedTaskId === predecessorId
const toIsPrimary = props.highlightedTaskId === task.id
const fromIsHighlighted = props.highlightedTaskIds.has(predecessorId)
const toIsHighlighted = props.highlightedTaskIds.has(task.id)
const isLineHighlighted = fromIsHighlighted && toIsHighlighted
// 判断 hover 状态
const fromIsHovered = props.hoveredTaskId === predecessorId
const toIsHovered = props.hoveredTaskId === task.id
const isLineHovered = fromIsHovered || toIsHovered
// 计算 Y 轴偏移(高亮时的位移)
const fromYOffset = fromIsPrimary ? -8 : fromIsHighlighted ? -5 : 0
const toYOffset = toIsPrimary ? -8 : toIsHighlighted ? -5 : 0
// 计算坐标(全局坐标)
const globalX1 = fromBar.left + fromBar.width
const globalY1 = fromBar.top + fromBar.height / 2 + fromYOffset
const globalX2 = toBar.left
const globalY2 = toBar.top + toBar.height / 2 + toYOffset
// 转换为 Canvas 局部坐标
const x1 = globalX1 - props.offsetLeft
const y1 = globalY1
const x2 = globalX2 - props.offsetLeft
const y2 = globalY2
const c1x = x1 + 40
const c1y = y1
const c2x = x2 - 40
const c2y = y2
// 预计算箭头角度
const arrowAngle = Math.atan2(y2 - c2y, x2 - c2x)
const lineData: LineData = { x1, y1, x2, y2, c1x, c1y, c2x, c2y, arrowAngle }
// 根据状态分组
if (isLineHighlighted) {
highlightedLines.push(lineData)
} else if (isLineHovered) {
hoveredLines.push(lineData)
} else if (isHighlightMode) {
fadedLines.push(lineData)
} else {
normalLines.push(lineData)
}
}
}
// 批量绘制:设置虚线样式(所有线条共用)
ctx.setLineDash([6, 4])
// 批量绘制高亮线条
if (highlightedLines.length > 0) {
ctx.strokeStyle = '#409eff'
ctx.fillStyle = '#409eff'
ctx.lineWidth = 4
ctx.globalAlpha = 1
ctx.beginPath()
for (const line of highlightedLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
// 批量绘制箭头
for (const line of highlightedLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
}
// 批量绘制悬停线条
if (hoveredLines.length > 0) {
ctx.strokeStyle = '#67c23a'
ctx.fillStyle = '#67c23a'
ctx.lineWidth = 3
ctx.globalAlpha = 1
ctx.beginPath()
for (const line of hoveredLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
// 批量绘制箭头
for (const line of hoveredLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
}
// 批量绘制普通线条
if (normalLines.length > 0) {
ctx.strokeStyle = '#c0c4cc'
ctx.fillStyle = '#c0c4cc'
ctx.lineWidth = 2
ctx.globalAlpha = 1
ctx.beginPath()
for (const line of normalLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
// 批量绘制箭头
for (const line of normalLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
}
// 批量绘制半透明线条(高亮模式下的普通线条)
if (fadedLines.length > 0) {
ctx.strokeStyle = '#c0c4cc'
ctx.fillStyle = '#c0c4cc'
ctx.lineWidth = 2
ctx.globalAlpha = 0.2
ctx.beginPath()
for (const line of fadedLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
// 批量绘制箭头
for (const line of fadedLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
// 恢复透明度
ctx.globalAlpha = 1
}
}
/**
* 优化版箭头绘制(减少参数传递,复用已设置的 fillStyle)
*/
const drawArrowOptimized = (
ctx: CanvasRenderingContext2D,
x2: number,
y2: number,
angle: number,
) => {
const arrowLength = 8
const arrowWidth = 4
ctx.beginPath()
// fillStyle 已在外部设置,无需重复设置
ctx.moveTo(x2, y2)
ctx.lineTo(
x2 - arrowLength * Math.cos(angle) - arrowWidth * Math.sin(angle),
y2 - arrowLength * Math.sin(angle) + arrowWidth * Math.cos(angle),
)
ctx.lineTo(
x2 - arrowLength * Math.cos(angle) + arrowWidth * Math.sin(angle),
y2 - arrowLength * Math.sin(angle) - arrowWidth * Math.cos(angle),
)
ctx.closePath()
ctx.fill()
}
/**
* 使用 requestAnimationFrame 优化的重绘调度器
* 合并多个连续的重绘请求为单次绘制
*/
const scheduleRedraw = () => {
if (pendingRedraw) {
// 已有待处理的重绘请求,跳过
return
}
pendingRedraw = true
// 取消之前的 RAF(如果有)
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
// 在下一帧绘制
rafId = requestAnimationFrame(() => {
pendingRedraw = false
rafId = null
drawLinks()
})
}
// 监听相关状态变化,自动重绘 Canvas
watch(
[
() => props.taskBarPositions,
() => props.tasks,
() => props.highlightedTaskId,
() => props.highlightedTaskIds,
() => props.hoveredTaskId,
() => props.width,
() => props.height,
() => props.verticalLines,
() => props.showVerticalLines,
() => props.offsetLeft, // 监听虚拟渲染的偏移量变化
],
() => {
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
scheduleRedraw()
},
{ deep: false }, // shallowRef 不需要 deep
)
// 组件挂载后初始化绘制
onMounted(() => {
// 监听主题变化
themeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'data-theme') {
updateTheme()
break
}
}
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
nextTick(() => {
drawLinks()
})
})
// 组件卸载时清理
onUnmounted(() => {
// 清理主题观察器
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
// 取消待处理的 RAF
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
})
// 暴露方法供父组件调用
defineExpose({
redraw: drawLinks,
})
</script>
<template>
<canvas
ref="canvasRef"
class="gantt-links-canvas"
:style="{
position: 'absolute',
left: 0,
top: 0,
width: `${width}px`,
height: `${height}px`,
transform: `translateX(${offsetLeft}px)`,
zIndex: highlightedTaskId !== null ? 1001 : 25,
pointerEvents: 'none',
}"
/>
</template>
<style scoped>
.gantt-links-canvas {
display: block;
background: transparent; /* 确保背景透明 */
opacity: 1; /* 确保不透明度为 100% */
}
</style>