diff --git a/src/components/GanttLinks.vue b/src/components/GanttLinks.vue index b58fccb..595ed00 100644 --- a/src/components/GanttLinks.vue +++ b/src/components/GanttLinks.vue @@ -23,6 +23,7 @@ interface Props { taskBarPositions: Record width: number height: number + offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染) highlightedTaskId: number | null highlightedTaskIds: Set hoveredTaskId: number | null @@ -34,6 +35,7 @@ interface Props { const props = withDefaults(defineProps(), { verticalLines: () => [], showVerticalLines: true, + offsetLeft: 0, }) // Canvas 引用 @@ -56,9 +58,6 @@ 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() @@ -66,11 +65,17 @@ const drawLinks = () => { // 设置实际像素尺寸 canvas.width = rect.width * dpr canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d', { alpha: true }) // 启用透明背景 + if (!ctx) { + // eslint-disable-next-line no-console + console.error('❌ Canvas context 获取失败,可能是尺寸超限') + return + } // 缩放上下文以适配高清屏 ctx.scale(dpr, dpr) - // 清空画布 + // 清空画布(透明) ctx.clearRect(0, 0, rect.width, rect.height) // 绘制月份分隔线(在关系线之前,作为背景) @@ -83,10 +88,15 @@ const drawLinks = () => { ctx.lineWidth = 1 // 优化:一次性绘制所有垂直线,避免多次 stroke() 调用 + // 虚拟渲染:减去偏移量,转换为 Canvas 局部坐标 ctx.beginPath() for (const line of props.verticalLines) { - ctx.moveTo(line.left, 0) - ctx.lineTo(line.left, rect.height) + const localX = line.left - props.offsetLeft + // 只绘制在 Canvas 可见范围内的线 + if (localX >= 0 && localX <= rect.width) { + ctx.moveTo(localX, 0) + ctx.lineTo(localX, rect.height) + } } ctx.stroke() @@ -102,6 +112,10 @@ const drawLinks = () => { // 是否处于高亮模式 const isHighlightMode = props.highlightedTaskId !== null + // 虚拟渲染:计算 Canvas 覆盖的范围 + const canvasStartX = props.offsetLeft + const canvasEndX = props.offsetLeft + rect.width + // 定义线条数据类型 interface LineData { x1: number @@ -122,16 +136,38 @@ const drawLinks = () => { const fadedLines: LineData[] = [] // 高亮模式下的普通线条(半透明) // 收集所有关系线数据并分组 + let totalRelations = 0 // 总关系数 + let culledRelations = 0 // 被裁剪的关系数 + let drawnRelations = 0 // 实际绘制的关系数 + for (const task of props.tasks) { if (!task.predecessor || !props.taskBarPositions[task.id]) continue const predecessorIds = getPredecessorIds(task.predecessor) for (const predecessorId of predecessorIds) { + totalRelations++ + const fromBar = props.taskBarPositions[predecessorId] const toBar = props.taskBarPositions[task.id] - if (!fromBar || !toBar || !currentTaskIds.has(predecessorId)) continue + if (!fromBar || !toBar || !currentTaskIds.has(predecessorId)) { + culledRelations++ + 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) { + culledRelations++ + continue // 完全在 Canvas 外,跳过 + } + + drawnRelations++ // 判断高亮状态 const fromIsPrimary = props.highlightedTaskId === predecessorId @@ -149,11 +185,17 @@ const drawLinks = () => { 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 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 @@ -329,6 +371,7 @@ watch( () => props.height, () => props.verticalLines, () => props.showVerticalLines, + () => props.offsetLeft, // 监听虚拟渲染的偏移量变化 ], () => { // 使用 RAF 调度重绘,合并连续的多次变化为单次绘制 @@ -369,6 +412,7 @@ defineExpose({ top: 0, width: `${width}px`, height: `${height}px`, + transform: `translateX(${offsetLeft}px)`, zIndex: highlightedTaskId !== null ? 1001 : 25, pointerEvents: 'none', }" @@ -378,5 +422,7 @@ defineExpose({ diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 052ae49..c513149 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -719,6 +719,11 @@ const debouncedUpdatePositions = debounce(() => { computeAllMilestonesPositions() }, 50) +// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发) +const debouncedUpdateCanvasPosition = debounce(() => { + updateSvgSize() // 重新计算 Canvas 位置和尺寸 +}, 50) + // 缓存时间轴数据的函数 const getCachedTimelineData = (): unknown => { const scale = currentTimeScale.value @@ -1932,15 +1937,46 @@ const svgHeight = ref(0) // Canvas 关系线尺寸(用于 GanttLinks 组件) const canvasWidth = ref(0) const canvasHeight = ref(0) +const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量 + +// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制) +// 可根据实际需求调整: +// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新 +// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年 +const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围 function updateSvgSize() { if (bodyContentRef.value) { - svgWidth.value = bodyContentRef.value.offsetWidth - // 使用计算的内容高度,确保SVG覆盖所有任务行 - svgHeight.value = contentHeight.value + // 获取 bodyContent 的总宽度和可视区域宽度 + const totalWidth = bodyContentRef.value.offsetWidth - // 同步更新 Canvas 尺寸 - canvasWidth.value = bodyContentRef.value.offsetWidth + // 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取 + // 因为 handleTimelineScroll 已经实时更新了这个值 + const scrollLeft = timelineScrollLeft.value + + // 虚拟渲染策略(统一模式): + // Canvas 始终使用固定安全宽度,通过 offsetLeft 动态定位 + canvasWidth.value = SAFE_CANVAS_WIDTH + + // 计算 Canvas 覆盖的起始位置 + // 策略:以当前滚动位置为基准,向左扩展 1/3,向右扩展 2/3 + const bufferLeft = SAFE_CANVAS_WIDTH / 3 + let idealOffsetLeft = Math.max(0, scrollLeft - bufferLeft) + + // 确保 Canvas 不超出内容范围 + // 1. 如果总宽度 <= Canvas 宽度,Canvas 从 0 开始 + // 2. 如果总宽度 > Canvas 宽度,Canvas 不能超过右边界 + if (totalWidth <= SAFE_CANVAS_WIDTH) { + idealOffsetLeft = 0 + } else { + const maxOffsetLeft = totalWidth - SAFE_CANVAS_WIDTH + idealOffsetLeft = Math.min(idealOffsetLeft, maxOffsetLeft) + } + + canvasOffsetLeft.value = idealOffsetLeft + + svgWidth.value = canvasWidth.value + svgHeight.value = contentHeight.value canvasHeight.value = contentHeight.value } } @@ -2373,6 +2409,9 @@ const handleTimelineScroll = (event: Event) => { isInitialLoad.value = false } + // 虚拟渲染:滚动时更新 Canvas 位置(防抖处理) + debouncedUpdateCanvasPosition() + // 小时视图简化处理 if (currentTimeScale.value === TimelineScale.HOUR) { isScrolling.value = true @@ -3281,12 +3320,13 @@ const handleAddSuccessor = (task: Task) => {
- +