Created
April 12, 2020 00:35
-
-
Save hyponymous/3e19f760a4215b9b18fbcc58fdd9e7b1 to your computer and use it in GitHub Desktop.
Multilane Timeline in d3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<head> | |
<meta charset="utf-8"> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js"> </script> | |
<script src="https://d3js.org/d3.v5.min.js"></script> | |
<style type="text/css"> | |
.timeline { | |
pointer-events: all; | |
} | |
.label { | |
font-family: "Helvetica Neue", sans-serif; | |
font-size: 9px; | |
} | |
.tick { | |
font-family: "Helvetica Neue", sans-serif; | |
font-size: 11px; | |
} | |
.gridline line { | |
stroke: lightgrey; | |
stroke-opacity: 0.7; | |
shape-rendering: crispEdges; | |
} | |
.gridline path { | |
stroke-width: 0; | |
} | |
</style> | |
</head> | |
<body> | |
<script> | |
const parseDate = dStr => dStr ? Date.parse(dStr) : new Date(); | |
const isInterval = d => d.type === 'interval' | |
const genTimeline = (parent, data) => { | |
const flattenedData = _.flatMap(data, lane => lane.events.map(d => ({ | |
...d, | |
swimlane: lane.swimlane, | |
}))) | |
const allDates = _.chain(flattenedData) | |
.filter(isInterval) | |
.flatMap(i => [i.start, i.end]) | |
.compact() | |
.map(parseDate) | |
.sortBy() | |
.value() | |
const totalWidth = d3.select(parent) | |
.node() | |
.getBoundingClientRect().width; | |
// set the dimensions and margins of the graph | |
const margin = {top: 20, right: 20, bottom: 30, left: 40}, | |
width = totalWidth - margin.left - margin.right, | |
height = 80 * data.length - margin.top - margin.bottom; | |
// append the svg object to the body of the page | |
// append a 'group' element to 'svg' | |
// moves the 'group' element to the top left margin | |
const svg = d3.select(parent).append('svg') | |
.attr('class', 'timeline') | |
.attr('width', width + margin.left + margin.right) | |
.attr('height', height + margin.top + margin.bottom) | |
.append('g') | |
.attr('transform', | |
'translate(' + margin.left + ',' + margin.top + ')'); | |
const timeRange = new Date() - allDates[0]; | |
const x_ = d3.scaleTime() | |
.domain([allDates[0] - 0.005 * timeRange, new Date()]) | |
.range([0, width]); | |
const y_ = d3.scaleBand() | |
.domain(data.map(d => d.swimlane)) | |
.range([0, height]) | |
.padding(0.82); | |
let x = x_ | |
let y = y_ | |
// x gridlines; | |
const xGridlines = svg.append('g') | |
.attr('class', 'gridline') | |
.attr('transform', 'translate(0,' + height + ')'); | |
const interval = svg.selectAll('.interval') | |
.data(flattenedData.filter(isInterval)) | |
.enter().append('g') | |
.attr('class', 'interval'); | |
const bar = interval.append('rect') | |
.attr('height', y.bandwidth()) | |
.style('fill', (d, i) => d.color || d3.schemeCategory10[i % 10]) | |
interval.append('text') | |
.attr('class', 'label') | |
.attr('dx', 0) | |
.attr('dy', 2 * y.bandwidth()) | |
.text(d => d.label) | |
// add the x Axis | |
const xAxis = svg.append('g') | |
.attr('transform', 'translate(0,' + height + ')'); | |
// add the y Axis | |
svg.append('g') | |
.call(d3.axisLeft(y)); | |
const draw = () => { | |
interval | |
.attr('transform', d => `translate(${ | |
x(parseDate(d.start)) | |
}, ${ | |
y(d.swimlane) | |
})`) | |
bar | |
.attr('width', d => x(parseDate(d.end)) - x(parseDate(d.start))) | |
xGridlines.call(d3.axisBottom(x) | |
.tickSize(-height) | |
.tickFormat('')) | |
xAxis.call(d3.axisBottom(x) | |
.tickArguments(d3.timeYear.every(1))) | |
} | |
draw(); | |
svg.call( | |
d3.zoom() | |
.scaleExtent([1.0, 100.0]) | |
.on('zoom', () => { | |
const transform = d3.event.transform; | |
x = transform.rescaleX(x_) | |
draw(); | |
})) | |
} | |
genTimeline('body', [ | |
{ | |
"swimlane": "live", | |
"events": [ | |
{ | |
"type": "comment", | |
"value": "this is where I (used to) live" | |
}, | |
{ | |
"type": "interval", | |
"start": "2010/01/15", | |
"end": "2012/12/19", | |
"label": "Truffle Parlor", | |
"color": "#61813c" | |
}, | |
{ | |
"type": "interval", | |
"start": "2012/12/20", | |
"end": "2016/03/31", | |
"label": "Gumdrop Backwater", | |
"color": "#ead8e4" | |
}, | |
{ | |
"type": "interval", | |
"start": "2016/04/01", | |
"label": "The Obtrusive Saloon", | |
"color": "#908e72" | |
} | |
] | |
}, | |
{ | |
"swimlane": "work", | |
"events": [ | |
{ | |
"type": "comment", | |
"value": "this is where I (used to) work" | |
}, | |
{ | |
"type": "interval", | |
"start": "2010/01/18", | |
"end": "2010/11/31", | |
"label": "Pencil Fax Co." | |
}, | |
{ | |
"type": "interval", | |
"start": "2010/12/01", | |
"end": "2013/10/07", | |
"label": "Overbill Chatroom Ltd." | |
}, | |
{ | |
"type": "interval", | |
"start": "2014/05/26", | |
"label": "Spousal Recapture Inc." | |
} | |
] | |
} | |
]); | |
</script> | |
</body> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment