基于echarts实现高级甘特图组件

前言

最近做一个工作流程的可视化时用AI生成的一个依赖echarts实现的甘特图,发文备份下,也分享下,有需要的可以直接拿去用。文末附源码,可直接复制运行。

参考文章

Vue 中使用 Echarts 实现项目进度甘特图
【Echarts】custom自定义图表实现甘特图

效果图

效果图

项目结构

master-detail-chart/
├── README.md                    # 项目说明文档
├── gantt-chart.js              # 甘特图组件
├── echarts.min.js              # ECharts库(甘特图依赖)
├── example.html                # 完整使用示例

甘特图组件特性

  • 基于ECharts的高性能甘特图渲染
  • 支持多段工作时间配置(如:8:00-12:00, 14:00-18:00)
  • 智能时间分割,区分工作时间和非工作时间
  • 百分比显示支持(整体占比或工作时间占比)
  • 支持时长和百分比同时显示
  • 自定义颜色映射和标记线
  • 鼠标悬停交互效果

使用方法

基础集成

  1. 将以下文件复制到您的项目中:

    • gantt-chart.js - 甘特图组件
    • echarts.min.js - ECharts库(甘特图依赖)
  2. 在HTML文件中引入必要的JavaScript文件:

<!-- 引入JavaScript库 -->
<script src="echarts.min.js"></script>
<script src="gantt-chart.js"></script>
  1. 在HTML中提供容器并限定宽高:
<div id="gannt-charts" style="width: 100%;height: 420px;"></div>

甘特图组件使用

基础甘特图
// 在主面板中初始化甘特图
new GanttChart('gannt-charts', {
    categories: ["环节1", "环节2", "环节3", "环节4", "环节5"],
    data: [
        {
            category: "开始",
            start: "2025-11-12 01:23:20",
            end: "2025-11-12 01:23:20",
            desc: "流程开始"
        },
        {
            category: "环节2", 
            start: "2025-11-12 01:23:21",
            end: "2025-11-12 09:19:01",
            desc: "初步处理阶段"
        }
    ]
});
完整配置示例
new GanttChart('gantt-chart', {
    categories: ["环节1", "环节2", "环节3", "环节4", "环节5"],
    data: [
        {
            category: "环节1",
            start: "2025-11-12 01:23:20",
            end: "2025-11-12 01:23:20",
            desc: ""
        },
        {
            category: "环节2",
            start: "2025-11-12 01:23:21", 
            end: "2025-11-12 09:19:01",
            desc: ""
        }
    ],
    workTime: [
        { start: '08:00:00', end: '12:00:00' },
        { start: '14:00:00', end: '18:00:00' },
        { start: '19:00:00', end: '20:00:00' }
    ],
    percentage: {
        show: true,
        type: "overall",
        percent: true,
        duration: true
    },
    config: {
        colorMap: {
            "环节1": "#7b9ce1",
            "环节2": "#bd6d6c", 
            "环节3": "#75d874",
            "环节4": "#e0bc78",
            "环节5": "#e1Ac78"
        }
    },
    hoverMarker: {
        show: true,
        lineStyle: {
            color: '#ff9800',
            width: 1,
            type: 'dashed'
        }
    },
    markLine: {
        show: true,
        data: [
            {
                x: "2025-11-12 12:00:00",
                name: "中午休息",
                lineStyle: {
                    color: "#ff6b6b",
                    width: 2,
                    type: "dashed"
                }
            }
        ]
    }
});

API

甘特图组件 API

构造函数
new GanttChart(containerId, options)
  • containerId (string): 容器元素的ID
  • options (object): 配置选项
配置选项
选项类型描述默认值
categoriesarray甘特图环节类别数组[]
dataarray甘特图数据数组[]
workTimearray工作时间段配置[{start: ‘00:00:00’, end: ‘23:59:59’}]
percentageobject百分比显示配置{show: true, type: ‘overall’, percent: true, duration: false}
percentage.showboolean是否显示百分比true
percentage.typestring百分比类型:‘overall’(整体占比)或 ‘workTime’(工作时间占比)‘overall’
percentage.percentboolean是否显示百分比数值true
percentage.durationboolean是否显示时长false
percentage.positionstring文本位置:‘center’、‘top’、‘bottom’‘center’
configobject配置对象{}
config.colorMapobject环节颜色映射{}
hoverMarkerobject鼠标悬停标记配置{}
hoverMarker.showboolean是否显示悬停标记false
markLineobject标记线配置{}
markLine.showboolean是否显示标记线false
markLine.dataarray标记线数据数组[]
数据格式
甘特图数据项格式
{
    category: "环节1",           // 环节类别
    start: "2025-11-12 01:23:20", // 开始时间
    end: "2025-11-12 01:23:20",   // 结束时间
    desc: "流程开始"             // 描述信息
}
工作时间段格式
{
    start: '08:00:00',  // 工作时间段开始
    end: '12:00:00'     // 工作时间段结束
}
标记线数据格式
{
    x: "2025-11-12 12:00:00",  // 标记线位置
    name: "中午休息",           // 标记线名称
    lineStyle: {               // 线条样式
        color: "#ff6b6b",
        width: 2,
        type: "dashed"
    }
}
核心方法
getWorkTimeOverlapRatio(startTimestamp, endTimestamp)

计算时间段与工作时间的重叠比例

formatDuration(milliseconds)

格式化持续时间(毫秒转换为可读格式)

isInWorkTime(startTimestamp, endTimestamp)

判断时间段是否与工作时间有重叠

splitTimeSegment(startTimestamp, endTimestamp)

将时间段按工作时间分割

甘特图高级功能

工作时间智能分割

甘特图组件支持多段工作时间配置,自动区分工作时间和非工作时间:

new GanttChart('gantt-chart', {
    workTime: [
        { start: '08:00:00', end: '12:00:00' },  // 上午工作时间
        { start: '14:00:00', end: '18:00:00' },  // 下午工作时间
        { start: '19:00:00', end: '22:00:00' }   // 晚上加班时间
    ],
    // 其他配置...
});

百分比显示配置

支持多种百分比显示方式:

// 显示整体时间占比
percentage: {
    show: true,
    type: "overall",
    percent: true,
    duration: false
}

// 显示工作时间占比
percentage: {
    show: true,
    type: "workTime", 
    percent: true,
    duration: true
}

// 只显示时长
percentage: {
    show: true,
    type: "overall",
    percent: false,
    duration: true
}

完整集成示例

查看 example.html 文件了解甘特图的完整集成示例。该示例展示了:

  1. 工作时间配置和百分比显示
  2. 标记线和悬停效果

依赖说明

  • 甘特图组件:依赖 ECharts 5.x 版本

源码

example.html

<!DOCTYPE html>
<html lang="zh-CN">
	<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<title>高级甘特图使用示例</title>
		<!-- 引入组件的CSS -->
		<style>
			body {
				margin: 0;
				padding: 0;
			}
		</style>
	</head>
	<body>
		<!-- 提供一个div容器 -->
		<div id="gannt-charts" style="width: 1000px;height: 420px;"></div>

		<!-- 引入组件的JavaScript -->
		<script src="echarts.min.js"></script>
		<script src="gantt-chart.js"></script>
		<script>
			// 初始化组件
			document.addEventListener('DOMContentLoaded', function() {
				new GanttChart('gannt-charts', {
					"categories": ["环节1", "环节2", "环节3", "环节4", "环节5"],
					"data": [{
							"category": "环节1",
							"start": "2025-11-12 01:23:20",
							"end": "2025-11-12 01:23:20",
							"desc": ""
						},
						{
							"category": "环节2",
							"start": "2025-11-12 01:23:21",
							"end": "2025-11-12 09:19:01",
							"desc": ""
						},
						{
							"category": "环节3",
							"start": "2025-11-12 09:19:01",
							"end": "2025-11-13 08:32:17",
							"desc": "111"
						},
						{
							"category": "环节4",
							"start": "2025-11-13 08:32:17",
							"end": "2025-11-13 08:56:07",
							"desc": ""
						},
						{
							"category": "环节3",
							"start": "2025-11-13 08:56:07",
							"end": "2025-11-13 08:59:08",
							"desc": ""
						},
						{
							"category": "环节5",
							"start": "2025-11-13 08:59:08",
							"end": "2025-11-13 08:59:08",
							"desc": ""
						}
					],
					"workTime": [{
						start: '08:00:00',
						end: '12:00:00'
					}, {
						start: '14:00:00',
						end: '18:00:00'
					}, {
						start: '19:00:00',
						end: '20:00:00'
					}],
					"percentage": {
						"show": true,
						"type": "overall",
						"percent": true,
						"duration": true
					},
					"config": {
						"colorMap": {
							"环节1": "#7b9ce1",
							"环节2": "#bd6d6c",
							"环节3": "#75d874",
							"环节4": "#e0bc78",
							"环节5": "#e1Ac78"
						}
					},
					"hoverMarker": {
						show: true,
						lineStyle: {
							color: '#ff9800',
							width: 1,
							type: 'dashed'
						},
						labelStyle: {
							color: '#ff9800',
							fontSize: 12,
							fontWeight: 'bold'
						}
					},
					"markLine": {
						"show": true,
						"data": [{
								"x": "2025-11-12 12:00:00",
								"name": "中午休息",
								"lineStyle": {
									"color": "#ff6b6b",
									"width": 2,
									"type": "dashed"
								},
								"label": {
									"show": true,
									"position": "insideEndTop",
									"formatter": "中午休息"
								}
							},
							{
								"x": "2025-11-13 08:00:00",
								"name": "第二天开始",
								"lineStyle": {
									"color": "#4ecdc4",
									"width": 3,
									"type": "solid"
								},
								"label": {
									"show": true,
									"position": "insideEndTop",
									"formatter": "第二天开始"
								}
							}
						]
					}
				})
			
			});
		</script>
	</body>
</html>

gantt-chart.js

class GanttChart {
	constructor(containerId, options = {}) {
		this.containerId = containerId;
		this.categories = options.categories || []
		this.data = options.data || []
		// 支持多段上下班时间配置
		this.workTime = options.workTime || [{
			start: '00:00:00',
			end: '23:59:59'
		}];

		this.markLine = options.markLine || {}

		// 新增配置选项
		this.showPercentage = options?.percentage?.show !== undefined ? options.percentage.show : true; // 默认显示占比
		this.percentageType = options?.percentage?.type || 'overall'; // 'overall' 或 'workTime'
		this.percentagePosition = options?.percentage?.position || 'center'; // 'center', 'top', 'bottom'

		// 支持新的配置方式:percent和duration参数
		this.showPercent = options?.percentage?.percent !== undefined ? options.percentage.percent :
			true; // 默认显示百分比
		this.showDuration = options?.percentage?.duration !== undefined ? options.percentage.duration :
			false; // 默认不显示时长

		// 兼容旧的format配置
		if (options?.percentage?.format) {
			this.percentageFormat = options.percentage.format;
			this.showPercent = this.percentageFormat === 'percent';
			this.showDuration = this.percentageFormat === 'duration';
		}

		// 标记线配置
		this.markLine = options.markLine || {}

		// 鼠标悬停标记线配置
		this.hoverMarker = options.hoverMarker || {};

		this.colorMap = options?.config?.colorMap || {}

		// 存储引用
		this.chartInstance = null;
		this.originalAxisLabelShow = true;

		this.init();
	}

	init() {
		const container = document.getElementById(this.containerId);
		if (!container) {
			console.error(`Container with id "${this.containerId}" not found.`);
			return;
		}
		this.render(container);
	}

	// 解析时间字符串为时分秒
	parseTimeString(timeString) {
		const [hours, minutes, seconds] = timeString.split(':').map(Number);
		return {
			hours,
			minutes,
			seconds
		};
	}

	// 获取某一天的所有工作时间段
	getWorkTimeSegmentsForDay(date) {
		const workSegments = [];
		const dayStart = new Date(date);
		dayStart.setHours(0, 0, 0, 0);

		this.workTime.forEach(workPeriod => {
			const {
				hours: startHour,
				minutes: startMinute,
				seconds: startSecond
			} = this.parseTimeString(workPeriod.start);
			const {
				hours: endHour,
				minutes: endMinute,
				seconds: endSecond
			} = this.parseTimeString(workPeriod.end);

			const workStart = new Date(dayStart);
			workStart.setHours(startHour, startMinute, startSecond, 0);

			const workEnd = new Date(dayStart);
			workEnd.setHours(endHour, endMinute, endSecond, 0);

			workSegments.push({
				start: workStart.getTime(),
				end: workEnd.getTime()
			});
		});

		return workSegments;
	}

	// 判断时间点是否在任意一个工作时间内
	isPointInWorkTime(timestamp) {
		if (typeof timestamp !== 'number' || isNaN(timestamp)) {
			console.warn('时间戳参数错误', timestamp);
			return false;
		}

		const date = new Date(timestamp);
		const workSegments = this.getWorkTimeSegmentsForDay(date);

		return workSegments.some(segment =>
			timestamp >= segment.start && timestamp <= segment.end
		);
	}

	// 判断时间段是否与任意一个工作时间段有重叠
	isInWorkTime(startTimestamp, endTimestamp) {
		if (typeof startTimestamp !== 'number' || typeof endTimestamp !== 'number') {
			console.warn('时间戳参数类型错误', {
				startTimestamp,
				endTimestamp
			});
			return false;
		}

		if (startTimestamp === endTimestamp) {
			return this.isPointInWorkTime(startTimestamp);
		}

		if (endTimestamp < startTimestamp) {
			console.warn('结束时间早于开始时间', {
				startTimestamp,
				endTimestamp
			});
			return false;
		}

		let currentStart = startTimestamp;
		const maxIterations = 365;

		for (let i = 0; i < maxIterations && currentStart < endTimestamp; i++) {
			const currentDate = new Date(currentStart);
			const workSegments = this.getWorkTimeSegmentsForDay(currentDate);

			// 检查是否与当天任意工作时间段有重叠
			for (const segment of workSegments) {
				const overlapStart = Math.max(currentStart, segment.start);
				const overlapEnd = Math.min(endTimestamp, segment.end);

				if (overlapStart < overlapEnd) {
					return true;
				}
			}

			// 移动到下一天
			const nextDay = new Date(currentDate);
			nextDay.setDate(nextDay.getDate() + 1);
			nextDay.setHours(0, 0, 0, 0);

			if (nextDay.getTime() <= currentStart) break;
			currentStart = nextDay.getTime();
		}

		return false;
	}

	// 优化后的时间段分割函数,支持多段工作时间
	splitTimeSegment(startTimestamp, endTimestamp) {
		const segments = [];

		// 特殊情况处理
		if (startTimestamp === endTimestamp) {
			segments.push({
				start: startTimestamp,
				end: endTimestamp,
				isWorkTime: this.isPointInWorkTime(startTimestamp),
				isZeroDuration: true
			});
			return segments;
		}

		if (endTimestamp < startTimestamp) {
			console.warn('结束时间早于开始时间', {
				startTimestamp,
				endTimestamp
			});
			return segments;
		}

		let currentStart = startTimestamp;

		while (currentStart < endTimestamp) {
			const currentDate = new Date(currentStart);
			const workSegments = this.getWorkTimeSegmentsForDay(currentDate);

			// 创建当天的结束时间(23:59:59.999)
			const dayEndTime = new Date(currentDate);
			dayEndTime.setHours(23, 59, 59, 999);

			let segmentEnd = endTimestamp;
			let isWorkTime = false;
			let foundOverlap = false;

			// 检查与所有工作时间段的重叠情况
			for (const workSegment of workSegments) {
				if (currentStart < workSegment.end && endTimestamp > workSegment.start) {
					// 找到重叠的工作时间段
					if (currentStart < workSegment.start) {
						// 情况1:开始时间在工作时间段前
						segmentEnd = Math.min(workSegment.start, endTimestamp);
						isWorkTime = false;
					} else if (currentStart < workSegment.end) {
						// 情况2:开始时间在工作时间段内
						segmentEnd = Math.min(workSegment.end, endTimestamp);
						isWorkTime = true;
					}
					foundOverlap = true;
					break;
				}
			}

			if (!foundOverlap) {
				// 没有与任何工作时间段重叠
				segmentEnd = Math.min(dayEndTime.getTime(), endTimestamp);
				isWorkTime = false;
			}

			// 确保时间段有效
			if (segmentEnd > currentStart) {
				segments.push({
					start: currentStart,
					end: segmentEnd,
					isWorkTime: isWorkTime,
					duration: segmentEnd - currentStart
				});
			}

			// 移动到下一个时间段的起点
			currentStart = segmentEnd;

			// 如果当前时间段结束在当天结束前,且还有剩余时间,需要检查是否跨天
			if (currentStart < endTimestamp) {
				// 如果当前时间等于当天结束时间,移动到下一天
				if (currentStart >= dayEndTime.getTime()) {
					const nextDay = new Date(currentDate);
					nextDay.setDate(nextDay.getDate() + 1);
					nextDay.setHours(0, 0, 0, 0);

					// 防止无限循环
					if (nextDay.getTime() <= currentStart) {
						console.warn('跨天处理异常,强制退出循环');
						break;
					}
					currentStart = nextDay.getTime();
				}
			}

			// 防止无限循环的保护机制
			if (segments.length > 100) {
				console.warn('分割段数过多,可能陷入无限循环', {
					startTimestamp,
					endTimestamp,
					segments
				});
				break;
			}
		}

		return segments;
	}

