Skip to content

Instantly share code, notes, and snippets.

@pbellon
Last active July 5, 2017 11:01
Show Gist options
  • Save pbellon/61ec72efb4ee4325bfccd6e035f724ce to your computer and use it in GitHub Desktop.
Save pbellon/61ec72efb4ee4325bfccd6e035f724ce to your computer and use it in GitHub Desktop.
Random pseudo-circles concave hull
license: mit
// adapted from https://github.com/emeeks/d3.geom.concaveHull
(function() {
d3.concaveHull = function() {
var calculateDistance = stdevDistance,
padding = 0,
delaunay;
function distance(a, b) {
var dx = a[0]-b[0],
dy = a[1]-b[1];
return Math.sqrt((dx * dx) + (dy * dy));
}
function stdevDistance(delaunay) {
var sides = [];
delaunay.forEach(function (d) {
sides.push(distance(d[0],d[1]));
sides.push(distance(d[0],d[2]));
sides.push(distance(d[1],d[2]));
});
var dev = d3.deviation(sides);
var mean = d3.mean(sides);
return mean + dev;
}
function concaveHull(vertices) {
delaunay = d3.voronoi().triangles(vertices);
var longEdge = calculateDistance(delaunay);
mesh = delaunay.filter(function (d) {
return distance(d[0],d[1]) < longEdge && distance(d[0],d[2]) < longEdge && distance(d[1],d[2]) < longEdge
})
var counts = {},
edges = {},
r,
result = [];
// Traverse the edges of all triangles and discard any edges that appear twice.
mesh.forEach(function(triangle) {
for (var i = 0; i < 3; i++) {
var edge = [triangle[i], triangle[(i + 1) % 3]].sort(ascendingCoords).map(String);
(edges[edge[0]] = (edges[edge[0]] || [])).push(edge[1]);
(edges[edge[1]] = (edges[edge[1]] || [])).push(edge[0]);
var k = edge.join(":");
if (counts[k]) delete counts[k];
else counts[k] = 1;
}
});
while (1) {
var k = null;
// Pick an arbitrary starting point on a boundary.
for (k in counts) break;
if (k == null) break;
result.push(r = k.split(":").map(function(d) { return d.split(",").map(Number); }));
delete counts[k];
var q = r[1];
while (q[0] !== r[0][0] || q[1] !== r[0][1]) {
var p = q,
qs = edges[p.join(",")],
n = qs.length;
for (var i = 0; i < n; i++) {
q = qs[i].split(",").map(Number);
var edge = [p, q].sort(ascendingCoords).join(":");
if (counts[edge]) {
delete counts[edge];
r.push(q);
break;
}
}
}
}
if (padding !== 0) {
result = pad(result, padding);
}
return result;
}
function pad(bounds, amount) {
var result = [];
bounds.forEach(function(bound) {
var padded = [];
var area = 0;
bound.forEach(function(p, i) {
// http://forums.esri.com/Thread.asp?c=2&f=1718&t=174277
// Area = Area + (X2 - X1) * (Y2 + Y1) / 2
var im1 = i - 1;
if(i == 0) {
im1 = bound.length - 1;
}
var pm = bound[im1];
area += (p[0] - pm[0]) * (p[1] + pm[1]) / 2;
});
var handedness = 1;
if(area > 0) handedness = -1
bound.forEach(function(p, i) {
// average the tangent between
var im1 = i - 1;
if(i == 0) {
im1 = bound.length - 2;
}
//var tp = getTangent(p, bound[ip1]);
var tm = getTangent(p, bound[im1]);
//var avg = { x: (tp.x + tm.x)/2, y: (tp.y + tm.y)/2 };
//var normal = rotate2d(avg, 90);
var normal = rotate2d(tm, 90 * handedness);
padded.push([p[0] + normal.x * amount, p[1] + normal.y * amount ])
})
result.push(padded)
})
return result
}
function getTangent(a, b) {
var vector = { x: b[0] - a[0], y: b[1] - a[1] }
var magnitude = Math.sqrt(vector.x*vector.x + vector.y*vector.y);
vector.x /= magnitude;
vector.y /= magnitude;
return vector
}
function rotate2d(vector, angle) {
//rotate a vector
angle *= Math.PI/180; //convert to radians
return {
x: vector.x * Math.cos(angle) - vector.y * Math.sin(angle),
y: vector.x * Math.sin(angle) + vector.y * Math.cos(angle)
}
}
function ascendingCoords(a, b) {
return a[0] === b[0] ? b[1] - a[1] : b[0] - a[0];
}
concaveHull.padding = function (newPadding) {
if (!arguments.length) return padding;
padding = newPadding;
return concaveHull;
}
concaveHull.distance = function (newDistance) {
if (!arguments.length) return calculateDistance;
calculateDistance = newDistance;
if (typeof newDistance === "number") {
calculateDistance = function () {return newDistance};
}
return concaveHull;
}
return concaveHull;
}
})()
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="d3-concaveHull.js"></script>
<script src="https://rawgit.com/mrdoob/stats.js/master/build/stats.min.js"></script>
<style>
body { margin:0;position:fixed;top:0;right:0;bottom:0;left:0; }
</style>
</head>
<body>
<script>
var DEBUG = true;
var simulation, stats;
var rand = (min , max)=>(Math.random()*max + min);
var randPick = (arr)=>(arr[Math.round(rand(0, arr.length-1))]);
// randomly return 1 or -1
var randSign = ()=>(Math.random()>0.5 ? 1:-1);
if(DEBUG){
stats = new Stats();
stats.showPanel( 0 ); // 0: fps, 1: ms, 2: mb, 3+: custom
document.body.appendChild( stats.dom );
}
var width = 960;
var height = 500;
// circle constants
var SHOW_CIRCLE_POINTS = false;
var PHI = Math.PI * 4;
var POINTS_PER_CIRCLE = 22;
var CURVE = d3.curveBasisClosed;
var RADIUS_JITTER = 0.12;
// hull constants
var SHOW_HULL = true;
var HULL_PADDING = 10;
var HULL_CURVE = CURVE;
// animations constants
var UPDATE_SIMULATION_INTERVAL = 500;
var animations = {
position: {
interval: 350,
duration: 2000
},
shape: {
interval:311,
duration: 1000
}
};
var circles = [
{ animating: false, x: 340, y: 150, radius: 23, color: 'cyan' },
{ animating: false, x: 540, y: 127, radius: 106, color: 'red' },
{ animating: false, x: 200, y: 200, radius: 76, color: 'blue' },
{ animating: false, x: 403, y: 370, radius: 80, color: 'green' },
];
var canvas = d3.select("body").append("canvas")
.attr("width", width)
.attr("height", height);
var context = canvas.node().getContext('2d');
var radialLine = d3.radialLine()
.angle(function(d){ return d.angle; })
.radius(function(d){ return d.radius; })
.curve(CURVE);
const hull = d3.concaveHull().padding(20).distance(200);
var hullLine = d3.line()
.curve(HULL_CURVE);
var circlePoints = function(radius, nbPoints){
var stepAngle = PHI/nbPoints;
var points = [];
for(var i=0; i<nbPoints; i++){
var jitterAngle = randSign()*(Math.random()/nbPoints);
var angle = stepAngle*i;
sign = Math.round(Math.random()) == 0 ? 1 : -1;
var jitterRadius = sign * Math.round(
(radius*RADIUS_JITTER)*(Math.random())
);
points.push({
angle: angle,
radius: radius + jitterRadius,
});
}
return points;
};
var reshapeCircle = (circle, duration)=>{
circle.reshaping = true;
var oldPoints = circle.points;
var newPoints = circlePoints(circle.radius, oldPoints.length);
var interpolator = d3.interpolateArray(oldPoints,newPoints);
var timer = d3.timer((time)=>{
var timeRatio = time/duration;
var points = interpolator(timeRatio);
// console.log(points);
circle.points = points;
if(timeRatio > 1.0){
timer.stop();
circle.reshaping = false;
}
})
};
var moveCircle = (circle, duration)=>{
var offsetX = randSign() * circle.radius * 0.3;
var offsetY = randSign() * circle.radius * 0.3;
var otherPosition = Object.assign({}, circle, {
x: circle.x + offsetX,
y: circle.y + offsetY
});
var interpolator = d3.interpolateObject(circle, otherPosition);
var revInterpolator = d3.interpolateObject(otherPosition, circle);
var timer = d3.timer(function(time){
circle.animating = true;
var timeRatio = time/duration;
var _interpolator = timeRatio <= 0.5 ? interpolator : revInterpolator;
var pos = _interpolator(timeRatio);
circle.x = pos.x;
circle.y = pos.y;
if(timeRatio > 1.0){
circle.animating = false;
timer.stop();
}
});
};
// intervals, used to animate regularly the viz
d3.interval(()=>(simulation.alpha(0.2)), UPDATE_SIMULATION_INTERVAL);
d3.interval((time)=>{
var _circles = circles.filter((circle)=>!circle.reshaping);
if(_circles.length){
var circle = randPick(_circles);
reshapeCircle(circle, animations.shape.duration);
}
});
d3.interval(function(time){
var _circles = circles.filter((circle)=>!circle.animating);
if(_circles.length){
var circle = randPick(_circles);
moveCircle(circle, animations.position.duration);
}
}, animations.position.interval);
var drawNodes = function(nodes){
nodes.forEach(function(node){
context.translate(node.x, node.y);
context.beginPath();
var path = radialLine(node.points);
context.fillStyle = node.color;
context.fill(new Path2D(path));
context.closePath();
context.translate(-node.x, -node.y);
});
};
var drawHull = function(nodes, padding){
const x = function(p){return Math.cos(p.angle) * (p.radius+padding);};
const y = function(p){return Math.sin(p.angle) * (p.radius+padding);};
const points = nodes.map(function(node){
const {x:cx, y:cy} = node;
return node.points.map((p)=>[ cx+x(p), cy+y(p) ]);
}).reduce((a,b)=>a.concat(b));
const path = hullLine(hull(points)[0]);
context.beginPath();
context.fillStyle = 'rgba(0,0,0,0)';
context.strokeStyle = '#bbb';
context.stroke(new Path2D(path));
context.closePath();
if(SHOW_CIRCLE_POINTS){
points.forEach((point)=>{
context.beginPath();
context.fillStyle = '#bbb';
context.arc(point[0], point[1], 2,0, Math.PI*2);
context.fill();
context.closePath();
});
}
}
var configSimulation = function(data, drawNodes){
var ticks = 0;
var pickPoint = function(i){ return i % 3 == 0; };
var shouldRun = function(mod){
return ticks % mod === 0;
}
var onTick = function(){
DEBUG && stats.begin();
ticks = ticks + 1;
context.clearRect(0, 0, width, height);
drawNodes(data);
if(SHOW_HULL){
drawHull(data, HULL_PADDING);
}
DEBUG && stats.end();
};
return d3.forceSimulation(data)
.force('x', d3.forceX().x(function(d){ return d.x }))
.force('y', d3.forceY().y(function(d){ return d.y }))
.on('tick', onTick);
}
// first initialisation of circles
circles.forEach(function(c){
c.points = circlePoints(c.radius, POINTS_PER_CIRCLE);
});
simulation = configSimulation(circles, drawNodes);
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment