Dotty particle transitions with pixi.js and the new d3v4 forceSimulation
Feel free to go large
N.b. using the RenderTexture approach to avoid redrawing circles, pixi is happy with high node counts. the force simulation starts slowing down around about the 2,000 node mark.
Further reading:
xxxxxxxxxx
<html>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.2.2/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
<script>
// measure doc
const width = document.body.clientWidth;
const height = document.body.clientHeight;
// determine proportions
const radius = (width > 1024) ? 3 : 2;
const spacing = radius * 3;
const fontSize = Math.floor(0.2 * width) + "px";
console.log("window is", width, "x", height, "px");
console.log("dot radius is", radius + "px");
console.log("font size is", fontSize);
// tweak these!
const options = {
width: width,
height: height,
imgWidth: width * 0.439790, // daft magic to ensure eye outline has appropriate point count
imgHeight: width * 0.439790 * 0.595000, // as above -- the golden ratio :)
x: width / 2,
y: height / 3,
radius: radius,
spacing: spacing,
fontSize: fontSize,
fill: 0x2e88fd, //"rgba(46, 136, 253, 1)", // #2e88fd
collisionStrength: 0.1,
velocityDecay: 0.2
};
const titles = ["viSFest"];//, "d3.unconf", "oct 16-17"];
// create pixi renderer and stage objects
//var renderer = new PIXI.CanvasRenderer(800, 600);
const renderer = new PIXI.autoDetectRenderer(width, height, { backgroundColor : 0xffffff });
const stage = new PIXI.Container();
// snapshot a circle to a texture for optimal rendering perf
const gfx = new PIXI.Graphics();
const tileSize = options.spacing;
const texture = PIXI.RenderTexture.create(tileSize, tileSize);
gfx.beginFill(options.fill);
gfx.drawCircle(tileSize/2, tileSize/2, options.radius);
gfx.endFill();
renderer.render(gfx, texture);
// add fx filters
// stage.filters = createEffectFilters();
// rasterize title text to build point maps
const titleCoords = titles.map(function(title){
return rasterizeText(title, options);
});
// determine coords for svg outline and start animation
getOutlineForSVG("eye.svg", options, function (eyeCoords) {
// each state contains a list of x/y coords to target
const states = [eyeCoords].concat(titleCoords);
// determine how many nodes needed for longest list of coords
const nodeCount = d3.max(states, function(state){
return state.length;
})
// create required nodes
const nodes = d3.range(nodeCount).map(function (index) {
// create a new Sprite using the texture
const sprite = new PIXI.Sprite(texture);
// center the sprite's anchor point
sprite.anchor.x = 0.5 * tileSize;
sprite.anchor.y = 0.5 * tileSize;
return {
_id: index,
sprite: sprite,
rTarget: options.radius,
active: false
};
});
console.log("created", nodeCount, "nodes");
// create force simulation that will animate the node positions
const simulation = d3.forceSimulation(nodes);
const strength = options.collisionStrength;
const decay = options.velocityDecay;
// define forces that will act on the above
const xForce = d3.forceX(function(d) { return d.xTarget; }).strength(strength);
const yForce = d3.forceY(function(d) { return d.yTarget; }).strength(strength);
const collisionForce = d3.forceCollide().radius(function(d) { return d.rTarget; });
const updateNodeLocations = function () {
nodes.forEach(function(node) {
node.sprite.position.x = node.x;
node.sprite.position.y = node.y;
})
}
const updateNodeTargets = function (nodes, coords, options) {
const coordCount = coords.length;
for (var i = 0, node; i < nodeCount; i++) {
node = nodes[i];
if (i < coordCount) {
// bring in previously inactive notes at random locations
if (!node.active) {
node.x = Math.random() * options.width;
node.y = Math.random() * options.height;
}
// set targets of force simulation according to next coords
node.xTarget = coords[i][0];
node.yTarget = coords[i][1];
node.active = true;
stage.addChild(node.sprite);
} else {
node.active = false;
stage.removeChild(node.sprite);
}
};
}
const restartSimulation = function (simulation, nodes, xForce, yForce) {
simulation
.nodes(nodes.filter(function(n){
return n.active;
}))
.force("x", xForce)
.force("y", yForce)
.alpha(1)
.restart();
}
var state = 0;
var targetCoords = states[state];
var gotoNextState = function () {
state = (state + 1) % states.length;
targetCoords = states[state];
console.log("advancing to state", state);
updateNodeTargets(nodes, targetCoords, options);
restartSimulation(simulation, nodes, xForce, yForce);
}
var animateSprites = function () {
requestAnimationFrame(animateSprites);
renderer.render(stage);
}
updateNodeTargets(nodes, targetCoords, options);
simulation
.velocityDecay(decay)
.force("x", xForce)
.force("y", yForce)
.force("collide", collisionForce)
.on("tick", updateNodeLocations)
.on("end", gotoNextState);
document.body.appendChild(renderer.view);
animateSprites();
})
//////////// welcome to the library ///////////////////////////////////////////
// Convert text into grid of points that lay on top of the text
// Inspired by FizzyText. cf https://bl.ocks.org/tophtucker/978513bc74d0b32d3795
function rasterizeText (text, options) {
var o = options || {};
var fontSize = o.fontSize || "200px",
fontWeight = o.fontWeight || "600",
fontFamily = o.fontFamily || "sans-serif",
textAlign = o.center || "center",
textBaseline = o.textBaseline || "middle",
spacing = o.spacing || 10,
width = o.width || 960,
height = o.height || 500,
x = o.x || (width / 2),
y = o.y || (height / 2);
var canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
var context = canvas.getContext("2d");
context.font = [fontWeight, fontSize, fontFamily].join(" ");
context.textAlign = textAlign;
context.textBaseline = textBaseline;
var dx = context.measureText(text).width,
dy = +fontSize.replace("px", ""),
bBox = [[x - dx / 2, y - dy / 2], [x + dx / 2, y + dy / 2]];
context.fillText(text, x, y);
var imageData = context.getImageData(0, 0, width, height);
return findPoints(imageData, bBox, spacing);
}
// scan image data for filled pixels,
// return list of x,y coords spaced as required
function findPoints (imageData, rect, spacing) {
var points = [];
for (var x = rect[0][0]; x < rect[1][0]; x += spacing) {
for (var y = rect[0][1]; y < rect[1][1]; y += spacing) {
var pixel = getPixel(imageData, x, y);
if (pixel[3] != 0) points.push([x, y]);
}
}
return points;
}
// read pixel from imageData at required coords
function getPixel (imageData, x, y) {
var i = 4 * (parseInt(x) + parseInt(y) * imageData.width);
var d = imageData.data;
return [ d[i], d[i+1], d[i+2], d[i+3] ];
}
// Blur effect filter
function createEffectFilters () {
const colorMatrix = new PIXI.filters.ColorMatrixFilter();
const blurFilter = new PIXI.filters.BlurFilter();
colorMatrix.saturate(2);
blurFilter.blur = 0.5;
return [colorMatrix, blurFilter];
}
// legacy version using svg
function createSVGCircles (svg, nodes, options) {
// create group to hold circle elements
const layer = svg.append("g").attr("class", "circles");
// bind svg circle elements to all nodes
const circles = layer.selectAll("circle")
.data(nodes)
.enter()
.append("circle");
// apply glow filter
layer.style("filter", "url(#glow)");
// init class, position, radius of circle elements
circles
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.attr("r", function(d) { return d.rTarget; })
.style("fill", options.fill);
return circles;
}
// get outline for contents of img element
// using same approach as text rasterizer
// c.f. https://jsfiddle.net/AbdiasSoftware/Y3K57/
const getOutlineForImage = function (img, width, height, spacing) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
const bounds = [[0,0],[width,height]];
canvas.width = width;
canvas.height = height;
context.drawImage(img, 0, 0, width, height);
return findPoints(
context.getImageData(0, 0, width, height),
bounds,
spacing
);
}
// load svg from path into img element, feed into routine above
// c.f. https://jsfiddle.net/AbdiasSoftware/Y3K57/
function getOutlineForSVG (path, options, onComplete) {
const img = new Image;
const spacing = options.spacing;
img.onload = function () {
const width = img.width;
const height = img.height;
// read outline coords and translate to viewport
const coords = getOutlineForImage(img, width, height, spacing);
const offset = {
x: options.x - width / 2,
y: options.y - height / 2
}
for (var i = 0; i < coords.length; i++) {
coords[i][0] += offset.x;
coords[i][1] += offset.y;
};
// return outline coords to callback
onComplete(coords);
};
img.crossOrigin = 'anonymous';
img.width = options.imgWidth;
img.height = options.imgHeight;
img.src = path;
}
</script>
</body>
</html>
https://cdnjs.cloudflare.com/ajax/libs/d3/4.2.2/d3.min.js
https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js