Implementing and Optimizing a Gantt Chart with Konva in Frontend Development
This article explains how to build a Gantt chart using Konva, detailing the layer structure, rendering logic, scrolling handling, performance optimizations, and provides TypeScript code examples for configuration, drawing, and event management.
Brief Description
When writing the article, the code is still being desensitized; some business functions are omitted. After full desensitization the code will be refined and open‑sourced. The previous article implemented a simple calendar view using a single class; the Gantt chart is more complex, so the functionality is split into finer modules. The open‑sourced code may not be directly usable in projects but demonstrates the implementation process.
Below is a simple mock‑up, layout, and file directory structure.
Structure Analysis
Dual‑layer mode
Static layer:
Year/Month/Quarter/Week each in a separate Group.
Date‑column title in a separate Group to keep the title from being offset by vertical scrolling.
Content Group where offsetX and offsetY are controlled during scrolling, avoiding layer position adjustments for each update.
Dynamic layer:
Separate Rect for horizontal and vertical scrollbars.
Each milestone (maker) and task has its own Group.
After allocating the structure, each module is initialized.
After initialization the layout has a basic skeleton; the next step is to fill each functional area with its specific features.
Render Year/Month Tab Area
If we were using plain DOM this would be simple; with Konva we need to define the structure:
Gray background Rect covering the whole tab width.
White selected background Rect occupying the width of the selected item.
Text for each option.
Click event that moves the white background Rect and adds animation.
export type Iunit = 'WEEK' | 'MONTH' | 'QUARTER' | 'YEAR';
export class DateRange {
readonly container: Konva.Group;
// 周|月|季|年
unit: Iunit = 'MONTH';
unitMap = new Map([
['WEEK', { name: '周', x: 28, index: 0 }],
['MONTH', { name: '月', x: 88, index: 1 }],
['QUARTER', { name: '季', x: 148, index: 2 }],
['YEAR', { name: '年', x: 208, index: 3 }],
]);
constructor(private readonly core: Core) {
this.unit = 'MONTH';
this.container = new Konva.Group({
x: this.core.config.containerWidth - 260,
});
const bgcolor = new Konva.Rect({
width: 245,
height: 30,
fill: 'rgba(235,236,237,1)',
opacity: 1,
cornerRadius: 2
})
const activeBgColor = new Konva.Rect({
x: 63,
y: 3,
width: 60,
height: 24,
// 白色
fill: 'rgba(255,255,255,1)',
opacity: 1,
cornerRadius: 2
});
this.container.add(bgcolor, activeBgColor);
const values = this.unitMap.values();
while (true) {
const iterator = values.next();
if (iterator.done) {
break;
}
const rect = new Konva.Rect({
x: iterator.value.x - 20,
y: 3,
width: 50,
height: 23,
fill: 'transparent',
// fill: 'red',
cornerRadius: 3
});
const text = new Konva.Text({
x: iterator.value.x,
y: 8,
width: 50,
height: 20,
fill: 'black',
text: iterator.value.name,
fontSize: 14
})
const itemGroup = new Konva.Group();
itemGroup.on('click', () => {
activeBgColor.to({
x: iterator.value.index * 60 + 3,
easing: Konva.Easings.StrongEaseOut,
duration: 0.2
})
})
itemGroup.add(rect, text);
this.container.add(itemGroup);
}
// 鼠标进入
this.container.on('mouseenter', () => this.core.cursor('pointer'));
// 鼠标离开
this.container.on('mouseleave', () => this.core.cursor('default'));
}
}Render Column Header Dates and Body (Month as Unit)
Only the visible Rect nodes need to be rendered, but we also need to know task Y positions for hover interactions. Using the Konva devtools we observed that the rendered area consists of multiple hidden‑border Rects and vertical date lines, so we replicate the parameters.
Render a table‑like structure with prepared parameters.
export class Config {
// 列数量
columnCount = 0;
// container的 offsetX
offsetX = 0;
offsetY = 0;
// 行高
rowHeight = 33;
// 列宽
columnWidth = 60;
// 行数量
rowCount = 40;
// 画布的宽度
containerWidth = 1080;
// 画布的高度
containerHeight = 600;
// 开始时间
startDate = "2024-08-22";
// 结束时间
endDate = "2025-09-25";
// 挂载节点
container = '.container';
// 模式
mode: 'edit' | 'read' = 'edit'
constructor(config?: Partial
) {
Object.assign(this, config);
this.columnCount = DatePostion.calculateColumnCount(this.startDate, this.endDate);
}
update(config: Partial
) {
Object.assign(this, config);
}
}Draw the table while taking scroll offsets into account, rendering only the visible cells; a dedicated render class handles this logic.
The key function is getDrawConfig , which, given offsetX, offsetY, container dimensions, etc., returns the cells that should appear in the viewport.
getDrawConfig函数,({ initRowCount }: Partial<{ initRowCount?: number }>) {
const {
offsetX,
offsetY,
containerHeight,
containerWidth: width,
startDate,
endDate,
columnCount
} = this.config;
const containerWidth = width - 20;
const rowHeight = () => this.config.rowHeight;
const columnWidth = () => this.config.columnWidth;
const rowCount = initRowCount || this.config.rowCount;
const rowStartIndex = getRowStartIndexForOffset({
itemType: "row",
rowHeight,
columnWidth,
rowCount,
columnCount,
instanceProps: this.instanceProps,
offset: offsetY,
});
const rowStopIndex = getRowStopIndexForStartIndex({
startIndex: rowStartIndex,
rowCount,
rowHeight,
columnWidth,
scrollTop: offsetY,
containerHeight,
instanceProps: this.instanceProps,
});
const columnStartIndex = getColumnStartIndexForOffset({
itemType: "column",
rowHeight,
columnWidth,
rowCount,
columnCount,
instanceProps: this.instanceProps,
offset: offsetX,
});
const columnStopIndex = getColumnStopIndexForStartIndex({
startIndex: columnStartIndex,
columnCount,
rowHeight,
columnWidth,
scrollLeft: offsetX,
containerWidth,
instanceProps: this.instanceProps,
});
const items = [];
if (columnCount > 0 && rowCount) {
for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) {
for (
let columnIndex = columnStartIndex;
columnIndex <= columnStopIndex;
columnIndex++
) {
const width = getColumnWidth(columnIndex, this.instanceProps);
const x = getColumnOffset({
index: columnIndex,
rowHeight,
columnWidth,
instanceProps: this.instanceProps,
});
const height = getRowHeight(rowIndex, this.instanceProps);
const y = getRowOffset({
index: rowIndex,
rowHeight,
columnWidth,
instanceProps: this.instanceProps,
});
const date = this.getDateFromX(x);
items.push({
x,
y,
width,
height,
rowIndex,
columnIndex,
date: date.date,
title: date.title,
name: `container_cell ${date.date}`,
isWeak: this.isWeekend(new Date(date.date)),
key: itemKey({ rowIndex, columnIndex }),
});
}
}
}
// this.config.update({ columnCount });
this.columnStartIndex = columnStartIndex;
this.columnStopIndex = columnStopIndex;
this.itemCells = items;
}Calling this function yields all cell data, which is then passed to the rendering functions for column headers and cells.
draw() {
const { containerGroup, headerTextGroup } = this.staticLayer;
containerGroup.removeChildren();
headerTextGroup.removeChildren();
this.getDrawConfig({});
const items = this.itemCells;
const columnStartIndex = this.columnStartIndex;
const columnStopIndex = this.columnStopIndex;
for (let index = 0; index <= (columnStopIndex - columnStartIndex); index++) {
const colindex = items[index];
const text = new Konva.Text({
x: colindex.x + colindex.width / 2,
y: -14,
text: colindex.date.slice(-4),
fontSize: 12,
fill: 'rabg(0,0,0,1)',
name: 'text',
key: colindex.key,
})
text.setAttr('offsetX', text.width() / 2) // center text
headerTextGroup.add(text)
}
items.forEach(({ x, y, width, height, rowIndex, columnIndex, name, key, isWeak, date }) => {
containerGroup.add(new RectBorderNode({
x,
y,
date,
height,
width,
hitStrokeWidth: 1,
strokeWidth: 0.2,
fill: isWeak ? 'rgba(243,245,247,1)' : '#FFF',
name,
key,
listening: false,
strokeBottomColor: '#C0C4C9',
strokeTopColor: '#C0C4C9',
strokeRightColor: 'rgba(201,192,196 , 0.3)',
strokeLeftColor: 'rgba(201,192,196 , 0.3)',
}))
})
}The rendered result looks like the following image.
Because the original Tencent Docs display only vertical lines, we set the Rect fill to white and render a few vertical lines to achieve the expected visual.
Complete Basic Rendering – Now Add Dynamic Parts (Horizontal Scrollbar)
First look at the horizontal scrollbar implementation to determine its width.
// dragmove updates the table and re‑draws
verticalBarRect.on('dragmove', (event) => {
const scrollbarX = event.target.x();
const scrollRatio = scrollbarX / maxScrollbarX;
const tempScrollLeft = scrollRatio * maxScroll;
this.core.moveOffsetX(tempScrollLeft);
});
verticalBarRect.on("dragend", () => {
this.render.draw();
});
// core.ts
moveOffsetX(offsetX: number) {
this.config.update({ offsetX })
this.staticLayer.containerGroup.x(-offsetX);
this.staticLayer.headerTextGroup.x(-offsetX);
this.render.scrollX();
}
// render.ts
scrollX() {
this.draw(); // re‑render
this.makerManager.update();
this.taskManager.moveX();
}During dragging we update the offsetX and then call draw() to re‑render.
We simulate an animation that continuously updates the scroll position.
setTimeout(() => {
this.request();
}, 200);
private x = 0;
request() {
requestAnimationFrame(() => {
if (this.config.offsetX > 800) {
return;
}
this.x += 2;
batchDrawManager.batchDraw(() => this.moveOffsetX(this.x))
this.request();
})
}Profiling shows a “Partially Presented Frame” issue and higher CPU usage because too much work is done per frame.
Optimize Scrolling Rendering Performance
1. Use Konva’s listening: false during dragmove to disable event handling on layers and groups, re‑enabling it on dragend.
2. Reduce node count during scrolling. Since the Gantt chart is not interactive while scrolling, we render only one row of cells and reuse its Rects, keeping visual consistency while dramatically cutting the number of nodes.
On scroll start: generate a single row of cells and draw only its Rects.
On scroll end: call the original draw function to generate all cells.
We added an animationDraw function that calls this.getDrawConfig({ initRowCount: 1 }) to produce just one row.
animationDraw() {
const {
staticLayer: { containerGroup, headerTextGroup },
columnStartIndex,
columnStopIndex,
itemCells: items,
} = this;
containerGroup.destroyChildren();
headerTextGroup.destroyChildren();
this.getDrawConfig({ initRowCount: 1 });
// ... render the single row ...
}After the optimization the performance profile improves noticeably.
Maker and Task Rendering
Several classes manage makers and tasks. During scrolling the manager’s update method updates the X coordinate of each maker.
Tasks are similar but also support drag‑to‑resize when mode = 'edit' . Permission configuration determines whether a task can be modified.
When there is no permission, a basic task instance is created; with permission, a ResizeTask extends the basic task with resize capability.
Vertical Scrolling
The vertical scrollbar updates offsetY and calls taskManager.moveY() to adjust each task’s Y coordinate. Cells are not re‑rendered during scrolling; they are refreshed only after scrolling ends, following the same performance strategy.
Publish‑Subscribe API
A well‑designed SDK should expose events to the caller. Example:
const gantt = new Gantt();
gantt.API.on("tapTask", (params) => {
console.log('params', params);
});
gantt.API.on("rightMenuTask", (params) => {
console.log('params', params);
});Additional Notes
Further work includes making the Config class fully configurable by the caller, handling parent‑child node relationships, and other extensions.
const gantt = new Gantt({
mode: 'edit',
startDate: '2024-10-30'
});
gantt.API.setData({
makers: [{ startDate: '2024-10-30' }],
tasks: [
{...}
]
});
gantt.API.on("tapTask", (params) => { console.log('params', params); });
gantt.API.on("rightMenuTask", (params) => { console.log('params', params); });Conclusion
The article presented functional analysis and performance optimization of a Konva‑based Gantt chart. The source code will be cleaned and open‑sourced later for readers to extend.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.