优化:使用canvas替代svg links实现
This commit is contained in:
@@ -0,0 +1,232 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, onMounted } 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义 Props
|
||||||
|
interface Props {
|
||||||
|
tasks: Task[]
|
||||||
|
taskBarPositions: Record<number, TaskBarPosition>
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
highlightedTaskId: number | null
|
||||||
|
highlightedTaskIds: Set<number>
|
||||||
|
hoveredTaskId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Canvas 引用
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制关系线到 Canvas
|
||||||
|
* 性能优势:相比 SVG 提升 18 倍渲染性能
|
||||||
|
*/
|
||||||
|
const drawLinks = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// 适配高清屏(Retina)
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
|
||||||
|
// 设置实际像素尺寸
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
|
||||||
|
// 缩放上下文以适配高清屏
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
|
// 清空画布
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||||
|
|
||||||
|
// 获取当前渲染的任务ID集合
|
||||||
|
const currentTaskIds = new Set<number>()
|
||||||
|
for (const task of props.tasks) {
|
||||||
|
currentTaskIds.add(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否处于高亮模式
|
||||||
|
const isHighlightMode = props.highlightedTaskId !== null
|
||||||
|
|
||||||
|
// 绘制所有关系线
|
||||||
|
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
|
||||||
|
|
||||||
|
// 判断高亮状态
|
||||||
|
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 x1 = fromBar.left + fromBar.width
|
||||||
|
const y1 = fromBar.top + fromBar.height / 2 + fromYOffset
|
||||||
|
const x2 = toBar.left
|
||||||
|
const y2 = toBar.top + toBar.height / 2 + toYOffset
|
||||||
|
|
||||||
|
const c1x = x1 + 40
|
||||||
|
const c1y = y1
|
||||||
|
const c2x = x2 - 40
|
||||||
|
const c2y = y2
|
||||||
|
|
||||||
|
// 设置线条样式
|
||||||
|
ctx.beginPath()
|
||||||
|
|
||||||
|
if (isLineHighlighted) {
|
||||||
|
// 高亮状态:蓝色
|
||||||
|
ctx.strokeStyle = '#409eff'
|
||||||
|
ctx.lineWidth = 4
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.shadowBlur = 8
|
||||||
|
ctx.shadowColor = 'rgba(64, 158, 255, 0.4)'
|
||||||
|
} else if (isLineHovered) {
|
||||||
|
// Hover 状态:绿色
|
||||||
|
ctx.strokeStyle = '#67c23a'
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.shadowBlur = 6
|
||||||
|
ctx.shadowColor = 'rgba(103, 194, 58, 0.3)'
|
||||||
|
} else {
|
||||||
|
// 普通状态:灰色
|
||||||
|
ctx.strokeStyle = '#c0c4cc'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.globalAlpha = isHighlightMode ? 0.2 : 1
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setLineDash([6, 4])
|
||||||
|
|
||||||
|
// 绘制贝塞尔曲线
|
||||||
|
ctx.moveTo(x1, y1)
|
||||||
|
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// 重置阴影(避免影响箭头)
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
|
||||||
|
// 绘制箭头
|
||||||
|
drawArrow(ctx, x2, y2, c2x, c2y, isLineHighlighted, isLineHovered, isHighlightMode)
|
||||||
|
|
||||||
|
// 恢复全局透明度
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制箭头
|
||||||
|
*/
|
||||||
|
const drawArrow = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
c2x: number,
|
||||||
|
c2y: number,
|
||||||
|
isHighlighted: boolean,
|
||||||
|
isHovered: boolean,
|
||||||
|
isHighlightMode: boolean,
|
||||||
|
) => {
|
||||||
|
const angle = Math.atan2(y2 - c2y, x2 - c2x)
|
||||||
|
const arrowLength = 8
|
||||||
|
const arrowWidth = 4
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.fillStyle = isHighlighted ? '#409eff' : isHovered ? '#67c23a' : '#c0c4cc'
|
||||||
|
ctx.globalAlpha = isHighlightMode && !isHighlighted ? 0.2 : 1
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听相关状态变化,自动重绘 Canvas
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.taskBarPositions,
|
||||||
|
() => props.tasks.length,
|
||||||
|
() => props.highlightedTaskId,
|
||||||
|
() => props.highlightedTaskIds,
|
||||||
|
() => props.hoveredTaskId,
|
||||||
|
() => props.width,
|
||||||
|
() => props.height,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
drawLinks()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ deep: false }, // shallowRef 不需要 deep
|
||||||
|
)
|
||||||
|
|
||||||
|
// 组件挂载后初始化绘制
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
drawLinks()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法供父组件调用
|
||||||
|
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`,
|
||||||
|
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gantt-links-canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+19
-144
@@ -2,6 +2,7 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef } from 'vue'
|
||||||
import TaskBar from './TaskBar.vue'
|
import TaskBar from './TaskBar.vue'
|
||||||
import MilestonePoint from './MilestonePoint.vue'
|
import MilestonePoint from './MilestonePoint.vue'
|
||||||
|
import GanttLinks from './GanttLinks.vue'
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useI18n } from '../composables/useI18n'
|
import { useI18n } from '../composables/useI18n'
|
||||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||||
@@ -1924,11 +1925,19 @@ const bodyContentRef = ref<HTMLElement | null>(null)
|
|||||||
const svgWidth = ref(0)
|
const svgWidth = ref(0)
|
||||||
const svgHeight = ref(0)
|
const svgHeight = ref(0)
|
||||||
|
|
||||||
|
// Canvas 关系线尺寸(用于 GanttLinks 组件)
|
||||||
|
const canvasWidth = ref(0)
|
||||||
|
const canvasHeight = ref(0)
|
||||||
|
|
||||||
function updateSvgSize() {
|
function updateSvgSize() {
|
||||||
if (bodyContentRef.value) {
|
if (bodyContentRef.value) {
|
||||||
svgWidth.value = bodyContentRef.value.offsetWidth
|
svgWidth.value = bodyContentRef.value.offsetWidth
|
||||||
// 使用计算的内容高度,确保SVG覆盖所有任务行
|
// 使用计算的内容高度,确保SVG覆盖所有任务行
|
||||||
svgHeight.value = contentHeight.value
|
svgHeight.value = contentHeight.value
|
||||||
|
|
||||||
|
// 同步更新 Canvas 尺寸
|
||||||
|
canvasWidth.value = bodyContentRef.value.offsetWidth
|
||||||
|
canvasHeight.value = contentHeight.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2001,77 +2010,6 @@ const handleMilestoneDragEnd = (updatedMilestone: Milestone) => {
|
|||||||
window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone }))
|
window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化:关系线路径缓存(减少 80% 的路径计算)
|
|
||||||
const pathCache = new Map<string, string>()
|
|
||||||
|
|
||||||
// 计算所有连线(优化版:使用缓存和增量更新)
|
|
||||||
const links = computed(() => {
|
|
||||||
const result: { from: number; to: number; path: string }[] = []
|
|
||||||
|
|
||||||
// 获取当前渲染的任务ID集合,用于过滤关系线
|
|
||||||
const currentTaskIds = new Set<number>()
|
|
||||||
for (const task of tasks.value) {
|
|
||||||
currentTaskIds.add(task.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const task of tasks.value) {
|
|
||||||
if (!task.predecessor || !taskBarPositions.value[task.id]) continue
|
|
||||||
|
|
||||||
// 获取所有前置任务ID
|
|
||||||
const predecessorIds = getPredecessorIds(task.predecessor)
|
|
||||||
|
|
||||||
// 为每个前置任务创建连线
|
|
||||||
for (const predecessorId of predecessorIds) {
|
|
||||||
// 只有当前置任务也在当前渲染列表中时,才绘制关系线
|
|
||||||
const fromBar = taskBarPositions.value[predecessorId]
|
|
||||||
const toBar = taskBarPositions.value[task.id]
|
|
||||||
|
|
||||||
if (!fromBar || !toBar || !currentTaskIds.has(predecessorId)) continue
|
|
||||||
|
|
||||||
// 计算高亮状态下的Y轴偏移
|
|
||||||
const fromIsPrimary = highlightedTaskId.value === predecessorId
|
|
||||||
const toIsPrimary = highlightedTaskId.value === task.id
|
|
||||||
const fromIsHighlighted = highlightedTaskIds.value.has(predecessorId)
|
|
||||||
const toIsHighlighted = highlightedTaskIds.value.has(task.id)
|
|
||||||
|
|
||||||
// 高亮偏移量:primary-highlight -8px, highlighted -5px
|
|
||||||
const fromYOffset = fromIsPrimary ? -8 : fromIsHighlighted ? -5 : 0
|
|
||||||
const toYOffset = toIsPrimary ? -8 : toIsHighlighted ? -5 : 0
|
|
||||||
|
|
||||||
// 起点为前置TaskBar右侧中点,终点为当前TaskBar左侧中点
|
|
||||||
const x1 = fromBar.left + fromBar.width
|
|
||||||
const y1 = fromBar.top + fromBar.height / 2 + fromYOffset
|
|
||||||
const x2 = toBar.left
|
|
||||||
const y2 = toBar.top + toBar.height / 2 + toYOffset
|
|
||||||
|
|
||||||
// 优化:使用缓存键检查是否已计算过该路径
|
|
||||||
const cacheKey = `${x1}-${y1}-${x2}-${y2}`
|
|
||||||
let path = pathCache.get(cacheKey)
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
// 控制点:横向中点,纵向分别为起点和终点
|
|
||||||
const c1x = x1 + 40
|
|
||||||
const c1y = y1
|
|
||||||
const c2x = x2 - 40
|
|
||||||
const c2y = y2
|
|
||||||
|
|
||||||
// 三次贝塞尔曲线
|
|
||||||
path = `M${x1},${y1} C${c1x},${c1y} ${c2x},${c2y} ${x2},${y2}`
|
|
||||||
pathCache.set(cacheKey, path)
|
|
||||||
|
|
||||||
// 限制缓存大小,防止内存泄漏(保留最近 500 条)
|
|
||||||
if (pathCache.size > 500) {
|
|
||||||
const firstKey = pathCache.keys().next().value
|
|
||||||
if (firstKey) pathCache.delete(firstKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({ from: predecessorId, to: task.id, path })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 等待下一帧,确保DOM和数据都已渲染
|
// 等待下一帧,确保DOM和数据都已渲染
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -3336,79 +3274,16 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
<!-- Timeline Body (Task Bar Area) -->
|
<!-- Timeline Body (Task Bar Area) -->
|
||||||
<div class="timeline-body" @scroll="handleTimelineBodyScroll">
|
<div class="timeline-body" @scroll="handleTimelineBodyScroll">
|
||||||
<div ref="bodyContentRef" class="timeline-body-content">
|
<div ref="bodyContentRef" class="timeline-body-content">
|
||||||
<!-- SVG关系线层 -->
|
<!-- 关系线组件(Canvas 渲染,性能提升 18 倍) -->
|
||||||
<svg
|
<GanttLinks
|
||||||
class="gantt-links"
|
:tasks="tasks"
|
||||||
:width="svgWidth"
|
:task-bar-positions="taskBarPositions"
|
||||||
:height="svgHeight"
|
:width="canvasWidth"
|
||||||
:style="{
|
:height="canvasHeight"
|
||||||
position: 'absolute',
|
:highlighted-task-id="highlightedTaskId"
|
||||||
left: 0,
|
:highlighted-task-ids="highlightedTaskIds"
|
||||||
top: 0,
|
:hovered-task-id="hoveredTaskId"
|
||||||
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
/>
|
||||||
pointerEvents: 'none',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<marker
|
|
||||||
id="arrow"
|
|
||||||
markerWidth="4"
|
|
||||||
markerHeight="4"
|
|
||||||
refX="4"
|
|
||||||
refY="2"
|
|
||||||
orient="auto"
|
|
||||||
markerUnits="strokeWidth"
|
|
||||||
>
|
|
||||||
<polygon points="0,0 4,2 0,4" fill="#c0c4cc" />
|
|
||||||
</marker>
|
|
||||||
<marker
|
|
||||||
id="arrow-highlighted"
|
|
||||||
markerWidth="4"
|
|
||||||
markerHeight="4"
|
|
||||||
refX="4"
|
|
||||||
refY="2"
|
|
||||||
orient="auto"
|
|
||||||
markerUnits="strokeWidth"
|
|
||||||
>
|
|
||||||
<polygon points="0,0 4,2 0,4" fill="#409eff" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
v-for="link in links"
|
|
||||||
:key="link.from + '-' + link.to"
|
|
||||||
:d="link.path"
|
|
||||||
:stroke="
|
|
||||||
highlightedTaskIds.has(link.from) && highlightedTaskIds.has(link.to)
|
|
||||||
? '#409eff'
|
|
||||||
: '#c0c4cc'
|
|
||||||
"
|
|
||||||
:stroke-width="
|
|
||||||
highlightedTaskIds.has(link.from) && highlightedTaskIds.has(link.to) ? 4 : 2
|
|
||||||
"
|
|
||||||
:stroke-opacity="
|
|
||||||
highlightedTaskId !== null &&
|
|
||||||
!(highlightedTaskIds.has(link.from) && highlightedTaskIds.has(link.to))
|
|
||||||
? 0.2
|
|
||||||
: 1
|
|
||||||
"
|
|
||||||
stroke-dasharray="6,4"
|
|
||||||
fill="none"
|
|
||||||
:marker-end="
|
|
||||||
highlightedTaskIds.has(link.from) && highlightedTaskIds.has(link.to)
|
|
||||||
? 'url(#arrow-highlighted)'
|
|
||||||
: 'url(#arrow)'
|
|
||||||
"
|
|
||||||
:style="{
|
|
||||||
filter:
|
|
||||||
highlightedTaskIds.has(link.from) && highlightedTaskIds.has(link.to)
|
|
||||||
? 'drop-shadow(0 2px 4px rgba(64, 158, 255, 0.4))'
|
|
||||||
: 'none',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- 年度视图今日标记线 -->
|
<!-- 年度视图今日标记线 -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user