	// 获取时间段与工作时间的重叠比例
	getWorkTimeOverlapRatio(startTimestamp, endTimestamp) {
		if (startTimestamp === endTimestamp) {
			return this.isPointInWorkTime(startTimestamp) ? 1 : 0;
		}

		if (endTimestamp < startTimestamp) {
			return 0;
		}

		const totalDuration = endTimestamp - startTimestamp;
		let workTimeDuration = 0;

		let currentStart = startTimestamp;
		const maxIterations = 365;

		for (let i = 0; i < maxIterations && currentStart < endTimestamp; i++) {
			const currentDate = new Date(currentStart);
			const workSegments = this.getWorkTimeSegmentsForDay(currentDate);

			// 计算与所有工作时间段的重叠时长
			for (const workSegment of workSegments) {
				const overlapStart = Math.max(currentStart, workSegment.start);
				const overlapEnd = Math.min(endTimestamp, workSegment.end);

				if (overlapStart < overlapEnd) {
					workTimeDuration += (overlapEnd - overlapStart);
				}
			}

			// 移动到下一天
			const nextDay = new Date(currentDate);
			nextDay.setDate(nextDay.getDate() + 1);
			nextDay.setHours(0, 0, 0, 0);

			if (nextDay.getTime() <= currentStart) break;
			currentStart = nextDay.getTime();
		}

		return totalDuration > 0 ? workTimeDuration / totalDuration : 0;
	}

	// 渲染项目函数 - 在矩形内部显示环节在整体时间中的占比
	renderItem(params, api) {
		const categoryIndex = api.value(0);
		const startTime = api.value(1);
		const endTime = api.value(2);
		const height = api.size([0, 1])[1] * 0.6;
		const originalColor = api.visual('color');

		const segments = this.splitTimeSegment(startTime, endTime);

		// 如果不显示占比,使用原始渲染逻辑
		if (!this.showPercentage) {
			if (segments.length === 1 && segments[0].isZeroDuration) {
				const pointCoord = api.coord([startTime, categoryIndex]);
				const minWidth = 2;

				let rectShape = {
					x: pointCoord[0],
					y: pointCoord[1] - height / 2,
					width: minWidth,
					height: height
				};

				rectShape = echarts.graphic.clipRectByRect(rectShape, {
					x: params.coordSys.x,
					y: params.coordSys.y,
					width: params.coordSys.width,
					height: params.coordSys.height
				});

				const finalColor = segments[0].isWorkTime ? originalColor : '#cccccc';

				return rectShape && {
					type: 'rect',
					transition: ['shape'],
					shape: rectShape,
					style: {
						...api.style(),
						fill: finalColor
					}
				};
			}

			const shapes = [];
			for (const segment of segments) {
				const segmentStart = api.coord([segment.start, categoryIndex]);
				const segmentEnd = api.coord([segment.end, categoryIndex]);

				let segmentWidth = segmentEnd[0] - segmentStart[0];
				if (segmentWidth <= 0 && segment.duration > 0) {
					segmentWidth = 1;
				}

				let rectShape = {
					x: segmentStart[0],
					y: segmentStart[1] - height / 2,
					width: segmentWidth,
					height: height
				};

				rectShape = echarts.graphic.clipRectByRect(rectShape, {
					x: params.coordSys.x,
					y: params.coordSys.y,
					width: params.coordSys.width,
					height: params.coordSys.height
				});

				if (rectShape && rectShape.width > 0) {
					const segmentColor = segment.isWorkTime ? originalColor : '#cccccc';

					shapes.push({
						type: 'rect',
						transition: ['shape'],
						shape: rectShape,
						style: {
							...api.style(),
							fill: segmentColor
						}
					});
				}
			}

			if (shapes.length === 0) return null;
			if (shapes.length === 1) return shapes[0];

			return {
				type: 'group',
				children: shapes
			};
		}

		// 显示占比的逻辑 - 支持新的配置方式
		let percentageText = '';

		if (this.percentageType === 'workTime') {
			// 工作时间占比
			const workTimeRatio = this.getWorkTimeOverlapRatio(startTime, endTime);
			const workDuration = workTimeRatio * (endTime - startTime);

			// 根据配置生成文本
			const percentText = this.showPercent ? (workTimeRatio * 100).toFixed(1) + '%' : '';
			const durationText = this.showDuration ? this.formatDuration(workDuration) : '';

			// 组合文本,支持换行显示
			if (this.showPercent && this.showDuration) {
				percentageText = durationText + '\n' + percentText;
			} else if (this.showPercent) {
				percentageText = percentText;
			} else if (this.showDuration) {
				percentageText = durationText;
			}
		} else {
			// 整体时间占比
			const totalTimeRange = this.getTotalTimeRange();
			const itemDuration = endTime - startTime;
			const overallRatio = totalTimeRange > 0 ? itemDuration / totalTimeRange : 0;

			// 根据配置生成文本
			const percentText = this.showPercent ? (overallRatio * 100).toFixed(1) + '%' : '';
			const durationText = this.showDuration ? this.formatDuration(itemDuration) : '';

			// 组合文本,支持换行显示
			if (this.showPercent && this.showDuration) {
				percentageText = durationText + '\n' + percentText;
			} else if (this.showPercent) {
				percentageText = percentText;
			} else if (this.showDuration) {
				percentageText = durationText;
			}
		}

		if (segments.length === 1 && segments[0].isZeroDuration) {
			const pointCoord = api.coord([startTime, categoryIndex]);
			const minWidth = 2;

			let rectShape = {
				x: pointCoord[0],
				y: pointCoord[1] - height / 2,
				width: minWidth,
				height: height
			};

			rectShape = echarts.graphic.clipRectByRect(rectShape, {
				x: params.coordSys.x,
				y: params.coordSys.y,
				width: params.coordSys.width,
				height: params.coordSys.height
			});

			const finalColor = segments[0].isWorkTime ? originalColor : '#cccccc';

			if (!rectShape) return null;

			const rectElement = {
				type: 'rect',
				transition: ['shape'],
				shape: rectShape,
				style: {
					...api.style(),
					fill: finalColor
				}
			};

			if (rectShape.width > 0) {
				const textPosition = this.calculateTextPosition(rectShape, this.percentagePosition);
				const textElement = {
					type: 'text',
					style: {
						text: percentageText,
						x: textPosition.x,
						y: textPosition.y,
						fill: this.getContrastColor(finalColor),
						fontSize: Math.max(10, Math.min(12, height * 0.6)),
						fontWeight: 'bold',
						textAlign: 'center',
						textBaseline: 'middle',
						// 支持多行文本显示
						lineHeight: Math.max(10, Math.min(12, height * 0.6)) * 1.2
					},
					silent: true,
					z: 100 // 确保文字位于最上层
				};

				return {
					type: 'group',
					children: [rectElement, textElement]
				};
			}

			return rectElement;
		}

		const shapes = [];
		let hasVisibleSegment = false;

		for (const segment of segments) {
			const segmentStart = api.coord([segment.start, categoryIndex]);
			const segmentEnd = api.coord([segment.end, categoryIndex]);

			let segmentWidth = segmentEnd[0] - segmentStart[0];
			if (segmentWidth <= 0 && segment.duration > 0) {
				segmentWidth = 1;
			}

			let rectShape = {
				x: segmentStart[0],
				y: segmentStart[1] - height / 2,
				width: segmentWidth,
				height: height
			};

			rectShape = echarts.graphic.clipRectByRect(rectShape, {
				x: params.coordSys.x,
				y: params.coordSys.y,
				width: params.coordSys.width,
				height: params.coordSys.height
			});

			if (rectShape && rectShape.width > 0) {
				hasVisibleSegment = true;
				const segmentColor = segment.isWorkTime ? originalColor : '#cccccc';

				shapes.push({
					type: 'rect',
					transition: ['shape'],
					shape: rectShape,
					style: {
						...api.style(),
						fill: segmentColor
					}
				});
			}
		}

		if (shapes.length === 0) return null;

		if (hasVisibleSegment) {
			const firstSegmentCoord = api.coord([startTime, categoryIndex]);
			const lastSegmentCoord = api.coord([endTime, categoryIndex]);

			const totalWidth = lastSegmentCoord[0] - firstSegmentCoord[0];
			const centerX = firstSegmentCoord[0] + totalWidth / 2;
			const centerY = firstSegmentCoord[1];

			const textPosition = this.calculateTextPosition({
					x: centerX - totalWidth / 2,
					y: centerY - height / 2,
					width: totalWidth,
					height: height
				},
				this.percentagePosition
			);

			const textElement = {
				type: 'text',
				style: {
					text: percentageText,
					x: textPosition.x,
					y: textPosition.y,
					fill: this.getTextColor(this.percentageType === 'workTime' ?
						this.getWorkTimeOverlapRatio(startTime, endTime) :
						(endTime - startTime) / this.getTotalTimeRange(), originalColor),
					fontSize: Math.max(10, Math.min(12, height * 0.6)),
					fontWeight: 'bold',
					textAlign: 'center',
					textBaseline: 'middle',
					textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
					// 支持多行文本显示
					lineHeight: Math.max(10, Math.min(12, height * 0.6)) * 1.2
				},
				silent: true,
				z: 100 // 确保文字位于最上层
			};

			if (shapes.length === 1) {
				return {
					type: 'group',
					children: [shapes[0], textElement]
				};
			} else {
				shapes.push(textElement);
				return {
					type: 'group',
					children: shapes
				};
			}
		}

		if (shapes.length === 1) return shapes[0];

		return {
			type: 'group',
			children: shapes
		};
	}

	// 计算文本位置
	calculateTextPosition(rectShape, position) {
		const {
			x,
			y,
			width,
			height
		} = rectShape;

		switch (position) {
			case 'top':
				return {
					x: x + width / 2, y: y + height * 0.2
				};
			case 'bottom':
				return {
					x: x + width / 2, y: y + height * 0.8
				};
			case 'center':
			default:
				return {
					x: x + width / 2, y: y + height / 5
				};
		}
	}

	// 获取总时间范围
	getTotalTimeRange() {
		if (!this.totalTimeRange) {
			const sampleData1 = this.data; // 从实际数据获取
			const startTimes = sampleData1.map(item => new Date(item.start).getTime());
			const endTimes = sampleData1.map(item => new Date(item.end).getTime());

			const overallStart = Math.min(...startTimes);
			const overallEnd = Math.max(...endTimes);

			this.totalTimeRange = overallEnd - overallStart;
		}
		return this.totalTimeRange;
	}

	// 新增方法:获取整个时间轴的范围
	getTotalTimeRange() {
		if (!this.totalTimeRange) {
			const startTimes = this.data.map(item => new Date(item.start).getTime());
			const endTimes = this.data.map(item => new Date(item.end).getTime());

			const overallStart = Math.min(...startTimes);
			const overallEnd = Math.max(...endTimes);

			this.totalTimeRange = overallEnd - overallStart;
		}
		return this.totalTimeRange;
	}

	// 辅助函数:根据背景色选择合适的文本颜色
	getContrastColor(hexColor) {
		if (!hexColor) return '#ffffff';

		// 移除 # 号
		const hex = hexColor.replace('#', '');

		// 转换为 RGB
		const r = parseInt(hex.substr(0, 2), 16);
		const g = parseInt(hex.substr(2, 2), 16);
		const b = parseInt(hex.substr(4, 2), 16);

		// 计算亮度 (YIQ公式)
		const brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;

		return brightness > 128 ? '#000000' : '#ffffff';
	}

	// 辅助函数:根据占比值选择合适的文本颜色
	getTextColor(ratio, originalColor) {
		if (ratio < 0.1) {
			return '#ff6b6b'; // 低占比用红色
		} else if (ratio < 0.3) {
			return '#ffff00'; // 中等占比用橙色
		} else {
			return '#4caf50'; // 高占比用绿色
		}
	}

	// 工具函数:格式化持续时间
	formatDuration(milliseconds) {
		const seconds = Math.floor(milliseconds / 1000);
		if (seconds === 0) return '0秒';

		const hours = Math.floor(seconds / 3600);
		const minutes = Math.floor((seconds % 3600) / 60);
		const remainingSeconds = seconds % 60;

		const parts = [];
		if (hours > 0) parts.push(`${hours}`);
		if (minutes > 0) parts.push(`${minutes}`);
		if (remainingSeconds > 0) parts.push(`${remainingSeconds}`);

		return parts.join('');
	}

	calculatePercentage(index, len) {
		if (len <= 1) return 0.5; // 如果只有1个元素,返回50%

		const startPercent = 0.2; // 起始百分比 20%
		const endPercent = 0.8; // 结束百分比 80%

		let result = 0;

		// 线性插值计算
		result = startPercent + (endPercent - startPercent) * (index / (len - 1));
		return ((1 - result) * 100).toFixed(0) + '%'
	}

	// 构建甘特图数据
	buildGanttData(rawData, categories, colorMap) {
		const data = [];
		let startTime = null;

		if (!Array.isArray(rawData)) {
			throw new Error('rawData 必须是一个数组');
		}

		if (!Array.isArray(categories) || categories.length === 0) {
			throw new Error('categories 必须是一个非空数组');
		}

		rawData.forEach(item => {
			if (!item.category || !item.start || !item.end) {
				console.warn('数据项格式错误,跳过:', item);
				return;
			}

			const start = new Date(item.start).getTime();
			const end = new Date(item.end).getTime();

			// 修改:反转类别索引,让环节1在顶部
			const categoryIndex = categories.length - 1 - categories.indexOf(item.category);

			if (categoryIndex === -1) {
				console.warn(`未知的环节类别: ${item.category},跳过该数据项`);
				return;
			}

			if (isNaN(start) || isNaN(end)) {
				console.warn('时间格式错误,跳过:', item);
				return;
			}

			if (startTime === null) {
				startTime = start;
			}

			data.push({
				name: item.category,
				value: [categoryIndex, start, end, end - start],
				local: this.calculatePercentage(categoryIndex, categories.length),
				desc: item.desc || '', // 添加desc字段
				itemStyle: {
					normal: {
						color: colorMap[item.category] || '#cccccc'
					}
				}
			});
		});

		if (startTime === null && rawData.length > 0) {
			startTime = new Date(rawData[0].start).getTime();
		}

		return {
			data,
			startTime
		};
	}

