Building our new Gantt chart
Our old Gantt chart served us well for the past six years. It was doing what it was designed to do, but some of the things we wanted to add were either impossible or incredibly difficult to accomplish. For example, giving customers more control over their data by not forcing features and other records to stay contained within the dates of their parents. Or allowing customers to order the records the way they wanted, to choose which records appear and which ones don’t, and many more. Internally, we also wanted to support more data types so that we could reuse the same Gantt chart for multiple views. So we decided it was finally time to completely overhaul it. Here are some of the lessons we learned as we built this great tool.
All code samples are greatly simplified to keep the article readable.
Be shallow about what you put in your Redux store
One of the things we wished we learned earlier was to keep our Redux store objects as shallow as possible. Early on in the project, an object would look almost the same in the backend as in the Redux store. At first, it seemed fine but after struggling with one too many (not to be confused with one-to-many) bugs, we decided to move some of the object properties out of the objects themselves and into their own key-value pair in the store.
Before getting into the examples, let’s take a high-level look at the data structure. The Gantt chart has records with attributes and children that are themselves records with attributes and children, etc.
records: {
recordId: {
...attributes,
children: {
childId: { ...attributes, children: [...] },
…
},
},
…
}
Let’s look at the feature that allows you to hide specific records from the Gantt chart. Our initial naive implementation was to add an isVisible
attribute to the records:
class Record {
constructor(isVisible = true) {
this.isVisible = isVisible;
}
}
This looks fine at first glance, but when we wanted to update a record’s visibility, we had to dig into the records tree:
const setIsVisible = (record, isVisible) => {
state = state.setIn([records, parent.id, ‘children’, record.id, ‘isVisible’], isVisible);
};
And that’s for records nested one level deep. If they’re nested deeper, then we had to figure out the whole path to the record with things like:
record.parents.map(parent => parent.id).interpose(‘children’)
It didn’t make for the most readable code. We also had to maintain the value even when the parents and/or grandparents were updated, manipulated, etc.
We eventually found a much better way to handle this. We decided to keep a separate list of hidden records and query that list whenever we wanted to know if a record was visible or not:
let state = Immutable.fromJS({
hiddenRecords: Immutable.Set(),
});
const setIsVisible = (record, isVisible) => {
state = state.update(‘hiddenRecords’, set => isVisible ? set.delete(record.id) : set.add(record.id);
};
const isVisible = (record) => {
return !state.get(‘hiddenRecords’).has(record.id);
};
Once we stumbled onto that pattern, we applied it to a number of other properties with great success. We added lists for expanded records, active records, selected records, and more.
It took some getting used to as we adjusted our mental model from working with an object-oriented system to a hybrid that is more functional.
SVG works great to draw a grid
If you look closely at our Gantt chart, you’ll see vertical lines separating the weeks, darker areas to show the weekends, and a red line to show where today is.
We used to populate the DOM with hundreds of DIVs with various borders to achieve the result we wanted. As an experiment, we tried constructing an SVG image dynamically and it turned out great: it was easy to implement, the code is clean, and the output is small compared to our previous DIV solution.
// All the shapes that make the final SVG image
const shapes = [];
// week dividers
for(let week = firstWeek, x = 0; week <= lastWeek; week++, x += pixelsPerWeek) {
shapes.push(
`<line id="week-${week}" x1="${x}" x2="${x}" y1="0" y2="100" stroke="rgb(204,204,204)" stroke-width="0.5" />`
);
}
// today
shapes.push(
`<rect id="today" x="${pixelsToToday}" y="0" width="2" height="100" fill="rgba(255,0,0,0.3)" />`
);
// ...and finally build the CSS style
return {
background: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="${timelineWidth}" height="100">${shapes.join(‘’)}</svg>') repeat-y`,
};
Migrating from the original Gantt chart to the new one
Our new Gantt chart behaves differently in a number of important ways and has a different data structure than the original one, so how do we get new users to use it? We didn’t want to tell them to re-enter the same data in the new one because most of them wouldn’t have done it. We also didn’t want to keep the original one and maintain both at the same time. The only solution was to find a way to “migrate” from the original one to the new one.
To accomplish this, we created a migration service that is run automatically when someone tries to access their original Gantt chart, converts it into the new one, and then redirects to it. We looked at every single configuration attribute in both implementations, found how they mapped between each other, and implemented the logic. We also made sure to preserve the original configuration in case we did something wrong. (Nothing major happened.) The migration service was inspired by the Rails database migration. Here’s what it looks like:
class LegacyGanttChartMigration
def upgrade
preserve_legacy_configuration
convert_date_attributes
convert_expanded_records
# … a few more convert calls
hide_features_without_dates
end
def downgrade
# revert the configuration preserved above
end
end
# and to migrate, all we had to do was call this
gantt_chart = LegacyGanttChartMigration.new(gantt_chart).upgrade
The migration was easy to test, easy to read, and worked like a charm.
Why we don’t use React Virtualized
When you read the description of React Virtualized, it looks like a perfect match for our Gantt chart. They even have a demo for a MultiGrid that behaves very closely to what we were looking for: locked (or frozen) rows at the top, locked columns on the left, and a main grid that can scroll in both directions and stay aligned with the locked rows and columns. That’s why we initially implemented our new Gantt chart with it. Unfortunately, we had a lot of difficulties trying to keep all the parts working nicely together. We had issues with scroll bars in the panels that messed up the alignment, and no matter what we tried, we couldn’t figure out how to work around those issues. We even tried only using ScrollSync but there was always a tiny delay that made moving around the Gantt chart feel bad.
So in the end, we completely dropped the library and implemented what we needed ourselves. The Gantt chart is split into three main panels. When the user scrolls, all we have to do is synchronize the scroll position to the relevant panels. For performance-intensive features such as drag-and-drop, we implemented our own windowing to only render what’s visible on the screen to avoid costly computation for objects that aren’t visible anyway.
This isn’t the end
We’re continuously adding features and fixing bugs on our Gantt chart. As we’ve discovered new patterns and ways to do things, we’ve built a decent backlog of things we’d like to improve. Our customers also provide us with invaluable feedback that we always take into account when deciding what to do next. If there’s something missing from our Gantt chart, let us know. If you’d like to help us build it, we’re always looking for new talent.