Skip to content

Instantly share code, notes, and snippets.

@hudsonb
Forked from pbeshai/.block
Last active January 27, 2019 00:22
Show Gist options
  • Save hudsonb/7beba51e38676fb6bafc0f266f7fa338 to your computer and use it in GitHub Desktop.
Save hudsonb/7beba51e38676fb6bafc0f266f7fa338 to your computer and use it in GitHub Desktop.
Animate thousands of points with canvas and D3
license: mit
height: 620
border: no

Animate thousands of points with SVG and D3

Based on pbeshai's "Animate thousands of points with canvas and D3". See his blog post for more details.

This version uses SVG rects rather than drawing on canvas. With 7000 rects performance is actually quite good. Up it to 20000 rects and the FPS drops off quickly.

/**
* Given a set of points, lay them out in a phyllotaxis layout.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} xOffset The x offset to apply to all points
* @param {Number} yOffset The y offset to apply to all points
*
* @return {Object[]} points with modified x and y
*/
function phyllotaxisLayout(points, pointWidth, xOffset = 0, yOffset = 0, iOffset = 0) {
// theta determines the spiral of the layout
const theta = Math.PI * (3 - Math.sqrt(5));
const pointRadius = pointWidth / 2;
points.forEach((point, i) => {
const index = (i + iOffset) % points.length;
const phylloX = pointRadius * Math.sqrt(index) * Math.cos(index * theta);
const phylloY = pointRadius * Math.sqrt(index) * Math.sin(index * theta);
point.x = xOffset + phylloX - pointRadius;
point.y = yOffset + phylloY - pointRadius;
});
return points;
}
/**
* Given a set of points, lay them out in a grid.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} gridWidth The width of the grid of points
*
* @return {Object[]} points with modified x and y
*/
function gridLayout(points, pointWidth, gridWidth) {
const pointHeight = pointWidth;
const pointsPerRow = Math.floor(gridWidth / pointWidth);
const numRows = points.length / pointsPerRow;
points.forEach((point, i) => {
point.x = pointWidth * (i % pointsPerRow);
point.y = pointHeight * Math.floor(i / pointsPerRow);
});
return points;
}
/**
* Given a set of points, lay them out randomly.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} width The width of the area to place them in
* @param {Number} height The height of the area to place them in
*
* @return {Object[]} points with modified x and y
*/
function randomLayout(points, pointWidth, width, height) {
points.forEach((point, i) => {
point.x = Math.random() * (width - pointWidth);
point.y = Math.random() * (height - pointWidth);
});
return points;
}
/**
* Given a set of points, lay them out in a sine wave.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} width The width of the area to place them in
* @param {Number} height The height of the area to place them in
*
* @return {Object[]} points with modified x and y
*/
function sineLayout(points, pointWidth, width, height) {
const amplitude = 0.3 * (height / 2);
const yOffset = height / 2;
const periods = 3;
const yScale = d3.scaleLinear()
.domain([0, points.length - 1])
.range([0, periods * 2 * Math.PI]);
points.forEach((point, i) => {
point.x = (i / points.length) * (width - pointWidth);
point.y = amplitude * Math.sin(yScale(i)) + yOffset;
});
return points;
}
/**
* Given a set of points, lay them out in a spiral.
* Mutates the `points` passed in by updating the x and y values.
*
* @param {Object[]} points The array of points to update. Will get `x` and `y` set.
* @param {Number} pointWidth The size in pixels of the point's width. Should also include margin.
* @param {Number} width The width of the area to place them in
* @param {Number} height The height of the area to place them in
*
* @return {Object[]} points with modified x and y
*/
function spiralLayout(points, pointWidth, width, height) {
const amplitude = 0.3 * (height / 2);
const xOffset = width / 2;
const yOffset = height / 2;
const periods = 20;
const rScale = d3.scaleLinear()
.domain([0, points.length -1])
.range([0, Math.min(width / 2, height / 2) - pointWidth]);
const thetaScale = d3.scaleLinear()
.domain([0, points.length - 1])
.range([0, periods * 2 * Math.PI]);
points.forEach((point, i) => {
point.x = rScale(i) * Math.cos(thetaScale(i)) + xOffset
point.y = rScale(i) * Math.sin(thetaScale(i)) + yOffset;
});
return points;
}
/**
* Generate an object array of `numPoints` length with unique IDs
* and assigned colors
*/
function createPoints(numPoints, pointWidth, width, height) {
const colorScale = d3.scaleSequential(d3.interpolateViridis)
.domain([numPoints - 1, 0]);
const points = d3.range(numPoints).map(id => ({
id,
color: colorScale(id),
}));
return randomLayout(points, pointWidth, width, height);
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<meta charset='UTF-8'>
<script src="https://d3js.org/d3.v4.min.js"></script>
<title>Animate thousands of rects with SVG and D3</title>
<style>
html, body {
padding: 0;
margin: 0;
}
.play-control {
position: absolute;
top: 0px;
left: 0px;
width: 600px;
height: 600px;
line-height: 600px;
text-align: center;
background-color: rgba(0, 0, 0, 0.1);
color: #f4f4f4;
text-shadow: rgba(0, 0, 0, 0.7) 3px 3px 0px;
font-size: 100px;
font-family: 'helvetica neue', calibri, sans-serif;
font-weight: 100;
cursor: pointer;
}
.play-control:hover {
color: #fff;
text-shadow: #000 3px 3px 0px;
background-color: rgba(0, 0, 0, 0.04);
}
</style>
</head>
<body>
<svg></svg>
<script src="common.js"></script>
<script src="script.js"></script>
</body>
</html>
// canvas settings
const width = 600;
const height = 600;
// point settings
const numPoints = 7000;
const pointWidth = 4;
const pointMargin = 3;
// animation settings
const duration = 1500;
const ease = d3.easeCubic;
let timer;
let currLayout = 0;
// create set of points
const points = createPoints(numPoints, pointWidth, width, height);
// wrap layout helpers so they only take points as an argument
const toGrid = (points) => gridLayout(points,
pointWidth + pointMargin, width);
const toSine = (points) => sineLayout(points,
pointWidth + pointMargin, width, height);
const toSpiral = (points) => spiralLayout(points,
pointWidth + pointMargin, width, height);
const toPhyllotaxis = (points) => phyllotaxisLayout(points,
pointWidth + pointMargin, width / 2, height / 2);
// store the layouts in an array to sequence through
const layouts = [toSine, toPhyllotaxis, toSpiral, toPhyllotaxis, toGrid];
// animate the points to a given layout
function animate(rects, layout) {
// store the source position
points.forEach(point => {
point.sx = point.x;
point.sy = point.y;
});
// get destination x and y position on each point
layout(points);
// store the destination position
points.forEach(point => {
point.tx = point.x;
point.ty = point.y;
});
timer = d3.timer((elapsed) => {
// compute how far through the animation we are (0 to 1)
const t = Math.min(1, ease(elapsed / duration));
// update point positions (interpolate between source and target)
points.forEach(point => {
point.x = point.sx * (1 - t) + point.tx * t;
point.y = point.sy * (1 - t) + point.ty * t;
});
// update what is drawn on screen
rects.data(points).attr('x', function(d) { return d.x })
.attr('y', function(d) { return d.y })
// if this animation is over
if (t === 1) {
// stop this timer for this layout and start a new one
timer.stop();
// update to use next layout
currLayout = (currLayout + 1) % layouts.length;
// start animation for next layout
animate(rects, layouts[currLayout]);
}
});
}
const svg = d3.select('body svg')
.attr('width', width)
.attr('height', height)
const rects = svg.selectAll('rect')
.data(points)
.enter()
.append('rect')
.attr('width', pointWidth)
.attr('height', pointWidth)
.style('fill', function(d) { return d.color })
// start off as a grid
toGrid(points);
rects.data(points).attr('x', function(d) { return d.x })
.attr('y', function(d) { return d.y })
d3.select('body').append('div')
.attr('class', 'play-control')
.text('PLAY')
.on('click', function () {
// start the animation
animate(rects, layouts[currLayout]);
// remove the play control
d3.select(this).style('display', 'none');
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment