Skip to content

Instantly share code, notes, and snippets.

@monfera
Last active March 5, 2018 10:33
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save monfera/8b87a9aade2214fc15ea76bdf7abe11a to your computer and use it in GitHub Desktop.
Save monfera/8b87a9aade2214fc15ea76bdf7abe11a to your computer and use it in GitHub Desktop.
Revolving infinity scroller
license: mit
height: 200

Experiment of testing rendering performance tradeoffs a few years ago (= don't worry about the code, it was a throwaway). I was unhappy with SVG and naive Canvas rendering and prototyped with this infinity revolver concept. There are many other ways of getting better results with simpler code or WebGL.

A main motivation for a line bundle consisting of hundreds or thousands of lines is to either show a population of related time series (e.g. financial assets) or to show the probability distribution of a stochastic process intuitively. This type of visual works best when some key line(s), e.g. expectation or selected asset(s) show up in salient colors against the backdrop of a muted grey line bundle.

The red bars show the actual visible window; what's outside is just a developer view. Rendering is only done when one of the two revolver panels go out of view and come back on the other side (this example uses two panels only, which is enough for this task).

A perhaps interesting feature of the solution is that time series of arbitrary chunks can be loaded; each line is comprised of blocks of whatever lengths. It's evidenced by how the time series lines end at various X coordinates in the developer view (outside the red window). Each block is either not rendered or rendered fully.

The prototype uses a kinetic scroller and "bonds" the small infinity revolver with a prerendered, very long SVG axis, which is just translated, avoiding text rendering on panning.

The time series are simple discretized Brownians. Only 100 time series are displayed though the scrolling works nicely till around 1000 time series (haven't bothered speeding up the data generation).

Uploaded with blockbuilder.org

function panBehavior(selection, trackingStartCallback, trackingCallback, kineticTargetCallback, kineticEndCallback) {
function mouseDown(d) {
var event = d3.event;
var elem = d3.select(this);
d.drag.startingX = event.touches ? event.touches[0].pageX : event.layerX;
if(rafId) {window.cancelAnimationFrame(rafId);rafId = null;}
if (d.drag.move === false && d.drag.smoothedSpeed) { // i.e. not true or undefined or null
elem.classed('stoppedChart', true);
elem.classed('scrollingChart', false);
this.style.webkitTransform = 'translate(' + d.drag.permX + 'px,0px)';
} else {
elem.classed('stoppedChart', true);
elem.classed('scrollingChart', false);
d.drag.permX = d.drag.permX || 0;
}
d.drag.smoothedSpeed = 0;
d.drag.move = true;
trackingStartCallback( d.drag.permX);
}
function mouseMove(d) {
var event = d3.event;
event.preventDefault();
if (!Object.keys(d.drag).length) {
return;
}
d.drag.lastTimestamp = event.timeStamp;
var x = event.touches ? event.touches[0].pageX : event.layerX;
var translateX, momentarySpeed;
if (d.drag.move) {
translateX = d.drag.permX + x - d.drag.startingX;
this.style.webkitTransform = 'translate(' + translateX + 'px,0px)';
momentarySpeed = x - (isNaN(d.drag.prevX) ? x : d.drag.prevX);
d.drag.smoothedSpeed = 0.1 * d.drag.smoothedSpeed + 0.9 * momentarySpeed;
d.drag.prevX = x;
trackingCallback(translateX);
}
}
var currentPlace, flyingTarget;
var elem = selection.node();
var rafId = null;
function mouseUp(d) {
var event = d3.event;
var elem = d3.select(this);
var x = event.touches ? d.drag.prevX : event.layerX;
d.drag.move = false;
d.drag.permX += x - d.drag.startingX;
if (event.timeStamp - d.drag.lastTimestamp < 100 && Math.abs(d.drag.smoothedSpeed) > 1) {
currentPlace = d.drag.permX;
flyingTarget = currentPlace + (d.drag.smoothedSpeed < 0 ? Math.max(-2000, 100 * d.drag.smoothedSpeed) : Math.min(2000, 100 * d.drag.smoothedSpeed));
//d.drag.permX += flyingDistance;
elem.classed('stoppedChart', false);
elem.classed('scrollingChart', true);
//this.style.webkitTransform = 'translate(' + d.drag.permX + 'px,0px)';
kineticTargetCallback(d.drag.permX);
rafId = window.requestAnimationFrame(fly);
} else {
this.style.webkitTransform = 'translate(' + d.drag.permX + 'px,0px)';
trackingStartCallback(d.drag.permX);
}
}
var prevTimestamp = null;
function fly(timestamp) {
if(!rafId) {
prevTimestamp = null;
return;
}
var newPlace = currentPlace + ((flyingTarget - currentPlace) < 0 ? -1 : 1) * (timestamp - ((prevTimestamp || (timestamp - (1000 / 60))))) * 0.01 * Math.abs(flyingTarget - currentPlace) / (1000 / 60);
elem.style.webkitTransform = 'translate(' + newPlace + 'px,0px)';
if(Math.abs(newPlace - currentPlace) > 0.1) {
currentPlace = newPlace;
elem.__data__.drag.permX = newPlace;
kineticTargetCallback(currentPlace);
prevTimestamp = timestamp;
window.requestAnimationFrame(fly);
} else {
rafId = null;
prevTimestamp = null;
}
}
selection.on('touchstart', mouseDown);
selection.on('mousedown', mouseDown);
selection.on('mousemove', mouseMove);
selection.on('touchmove', mouseMove);
selection.on('touchend', mouseUp);
selection.on('mouseup', mouseUp);
trackingStartCallback(0);
selection.on('transitionend', kineticEndCallback);
return selection;
}
function identity(x) {
return x;
}
function property(/*arguments*/) {
var properties = Array.prototype.slice.call(arguments);
return function (x) {
var result = x, i;
for (i = 0; i < properties.length; i++) {
result = result[properties[i]];
}
return result;
};
}
function add(a, b) {
return a + b;
}
function extentUnion(extents) {
return extents.reduce(function(prev, next) {
return [Math.min(prev[0], next[0]), Math.max(prev[1], next[1])];
}, [Infinity, -Infinity]);
}
function key(x) {
if (x.key === void(0)) {
if (x instanceof Array)
return JSON.stringify(x.slice(0, 2));
else
return x;
} else {
return x.key;
}
}
function constant(c) {
return function () {
return c;
};
}
function tuple(/*arguments*/) {
var functions = Array.prototype.slice.call(arguments);
return function (x) {
return functions.map(function (elem, i, funs) {
return funs[i](x)
});
};
}
function comp(/*arguments*/) {
var functions = Array.prototype.slice.call(arguments);
return function (/*arguments*/) {
var result = functions[functions.length - 1].apply(null, arguments),
i;
for (i = functions.length - 2; i >= 0; i--) {
result = functions[i].call(null, result);
}
return result;
}
}
var d3u = {
repeat: tuple(identity),
descend: identity,
bind: function bind(rootSelection, element, dataFlow, classes, mixins) {
if(mixins) {
if(typeof mixins !== 'function') {
mixins = function() {return mixins;}
}
} else {
mixins = function() {return [];}
}
var classesToClassAttr = function (classNames) {
return classNames.join(' ');
},
classesToCssSelector = function (classNames) {
return (['']).concat(classNames).join(' .')
},
cssClasses = classesToCssSelector(classes),
binding = rootSelection.selectAll(cssClasses).data(dataFlow, key);
binding.entered = binding.enter().append(element);
binding.entered.attr('class', function(d, i) {
var classesWithMixins = classes.concat(mixins(d,i));
return classesToClassAttr(classesWithMixins);
});
return binding;
},
translateSVG: function translateSVG(funX, funY) {
return function (d, i) {
return 'translate(' + funX(d, i) + ',' + funY(d, i) + ')';
};
},
scaleSVG: function scaleSVG(funX, funY) {
return function (d, i) {
return 'scale(' + funX(d, i) + ',' + funY(d, i) + ')';
};
},
tableBind: function tableBind(root, columnClass, cellClass) {
var columnBinding = d3u.bind(root, 'g', d3u.repeat, [columnClass]);
var cellBinding = d3u.bind(columnBinding, 'g', d3u.descend, [cellClass]);
return {
column: columnBinding,
cell: cellBinding
}
},
formatWithPrefix: function formatWithPrefix(value) {
var absv = Math.abs(value);
if (absv > 1000000)
return d3.format(absv < 9950000 ? ",.1f" : ",.0f")(value / 1000000) + 'M';
else if (absv > 1000)
return d3.format(absv < 9950 ? ",.1f" : ",.0f")(value / 1000) + 'k';
else
return d3.format(",.0f")(value);
},
pointMarker: function(selection) {
selection.each(function(d) {
d3u.bind(selection, 'circle', d3u.repeat, ['metrics-dropdown-probe']).attr({r: '3px', fill: 'red'});
});
},
parseMatrix: function parseMatrix(cssString) {
return cssString.replace(/^\w*\(/, '').replace(')', '').split(/\s*,\s*/);
},
scaleMaker: function scaleMaker(domainMin, domainMax, rangeMin, rangeMax) {
var rangeExtent = rangeMax - rangeMin,
domainExtent = domainMax - domainMin,
slope = rangeExtent / domainExtent,
a = slope,
b = rangeMin - slope * domainMin;
return function scaleMakerInner(n) {
return a * n + b;
}
}
};
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="style.css">
<script src="https://d3js.org/d3.v3.min.js"></script>
<script src="d3u.js"></script>
<script src="model.js"></script>
<script src="infiniteCanvas.js"></script>
<script src="behaviors.js"></script>
<script defer="defer" src="view.js"></script>
</head>
<body onload="start();">
</body>
function InfiniteCanvas(options) {
this.canvasWidth = options.canvasWidth || 256;
this.maxPanelWidth = options.maxPanelWidth || 0; // needed for overlap
this.height = options.height || 256;
this.visibleRange = options.visibleRange || [64, 192];
this.canvasNumber = options.canvasNumber || 2;
this.root = options.root || document.querySelector('body');
this.class = options.class || 'moving-canvas';
var _this = this;
this.movingCanvases = Array.apply(null, new Array(this.canvasNumber)).map(function(ignore, i) {
var elem = document.createElement('canvas');
var d = {
offset: i * _this.canvasWidth,
context: elem.getContext('2d'),
cache: {}
};
elem.__data__ = d;
elem.setAttribute('class', _this.class);
elem.setAttribute('width', _this.canvasWidth + _this.maxPanelWidth);
elem.setAttribute('height', _this.height);
elem.style.position = 'absolute';
elem.style.top = '0px';
elem.style.left = '0px';
elem.style.WebkitTransform = 'translateX(' + d.offset + 'px)';
_this.root.appendChild(elem);
return elem;
});
this.leftCanvas = this.movingCanvases[0];
this.rightCanvas = this.movingCanvases[1];
}
InfiniteCanvas.prototype.flip = function flip() {
var tmp = this.rightCanvas;
this.rightCanvas = this.leftCanvas;
this.leftCanvas = tmp;
};
InfiniteCanvas.prototype.roll = function roll(elem, direction) {
var d = elem.__data__;
d.offset += direction * 2 * this.canvasWidth;
elem.__data__.context.clearRect(0,0, this.canvasWidth + this.maxPanelWidth, this.height);
elem.__data__.cache = {};
elem.style.WebkitTransform = 'translateX(' + d.offset + 'px)';
this.flip();
};
InfiniteCanvas.prototype.updateCanvasPositions = function updateCanvasPositions(visibleLow, visibleHigh) {
if(this.leftCanvas.__data__.offset + this.canvasWidth <= visibleLow) {
this.roll(this.leftCanvas, 1);
} else if(this.rightCanvas.__data__.offset > visibleHigh) {
this.roll(this.rightCanvas, -1);
}
};
InfiniteCanvas.prototype.context = function context(canvas) {
return canvas.__data__.context;
};
InfiniteCanvas.prototype.cache = function cache(canvas) {
return canvas.__data__.cache;
};
InfiniteCanvas.prototype.getCanvas = function getContext(offset) {
return offset < this.rightCanvas.__data__.offset ? this.leftCanvas : this.rightCanvas;
};
InfiniteCanvas.prototype.getContexts = function getContexts() {
return [this.leftCanvas, this.rightCanvas].map(this.context.bind(this));
};
var tserSetCounter = 0;
var tserCounter = 0;
var tserSectionCounter = 0;
var pointCounter = 0;
var lastPoint = 0;
var palette = ['categorical0', 'categorical1', 'categorical2', 'categorical3', 'categorical4'];
var model = {
tserSectionMaker: function tserSectionMaker(options) {
var n = options.blockSize || 256;
var cssStyles = options.cssStyles || [];
// Simplified geometric brownian motion represented as log returns
var current = lastPoint;
var result = {
key: tserSectionCounter,
from: pointCounter,
until: pointCounter + n,
cssStyles: cssStyles,
pathDrawer: options.pathDrawer,
value: Array.apply(null, new Array(n)).map(function() {
function normalRandom() {
return Math.random() + Math.random() + Math.random() + Math.random() + Math.random() + Math.random() - 3;
}
current = current + normalRandom();
return current;
})
};
pointCounter += n;
tserSectionCounter += 1;
lastPoint = current;
return result;
},
tserMaker: function(n, maxBlockSize) {
return d3.range(n).map(function () {
return model.tserSectionMaker({
blockSize: maxBlockSize / 4 + Math.round(maxBlockSize * 3 / 4 * Math.random()),
cssStyles: [palette[Math.floor(palette.length * Math.random())]]
});
});
}
};
function Tser(n) {
pointCounter = 0;
lastPoint = 0;
this.key = tserCounter++;
this.value = model.tserMaker(n, maxBlockWidth);
this.xWidth = this.value.reduce(function(prev, next) {
return prev + next.until - next.from;
}, 0);
this.xDomain = extentUnion(this.value.map(function(v) {return [v.from, v.until];}));
this.yDomain = extentUnion(this.value.map(function(v) {return d3.extent(v.value);}));
}
function TserSet(m, n) {
this.key = tserSetCounter++;
this.value = d3.range(m).map(function() {return new Tser(n);});
}
* {
-webkit-backface-visibility: hidden;
-webkit-perspective: 1000px;
font-family: arial, sans-serif;
font-size: 10px;
}
body {
margin: 0;
background-color: white;/*#fdf6e3;*/ /*#eee8d5;*/ /*#002b36*/
}
path {
fill: none;
stroke: grey;
/*pointer-events: none*/
}
div#background {
/*
background-color: #fafafa;
*/
}
@media screen and (max-device-width: 1025px) {
/* iOS rAF is choppy with the current setup (30/15FPS?) */
.scrollingChart {
transition: -webkit-transform 16ms linear;
}
.stoppedChart {
transition: -webkit-transform 16ms linear;
}
}
.moving-canvas {
border-left: 1px solid blue;
border-right: 1px dotted blue;
}
/*
.panel {
opacity: 0.1;
}
*/
.fadingIn {
opacity: 0.99; /* debug - this shows if element accidentally stays*/
transition: opacity 0ms linear;
}
.fadingOut {
opacity: 0.1; /* debug - this shows if element accidentally stays*/
transition: opacity 500ms linear 5s;
}
.movingBackground {
opacity: 0.0;
}
line.border {
stroke: red;
stroke-width: 2px;
fill: none;
}
.panelBox {
fill: none;
stroke: black;
stroke-width: 1px;
stroke-opacity: 0.1;
}
.tick>line {
stroke: black;
opacity: 0.5;
}
.tick:nth-child(5n+1)>line {
stroke-width: 3px;
}
.tick>text {
fill-opacity: 0.5
}
.tick:nth-child(5n+1)>text {
font-weight: bold;
}
.path { stroke-opacity: 1}
.categorical0 {stroke:rgb(166,206,227); stroke-width: 5px; }
.categorical1 {stroke:rgb(31,120,180)}
.categorical2 {stroke:rgb(178,223,138)}
.categorical3 {stroke:rgb(51,160,44)}
.categorical4 {stroke:red;stroke-width: 0.5px}
var maxBlockWidth = 80;
var panelWidth = maxBlockWidth * 4;
var height = 200;
var width = 960;
var iOS = /(iPad|iPhone|iPod)/g.test(navigator.userAgent);
var visibleRange = [panelWidth, 2 * panelWidth];
var visibleLow, visibleHigh;
function pluck(array, prop) {
var i;
var result = [];
for(i = 0; i < array.length; i++) {
result.push(array[i][prop]);
}
return result;
}
function constant(c) {
return function() {
return c;
};
}
function start() {
var previousLow, previousHigh, targetLow, targetHigh, kineticInProgress;
function startTrackX(x) {
kineticInProgress = false;
trackX(x);
}
function trackX(x) {
if(kineticInProgress) return;
previousLow = visibleLow;
previousHigh = visibleHigh;
var low = visibleRange[0] - x;
var high = visibleRange[1] - x;
visibleLow = low;
visibleHigh = high;
renderSlippy();
}
function startKineticX(x) {
kineticInProgress = true;
previousLow = visibleLow;
previousHigh = visibleHigh;
var low = visibleRange[0] - x;
var high = visibleRange[1] - x;
targetLow = low;
targetHigh = high;
visibleLow = targetLow;
visibleHigh = targetHigh;
renderSlippy();
}
function endKineticX() {
if(!kineticInProgress) {
return;
}
kineticInProgress = false;
visibleLow = targetLow;
visibleHigh = targetHigh;
renderSlippy();
}
var tserSet = new TserSet(100, 160);
var svgWidth = d3.max(pluck(tserSet.value, 'xWidth'));
var xScale = d3.scale.linear()
.domain(extentUnion(pluck(tserSet.value, 'xDomain')))
.range([0, svgWidth]);
var yScale = d3.scale.linear()
.domain(extentUnion(pluck(tserSet.value, 'yDomain')))
.range([height, 0]);
var body = d3.selectAll('body');
var container = d3u.bind(body, 'div', constant([{key:0}]), ['container']);
container.entered
.style('width', width + 'px')
.style('height', height + 'px')
.style('overflow', 'hidden');
var stationary = d3u.bind(container, 'div', constant([{key:0, drag:{}}]), ['stationary']);
stationary.entered
.style('width', width + 'px')
.style('height', height + 'px')
.style('overflow', 'hidden');
var standingSvg = d3u.bind(stationary, 'svg', d3u.repeat, ['standing-svg']);
standingSvg.entered
.attr('width', svgWidth)
.attr('height', height)
.style('position', 'absolute')
.style('top', '0px');
var visibleBoundaries = d3u.bind(standingSvg, 'line', constant(visibleRange), ['border']);
visibleBoundaries.entered
.attr('x1', function(d, i) {return i ? d : d + maxBlockWidth;})
.attr('x2', function(d, i) {return i ? d : d + maxBlockWidth;})
.attr('y1', yScale.range()[0])
.attr('y2', yScale.range()[1]);
var slipping = d3u.bind(stationary, 'div', constant([{key:0, drag:{}}]), ['slipping']);
slipping.entered
.style('position', 'absolute')
.style('top', '0px');
var movingSvg = d3u.bind(slipping, 'svg', d3u.repeat, ['moving-svg']);
movingSvg
.attr('width', svgWidth)
.attr('height', height);
var infiniteCanvas = new InfiniteCanvas({
maxPanelWidth: maxBlockWidth,
canvasWidth: panelWidth,
height: height,
visibleRange: visibleRange,
root: slipping.node()
});
var movingBackgroundGroup = d3u.bind(movingSvg, 'g', constant([{key: 0}]), ['movingBackgroundGroup']);
var movingBackground = d3u.bind(movingBackgroundGroup, 'rect', d3u.repeat, ['movingBackground']);
movingBackground.entered
.attr('width', svgWidth)
.attr('height', height);
var xAxisLayer = d3u.bind(movingSvg, 'g', d3u.repeat, ['xAxisLayer']);
var ticksPerUnit = 0.01;
var xAxis = d3.svg.axis().scale(xScale).orient('top').ticks(ticksPerUnit * svgWidth);
renderAxisX(xAxisLayer);
function renderAxisX(layer) {
layer.entered
.attr('transform', d3u.translateSVG(constant(0), constant(height)))
.call(xAxis);
}
function renderSlippy() {
// Render children
renderPanels();
}
var fastScaleX = d3u.scaleMaker(xScale.domain()[0], xScale.domain()[1], xScale.range()[0], xScale.range()[1]);
var fastScaleY = d3u.scaleMaker(yScale.domain()[0], yScale.domain()[1], yScale.range()[0], yScale.range()[1]);
var pipeline = [];
function buildPanelCache(tserSet) {
var block, innerValue, value = tserSet.value, result = {}, key;
for(var i = 0; i < value.length; i++) {
innerValue = value[i].value;
for(var j = 0; j < innerValue.length; j++) {
block = innerValue[j];
key = panelWidth * Math.floor(block.from / panelWidth)
if(!result[key]) result[key] = [];
result[key].push(block);
}
}
return result;
}
var k = 0;
var prevKey = null;
var panelCache = buildPanelCache(tserSet);
function renderPanels() {
var key;
if(previousLow < visibleLow) {
key = panelWidth * Math.floor(visibleHigh / panelWidth);
} else // if (previousHigh > visibleHigh)
{
key = panelWidth * Math.floor(visibleLow / panelWidth);
}
if(prevKey === key) return;
prevKey = key;
pipeline = panelCache[key] || [];
k = pipeline.length;
infiniteCanvas.updateCanvasPositions(visibleLow, visibleHigh);
requestAnimationFrame(renderSnippets);
}
var contexts = infiniteCanvas.getContexts();
contexts.map(function(context) {
context.beginPath();
context.strokeStyle = 'black';
context.globalAlpha = 0.1;
});
function draw(context, offset, tser) {
var j;
context.moveTo(fastScaleX(offset), fastScaleY(tser[0]));
for (j = 1; j < tser.length; j++) {
context.lineTo(fastScaleX(offset + j), fastScaleY(tser[j]));
}
}
function renderSnippets(timestamp) {
var d, key, tser, offset, renderedSomething = false;
if(k) {
do {
d = pipeline[--k];
offset = d.from;
tser = d.value;
key = d.key;
var canvas = infiniteCanvas.getCanvas(offset);
offset = offset % panelWidth;
var cache = infiniteCanvas.cache(canvas);
if(!cache[key]) {
draw(infiniteCanvas.context(canvas), offset, tser);
renderedSomething = true;
cache[key] = true;
} else {}
} while (!(iOS && renderedSomething) && performance.now() - timestamp < 10 && k);
}
contexts.forEach(function(context) {
context.stroke();
context.beginPath();
});
if(k) {
requestAnimationFrame(renderSnippets);
}
}
slipping.call(
panBehavior,
startTrackX,
trackX,
startKineticX,
endKineticX,
trackX
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment