Public
Edited
Mar 13, 2024
19 forks
Importers
32 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function Gantt(_data, {
// Data Accessors
key = (d, i) => i, // given d in data, return a unique key
start = (d, i) => d.start, // given d in data, return the start of the bar as a date or number
end = (d, i) => d.end, // given d in data, return the end of the bar as a date or number
lane = (d, i) => 0, // given d in data, return the lane the bar belongs to
color = (d, i) => 'black', // given d in data, return the color of the bar (might be a d3.scale of some sort, or just a function)
label = undefined, // given d in data, return the label for the bar if desired
labelMinWidth = 50, // Minimum bar width to allow writing labels on.
title = undefined, // given d in data, return the title to displayed on hover.
layout = assignLanes, // Function to use for layout of bars into lanes and rows. Defaults to assignlanes
// Chart Config
margin = {top: 30, right:20, bottom: 30, left: 20, laneGutter: 120}, // Standard d3 margin convention, plus an extra bit of space to write lane names.
width = 600, // Width of chart
fixedRowHeight = true, // Whether to use a fixed row height, otherwise specify total height and work it out dynamically.
height = null, // Height of chart. Leave undefined and use rowHeight to have the height determined by the number of lanes required * rowHeight
rowHeight = 50, // Height of an individual row. Determines the overall chart height if you dont otherwise constrain height.
roundRadius = 4, // Rounded corner radius for bars.
showLaneBoundaries = true, // Whether to draw dividing lines for swim lanes
showLaneLabels = 'left', // Whether to label lanes, enter left, right, or false
xScale = d3.scaleTime(), // Kind of scale to use for the x axis, choose d3.scaleTime for dates, or d3.scaleLinear for numbers as your start and end values.
xDomain = undefined, // Constrain the x axis by providing a domain to clip it to.
yPadding = 0.2, // Padding between rows (float from 0-1)
xPadding = 5, // Padding between bars in the same row (pixels)
showAxis = true,
svg = undefined, // An existing svg element to insert the resulting content into.
// Supplemental data.
referenceLines = [], // Can be an array of {start: Date, label: string, color: string} objects.
} = {}) {
// SETUP
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},
})
}
Insert cell
Insert cell
function assignRows(data, {
start = (d, i) => d.start, // given d in data, return the start of the bar as a date or number
end = (d, i) => d.end, // given d in data, return the end of the bar as a date or number
xScale = d3.scaleTime(), // Scale used to add padding to bars
xPadding = 0, // Padding between bars in pixels.
monotonic = false, // If set to true assign an new monotically increasing row to EVERY bar.
} = {}) {
// Algorithm used to assign bars to lanes.
const slots = [];
const findSlot = (slots, barStart, barEnd) => {
// Add some padding to bars to leave space between them
// Do comparisons in pixel space for cleaner padding.
const barStartPx = Math.round(xScale(barStart));
const barEndPaddedPx = Math.round(xScale(barEnd) + xPadding);
for (var i = 0; i < slots.length; i++) {
if ((slots[i][1] <= barStartPx) && !monotonic) {
slots[i][1] = barEndPaddedPx;
return slots[i][0];
}
}
// Otherwise add a new slot and return that.
slots.push([slots.length, barEndPaddedPx]);
return slots.length - 1;
};

return data
.sort((a, b) => Number(start(a)) - Number(start(b))) // Sort by the date.
.map(d => ({...d, rowNo: findSlot(slots, start(d), end(d))}))

}
Insert cell
Insert cell
function assignLanes(data, {
start = (d, i) => d.start, // given d in data, return the start of the bar as a date or number
end = (d, i) => d.end, // given d in data, return the end of the bar as a date or number
lane = (d, i) => 0, // given d in data, return the swim lane it belongs to,
xScale = d3.scaleTime(), //padding between rows.
xPadding = 0,
monotonic = false, // Monotonically increase lane number, dont fill existing lanes with more bars.
} = {}) {
// Assign rows, but grouped by some keys so that bars are arranged in groups belonging to the same lane.
const groups = d3.flatGroup(data, lane)
const newData = [];
var rowCount = 0;
groups.forEach((g,i) => {
const [laneName, _groupData] = g;
// For each group assign rows.
const groupData = assignRows(_groupData, {start, end, xScale, xPadding, monotonic});
groupData.forEach(d => {
newData.push({
...d,
lane: laneName,
laneNo: i,
rowNo: rowCount + d.rowNo,
});
// Offset future rows by the maximum row number from this gorup.
})
rowCount += d3.max(groupData.map(d => d.rowNo)) + 1;
})
return newData;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

{
// Update the chart (rather than reinitialising it) when we change the options.
chart.gantt._update.bars(archigos.filter(d => countries.includes(d.countryname)), 1000);
// Replace titles with a tooltip at the bottom of the chart.
const tooltip = new Tooltip();
const bars = d3.select(chart).select('.gantt__group-bars').selectAll('g');
bars
.on('mouseover', (e, d) => tooltip.show(e, d, chart.gantt))
.on('mouseout', (e, d) => tooltip.hide());
bars.select('title').remove();
d3.select(chart).select('svg').append(() => tooltip.g.node());

// Set some style for our chart.
return html`
<style type="text/css">
.gantt {
font-family: "Roboto", sans-serif;
}
</style>
`
}
Insert cell
Insert cell
Insert cell
// Update the chart (rather than reinitialising it) when we change the options.
chart.gantt._color((d, i) => cm(d[colorBy]));

Insert cell
class Tooltip {
constructor() {
this.g = d3.create('svg:g')
.attr('pointer-events', 'none')
.attr('display','none')
.attr('font-size', '12px')
.attr('text-anchor','middle')
this._title = this.g.append('text').attr('y', 10);
}
show(e, d, gantt) {
const midpoint = ( gantt._scales.x(gantt._start()(d)) + gantt._scales.x(gantt._end()(d)) ) / 2
const midpointClipped = Math.round(Math.min(Math.max(midpoint, 200), gantt._width() - 200)); // Make sure it doesnt go too far off either end.
this.g.attr("display", null);
this.g.attr("transform", `translate(${midpointClipped}, ${gantt._height() - 15})`);
this._title.text(gantt._title()(d));
}
hide() {
this.g.attr("display", "none");
}
}
Insert cell
import {Legend, Swatches} from "@d3/color-legend"
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more