Skip to content

Instantly share code, notes, and snippets.

@msand
Forked from pbeshai/.block
Last active January 27, 2019 03:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save msand/bbde0d63ad7df929571122c094d8b7c2 to your computer and use it in GitHub Desktop.
Save msand/bbde0d63ad7df929571122c094d8b7c2 to your computer and use it in GitHub Desktop.
Animate thousands of points with Web Animations and D3
license: mit
height: 620
border: no

Animate thousands of points with Web Animations and D3

Using D3 and d3-transition works great when animating hundreds of points with SVG, but performance breaks down when you need to animate more than a thousand. This block demonstrates a simple approach to animating thousands of points between different layouts using canvas, d3-timer, and d3-ease.

See the blog post for more details.

/**
* 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 points with Web Animations and D3</title>
<style>
html, body {
padding: 0;
margin: 0;
}
canvas {
cursor: pointer;
}
.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];
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 })
const r = rects._groups[0];
// start off as a grid
toGrid(points);
rects.data(points).attr('transform', function(d) { return 'matrix(1,0,0,1,' + d.x + ',' + d.y + ')' })
const layoutTransforms = layouts.map(layout => {
return layout(points).map((point, i) => {
const { x, y } = point;
return { transform: 'matrix(1,0,0,1,' + x + ',' + y + ')' };
});
});
const pointTransforms = points.map((point, i) => {
return layoutTransforms.map(layout => layout[i]);
})
// cycle all the layouts
function animate() {
const animations = pointTransforms.map((transform, i) => {
const rect = r[i];
const player = rect.animate(transform, {
duration: duration * layouts.length,
});
return player;
});
animations[0].onfinish = function(e) {
d3.select('.play-control').style('display', 'block');
}
}
d3.select('body').append('div')
.attr('class', 'play-control')
.text('PLAY')
.on('click', function () {
// start the animation
animate();
// 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