Skip to content

Instantly share code, notes, and snippets.

@searler
Created July 9, 2015 02:06
Show Gist options
  • Save searler/2c467845dc049fc1ac66 to your computer and use it in GitHub Desktop.
Save searler/2c467845dc049fc1ac66 to your computer and use it in GitHub Desktop.
d3 swimlane with data from server, keyboard scroll and tooltips
<html>
// derived from http://bl.ocks.org/bunkat/1962173
<head>
<title>Swimlane using d3.js</title>
<script src="./d3.js" charset="utf-8"></script>
<script src="http://labratrevenge.com/d3-tip/javascripts/d3.tip.v0.6.3.js"></script>
<style>
.chart {
shape-rendering: crispEdges;
}
.mini text {
font: 9px sans-serif;
}
.main text {
font: 12px sans-serif;
}
.month text {
text-anchor: start;
}
.todayLine {
stroke: blue;
stroke-width: 1.5;
}
.axis line, .axis path {
stroke: black;
}
.miniItem {
stroke-width: 6;
}
.receive {
stroke: gray;
fill: #ddd;
}
.transmit {
stroke: green;
fill: lightgreen;
}
.brush .extent {
stroke: gray;
fill: blue;
fill-opacity: .165;
}
.d3-tip {
line-height: 1;
font-weight: bold;
padding: 12px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 2px;
}
</style>
</head>
<body>
<script type="text/javascript">
d3.json("data.json", function (error, json) {
if (error) return console.warn(error);
json.items = json.items.map(function (obj) {
obj.start = new Date(obj.start);
obj.end = new Date(obj.end);
return obj;
});
run(json);
});
function run(data) {
var lanes = data.lanes
, items = data.items;
var margin = {top: 20, right: 15, bottom: 15, left: 60}
, width = 1060 - margin.left - margin.right
, height = 900 - margin.top - margin.bottom
, miniHeight = lanes.length * 12 + 50
, mainHeight = height - miniHeight - 50;
var minTime = d3.min(items, function (d) {
return d.start;
});
var maxTime = d3.max(items, function (d) {
return d.end;
});
var x = d3.time.scale()
.domain([minTime, maxTime])
.range([0, width]);
var x1 = d3.time.scale().range([0, width]);
var ext = d3.extent(lanes, function (d) {
return d.id;
});
var y1 = d3.scale.linear().domain([ext[0], ext[1] + 1]).range([0, mainHeight]);
var y2 = d3.scale.linear().domain([ext[0], ext[1] + 1]).range([0, miniHeight]);
var chart = d3.select('body')
.append('svg:svg')
.attr('width', width + margin.right + margin.left)
.attr('height', height + margin.top + margin.bottom)
.attr('class', 'chart');
var tip = d3.tip()
.attr('class', 'd3-tip')
.html(function (d) {
return " <span>" + d.desc + "</span>";
}).offset(function () {
var bb = this.getBBox();
if (bb.x < 0)
return [this.getBBox().height / 2, -bb.x]
else if (bb.x + bb.width > width)
return [this.getBBox().height / 2, -bb.width - 200]
else
return [this.getBBox().height / 2, 0]
});
chart.call(tip);
// clip main
chart.append('defs').append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', width)
.attr('height', mainHeight);
var main = chart.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
.attr('width', width)
.attr('height', mainHeight)
.attr('class', 'main');
var mini = chart.append('g')
.attr('transform', 'translate(' + margin.left + ',' + (mainHeight + 60) + ')')
.attr('width', width)
.attr('height', miniHeight)
.attr('class', 'mini');
// draw the lanes for the main chart
main.append('g').selectAll('.laneLines')
.data(lanes)
.enter().append('line')
.attr('x1', 0)
.attr('y1', function (d) {
return d3.round(y1(d.id)) + 0.5;
})
.attr('x2', width)
.attr('y2', function (d) {
return d3.round(y1(d.id)) + 0.5;
})
.attr('stroke', 'lightgray');
main.append('g').selectAll('.laneText')
.data(lanes)
.enter().append('text')
.text(function (d) {
return d.label;
})
.attr('x', -10)
.attr('y', function (d) {
return y1(d.id + .5);
})
.attr('dy', '0.5ex')
.attr('text-anchor', 'end')
.attr('class', 'laneText');
// draw the lanes for the mini chart
mini.append('g').selectAll('.laneLines')
.data(lanes)
.enter().append('line')
.attr('x1', 0)
.attr('y1', function (d) {
return d3.round(y2(d.id)) + 0.5;
})
.attr('x2', width)
.attr('y2', function (d) {
return d3.round(y2(d.id)) + 0.5;
})
.attr('stroke', function (d) {
return d.label === '' ? 'white' : 'lightgray'
});
mini.append('g').selectAll('.laneText')
.data(lanes)
.enter().append('text')
.text(function (d) {
return d.label;
})
.attr('x', -10)
.attr('y', function (d) {
return y2(d.id + .5);
})
.attr('dy', '0.5ex')
.attr('text-anchor', 'end')
.attr('class', 'laneText');
// draw the x axis
var miniMinutesAxis = d3.svg.axis()
.scale(x)
.orient('bottom')
.ticks(d3.time.minute, 1)
.tickFormat(d3.time.format('%M'))
.tickSize(6, 0, 0);
var miniHourAxis = d3.svg.axis()
.scale(x)
.orient('top')
.ticks(d3.time.hour, 1)
.tickFormat(d3.time.format('%H'))
.tickSize(15, 0, 0);
var maxBottomAxis = d3.svg.axis()
.scale(x1)
.orient('bottom')
.tickSize(6, 0, 0);
var maxTopAxis = d3.svg.axis()
.scale(x1)
.orient('top')
.tickSize(15, 0, 0);
main.append('g')
.attr('transform', 'translate(0,' + mainHeight + ')')
.attr('class', 'main axis date')
.call(maxBottomAxis);
main.append('g')
.attr('transform', 'translate(0,0.5)')
.attr('class', 'main axis month')
.call(maxTopAxis)
.selectAll('text')
.attr('dx', 5)
.attr('dy', 12);
mini.append('g')
.attr('transform', 'translate(0,' + miniHeight + ')')
.attr('class', 'axis minute')
.call(miniMinutesAxis);
mini.append('g')
.attr('transform', 'translate(0,0.5)')
.attr('class', 'axis hour')
.call(miniHourAxis)
.selectAll('text')
.attr('dx', 5)
.attr('dy', 12);
// draw the items
var itemRects = main.append('g')
.attr('clip-path', 'url(#clip)');
mini.append('g').selectAll('miniItems')
.data(getPaths(items))
.enter().append('path')
.attr('class', function (d) {
return 'miniItem ' + d.class;
})
.attr('d', function (d) {
return d.path;
});
// invisible hit area to move around the selection window
mini.append('rect')
.attr('pointer-events', 'painted')
.attr('width', width)
.attr('height', miniHeight)
.attr('visibility', 'hidden')
.on('mouseup', moveBrush);
// draw the selection area
var brush = d3.svg.brush()
.x(x)
.extent([d3.min(items, function (d) {
return d.start;
}),
d3.max(items, function (d) {
return d.end;
})])
.on("brush", display);
d3.select('body')
.on("keydown", function () {
var minExtent = brush.extent()[0].getTime();
var maxExtent = brush.extent()[1].getTime();
var move = 0;
if (d3.event.keyCode == 37)
move = d3.min([(maxExtent - minExtent) / 4, minExtent - minTime]) * -1;
if (d3.event.keyCode == 39)
move = d3.min([(maxExtent - minExtent) / 4, maxTime - maxExtent]) * 1;
tip.hide();
brush.extent([new Date(minExtent + move), new Date(maxExtent + move)]);
brush.event(d3.select('body'));
});
mini.append('g')
.attr('class', 'x brush')
.call(brush)
.selectAll('rect')
.attr('y', 1)
.attr('height', miniHeight - 1);
mini.selectAll('rect.background').remove();
display();
function display() {
var rects, labels
, minExtent = brush.extent()[0]
, maxExtent = brush.extent()[1]
, visItems = items.filter(function (d) {
return d.start < maxExtent && d.end > minExtent
});
mini.select('.brush').call(brush.extent([minExtent, maxExtent]));
x1.domain([minExtent, maxExtent]);
var displayText = (maxExtent - minExtent) < 3000;
if ((maxExtent - minExtent) > 300000) {
maxTopAxis.ticks(d3.time.hours, 1).tickFormat(d3.time.format('%H'));
maxBottomAxis.ticks(d3.time.minutes, 1).tickFormat(d3.time.format('%M'));
}
else if ((maxExtent - minExtent) > 60000) {
maxTopAxis.ticks(d3.time.minutes, 1).tickFormat(d3.time.format('%M'));
maxBottomAxis.ticks(d3.time.seconds, 5).tickFormat(d3.time.format('%S'));
}
else if ((maxExtent - minExtent) > 3000) {
maxTopAxis.ticks(d3.time.minutes, 1).tickFormat(d3.time.format('%M'));
maxBottomAxis.ticks(d3.time.seconds, 1).tickFormat(d3.time.format('%S'));
}
else {
maxTopAxis.ticks(d3.time.seconds, 1).tickFormat(d3.time.format('%S'));
maxBottomAxis.ticks(d3.time.milliseconds, 100).tickFormat(d3.time.format('%L'));
}
// update the axis
main.select('.main.axis.date').call(maxBottomAxis);
main.select('.main.axis.month').call(maxTopAxis)
.selectAll('text')
.attr('dx', 5)
.attr('dy', 12);
// upate the item rects
rects = itemRects.selectAll('rect')
.data(visItems, function (d) {
return d.id;
})
.attr('x', function (d) {
return x1(d.start);
})
.attr('width', function (d) {
return x1(d.end) - x1(d.start);
});
rects.enter().append('rect')
.attr('x', function (d) {
return x1(d.start);
})
.attr('y', function (d) {
return y1(d.lane) + .1 * y1(1) + 0.5;
})
.attr('width', function (d) {
return x1(d.end) - x1(d.start);
})
.attr('height', function (d) {
return .8 * y1(1);
})
.attr('class', function (d) {
return 'mainItem ' + d.class;
})
.on('mouseover', tip.show)
.on('mouseout', tip.hide)
.text(function (d) {
return d.desc;
});
rects.exit().remove();
// update the item labels
labels = itemRects.selectAll('text')
.data(displayText ? visItems : [], function (d) {
return d.id;
})
.attr('x', function (d) {
return x1(Math.max(d.start, minExtent)) + 2;
});
labels.enter().append('text')
.text(function (d) {
return d.desc;
})
.attr('x', function (d) {
return x1(Math.max(d.start, minExtent)) + 2;
})
.attr('y', function (d) {
return y1(d.lane) + .4 * y1(1) + 0.5;
})
.attr('text-anchor', 'start')
.attr('class', 'itemLabel');
labels.exit().remove();
}
function moveBrush() {
var origin = d3.mouse(this)
, point = x.invert(origin[0])
, halfExtent = (brush.extent()[1].getTime() - brush.extent()[0].getTime()) / 2
, start = new Date(point.getTime() - halfExtent)
, end = new Date(point.getTime() + halfExtent);
brush.extent([start, end]);
display();
}
// generates a single path for each item class in the mini display
// ugly - but draws mini 2x faster than append lines or line generator
// is there a better way to do a bunch of lines as a single path with d3?
function getPaths(items) {
var paths = {}, d, offset = .5 * y2(1) + 0.5, result = [];
for (var i = 0; i < items.length; i++) {
d = items[i];
if (!paths[d.class]) paths[d.class] = '';
paths[d.class] += ['M', x(d.start), (y2(d.lane) + offset), 'H', x(d.end)].join(' ');
}
for (var className in paths) {
result.push({class: className, path: paths[className]});
}
return result;
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment