Skip to content

Instantly share code, notes, and snippets.

@tpreusse
Last active August 29, 2015 14:12
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tpreusse/bd34c6669837dd50123d to your computer and use it in GitHub Desktop.
Save tpreusse/bd34c6669837dd50123d to your computer and use it in GitHub Desktop.
Fast Hoverable Dots beyond 20'000

Rendering more than 20'000 SVG circles will slow down every browser. Render time, scroll and hover performance degrade to an in-actable level. This examples illustrates how to render an almost infinite amount of hoverable circles with canvas, d3.timer (requestAnimationFrame) and d3.geom.quadtree.

Be aware of iOS image size limitation which also apply to canvas. Potential solution is to disable retina (window.devicePixelRatio) on older iPads and iPhones.

Open the example in a new window to see the rendering adapt to scrolling and window width changes.

This is an extracted example of the rendering code behind 2014.nzz.ch.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body { position:relative; margin: 0; }
canvas { position: absolute; left: 0; transition: opacity 400ms; }
svg { position: relative; z-index: 1; }
circle { stroke: #000; stroke-width: 2; fill: none; }
</style>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
<script>
// bl.ocks.org disabled scrolling
var limitedHeight = window.top !== window && window.top.location.hostname.match(/bl\.ocks\.org/);
// setup
var xScale = d3.time.scale().clamp(true).domain([
new Date(2015, 0, 1),
new Date(2015, 11, 31)
]);
var sizeScale = d3.scale.sqrt().domain([0, 1]).range([0.5, limitedHeight ? 3 : 13]);
var opacityScale = d3.scale.linear().domain([0, 1]).range([0.1, 1]);
// render
var padding = sizeScale.range()[1];
var canvasHeight = limitedHeight ? 15 : 80;
var canvasWidth;
var container = d3.select('body');
var angle = 2 * Math.PI;
function measure() {
canvasWidth = +container.style('width').replace('px', '');
}
var svg = container.append('svg').attr('width', '100%');
var drawing, drawContexts = [], batchSize = 100;
function render() {
drawContexts = [];
var width = canvasWidth,
height = canvasHeight,
scale = window.devicePixelRatio || 1;
xScale.range([padding, width - padding]);
var topicCanvases = container.selectAll('.topic').data(topics);
topicCanvases.exit().remove();
topicCanvases.enter().append('canvas').classed('topic', 1);
svg.attr('height', topics.length * height);
topicCanvases
.each(function(topic, i) {
topic.yOffset = height * i;
var context = this.getContext('2d');
this.width = width * scale;
this.height = height * scale;
this.style.width = width + 'px';
this.style.height = height + 'px';
this.style.top = topic.yOffset + 'px';
this.style.opacity = 0.3;
context.scale(scale, scale);
context.clearRect(0, 0, width, height);
var q = topic.quadtree = d3.geom.quadtree()
.x(function(d) { return d.x; })
.y(function(d) { return d.y; })
.extent([
[0, 0],
[width, height]
])([]);
context.fillStyle = topic.color;
drawContexts.push({
element: this,
context: context,
topic: topic,
quadtree: q,
iterator: -1
});
})
prioritizeDrawing();
if(!drawing) {
d3.timer(function() {
var drawContext = drawContexts[0];
if(!drawContext) {
drawing = false;
return true;
}
drawing = true;
var quadtree = drawContext.quadtree,
context = drawContext.context,
circles = drawContext.topic.circles,
size = circles.length;
while(++drawContext.iterator < size) {
var d = circles[drawContext.iterator];
d.radius = sizeScale(d.textLength);
d.x = xScale(d.date);
d.y = canvasHeight - d.radius;
quadtree.visit(stack(d));
quadtree.visit(stack(d));
quadtree.visit(stack(d));
quadtree.visit(stack(d));
quadtree.visit(stack(d));
d.y = Math.max(d.y, d.radius);
quadtree.add(d);
context.globalAlpha = opacityScale(d.relevance);
context.beginPath();
context.arc(d.x, d.y, d.radius, 0, angle);
context.fill();
if(drawContext.iterator % batchSize === 0) {
return;
}
}
drawContext.element.style.opacity = 1;
drawContexts.shift();
});
}
}
function stack(node) {
var r = node.radius + padding,
nx1 = node.x - r,
nx2 = node.x + r,
ny1 = node.y - r,
ny2 = node.y + r;
return function(quad, x1, y1, x2, y2) {
if (quad.point && (quad.point !== node)) {
var x = node.x - quad.point.x,
y = (node.y - quad.point.y) || 1,
l = Math.sqrt(x * x + y * y),
r = node.radius + quad.point.radius;
if (l < r) {
l = (l - r) / l * 1;
node.x -= x *= l;
node.y -= Math.abs(y *= l);
}
}
return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
};
}
// draw visible areas first
function prioritizeDrawing() {
var top = window.pageYOffset - canvasHeight;
var height = window.innerHeight;
drawContexts.forEach(function(drawContext) {
var distance = drawContext.topic.yOffset - top;
if(distance < 0) {
distance = Math.abs(distance) + height;
}
drawContext.distance = distance;
});
drawContexts.sort(function(a, b) {
return d3.ascending(a.distance, b.distance);
});
}
// hover
// - use svg as 2nd layer
// - unused space is cheaper in svg than canvas
var hitTolerance = padding / 3;
var hoveredCircle = svg.append('circle');
function find() {
var p = d3.mouse(container.node());
var circle, topic, hit;
(topics || []).some(function(t) {
topic = t;
var distance = Math.abs(topic.yOffset - p[1]);
if(distance > (canvasHeight * 2) || !topic.quadtree) {
return;
}
var relativeP = [p[0], p[1] - topic.yOffset];
circle = topic.quadtree.find(relativeP);
if(!circle) {
return;
}
var x = relativeP[0] - circle.x,
y = relativeP[1] - circle.y,
l = Math.sqrt(x * x + y * y);
hit = l <= circle.radius + hitTolerance;
if(hit) {
return true;
}
});
if(hit) {
return {
circle: circle,
topic: topic
};
}
}
function focus() {
var hit = find();
if(hit) {
d3.event.preventDefault();
hoveredCircle.attr({
r: hit.circle.radius,
cx: hit.circle.x,
cy: hit.circle.y + hit.topic.yOffset
}).style('opacity', 1);
}
else {
blur();
}
}
function blur() {
hoveredCircle.style('opacity', 0);
}
container
.on('touchstart', focus)
.on('touchmove', focus)
.on('touchend', blur)
.on('mouseover', focus)
.on('mousemove', focus)
.on('mouseout', blur)
.on('dblclick', render);
// generate random data
var timeRange = d3.time.hours.apply(d3, xScale.domain());
var textLengthDistribution = d3.scale.linear().domain([0, 0.4, 0.99, 1]).range([0, 0.05, 0.2, 1]);
var colors = d3.scale.category10();
var topics = d3.range(0, 80).map(function(t) {
var relevanceDistribution = d3.scale.linear()
.domain([0, 0.3, 1])
.range([0.1, Math.random() * 0.3, 1]);
var timeDistribution = d3.scale.linear()
.domain(d3.range(0, 1, 0.05))
.range(d3.range(0, 1, 0.05).map(function(r) { return r + (r ? (0.2 * Math.random()) : 0); }));
var circles = d3.range(100 + ~~(Math.random() * 1200)).map(function(i) {
return {
textLength: textLengthDistribution(Math.random()),
relevance: relevanceDistribution(Math.random()),
date: timeRange[Math.ceil(timeDistribution(Math.random()) * timeRange.length) - 1]
};
});
circles.sort(function(a, b) {
return d3.descending(a.relevance, b.relevance);
});
return {
color: colors(~~(t / 10)),
circles: circles
};
});
// hotwire
d3.select(window)
.on('resize', function() {
var width = canvasWidth;
measure();
if(width !== canvasWidth) {
render();
}
})
.on('scroll', prioritizeDrawing);
measure();
render();
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment