A toy example to demonstrate the virtual scrolling implemented in tideline.
Last active
August 29, 2015 14:10
-
-
Save jebeck/1974647d476b67a0439d to your computer and use it in GitHub Desktop.
Tideline-style virtual scrolling
This file contains 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> | |
<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