At work, I’ve had a task to implement a Gantt chart diagram to show dependencies and order of some… let’s say, milestones. Given this feature is in a very unstable beta in Google Charts, I thought to myself: “Why don’t I implement it on my own?”. And tried to recall my D3 knowledge.

I’ve also found a minimalistic, but helpful example / screenshot of some Gantt chart implementation:

The challenges I’ve faced were:

  1. order milestones on a timeline
  2. scale milestones to fit in a viewport
  3. create pretty connection lines
  4. center text inside each milestone

And since D3 is a data-driven library, I’ve used map/reduce where possible.

Here’s how the result looked like:

The full implementation code is under the cut.

var createGanttChart = function (placeholder, data, {
itemHeight,
svgOptions
}) {
// prepare data
let minStartDate, maxEndDate;

let margin = (svgOptions && svgOptions.margin) || {
top: itemHeight * 2,
left: itemHeight * 2
};

let scaleWidth = ((svgOptions && svgOptions.width) || 600);
let scaleHeight = Math.max((svgOptions && svgOptions.height) || 200, data.length * itemHeight * 2);

scaleWidth -= margin.left * 2;
scaleHeight -= margin.top * 2;

let svgWidth = scaleWidth + (margin.left * 2);
let svgHeight = scaleHeight + (margin.top * 2);

let fontSize = (svgOptions && svgOptions.fontSize) || 12;

data = data.map(function(e) {
if ((!e.startDate || !e.endDate) && !e.duration) {
throw new Exception('Wrong element format: should contain either startDate and duration, or endDate and duration or startDate and endDate');
}

if (e.startDate)
e.startDate = moment(e.startDate);

if (e.endDate)
e.endDate = moment(e.endDate);

if (e.startDate && !e.endDate && e.duration) {
e.endDate = moment(e.startDate);
e.endDate.add(e.duration[0], e.duration[1]);
}

if (!e.startDate && e.endDate && e.duration) {
e.startDate = moment(e.endDate);
e.startDate.subtract(e.duration[0], e.duration[1]);
}

if (!minStartDate || e.startDate.isBefore(minStartDate)) minStartDate = moment(e.startDate);

if (!minStartDate || e.endDate.isBefore(minStartDate)) minStartDate = moment(e.endDate);

if (!maxEndDate || e.endDate.isAfter(maxEndDate)) maxEndDate = moment(e.endDate);

if (!maxEndDate || e.startDate.isAfter(maxEndDate)) maxEndDate = moment(e.startDate);

if (!e.dependsOn)
e.dependsOn = [];

return e;
});

// add some padding to axes
minStartDate.subtract(2, 'days');
maxEndDate.add(2, 'days');

let dataCache = data.reduce(function (acc, e) {
acc[e.id] = e;
return acc;
}, {});

let fillParents = function (eltId, result) {
dataCache[eltId].dependsOn.forEach(function (parentId) {
if (!result[parentId])
result[parentId] = [];

if (result[parentId].indexOf(eltId) < 0)
result[parentId].push(eltId);

fillParents(parentId, result);
});
};

let childrenCache = data.reduce(function (acc, e) {
if (!acc[e.id])
acc[e.id] = [];

fillParents(e.id, acc);
return acc;
}, {});

data = data.sort(function(e1, e2) {
if (childrenCache[e1.id] && childrenCache[e2.id] && childrenCache[e1.id].length > childrenCache[e2.id].length)
// if (moment(e1.endDate).isBefore(moment(e2.endDate)))
return -1;
else
return 1;
});

// create container element
let svg = d3.select(placeholder).append('svg').attr('width', svgWidth).attr('height', svgHeight);

const xScale = d3.scaleTime()
.domain([minStartDate.toDate(), maxEndDate.toDate()])
.range([0, scaleWidth]);

const xAxis = d3.axisBottom(xScale);

const g1 = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);

const linesContainer = g1.append('g').attr('transform', `translate(0,${margin.top})`);
const barsContainer = g1.append('g').attr('transform', `translate(0,${margin.top})`);

g1.append('g').call(xAxis);

