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

项目结构
master-detail-chart/
├── README.md # 项目说明文档
├── gantt-chart.js # 甘特图组件
├── echarts.min.js # ECharts库(甘特图依赖)
├── example.html # 完整使用示例
甘特图组件特性
- 基于ECharts的高性能甘特图渲染
- 支持多段工作时间配置(如:8:00-12:00, 14:00-18:00)
- 智能时间分割,区分工作时间和非工作时间
- 百分比显示支持(整体占比或工作时间占比)
- 支持时长和百分比同时显示
- 自定义颜色映射和标记线
- 鼠标悬停交互效果
使用方法
基础集成
-
将以下文件复制到您的项目中:
gantt-chart.js- 甘特图组件echarts.min.js- ECharts库(甘特图依赖)
-
在HTML文件中引入必要的JavaScript文件:
<!-- 引入JavaScript库 -->
<script src="echarts.min.js"></script>
<script src="gantt-chart.js"></script>
- 在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): 容器元素的IDoptions(object): 配置选项
配置选项
| 选项 | 类型 | 描述 | 默认值 |
|---|---|---|---|
categories | array | 甘特图环节类别数组 | [] |
data | array | 甘特图数据数组 | [] |
workTime | array | 工作时间段配置 | [{start: ‘00:00:00’, end: ‘23:59:59’}] |
percentage | object | 百分比显示配置 | {show: true, type: ‘overall’, percent: true, duration: false} |
percentage.show | boolean | 是否显示百分比 | true |
percentage.type | string | 百分比类型:‘overall’(整体占比)或 ‘workTime’(工作时间占比) | ‘overall’ |
percentage.percent | boolean | 是否显示百分比数值 | true |
percentage.duration | boolean | 是否显示时长 | false |
percentage.position | string | 文本位置:‘center’、‘top’、‘bottom’ | ‘center’ |
config | object | 配置对象 | {} |
config.colorMap | object | 环节颜色映射 | {} |
hoverMarker | object | 鼠标悬停标记配置 | {} |
hoverMarker.show | boolean | 是否显示悬停标记 | false |
markLine | object | 标记线配置 | {} |
markLine.show | boolean | 是否显示标记线 | false |
markLine.data | array | 标记线数据数组 | [] |
数据格式
甘特图数据项格式
{
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 文件了解甘特图的完整集成示例。该示例展示了:
- 工作时间配置和百分比显示
- 标记线和悬停效果
依赖说明
- 甘特图组件:依赖 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();
});
}
}

1万+

被折叠的 条评论
为什么被折叠?



