function Gantt(_data, {
key = (d, i) => i,
start = (d, i) => d.start,
end = (d, i) => d.end,
lane = (d, i) => 0,
color = (d, i) => 'black',
label = undefined,
labelMinWidth = 50,
title = undefined,
layout = assignLanes,
margin = {top: 30, right:20, bottom: 30, left: 20, laneGutter: 120},
width = 600,
fixedRowHeight = true,
height = null,
rowHeight = 50,
roundRadius = 4,
showLaneBoundaries = true,
showLaneLabels = 'left',
xScale = d3.scaleTime(),
xDomain = undefined,
yPadding = 0.2,
xPadding = 5,
showAxis = true,
svg = undefined,
referenceLines = [],
} = {}) {
if (svg === undefined) svg = d3.create('svg')
.attr('class','gantt')
.attr('width', width);
if (!fixedRowHeight) svg.attr('height', height);
const axisGroup = svg.append('g')
.attr('class','gantt__group-axis').attr('transform',`translate(0, ${margin.top})`);
const barsGroup = svg.append('g')
.attr('class','gantt__group-bars');
const lanesGroup = svg.append('g')
.attr('class','gantt__group-lanes');
const referenceLinesGroup = svg.append('g')
.attr('class','gantt_group-reference-lines');
var x = xScale.range([
margin.left + (showLaneLabels === 'left'? margin.laneGutter : 0),
width - margin.right - (showLaneLabels === 'right' ? margin.laneGutter : 0)
]);
var y = d3.scaleBand()
.padding(yPadding)
.round(true);
function updateReferenceLines(referenceLines) {
// Update reference lines
referenceLinesGroup.selectAll('g').data(referenceLines).join(
enter => {
const g = enter.append('g')
.attr('transform', d => `translate(${x(d.start)}, 0)`);
g.append("path")
.attr("d", d3.line()([[0, margin.top],[0, height - margin.bottom]]))
.attr("stroke", (d) => d.color || "darkgrey")
.attr("stroke-dasharray", "10,5");
g.append("text")
.text((d) => d.label || "")
.attr("x", 5)
.attr("y", height - margin.bottom + 10)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "bottom")
.attr("font-size", "0.75em")
.attr("fill", (d) => d.color || "darkgrey");
return g;
},
update => {
update.attr('transform', d => `translate(${x(d.start)}, 0)`)
update.select('path')
.attr("d", d3.line()([[0, margin.top],[0, height - margin.bottom]]))
.attr("stroke", (d) => d.color || "darkgrey")
update.select('text')
.text((d) => d.label || "")
.attr("y", height - margin.bottom + 10)
.attr("fill", (d) => d.color || "darkgrey");
return update;
},
exit => {
exit.remove();
}
)
}
function updateBars(_newData, duration=0) {
// Persist data|
_data = _newData;
// Create x scales using our raw data. Since we need a scale to map it with assignLanes
const xDomainData = [
d3.min([..._data.map(d=> start(d)), ...referenceLines.map(d => d.start)]),
d3.max([..._data.map(d=> end(d)), ...referenceLines.map(d => d.start)]),
];
// Update the x domain
x.domain(xDomain || xDomainData).nice();
// Map our _data to swim lanes
const data = layout(_data, {start: start, end: end, lane: lane, xScale: x, xPadding: xPadding});
const nRows = d3.max(data.map(d => d.rowNo + 1));
// Calculate the height of our chart if not specified exactly.
if (fixedRowHeight) {
height = (rowHeight * nRows) + margin.top + margin.bottom;
svg.attr('height', height);
} else {
rowHeight = (height - margin.top - margin.bottom) / nRows
}
// Update the yDomain
const yDomain = [... new Set(data.map(d => d.rowNo))];
y.domain(yDomain)
.range([margin.top, height - margin.bottom]);
function barLength(d, i, shrink=0.0) {
return Math.max(
Math.round(x(end(d)) - x(start(d)) - shrink),
0
); // Subtract 2 for a pixels gap between every bar.
}
// Update bars
barsGroup.selectAll('g').data(data, (d, i) => key(d, i)).join(
enter => {
const g = enter.append('g');
g
// It looks nice if we start in the correct y position and scale out
.attr('transform', d => `translate(${width / 2}, ${y(d.rowNo)})`)
.transition()
.ease(d3.easeExpOut)
.duration(duration)
.attr('transform', d => `translate(${x(start(d))}, ${y(d.rowNo)})`)
;
const rect = g.append('rect')
.attr('height', y.bandwidth())
.attr('rx', roundRadius)
.attr('fill', d => color(d))
.attr('stroke', 'white')
.attr('stroke-width', 1)
.transition()
.duration(duration)
.attr('width', d => barLength(d))
if (title !== undefined) {
g.append('title').text(d => title(d));
}
if (label !== undefined) {
// Add a clipping path for text
const slugify = (text) => text.toString().toLowerCase().split(/[^a-z0-9]/).filter(d => d).join('-');
const clip = g.append('clipPath')
.attr('id', (d, i) => `barclip-${slugify(key(d, i))}`)
.append('rect')
.attr('width', (d, i) => barLength(d,i,4))
.attr('height', y.bandwidth())
.attr('rx', roundRadius)
g.append('text')
.attr('x', Math.max(roundRadius*0.75, 5))
.attr('y', y.bandwidth() / 2)
.attr("dominant-baseline", "middle")
.attr("font-size", d3.min([y.bandwidth() * 0.6,16]))
.attr("fill", "white")
.attr('visibility', d => barLength(d) >= labelMinWidth ? 'visible' : 'hidden') // Hide labels on short bars
.attr('clip-path', (d, i) => `url(#barclip-${slugify(key(d, i))}`)
.text(d => label(d))
}
return g;
},
update => {
update
.transition()
.duration(duration)
.attr('transform', d => `translate(${x(start(d))}, ${y(d.rowNo)})`);
update.select('rect')
.transition()
.duration(duration)
.attr('fill', d => color(d))
.attr('width', d => barLength(d))
.attr('height', y.bandwidth());
if (title !== undefined) {
update.select('title').text(d => title(d));
}
if (label !== undefined) {
update.select('clipPath').select('rect')
.transition()
.duration(duration)
.attr('width', (d, i) => barLength(d,i,4))
.attr('height', y.bandwidth())
update.select('text')
.attr('y', y.bandwidth() / 2)
.attr("font-size", d3.min([y.bandwidth() * 0.6,16]))
.attr('visibility', d => barLength(d) >= labelMinWidth ? 'visible' : 'hidden') // Hide labels on short bars
.text(d => label(d))
}
return update;
},
exit => {
exit.remove();
}
);
if (showLaneBoundaries) {
const lanes = d3.flatRollup(data, v => d3.max(v, d => d.rowNo), d => lane(d));
lanesGroup.selectAll('g').data(lanes).join(
enter => {
const g = enter.append('g').attr('transform', d => `translate(0, ${y(d[1]) + y.step() - y.paddingInner() * y.step() * 0.5 })`);
g.append("path")
.attr("d", d3.line()([[margin.left, 0], [width - margin.right, 0]]))
.attr("stroke", "grey");
if (showLaneLabels) {
g.append("text")
.text((d) => d[0])
.attr("x", showLaneLabels === 'left' ? margin.left + 5 : showLaneLabels === 'right' ? width - margin.right - 5 : 0)
.attr("y", -5)
.attr("text-anchor", showLaneLabels === 'left' ? "beginning" :"end")
.attr("dominant-baseline", "bottom")
.attr("font-size", "0.75em")
.attr("fill", "grey");
}
return g;
},
update => {
update
.transition()
.duration(duration)
.attr('transform', d => `translate(0, ${y(d[1]) + y.step() - y.paddingInner() * y.step() * 0.5 })`);
update.select('text').text((d) => d[0])
return update;
},
exit => {
exit.remove();
}
)
}
// Draw axis
if (showAxis) {
axisGroup
.transition()
.duration(duration)
.call(d3.axisTop(x))
}
// Update the reference lines, since our axis has adjusted
updateReferenceLines(referenceLines);
}
updateBars(_data, 0);
return Object.assign(
svg.node(),
{
_key: (f) => {
if (f === undefined) return key;
key = f;
updateBars(_data);
return key;
},
_start: (f) => {
if (f === undefined) return start;
start = f;
updateBars(_data);
return start;
},
_end: (f) => {
if (f === undefined) return end;
end = f ;
updateBars(_data);
return end;
},
_lane: (f) => {
if (f === undefined) return lane;
lane = f;
updateBars(_data);
return lane;
},
_color: (f) => {
if (f === undefined) return color;
color = f;
updateBars(_data);
return color;
},
_title: (f) => {
if (f === undefined) return title;
title = f;
updateBars(_data,);
return title;
},
_label: (f) => {
if (f === undefined) return label;
label = f;
updateBars(_data);
return label;
},
_height: (f) => {
if (f === undefined) return height;
height = f;
updateBars(_data);
return height;
},
_width: (f) => {
if (f === undefined) return width;
width = f;
updateBars(_data);
return width;
},
_margin: (f) => {
if (f === undefined) return margin;
margin = f;
updateBars(_data);
return margin;
},
_scales: {x, y},
_update: {bars: updateBars, referenceLines: updateReferenceLines},
})
}