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> |