	// 构建markLine系列
	buildMarkLineSeries(startTime, categories) {
		if (!this.markLine.show || !Array.isArray(this.markLine.data) || this.markLine.data.length === 0) {
			return [];
		}

		const series = [];

		this.markLine.data.forEach((markLineData, index) => {
			const xValue = typeof markLineData.x === 'string' ? new Date(markLineData.x).getTime() :
				markLineData.x;
			const name = markLineData.name || `标记线${index + 1}`;
			const lineStyle = {
				...this.markLine.lineStyle,
				...markLineData.lineStyle
			};
			const label = {
				...this.markLine.label,
				...markLineData.label
			};

			series.push({
				type: 'line',
				name: name,
				markLine: {
					silent: true,
					data: [{
						xAxis: xValue,
						name: name,
						lineStyle: lineStyle,
						label: label
					}],
					lineStyle: lineStyle,
					label: label
				},
				data: []
			});
		});

		return series;
	}

	// 创建 ECharts 配置
	createGanttOption(ganttData, categories) {
		const {
			data,
			startTime
		} = ganttData;
		const self = this;

		// 构建markLine数据
		const markLineSeries = this.buildMarkLineSeries(startTime, categories);

		// 初始化悬停标记线系列
		const hoverMarkStartSeries = {
			name: '悬停开始标记',
			type: 'line',
			show: false,
			data: [],
			symbol: 'none',
			xAxisIndex: 0,
			yAxisIndex: 0,
			animation: false, // 去掉动画效果
			lineStyle: this.hoverMarker.lineStyle,
			z: -1, // 降低层级,确保文字在最上层
			markLine: {
				symbol: 'none',
				data: [],
				lineStyle: this.hoverMarker.lineStyle,
				label: {
					show: true,
					position: 'end', // 保持位置在标记线右侧
					offset: [-40, 0], // 向左偏移10像素,防止重叠
					formatter: function(params) {
						const date = new Date(params.value);
						const year = date.getFullYear();
						const month = String(date.getMonth() + 1).padStart(2, '0');
						const day = String(date.getDate()).padStart(2, '0');
						const hours = String(date.getHours()).padStart(2, '0');
						const minutes = String(date.getMinutes()).padStart(2, '0');
						const seconds = String(date.getSeconds()).padStart(2, '0');
						return `${year}/${month}/${day}\n${hours}:${minutes}:${seconds}`;
					},
					...this.hoverMarker.labelStyle
				}
			}
		};

		const hoverMarkEndSeries = {
			name: '悬停结束标记',
			type: 'line',
			show: false,
			data: [],
			symbol: 'none',
			xAxisIndex: 0,
			yAxisIndex: 0,
			animation: false, // 去掉动画效果
			lineStyle: this.hoverMarker.lineStyle,
			z: -1, // 降低层级,确保文字在最上层
			markLine: {
				symbol: 'none',
				data: [],
				lineStyle: this.hoverMarker.lineStyle,
				label: {
					show: true,
					position: 'end', // 结束时间标签在标记线右侧
					offset: [40, 0], // 向右偏移10像素,防止重叠
					formatter: function(params) {
						const date = new Date(params.value);
						const year = date.getFullYear();
						const month = String(date.getMonth() + 1).padStart(2, '0');
						const day = String(date.getDate()).padStart(2, '0');
						const hours = String(date.getHours()).padStart(2, '0');
						const minutes = String(date.getMinutes()).padStart(2, '0');
						const seconds = String(date.getSeconds()).padStart(2, '0');
						return `${year}/${month}/${day}\n${hours}:${minutes}:${seconds}`;
					},
					...this.hoverMarker.labelStyle
				}
			}
		};

		const option = {
			tooltip: {
				formatter: function(params) {
					try {
						// 检查params.value是否存在且是数组
						if (!params.value || !Array.isArray(params.value) || params.value.length < 4) {
							// 如果数据格式不正确,使用params.data中的信息
							const startTime = params.data && params.data.start ? new Date(params.data.start)
								.toLocaleString() : '未知';
							const endTime = params.data && params.data.end ? new Date(params.data.end)
								.toLocaleString() : '未知';
							const duration = params.data && params.data.start && params.data.end ?
								self.formatDuration(new Date(params.data.end) - new Date(params.data
									.start)) : '未知';
							const descText = params.data && params.data.desc ?
								`<br/>描述:${params.data.desc}` : '';

							return `${params.marker}${params.name}<br/>
								开始:${startTime}<br/>
								结束:${endTime}<br/>
								持续时间:${duration}${descText}`;
						}

						const startTime = new Date(params.value[1]).toLocaleString();
						const endTime = new Date(params.value[2]).toLocaleString();
						const duration = self.formatDuration(params.value[3]);
						const isInWorkTime = self.isInWorkTime(params.value[1], params.value[2]);
						const workTimeStatus = isInWorkTime ? '(上班时间)' : '(非上班时间)';
						const overlapRatio = self.getWorkTimeOverlapRatio(params.value[1], params.value[2]);
						const overlapText = overlapRatio > 0 && overlapRatio < 1 ?
							`<br/>工作时间占比:${(overlapRatio * 100).toFixed(1)}%` : '';
						const descText = params.data.desc ? `<br/>描述:${params.data.desc}` : '';

						return `${params.marker}${params.name}<br/>
								开始:${startTime}<br/>
								结束:${endTime}<br/>
								持续时间:${duration}<br/>
								状态:${workTimeStatus}${overlapText}${descText}`;
					} catch (error) {
						console.error('Tooltip 格式化错误:', error);
						// 错误处理时也检查数据格式
						const descText = params.data && params.data.desc ? `<br/>描述:${params.data.desc}` :
							'';

						if (params.data && params.data.start && params.data.end) {
							return `${params.marker}${params.name}<br/>
								开始:${new Date(params.data.start).toLocaleString()}<br/>
								结束:${new Date(params.data.end).toLocaleString()}<br/>
								持续时间:${self.formatDuration(new Date(params.data.end) - new Date(params.data.start))}${descText}`;
						} else {
							return `${params.marker}${params.name}<br/>
								数据格式错误,无法显示详细信息`;
						}
					}
				}
			},
			dataZoom: [{
					type: 'slider',
					filterMode: 'weakFilter',
					showDataShadow: false,
					top: 380,
					labelFormatter: ''
				},
				{
					type: 'inside',
					filterMode: 'weakFilter'
				}
			],
			grid: {
				top: '15%',
				bottom: '13%',
				left: '10%',
				right: '10%'
			},
			xAxis: {
				min: startTime,
				position: 'top',
				scale: true,
				axisLabel: {
					show: true,
					formatter: function(val) {
						const date = new Date(val);
						const totalDuration = val - startTime;
						const oneDay = 24 * 60 * 60 * 1000;

						if (totalDuration < oneDay) {
							const hours = String(date.getHours()).padStart(2, '0');
							const minutes = String(date.getMinutes()).padStart(2, '0');
							const seconds = String(date.getSeconds()).padStart(2, '0');
							return `${hours}:${minutes}:${seconds}`;
						} else {
							const month = String(date.getMonth() + 1).padStart(2, '0');
							const day = String(date.getDate()).padStart(2, '0');
							const hours = String(date.getHours()).padStart(2, '0');
							const minutes = String(date.getMinutes()).padStart(2, '0');
							return `${month}-${day} ${hours}:${minutes}`;
						}
					},
					interval: 0,
					rotate: 45,
					margin: 15
				},
				splitLine: {
					show: false
				},
				axisLine: {
					show: true,
					symbol: ['none', 'arrow'],
					symbolSize: [8, 15],
					symbolOffset: [0, 13]
				}
			},
			yAxis: {
				breaks: [{
					start: 50,
					end: 100,
					gap: '2%'
				}],
				breakArea: {
					itemStyle: {
						opacity: 1
					},
					zigzagMaxSpan: 15,
					zigzagAmplitude: 2,
					zigzagZ: 200
				},
				data: [...categories].reverse(),
				axisTick: {
					show: false
				},
				splitLine: {
					show: true,
					lineStyle: {
						type: 'dotted',
						width: 3
					},
					interval: function(index) {
						return index > 0;
					}
				},
				axisLine: {
					show: true,
					symbol: ['arrow', 'none'],
					symbolSize: [8, 15],
					symbolOffset: [-13, 0]
				}
			},
			series: [{
				type: 'custom',
				renderItem: this.renderItem.bind(this),
				itemStyle: {
					opacity: 0.8
				},
				encode: {
					x: [1, 2],
					y: 0
				},
				data: data,
				z: 10, // 提高层级,确保文字在最上层
				// 添加鼠标事件
				emphasis: {
					focus: 'series',
					itemStyle: {
						opacity: 1
					}
				}
			}, ...markLineSeries, hoverMarkStartSeries, hoverMarkEndSeries]
		};

		return option;
	}

	// 处理鼠标悬停事件
	handleMouseOver(params) {
		if (!this.chartInstance || !this.hoverMarker.show) return;

		// 隐藏X轴刻度
		this.originalAxisLabelShow = this.chartInstance.getOption().xAxis[0].axisLabel.show;
		this.chartInstance.setOption({
			xAxis: {
				axisLabel: {
					show: false
				}
			}
		});

		// 设置悬停标记线数据
		const startMarkData = [];
		const endMarkData = [];

		// 获取当前悬停的数据项
		if (params.data && params.data.value) {
			// 开始标记线 - 垂直线
			startMarkData.push({
				xAxis: params.data.value[1],
				y: params.data.local
			});

			// 结束标记线 - 垂直线
			endMarkData.push({
				xAxis: params.data.value[2],
				y: params.data.local
			});
		}

		// 更新标记线系列
		this.chartInstance.setOption({
			series: [{
					name: '悬停开始标记',
					show: true,
					markLine: {
						data: startMarkData
					}
				},
				{
					name: '悬停结束标记',
					show: true,
					markLine: {
						data: endMarkData
					}
				}
			]
		});
	}

	// 处理鼠标移出事件
	handleMouseOut() {
		if (!this.chartInstance || !this.hoverMarker.show) return;

		// 恢复X轴刻度显示
		this.chartInstance.setOption({
			xAxis: {
				axisLabel: {
					show: this.originalAxisLabelShow
				}
			}
		});

		// 隐藏标记线
		this.chartInstance.setOption({
			series: [{
					name: '悬停开始标记',
					show: false,
					markLine: {
						data: []
					}
				},
				{
					name: '悬停结束标记',
					show: false,
					markLine: {
						data: []
					}
				}
			]
		});
	}

	render(container) {
		const progressChart = echarts.init(container);
		this.chartInstance = progressChart;
		const result = this.buildGanttData(this.data, this.categories, this.colorMap);
		const option = this.createGanttOption(result, this.categories);

		progressChart.setOption(option);

		// 添加鼠标事件监听器
		if (this.hoverMarker.show) {
			progressChart.on('mouseover', params => {
				if (params.seriesType === 'custom') {
					this.handleMouseOver(params);
				}
			});

			progressChart.on('mouseout', params => {
				if (params.seriesType === 'custom') {
					this.handleMouseOut();
				}
			});
		}

		window.addEventListener('resize', () => {
			progressChart.resize();
		});
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值