Skip to content

Instantly share code, notes, and snippets.

@jebeck
Last active August 29, 2015 14:10
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save jebeck/1974647d476b67a0439d to your computer and use it in GitHub Desktop.
Tideline-style virtual scrolling

Tideline-style virtual scrolling

A toy example to demonstrate the virtual scrolling implemented in tideline.

<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<script src='http://d3js.org/d3.v3.min.js' charset='utf-8'></script>
<script src='http://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.min.js'></script>
<script src='http://cdnjs.cloudflare.com/ajax/libs/crossfilter/1.3.11/crossfilter.min.js'></script>
<link rel="stylesheet" type="text/css" href="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css">
<style type="text/css">
#main {
width: 800px;
height: 480px;
margin: 0 auto;
background-color: #f5f5f5;
}
#nav {
width: 800px;
height: 60px;
margin: 0 auto;
background-color: #d6d6d6;
border-bottom: 5px white solid;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.button-holder {
margin-top: 8px;
}
button {
margin-left: 5px;
margin-right: 5px;
}
#axisGroup path {
fill: none;
stroke: none;
}
#axisGroup line {
fill: none;
stroke: #333333;
shape-rendering: crispEdges;
}
#axisGroup text {
font-family: sans-serif;
text-transform: lowercase;
fill: #333333;
}
</style>
<title>Tideline scrolling</title>
</head>
<body>
<div id="main">
<div id="nav">
<div class="button-holder">
<button id="backbtn" type="button" class="btn btn-default">Back</button>
</div>
<div class="button-holder">
<button id="forwardbtn" type="button" class="btn btn-default">Forward</button>
</div>
</div>
</div>
<script type="text/javascript">
// some variables
var w = 800, h = 410, axis = 40, scroll = 20;
var barWidth = 25;
// add the SVG
var svg = d3.select('#main').append('svg')
.attr({
width: w,
height: h + scroll
});
// let's make some data!
// first, dates for our x-plotting
var now = new Date();
var s = d3.time.year.floor(now), e = d3.time.year.ceil(now);
var dates = d3.time.hour.range(s, e);
// drop these in along with random numbers from 1-100 for the y-plotting
var data = _.map(dates, function(date) {
return {
x: date.valueOf(),
y: Math.ceil(Math.random() * 100)
};
});
// set up crossfilter for data filtering
var filterData = crossfilter(data);
var dataByDate = filterData.dimension(function(d) { return d.x; });
// set up scales
var xScale = d3.time.scale()
.domain([s, d3.time.day.offset(s, 1)])
.range([0, w]);
// the xScale gets modified on zoom, but we need to plot all bars based on the original
var origXScale = xScale.copy();
var extent = d3.extent(data, function(d) { return d.y; });
var yScale = d3.scale.linear()
.domain(extent)
.range([h - scroll, axis]);
// change bar color depending on height, just for funsies
var funScale = d3.scale.linear()
.domain(extent)
.range(['#302e86', '#b6211d']);
var scrollGroup = svg.append('g')
.attr('id', 'scrollGroup');
var barsGroup = scrollGroup.append('g')
.attr('id', 'barsGroup');
// initial plot with 1-day buffer on each side
plotBars(dataByDate.filter([d3.time.day.offset(s, -1).valueOf(), d3.time.day.offset(s, 2).valueOf()]).top(Infinity));
// factor out bar plotting into a function that binds data
// and creates or deletes bars as necessary given the current data
function plotBars(data) {
var currBars = barsGroup.selectAll('rect')
.data(data, function(d) { return d.x; });
currBars.enter()
.append('rect')
.attr({
x: function(d) {
return origXScale(d.x) - barWidth/2;
},
y: function(d) {
return yScale(d.y);
},
width: barWidth,
height: function(d) {
return h - yScale(d.y);
},
fill: function(d) {
return funScale(d.y);
}
});
currBars.exit().remove();
}
// use a dispatcher to handle and trigger refiltering data and plotting bars
var dispatcher = d3.dispatch('plotBars');
dispatcher.on('plotBars', function(date) {
// refilter data based on current position and plot
plotBars(dataByDate.filter([d3.time.day.offset(date, -1).valueOf(), d3.time.day.offset(date, 2).valueOf()]).top(Infinity));
});
// add an axis
// customize the time formatting because zero-padding bugs the stuffing out of me...
// i.e., this is not necessary ;)
var format = d3.time.format.multi([
[".%L", function(d) { return d.getMilliseconds(); }],
[":%S", function(d) { return d.getSeconds(); }],
["%-I:%M", function(d) { return d.getMinutes(); }],
["%-I %p", function(d) { return d.getHours(); }],
["%a %-d", function(d) { return d.getDay() && d.getDate() != 1; }],
["%b %-d", function(d) { return d.getDate() != 1; }],
["%b", function(d) { return true; }]
]);
// here's the good stuff
// the xScale for the axis needs to use the entire domain
// and the entire range of our reallllly wide scrollGroup
// which we can find the end of using the xScale
var axisScale = d3.time.scale()
.domain([s,e])
.range([0, xScale(e)]);
var xAxis = d3.svg.axis()
.scale(axisScale)
.ticks(d3.time.hours, 6)
.tickFormat(format)
.innerTickSize(6)
.outerTickSize(0);
scrollGroup.append('g')
.attr('id', 'axisGroup')
.call(xAxis);
// now let's set up panning via d3's zoom behavior
var maxTranslation = 0, minTranslation = -xScale(d3.time.day.offset(e, -1));
// we only want panning to move the scroll thumb if panning is the source of scrolling
// not if the scroll thumb itself is driving the panning
// so we toggle a bool true/false to control this
var moveScrollThumb = true;
var pan = d3.behavior.zoom()
// setting scaleExtent to [1,1] disables zoom
// i.e., min zoom is 1, max zoom is 1 ("You can have any color, as long as it's black")
.scaleExtent([1,1])
.x(xScale)
.on('zoom', function() {
var e = d3.event;
// don't let someone pan farther back in time than the beginning of the scale
if (e.translate[0] < minTranslation) {
e.translate[0] = minTranslation;
}
// don't let someone pan farther forward in time than the end of the scale
else if (e.translate[0] > maxTranslation) {
e.translate[0] = maxTranslation;
}
pan.translate([e.translate[0], 0]);
scrollGroup.attr('transform', 'translate(' + e.translate[0] + ',0)');
})
.on('zoomend', function() {
// adjust the position of the scroll thumb if we panned by click-and-drag or forward/back button
var current = xScale.domain()[0];
if (moveScrollThumb) {
scrollBar.select('circle').attr('cx', function(d) {
return scrollScale(current);
});
}
moveScrollThumb = true;
// trigger refilter and plot of data based on current date, updated xScale
dispatcher.plotBars(current);
});
svg.call(pan);
// now we add programmatic panning by a day backwards and forwards
// to move one day per button press, we find the width that equals scrolling a day
// this is equal to the xScale domain, which is just the width
var scrollWidth = w;
function panBack() {
var currentTranslation = pan.translate()[0];
scrollGroup.transition()
.duration(500)
.tween('zoom', function() {
// positive translations move the scrollGroup right === backwards in time
var ix = d3.interpolate(currentTranslation, currentTranslation + scrollWidth);
return function(t) {
pan.translate([ix(t), 0]);
pan.event(scrollGroup);
};
});
}
d3.select('#backbtn').on('click', panBack);
function panForward() {
var currentTranslation = pan.translate()[0];
scrollGroup.transition()
.duration(500)
.tween('zoom', function() {
// negative translations move the scrollGroup left === forwards in time
var ix = d3.interpolate(currentTranslation, currentTranslation - scrollWidth);
return function(t) {
pan.translate([ix(t), 0]);
pan.event(scrollGroup);
};
});
}
d3.select('#forwardbtn').on('click', panForward);
// now the scroll bar
var scrollThumbRadius = 8, scrollColor = '#333333', scrollHeight = (h + scroll) - scroll/2;
// create a scale for the scrollbar representing the entire time domain of the available data
var scrollScale = d3.time.scale()
.domain([s,e])
.range([scrollThumbRadius, w - scrollThumbRadius]);
var scrollBar = svg.append('g')
.attr('id', 'scrollBar');
scrollBar.append('line')
.attr({
x1: 0,
x2: w,
y1: scrollHeight,
y2: scrollHeight,
stroke: scrollColor,
'stroke-width': '1.5px'
});
var dxLeftest = scrollThumbRadius;
var dxRightest = w - scrollThumbRadius;
var drag = d3.behavior.drag()
.origin(function(d) { return d; })
.on('dragstart', function() {
d3.event.sourceEvent.stopPropagation();
})
.on('drag', function(d) {
moveScrollThumb = false;
d.x += d3.event.dx;
// don't allow the user to pull the thumb off the right end of the scale
if (d.x > dxRightest) {
d.x = dxRightest;
}
// or off the left end
if (d.x < dxLeftest) {
d.x = dxLeftest;
}
// adjust the position of the scroll thumb according to the drag
d3.select(this).attr('cx', function(d) {
return d.x;
});
// find the date that represents the current position of scroll thumb
var date = scrollScale.invert(d.x);
// figure out the pixel offset between current location of data view
// and the current date of the scroll thumb
var currentTranslation = -xScale(date) + pan.translate()[0];
// trigger a pan with the calculated offset
pan.translate([currentTranslation, 0]);
pan.event(scrollGroup);
});
// make a scroll thumb
scrollBar.selectAll('circle')
.data([{x: scrollThumbRadius}])
.enter()
.append('circle')
.attr({
fill: scrollColor,
cx: scrollScale(s),
cy: scrollHeight,
r: scrollThumbRadius
})
.call(drag);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment