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:

order milestones on a timeline
scale milestones to fit in a viewport
create pretty connection lines
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 } });