This example is an implementation of a simple flocking algorithm that uses the dynamics of repulsion, attraction, and orientation zones. This example is based off of the algorithm described by Couzin.
The basic principle is that each agent has three regions in a growing radius around it: a zone of repulsion, a zone of orientation, and a zone of attraction respectively. By editing the sizes of these zones you can expect to experience different behaviour.
Play
button to start animationPause
button to stop animationNext
button to do one time step of the algorithm.Dragging/Zooming behavior forked from mbostock's block: Drag & Zoom II
forked from lwthatcher's block: Directional Forces
xxxxxxxxxx
<meta charset="utf-8">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<style> /* set the CSS */
.point {
fill: darkslategrey;
}
.line {
stroke: grey;
stroke-width: 1.5;
opacity: .5;
}
.repulsed {
fill: red;
stroke: red;
}
.attracted {
fill: blue;
stroke: blue;
}
.oriented {
fill: LawnGreen;
stroke: LawnGreen;
}
.ao {
fill: DarkTurquoise;
stroke: DarkTurquoise;
}
.moved {
stroke: black;
fill: black;
}
.R_r {
fill: lightsalmon;
}
.R_o {
fill: Chartreuse;
}
.R_a {
fill: lightskyblue;
}
.num {
float: left;
}
.counter {
width: 50px;
float: right;
margin-right: 40px;
}
</style>
<div class="row">
<div class="col-sm-10">
<svg id="svg" width="800" height="500"></svg>
</div>
<div class="col-sm-2">
<div class="row">
<b>Radii:</b>
</div>
<div class="row">
<div class="num">Repulsion: </div>
<input class="counter" type="number" onchange="updateR_r(this.value)" value=50></input>
</div>
<div class="row">
<div class="num">Orientation: </div>
<input class="counter" type="number" onchange="updateR_o(this.value)" value=100></input>
</div>
<div class="row">
<div class="num">Attraction: </div>
<input class="counter" type="number" onchange="updateR_a(this.value)" value=150></input>
</div>
<hr>
<div class="row">
Controls:
<br>
<button onclick="play()"><span class="glyphicon glyphicon-play"></button>
<button onclick="pause()"><span class="glyphicon glyphicon-pause"></button>
<button onclick="next()"><span class="glyphicon glyphicon-step-forward"></button>
</div>
<hr>
<div class="row">
<button class="btn btn-danger" onclick="reset()">Reset</button>
</div>
</div>
</div>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script>
// === VARIABLES ===
// Global vars
var R_r = 50;
var R_o = 100;
var R_a = 150;
var NUM_AGENTS = 30;
// Helper vars
var POINT_SIZE = 2.5;
var LINE_LENGTH = 30;
var TRAVEL_LENGTH = 15;
var DT = 350;
var INIT_SPARSITY = 100;
var timer;
// === INIT ===
var svg = d3.select("svg"),
width = +svg.attr("width"),
height = +svg.attr("height"),
transform = d3.zoomIdentity;
var points = d3.range(NUM_AGENTS).map(phyllotaxis(INIT_SPARSITY));
var g = svg.append("g");
// === D3 FUNCTIONS ===
var t = d3.transition()
.duration(DT-50)
.ease(d3.easeLinear);
var zoom = d3.zoom()
.scaleExtent([1 / 2.5, 8])
.on("zoom", zoomed);
function zoomed() {
transform = d3.event.transform;
g.attr("transform", d3.event.transform);
}
// === CONTROLS ===
var play = function() {
timer = d3.interval(() => {
next();
}, DT);
}
var pause = function() {
timer.stop();
}
function updateR_r(value) {
R_r = value;
d3.selectAll(".R_r").transition(t)
.attr("r", (d) => { return show_radius(d, R_r); });
}
function updateR_o(value) {
R_o = value;
d3.selectAll(".R_o").transition(t)
.attr("r", (d) => { return show_radius(d, R_o); });
}
function updateR_a(value) {
R_a = value;
d3.selectAll(".R_a").transition(t)
.attr("r", (d) => { return show_radius(d, R_a); });
}
function reset() {
svg.selectAll(".point").remove();
svg.selectAll(".line").remove();
svg.selectAll(".r").remove();
points = d3.range(NUM_AGENTS).map(phyllotaxis(INIT_SPARSITY));
start();
}
// === HELPER FUNCTIONS ===
function show_radius(d, r) {
if (d.show) return r;
else return 0;
}
// === EVENT HANDLERS ===
function onclick(d) {
d.show = !d.show;
console.info(d);
d3.select(".R_r.r--" + d.idx).transition(t)
.attr("r", (d) => { return show_radius(d, R_r); });
d3.select(".R_o.r--" + d.idx).transition(t)
.attr("r", (d) => { return show_radius(d, R_o); });
d3.select(".R_a.r--" + d.idx).transition(t)
.attr("r", (d) => { return show_radius(d, R_a); });
if (d.show) {
g.append("line")
.datum(d)
.attr('id', (d) => {return 'ln-' + d.idx})
.attr('class', 'line')
.attr('x1', (d) => d.x)
.attr('y1', (d) => d.y)
.attr('x2', (d) => d.x + LINE_LENGTH * Math.cos(toRadians(d.angle)))
.attr('y2', (d) => d.y + LINE_LENGTH * Math.sin(toRadians(d.angle)))
}
else {
g.select('#ln-'+d.idx).remove();
}
}
function dragged(d) {
dx = d3.event.x;
dy = d3.event.y;
// update point
d3.select(this)
.attr("cx", d.x = dx)
.attr("cy", d.y = dy);
// update collision state
checkCollisions();
// update line
d3.select("#ln-"+d.idx)
.attr('x1', dx)
.attr('y1', dy)
.attr('x2', dx + LINE_LENGTH * Math.cos(toRadians(d.angle)))
.attr('y2', dy + LINE_LENGTH * Math.sin(toRadians(d.angle)));
// update radius
d3.select(".R_r.r--" + d.idx)
.attr("cx", d.x = dx).attr("cy", d.y = dy)
.attr("r", (d) => { return show_radius(d, R_r); });
d3.select(".R_o.r--" + d.idx)
.attr("cx", d.x = dx).attr("cy", d.y = dy)
.attr("r", (d) => { return show_radius(d, R_o); })
d3.select(".R_a.r--" + d.idx)
.attr("cx", d.x = dx).attr("cy", d.y = dy)
.attr("r", (d) => { return show_radius(d, R_a); })
}
// === START ===
function start() {
// SETUP POINTS
g.selectAll(".point")
.data(points)
.enter().append("circle")
.attr("id", (d) => "pt-"+d.idx)
.attr("class", "point")
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.attr("r", POINT_SIZE)
.on("click", onclick)
.call(d3.drag()
.on("drag", dragged));
// draw zones
g.selectAll(".R_a")
.data(points)
.enter().append("circle")
.attr("class", (d) => {return "r R_a r--" + d.idx;})
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.attr("r", (d) => { return show_radius(d, R_a); })
.attr("opacity", 0.10);
g.selectAll(".R_o")
.data(points)
.enter().append("circle")
.attr("class", (d) => {return "r R_o r--" + d.idx;})
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.attr("r", (d) => { return show_radius(d, R_o); })
.attr("opacity", 0.15);
g.selectAll(".R_r")
.data(points)
.enter().append("circle")
.attr("class", (d) => {return "r R_r r--" + d.idx;})
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.attr("r", (d) => { return show_radius(d, R_r); })
.attr("opacity", 0.35);
// overlay points on top
g.selectAll(".point").raise();
svg.call(zoom);
checkCollisions();
}
// invoke immediately
start();
// === UPDATE FUNCTIONS ===
function next() {
// move points
g.selectAll(".point").transition(t)
.attr("cx",(d) => { d.x = d.next.x; return d.x;})
.attr("cy",(d) => { d.y = d.next.y; return d.y;});
// move radii
g.selectAll(".r").transition(t)
.attr("cx",(d) => { return d.x;})
.attr("cy",(d) => { return d.y;});
// get next positions
updateZones();
// update styles
g.selectAll(".point").transition(t)
.attr("class", (d) => {return "point " + d.next.style;});
// update lines
g.selectAll(".line").transition(t)
.attr('x1', (d) => d.x)
.attr('y1', (d) => d.y)
.attr('x2', (d) => d.next.x)
.attr('y2', (d) => d.next.y)
.attr('class', (d) => {return "line " + d.next.style;});
}
function updateZones() {
d3.selectAll(".point").each((pt1) => {
var r_pts = pointsInRadius(pt1, R_r);
var ptclass = "";
// nr > 0
if (r_pts.length > 0) {
var new_angle = r_angle(pt1, r_pts);
pt1.angle = new_angle;
ptclass = 'repulsed';
}
// nr == 0
else {
var o_pts = betweenRadii(pt1, R_r, R_o),
a_pts = betweenRadii(pt1, R_o, R_a);
if (o_pts.length > 0 || a_pts.length > 0) {
if (a_pts.length > 0 && o_pts.length === 0) {
ptclass = "attracted";
pt1.angle = a_angle(pt1, a_pts);
}
else if (o_pts.length > 0 && a_pts.length === 0) {
ptclass = "oriented"
pt1.angle = o_angle(pt1, o_pts);
}
else {
ptclass = "ao"
var dir_a = a_angle(pt1, a_pts);
var dir_o = o_angle(pt1, o_pts);
pt1.angle = (dir_a + dir_o)/2;
}
}
}
var dx = pt1.x + TRAVEL_LENGTH * Math.cos(toRadians(pt1.angle));
var dy = pt1.y + TRAVEL_LENGTH * Math.sin(toRadians(pt1.angle));
pt1.next = {"x":dx, "y":dy, "style": ptclass};
});
}
function checkCollisions() {
d3.selectAll(".point").each((pt1) => {
var pts = pointsInRadius(pt1, R_r);
var point = d3.select("#pt-"+pt1.idx);
var dx, dy;
// nr > 0
if (pts.length > 0) {
var new_angle = r_angle(pt1, pts);
pt1.angle = new_angle;
if (!point.classed('repulsed')) {
point.attr('class', 'point repulsed');
g.select("#ln-"+pt1.idx).attr('class', 'line repulsed')
.attr('x2', dx)
.attr('y2', dy);
}
g.select("#ln-"+pt1.idx)
.attr('x2', pt1.x + LINE_LENGTH * Math.cos(toRadians(pt1.angle)))
.attr('y2', pt1.y + LINE_LENGTH * Math.sin(toRadians(pt1.angle)));
}
// nr == 0
else {
var o_pts = betweenRadii(pt1, R_r, R_o),
a_pts = betweenRadii(pt1, R_o, R_a);
if (o_pts.length > 0 || a_pts.length > 0) {
if (a_pts.length > 0 && o_pts.length === 0) {
point.attr('class', 'point attracted');
pt1.angle = a_angle(pt1, a_pts);
g.select("#ln-"+pt1.idx).attr('class', 'line attracted')
.attr('x2', pt1.x + LINE_LENGTH * Math.cos(toRadians(pt1.angle)))
.attr('y2', pt1.y + LINE_LENGTH * Math.sin(toRadians(pt1.angle)));
}
else if (o_pts.length > 0 && a_pts.length === 0) {
point.attr('class', 'point oriented');
pt1.angle = o_angle(pt1, o_pts);
g.select("#ln-"+pt1.idx).attr('class', 'line oriented')
.attr('x2', pt1.x + LINE_LENGTH * Math.cos(toRadians(pt1.angle)))
.attr('y2', pt1.y + LINE_LENGTH * Math.sin(toRadians(pt1.angle)));
}
else {
point.attr('class', 'point ao');
var dir_a = a_angle(pt1, a_pts);
var dir_o = o_angle(pt1, o_pts);
pt1.angle = (dir_a + dir_o)/2;
g.select("#ln-"+pt1.idx).attr('class', 'line ao')
.attr('x2', pt1.x + LINE_LENGTH * Math.cos(toRadians(pt1.angle)))
.attr('y2', pt1.y + LINE_LENGTH * Math.sin(toRadians(pt1.angle)));
}
}
else if (point.classed('repulsed') || point.classed('attracted')) {
point.attr('class', 'point moved');
g.select("#ln-"+pt1.idx).attr('class', 'line moved');
}
}
dx = pt1.x + LINE_LENGTH * Math.cos(toRadians(pt1.angle));
dy = pt1.y + LINE_LENGTH * Math.sin(toRadians(pt1.angle));
pt1.next = {"x":dx, "y":dy}
});
}
// === MATH FUNCTIONS ===
function r_angle(d, pts) {
var sum = 0;
for (var p of pts) {
sum += Math.atan2(p.y-d.y, p.x-d.x)
}
var ave = sum / pts.length;
return toDegrees(ave) + 180;
}
function a_angle(d, pts) {
var sum = 0;
for (var p of pts) {
sum += Math.atan2(p.y-d.y, p.x-d.x)
}
var ave = sum / pts.length;
return toDegrees(ave);
}
function o_angle(d, pts) {
var sum = 0;
for (var p of pts) {
sum += p.angle;
}
var ave = sum / pts.length;
return ave;
}
function pointsInRadius(pt1, r) {
var result = [];
d3.selectAll(".point").each((d) => {
if (pt1.idx != d.idx && withinRadius(pt1, d, r)) {
result.push(d);
}
});
return result;
}
function betweenRadii(pt1, r_inner, r_outer) {
var result = [];
d3.selectAll(".point").each((d) => {
if (pt1.idx != d.idx && withinRadius(pt1, d, r_outer) && !withinRadius(pt1, d, r_inner)) {
result.push(d);
}
});
return result;
}
function withinRadius(pt1, pt2, r) {
var dx = pt1.x-pt2.x;
var dy = pt1.y-pt2.y;
return dx*dx + dy*dy <= r*r;
}
function toDegrees (angle) {
return (angle * (180 / Math.PI)) % 360;
}
function toRadians (angle) {
return angle * (Math.PI / 180);
}
function phyllotaxis(radius) {
var theta = Math.PI * (3 - Math.sqrt(5));
return function(i) {
var r = radius * Math.sqrt(i), a = theta * i;
return {
idx: i,
x: width / 2 + r * Math.cos(a),
y: height / 2 + r * Math.sin(a),
angle: toDegrees(a)+180,
show: (() => {return i === 0})() //only for the center one
};
};
}
</script>
https://d3js.org/d3.v4.min.js