Built with blockbuilder.org
forked from pbellon's block: Random pseudo-circles
license: mit |
Built with blockbuilder.org
forked from pbellon's block: Random pseudo-circles
// 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> |