<main>
<svg id="view">
<g id="path-layer"></g>
</svg>
</main>
body {
background: #222;
}
main {
width: 100vw;
height: 100vh;
overflow: hidden;
}
#view {
width: 100%;
height: 100%;
background: #222;
}
.repeller {
fill: orange;
fill-opacity: 0.1;
stroke: orange;
stroke-opacity: 0.8;
}
.point {
fill: #5a5a5a;
&.first {
fill: orange;
}
}
.path {
stroke: #bbb;
stroke-width: 2;
fill: none;
}
#path-layer {
pointer-events: none;
}
View Compiled
console.clear();
var log = console.log.bind(console);
//
// POINT
// ========================================================================
class Point {
endX = 0;
endY = 0;
radius = config.radius || 5;
speed = config.speed || 20;
minDist = config.minDist || 0;
maxDist = config.maxDist || 50;
element = createSvg("circle", {
appendTo: parent,
transforms: true,
class: `point${!prev ? " first" : ""}`,
r: this.radius
});
transform = this.element._gsTransform;
firstRun = true;
next = null;
constructor(public parent, public prev, public config) {
_.bindAll(this, "setMove", "startMove");
if (this.prev) prev.next = this;
this.setMove();
this.firstRun = false;
}
get x() { return this.transform.x; }
set x(x) { TweenLite.set(this.element, { x }); }
get y() { return this.transform.y; }
set y(y) { TweenLite.set(this.element, { y }); }
setMove(updateNext) {
var lastX = this.endX;
var lastY = this.endY;
if (this.prev) {
var scale = this.firstRun ? 2 : 1;
var signX = randomSign();
var signY = randomSign();
var min = this.minDist * scale;
var max = this.maxDist * scale;
this.endX = screen.randomX(this.prev.endX + min * signX, this.prev.endX + max * signX);
this.endY = screen.randomY(this.prev.endY + min * signY, this.prev.endY + max * signY);
} else {
this.endX = screen.randomX();
this.endY = screen.randomY();
}
var sameX = fuzzyEqual(lastX, this.endX, 1);
var sameY = fuzzyEqual(lastY, this.endY, 1);
if (sameX || sameY) {
_.defer(this.setMove);
return;
}
this.startMove();
}
startMove() {
TweenLite.to(this.element, this.duration, {
x: this.endX,
y: this.endY,
ease: Power1.easeInOut,
onComplete: this.setMove
});
}
get duration() {
if (this.firstRun) return 0.01;
var dx = this.x - this.endX;
var dy = this.y - this.endY;
var dist = Math.sqrt(dx * dx + dy * dy);
return dist / this.speed * _.random(0.9, 1.1);
}
onHit(x, y) {
TweenLite.to(this.element, 0.25, { x, y, onComplete: this.setMove });
}
}
//
// PATH
// ========================================================================
class Path {
points = [];
group = createSvg("g", {
appendTo: parent,
class: "path-group"
});
path = createSvg("path", {
appendTo: this.group,
class: "path"
});
k = _.isNumber(config.tension) ? config.tension : 1;
constructor(public parent, public config) {
_.bindAll(this, "render");
var pointProps = "minDist,maxDist,speed,radius".split(",")
var pointConfig = _.pick(config, pointProps);
var min = Math.max(config.minPoints, 3);
var max = Math.max(config.maxPoints, 3);
var prev = null;
_.times(_.random(min, max), i => {
prev = new Point(this.group, prev, pointConfig);
this.points.push(prev);
});
TweenLite.ticker.addEventListener("tick", this.render, null, false, 1);
}
getRect() { return this.group.getBoundingClientRect(); }
render() {
var data = _.reduce(this.points, (res, point) => {
res.push(point.x, point.y);
return res;
}, []);
var size = data.length;
var last = size - 4;
var path = `M${data[0]},${data[1]}`;
for (var i = 0; i < size - 2; i +=2) {
var x0 = i ? data[i - 2] : data[0];
var y0 = i ? data[i - 1] : data[1];
var x1 = data[i + 0];
var y1 = data[i + 1];
var x2 = data[i + 2];
var y2 = data[i + 3];
var x3 = i !== last ? data[i + 4] : x2;
var y3 = i !== last ? data[i + 5] : y2;
var cp1x = x1 + (x2 - x0) / 6 * this.k;
var cp1y = y1 + (y2 - y0) / 6 * this.k;
var cp2x = x2 - (x3 - x1) / 6 * this.k;
var cp2y = y2 - (y3 - y1) / 6 * this.k;
path += ` C${cp1x},${cp1y},${cp2x},${cp2y},${x2},${y2}`;
}
this.path.setAttribute("d", path);
}
}
//
// REPELLER
// ========================================================================
class Repeller {
vRatio = 0.05;
x = screen.randomX();
y = screen.randomY();
endX = this.x;
endY = this.y;
element = createSvg("circle", {
appendTo: parent,
class: "repeller",
r: this.radius,
x: this.x,
y: this.y
});
tracker = VelocityTracker.track(this, "x,y");
constructor(public parent, public radius = 50) {
_.bindAll(this, "update", "checkHit", "onMove");
document.addEventListener("mousemove", this.onMove);
TweenLite.ticker.addEventListener("tick", this.update, null, false, 100);
}
get vx() { return this.tracker.getVelocity("x") || 10; }
get vy() { return this.tracker.getVelocity("y") || 10; }
update() {
TweenLite.set(this.element, { x: this.x, y: this.y });
var rect1 = this.getRect();
_.forEach(paths, path => {
var rect2 = path.getRect();
if (rectIntersects(rect1, rect2)) {
_.forEach(path.points, this.checkHit);
}
});
}
checkHit(point) {
var dx = this.x - point.x;
var dy = this.y - point.y;
var distSq1 = dx * dx + dy * dy;
var distSq2 = (this.radius + point.radius) ** 2;
if (distSq1 < distSq2) {
var dist1 = Math.sqrt(distSq1);
var dist2 = Math.sqrt(distSq2);
var angle = Math.atan2(dy, dx);
var x1 = this.x + dist1 * Math.cos(angle);
var y1 = this.y + dist1 * Math.sin(angle);
var x2 = this.x + dist2 * Math.cos(angle);
var y2 = this.y + dist2 * Math.sin(angle);
var vx = this.vx * this.vRatio;
var vy = this.vy * this.vRatio;
var x = point.x + x1 - x2 + vx;
var y = point.y + y1 - y2 + vy;
point.onHit(x, y);
}
}
getRect() {
return this.element.getBoundingClientRect();
}
onMove(event) {
this.x = event.pageX;
this.y = event.pageY;
}
}
//
// SCREEN
// ========================================================================
class Screen {
width = window.innerWidth;
height = window.innerHeight;
constructor(public space = 100) {
window.addEventListener("resize", _.bind(this.onResize, this));
}
get minX() { return this.space; }
get minY() { return this.space; }
get maxX() { return this.width - this.space; }
get maxY() { return this.height - this.space; }
randomX(min, max) {
min = clamp(_.isNumber(min) ? min : this.minX, this.minX, this.maxX);
max = clamp(_.isNumber(max) ? max : this.maxX, this.minX, this.maxX);
return _.random(min, max);
}
randomY(min, max) {
min = clamp(_.isNumber(min) ? min : this.minY, this.minY, this.maxY);
max = clamp(_.isNumber(max) ? max : this.maxY, this.minY, this.maxY);
return _.random(min, max);
}
onResize(event) {
this.width = window.innerWidth;
this.height = window.innerHeight;
}
}
//
// HELPERS
// ========================================================================
function createSvg(type, config) {
var node = document.createElementNS(xmlns, type);
if (config) {
if (config.appendTo) config.appendTo.appendChild(node);
if (config.transforms) _.defaults(config, { x: "+=0" });
var attrs = "id,class,cx,cy,r,d,points".split(",");
var css = _.assign(_.omit(config, attrs, "appendTo", "attr", "transforms"));
css.attr = _.assign(_.pick(config, attrs), _.omit(config.attr));
TweenLite.set(node, css);
}
return node;
}
function fuzzyEqual(a, b, epsilon = 0.0001) {
return Math.abs(a - b) < epsilon;
}
function randomSign() {
return Math.random() < 0.5 ? -1 : 1;
}
function rectIntersects(r1, r2) {
return !(r1.left + r1.width < r2.left || r1.top + r1.height < r2.top ||
r2.left + r2.width < r1.left || r2.top + r2.height < r1.top);
}
function clamp(val, min, max) {
return val < min ? min : val > max ? max : val;
}
//
// RUN
// ========================================================================
var xmlns = "http://www.w3.org/2000/svg";
var numPaths = 3;
var paths = [];
var view = document.querySelector("#view");
var pathLayer = document.querySelector("#path-layer");
var screen = new Screen(100);
var repeller = new Repeller(view, 60);
_.times(numPaths, i => {
var path = new Path(pathLayer, {
radius: 4,
tension: 1,
minDist: 0,
maxDist: 100,
minPoints: 5,
maxPoints: 8,
speed: 25
});
paths.push(path);
});
View Compiled
This Pen doesn't use any external CSS resources.