let rectangleData = data.map(function (d, i) {
let x = xScale(d.startDate.toDate());
let xEnd = xScale(d.endDate.toDate());
let y = i * itemHeight * 1.5;
let width = xEnd - x;
let height = itemHeight;

let label = d.label;
let charWidth = (width / fontSize);
let dependsOn = d.dependsOn;
let id = d.id;

let tooltip = d.label;

let singleCharWidth = fontSize * 0.5;
let singleCharHeight = fontSize * 0.45;

if (label.length > charWidth) {
label = label.split('').slice(0, charWidth - 3).join('') + '...';
}

let labelX = x + ((width / 2) - ((label.length / 2) * singleCharWidth));
let labelY = y + ((height / 2) + (singleCharHeight));

return {
x,
y,
xEnd,
width,
height,
id,
dependsOn,
label,
labelX,
labelY,
tooltip
};
});

// create axes
let bars = barsContainer
.selectAll('g')
.data(rectangleData)
.enter()
.append('g');

// prepare dependencies polyline data
let cachedData = rectangleData.reduce((acc, e) => {
acc[e.id] = e;
return acc;
}, {});

let cachedIds = rectangleData.map(e => e.id);

let storedConnections = rectangleData.reduce((acc, e) => { acc[e.id] = 0; return acc }, {});

let polylineData = rectangleData.reduce(function(acc, d) {
return acc.concat(
d.dependsOn
.map(parentId => cachedData[parentId])
.map(function (parent) {
let points = [],
color = '#' + (Math.max(0.1, Math.min(0.9, Math.random())) * 0xFFF << 0).toString(16);

storedConnections[parent.id]++;
storedConnections[d.id]++;

let deltaParentConnections = storedConnections[parent.id] * (itemHeight / 4);
let deltaChildConnections = storedConnections[d.id] * (itemHeight / 4);

if (true) { // cachedIds.indexOf(parent.id) < cachedIds.indexOf(d.id)) {
// if parent is right above the current bar - put four points at different heights
points = [
d.x, (d.y + (itemHeight / 2)),
d.x - deltaChildConnections, (d.y + (itemHeight / 2)),
d.x - deltaChildConnections, (d.y - (itemHeight * 0.25)),
parent.xEnd + deltaParentConnections, (d.y - (itemHeight * 0.25)),
parent.xEnd + deltaParentConnections, (parent.y + (itemHeight / 2)),
parent.xEnd, (parent.y + (itemHeight / 2))
];
} else {
// otherwise - use three points
points = [
d.x, (d.y + (itemHeight / 2)),
d.x - deltaChildConnections, (d.y + (itemHeight / 2)),
parent.xEnd + deltaParentConnections, (d.y + (itemHeight / 2)),
parent.xEnd + deltaParentConnections, (parent.y + (itemHeight / 2)),
parent.xEnd, (parent.y + (itemHeight / 2))
];
}

return {
points: points.join(','),
color: color
};
})
);
}, []);

let lines = linesContainer
.selectAll('polyline')
.data(polylineData)
.enter()
.append('polyline')
.style('fill', 'none')
.style('stroke', d => d.color)
.attr('points', d => d.points);

bars
.append('rect')
.attr('rx', itemHeight / 2)
.attr('ry', itemHeight / 2)
.attr('x', d => d.x)
.attr('y', d => d.y)
.attr('width', d => d.width)
.attr('height', d => d.height)
.style('fill', '#ddd')
.style('stroke', 'black');

bars
.append('text')
.style('stroke', 'black')
.attr('x', d => d.labelX)
.attr('y', d => d.labelY)
.text(d => d.label);

bars
.append('title')
.text(d => d.tooltip);
};

And here’s the usage example:

var data = [{
startDate: '2017-02-27',
endDate: '2017-03-04',
label: 'milestone 01',
id: 'm01',
dependsOn: []
}, {
startDate: '2017-02-23',
endDate: '2017-03-01',
label: 'milestone 01',
id: 'm06',
dependsOn: ['m01']
}, {
duration: [7, 'days'],
endDate: '2017-03-24',
label: 'milestone 02',
id: 'm02',
dependsOn: ['m04']
}, {
startDate: '2017-02-27',
duration: [12, 'days'],
label: 'milestone 03',
id: 'm03',
dependsOn: ['m01']
}, {
endDate: '2017-03-17',
duration: [5, 'days'],
label: 'milestone 04',
id: 'm04',
dependsOn: ['m01']
}];

createGanttChart(document.querySelector('body'), data, {
itemHeight: 20,
svgOptions: {
width: 1200,
height: 400,
fontSize: 12
}
});