支持canvas links虚拟渲染,以支持超大宽度的视图(小时视图等)
This commit is contained in:
@@ -23,6 +23,7 @@ interface Props {
|
|||||||
taskBarPositions: Record<number, TaskBarPosition>
|
taskBarPositions: Record<number, TaskBarPosition>
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染)
|
||||||
highlightedTaskId: number | null
|
highlightedTaskId: number | null
|
||||||
highlightedTaskIds: Set<number>
|
highlightedTaskIds: Set<number>
|
||||||
hoveredTaskId: number | null
|
hoveredTaskId: number | null
|
||||||
@@ -34,6 +35,7 @@ interface Props {
|
|||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
verticalLines: () => [],
|
verticalLines: () => [],
|
||||||
showVerticalLines: true,
|
showVerticalLines: true,
|
||||||
|
offsetLeft: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Canvas 引用
|
// Canvas 引用
|
||||||
@@ -56,9 +58,6 @@ const drawLinks = () => {
|
|||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
// 适配高清屏(Retina)
|
// 适配高清屏(Retina)
|
||||||
const dpr = window.devicePixelRatio || 1
|
const dpr = window.devicePixelRatio || 1
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
@@ -66,11 +65,17 @@ const drawLinks = () => {
|
|||||||
// 设置实际像素尺寸
|
// 设置实际像素尺寸
|
||||||
canvas.width = rect.width * dpr
|
canvas.width = rect.width * dpr
|
||||||
canvas.height = rect.height * 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.scale(dpr, dpr)
|
||||||
|
|
||||||
// 清空画布
|
// 清空画布(透明)
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||||
|
|
||||||
// 绘制月份分隔线(在关系线之前,作为背景)
|
// 绘制月份分隔线(在关系线之前,作为背景)
|
||||||
@@ -83,10 +88,15 @@ const drawLinks = () => {
|
|||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
|
|
||||||
// 优化:一次性绘制所有垂直线,避免多次 stroke() 调用
|
// 优化:一次性绘制所有垂直线,避免多次 stroke() 调用
|
||||||
|
// 虚拟渲染:减去偏移量,转换为 Canvas 局部坐标
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
for (const line of props.verticalLines) {
|
for (const line of props.verticalLines) {
|
||||||
ctx.moveTo(line.left, 0)
|
const localX = line.left - props.offsetLeft
|
||||||
ctx.lineTo(line.left, rect.height)
|
// 只绘制在 Canvas 可见范围内的线
|
||||||
|
if (localX >= 0 && localX <= rect.width) {
|
||||||
|
ctx.moveTo(localX, 0)
|
||||||
|
ctx.lineTo(localX, rect.height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
@@ -102,6 +112,10 @@ const drawLinks = () => {
|
|||||||
// 是否处于高亮模式
|
// 是否处于高亮模式
|
||||||
const isHighlightMode = props.highlightedTaskId !== null
|
const isHighlightMode = props.highlightedTaskId !== null
|
||||||
|
|
||||||
|
// 虚拟渲染:计算 Canvas 覆盖的范围
|
||||||
|
const canvasStartX = props.offsetLeft
|
||||||
|
const canvasEndX = props.offsetLeft + rect.width
|
||||||
|
|
||||||
// 定义线条数据类型
|
// 定义线条数据类型
|
||||||
interface LineData {
|
interface LineData {
|
||||||
x1: number
|
x1: number
|
||||||
@@ -122,16 +136,38 @@ const drawLinks = () => {
|
|||||||
const fadedLines: LineData[] = [] // 高亮模式下的普通线条(半透明)
|
const fadedLines: LineData[] = [] // 高亮模式下的普通线条(半透明)
|
||||||
|
|
||||||
// 收集所有关系线数据并分组
|
// 收集所有关系线数据并分组
|
||||||
|
let totalRelations = 0 // 总关系数
|
||||||
|
let culledRelations = 0 // 被裁剪的关系数
|
||||||
|
let drawnRelations = 0 // 实际绘制的关系数
|
||||||
|
|
||||||
for (const task of props.tasks) {
|
for (const task of props.tasks) {
|
||||||
if (!task.predecessor || !props.taskBarPositions[task.id]) continue
|
if (!task.predecessor || !props.taskBarPositions[task.id]) continue
|
||||||
|
|
||||||
const predecessorIds = getPredecessorIds(task.predecessor)
|
const predecessorIds = getPredecessorIds(task.predecessor)
|
||||||
|
|
||||||
for (const predecessorId of predecessorIds) {
|
for (const predecessorId of predecessorIds) {
|
||||||
|
totalRelations++
|
||||||
|
|
||||||
const fromBar = props.taskBarPositions[predecessorId]
|
const fromBar = props.taskBarPositions[predecessorId]
|
||||||
const toBar = props.taskBarPositions[task.id]
|
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
|
const fromIsPrimary = props.highlightedTaskId === predecessorId
|
||||||
@@ -149,11 +185,17 @@ const drawLinks = () => {
|
|||||||
const fromYOffset = fromIsPrimary ? -8 : fromIsHighlighted ? -5 : 0
|
const fromYOffset = fromIsPrimary ? -8 : fromIsHighlighted ? -5 : 0
|
||||||
const toYOffset = toIsPrimary ? -8 : toIsHighlighted ? -5 : 0
|
const toYOffset = toIsPrimary ? -8 : toIsHighlighted ? -5 : 0
|
||||||
|
|
||||||
// 计算坐标
|
// 计算坐标(全局坐标)
|
||||||
const x1 = fromBar.left + fromBar.width
|
const globalX1 = fromBar.left + fromBar.width
|
||||||
const y1 = fromBar.top + fromBar.height / 2 + fromYOffset
|
const globalY1 = fromBar.top + fromBar.height / 2 + fromYOffset
|
||||||
const x2 = toBar.left
|
const globalX2 = toBar.left
|
||||||
const y2 = toBar.top + toBar.height / 2 + toYOffset
|
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 c1x = x1 + 40
|
||||||
const c1y = y1
|
const c1y = y1
|
||||||
@@ -329,6 +371,7 @@ watch(
|
|||||||
() => props.height,
|
() => props.height,
|
||||||
() => props.verticalLines,
|
() => props.verticalLines,
|
||||||
() => props.showVerticalLines,
|
() => props.showVerticalLines,
|
||||||
|
() => props.offsetLeft, // 监听虚拟渲染的偏移量变化
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
|
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
|
||||||
@@ -369,6 +412,7 @@ defineExpose({
|
|||||||
top: 0,
|
top: 0,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
|
transform: `translateX(${offsetLeft}px)`,
|
||||||
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}"
|
}"
|
||||||
@@ -378,5 +422,7 @@ defineExpose({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.gantt-links-canvas {
|
.gantt-links-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
background: transparent; /* 确保背景透明 */
|
||||||
|
opacity: 1; /* 确保不透明度为 100% */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -719,6 +719,11 @@ const debouncedUpdatePositions = debounce(() => {
|
|||||||
computeAllMilestonesPositions()
|
computeAllMilestonesPositions()
|
||||||
}, 50)
|
}, 50)
|
||||||
|
|
||||||
|
// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发)
|
||||||
|
const debouncedUpdateCanvasPosition = debounce(() => {
|
||||||
|
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
||||||
|
}, 50)
|
||||||
|
|
||||||
// 缓存时间轴数据的函数
|
// 缓存时间轴数据的函数
|
||||||
const getCachedTimelineData = (): unknown => {
|
const getCachedTimelineData = (): unknown => {
|
||||||
const scale = currentTimeScale.value
|
const scale = currentTimeScale.value
|
||||||
@@ -1932,15 +1937,46 @@ const svgHeight = ref(0)
|
|||||||
// Canvas 关系线尺寸(用于 GanttLinks 组件)
|
// Canvas 关系线尺寸(用于 GanttLinks 组件)
|
||||||
const canvasWidth = ref(0)
|
const canvasWidth = ref(0)
|
||||||
const canvasHeight = 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() {
|
function updateSvgSize() {
|
||||||
if (bodyContentRef.value) {
|
if (bodyContentRef.value) {
|
||||||
svgWidth.value = bodyContentRef.value.offsetWidth
|
// 获取 bodyContent 的总宽度和可视区域宽度
|
||||||
// 使用计算的内容高度,确保SVG覆盖所有任务行
|
const totalWidth = bodyContentRef.value.offsetWidth
|
||||||
svgHeight.value = contentHeight.value
|
|
||||||
|
|
||||||
// 同步更新 Canvas 尺寸
|
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
||||||
canvasWidth.value = bodyContentRef.value.offsetWidth
|
// 因为 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
|
canvasHeight.value = contentHeight.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2373,6 +2409,9 @@ const handleTimelineScroll = (event: Event) => {
|
|||||||
isInitialLoad.value = false
|
isInitialLoad.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 虚拟渲染:滚动时更新 Canvas 位置(防抖处理)
|
||||||
|
debouncedUpdateCanvasPosition()
|
||||||
|
|
||||||
// 小时视图简化处理
|
// 小时视图简化处理
|
||||||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||||||
isScrolling.value = true
|
isScrolling.value = true
|
||||||
@@ -3281,12 +3320,13 @@ 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">
|
||||||
<!-- 关系线组件(Canvas 渲染,性能提升 18 倍) -->
|
<!-- 关系线组件(Canvas 渲染,支持虚拟渲染) -->
|
||||||
<GanttLinks
|
<GanttLinks
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:task-bar-positions="taskBarPositions"
|
:task-bar-positions="taskBarPositions"
|
||||||
:width="canvasWidth"
|
:width="canvasWidth"
|
||||||
:height="canvasHeight"
|
:height="canvasHeight"
|
||||||
|
:offset-left="canvasOffsetLeft"
|
||||||
:highlighted-task-id="highlightedTaskId"
|
:highlighted-task-id="highlightedTaskId"
|
||||||
:highlighted-task-ids="highlightedTaskIds"
|
:highlighted-task-ids="highlightedTaskIds"
|
||||||
:hovered-task-id="hoveredTaskId"
|
:hovered-task-id="hoveredTaskId"
|
||||||
|
|||||||
Reference in New Issue
Block a user