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