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.
xxxxxxxxxx
<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>
https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js