A25 ·
Metaballs
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<rect width="100%" height="100%" fill="#080327" />
<g stroke-width="8" id="js-amoeba">
<circle id="js-parent-membrane" cx="400" cy="600" r="96"
fill="#080327" stroke="#3050D5" />
<path id="js-membrane-connector" d="" fill="#080327" stroke="#3050D5" />
<circle id="js-child-membrane" class="no-touch" cx="400" cy="600" r="64"
fill="#080327" stroke="#3050D5" opacity="0" />
<g class="move">
<!-- child ectoplasm -->
<circle id="js-child-ectoplasm" cx="500" cy="600" r="32"
fill="#080327" stroke="#FFB742" />
<!-- parent core -->
<g id="js-parent-core" class="no-touch" opacity="1">
<circle cx="500" cy="600" r="48"
fill="#080327" stroke="#FFB742" />
<circle class="no-touch" cx="500" cy="600" r="12"
fill="#FF5645" />
</g>
<path id="js-ectoplasm-connector" class="no-touch" d="" fill="#080327" stroke="#FFB742" />
<!-- child nucleus -->
<circle id="js-child-nucleus" class="no-touch" cx="500" cy="600" r="12"
fill="#FF5645" />
<path id="js-nucleus-connector" class="no-touch" d="" fill="#FF5645" />
</g>
</g>
</svg>
</main> .move {
cursor: move;
}
.ew-resize {
cursor: ew-resize;
}
.no-touch {
pointer-events: none;
}
@keyframes pulse {
from {
transform: scale3d(1, 1, 1);
}
50% {
transform: scale3d(1.1, 1.1, 1.1);
}
to {
transform: scale3d(1, 1, 1);
}
}
.pulse {
animation-name: pulse;
transform-origin: center;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
} // Dom Nodes
const amoeba = {
parent: {
membrane: document.querySelector('#js-parent-membrane'),
core: document.querySelector('#js-parent-core'),
ectoplasm: document.querySelectorAll('#js-parent-core > circle')[0],
nucleus: document.querySelectorAll('#js-parent-core > circle')[1],
},
child: {
ectoplasm: document.querySelector('#js-child-ectoplasm'),
membrane: document.querySelector('#js-child-membrane'),
nucleus: document.querySelector('#js-child-nucleus'),
},
membraneConnector: document.querySelector('#js-membrane-connector'),
ectoplasmConnector: document.querySelector('#js-ectoplasm-connector'),
nucleusConnector: document.querySelector('#js-nucleus-connector'),
};
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
PARENT_MEMBRANE: 96,
CHILD_MEMBRANE: 64,
PARENT_ECTO: 48,
CHILD_ECTO: 32,
NUCLEUS: 12,
};
const animationFrame$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame);
/**
* Create an observable stream to handle drag gesture
*/
const drag = ({ element, pan$, onStart, onEnd }) => {
const panStart$ = pan$.filter(e => e.type === 'panstart');
const panMove$ = pan$.filter(e => e.type === 'panmove');
const panEnd$ = pan$.filter(e => e.type === 'panend');
return panStart$.switchMap(() => {
const { start, w, h } = getStartInfo(element);
return panMove$.map(scaleToCanvas({ start, w, h })).takeUntil(panEnd$);
});
};
/**
* Generate the drag handler for a DOM element
*/
function handleDrag(element) {
const hammerPan = new Hammer(element, {
direction: Hammer.DIRECTION_ALL,
});
hammerPan.get('pan').set({ direction: Hammer.DIRECTION_ALL });
const pan$ = Rx.Observable.fromEvent(hammerPan, 'panstart panmove panend');
const drag$ = drag({
element: element,
pan$,
});
return animationFrame$.withLatestFrom(drag$, (_, p) => p);
}
/**
* Amoeba drag location as an observable
*/
const amoebaDrag$ = handleDrag(amoeba.child.ectoplasm);
// Derive lerped location from amoebaDrag$
const dragWithLerp = (lerpiness = 0.05, start = [400, 600]) =>
amoebaDrag$
.scan(lerp(lerpiness))
.map(p => [p.x, p.y])
.startWith(start);
/**
* Movement and mitosis logic
*/
const mitosis$ = new Rx.Subject();
// Move individual parts based on drag$
const membrane$ = dragWithLerp(0.03)
.do(loc => {
moveTo(loc, amoeba.parent.membrane);
moveTo(loc, amoeba.parent.ectoplasm);
moveTo(loc, amoeba.parent.nucleus);
})
.takeUntil(mitosis$)
.share();
const nucleus$ = dragWithLerp(0.075)
.do(loc => moveTo(loc, amoeba.child.nucleus))
.share();
const core$ = dragWithLerp(0.07)
.do(loc => {
moveTo(loc, amoeba.child.membrane);
moveTo(loc, amoeba.child.ectoplasm);
})
.share();
/**
* Various metaball style connectors
*/
const membraneConnector$ = Rx.Observable
.combineLatest(membrane$, core$, (membraneLoc, coreLoc) =>
metaball(
SIZES.PARENT_MEMBRANE,
SIZES.CHILD_MEMBRANE,
membraneLoc,
coreLoc,
() => {
mitosis$.next(true);
},
),
)
.share()
.do(path => {
amoeba.membraneConnector.setAttribute('d', path);
})
.takeUntil(mitosis$)
.do(null, null, () => {
amoeba.membraneConnector.setAttribute('d', '');
amoeba.child.membrane.setAttribute('opacity', 1);
amoeba.parent.core.setAttribute('opacity', 1);
});
const ectoplasmConnector$ = Rx.Observable
.combineLatest(membrane$, core$, (membraneLoc, coreLoc) =>
metaball(SIZES.PARENT_ECTO, SIZES.CHILD_ECTO, membraneLoc, coreLoc),
)
.share()
.do(path => {
amoeba.ectoplasmConnector.setAttribute('d', path);
})
.takeUntil(mitosis$)
.do(null, null, () => {
amoeba.ectoplasmConnector.setAttribute('d', '');
});
const nucleusConnector$ = Rx.Observable
.combineLatest(membrane$, nucleus$, (membraneLoc, nucleusLoc) =>
metaball(SIZES.NUCLEUS, SIZES.NUCLEUS, membraneLoc, nucleusLoc),
)
.share()
.do(path => {
amoeba.nucleusConnector.setAttribute('d', path);
})
.takeUntil(mitosis$)
.do(null, null, () => {
amoeba.nucleusConnector.setAttribute('d', '');
});
Rx.Observable
.merge(
membrane$,
nucleus$,
core$,
membraneConnector$,
ectoplasmConnector$,
nucleusConnector$,
)
.subscribe(() => {});
/**
* Based on Metaball script by SATO Hiroyuki
* http://park12.wakwak.com/~shp/lc/et/en_aics_script.html
*/
function metaball(
radius1,
radius2,
center1,
center2,
onMitosis = () => {},
handleLenRate = 2.4,
v = 0.5,
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0) {
return '';
}
if (d > maxDist) {
onMitosis();
return '';
}
if (d <= Math.abs(radius1 - radius2)) {
return '';
} else if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angle1 = angle(center2, center1);
const angle2 = Math.acos((radius1 - radius2) / d);
const angle1a = angle1 + u1 + (angle2 - u1) * v;
const angle1b = angle1 - u1 - (angle2 - u1) * v;
const angle2a = angle1 + Math.PI - u2 - (Math.PI - u2 - angle2) * v;
const angle2b = angle1 - Math.PI + u2 + (Math.PI - u2 - angle2) * v;
// Points
const p1a = getVector(center1, angle1a, radius1);
const p1b = getVector(center1, angle1b, radius1);
const p2a = getVector(center2, angle2a, radius2);
const p2b = getVector(center2, angle2b, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleLenRate, dist(p1a, p2a) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1a, angle1a - HALF_PI, r1);
const h2 = getVector(p2a, angle2a + HALF_PI, r2);
const h3 = getVector(p2b, angle2b - HALF_PI, r2);
const h4 = getVector(p1b, angle1b + HALF_PI, r1);
return metaballToPath(
p1a,
p2a,
p1b,
p2b,
h1,
h2,
h3,
h4,
d > radius1,
radius2,
);
}
function metaballToPath(p1a, p2a, p1b, p2b, h1, h2, h3, h4, escaped, r) {
// prettier-ignore
return [
'M', p1a,
'C', h1, h2, p2a,
'A', r, r, 0, escaped ? 1 : 0, 0, p2b,
'C', h3, h4, p1b,
].join(' ');
}
/**
* Utils
*/
function getStartInfo(element) {
const start = {
x: +element.getAttribute('cx'),
y: +element.getAttribute('cy'),
};
const w = document.body.clientWidth;
const h = document.body.clientHeight;
return { start, w, h };
}
function scaleToCanvas({ start: { x, y }, w, h }) {
const svgW = w > h ? VIEWBOX_SIZE.W : VIEWBOX_SIZE.W * w / h;
const svgH = w > h ? VIEWBOX_SIZE.H * h / w : VIEWBOX_SIZE.H;
return e => ({
x: x + mapFromToRange(e.deltaX, 0, w, 0, svgW),
y: y + mapFromToRange(e.deltaY, 0, h, 0, svgH),
});
}
function mapFromToRange(x, x1, x2, y1, y2) {
return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
}
function moveTo([x, y], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
/**
* Lerp
* based on @davidkpiano's RXCSS
*/
function lerp(rate) {
return ({ x, y }, targetValue) => {
const mapValue = (value, tValue) => {
const delta = (tValue - value) * rate;
return value + delta;
};
return {
x: mapValue(x, targetValue.x),
y: mapValue(y, targetValue.y),
};
};
} Metaballs, not to be confused with meatballs, are organic looking squishy gooey blobs. From a mathematical perspective they are an iso-surface. They are rendered using equations such as f(x,y,z) = r / ((x - x0)2 + (y - y0)2 + (z - z0)2). Jamie Wong has a fantastic tutorial on rendering metaballs with canvas.
We can replicate the metaball effect using CSS & SVG by applying both blur and contrast filters to an element. For example in Chris Gannon’s Bubble Slider below.
SVG Metaball
I discovered another approach to creating this metaball effect from Paper.js examples. Back in the days of Scriptographer Hiroyuki Sato created a script for generating gooey blobs in Adobe Illustrator. Unlike the previous techniques this does not render pixels or rely on filters. Instead it connects two circles with a membrane. Which means that the we can generate the entire blob as a path. For the Amoeba CodePen I followed exactly this technique.
<script type="text/paperscript" canvas="meta-ball">
// Ported from original Metaball script by SATO Hiroyuki
// http://park12.wakwak.com/~shp/lc/et/en_aics_script.html
project.currentStyle = {
fillColor: '#2e2f30'
};
var ballPositions = [[255, 220]];
var handle_len_rate = 2.4;
var circlePaths = [];
var circlePath = new Path.Circle({
center: ballPositions[0],
radius: 200,
});
circlePaths.push(circlePath);
circlePaths[0].selected = true;
circlePaths[0].selectedColor = '#ff485e';
var largeCircle = new Path.Circle({
center: [535, 120],
radius: 80
});
circlePaths.push(largeCircle);
function onMouseMove(event) {
largeCircle.position = event.point;
generateConnections(circlePaths);
}
var connections = new Group();
function generateConnections(paths) {
// Remove the last connection paths:
connections.children = [];
var debug = true;
for (var i = 0, l = paths.length; i < l; i++) {
for (var j = i - 1; j >= 0; j--) {
var path = metaball(paths[i], paths[j], 0.5, handle_len_rate, 400, j === 0, debug);
if (path) {
connections.appendTop(path);
path.removeOnMove();
}
debug = false;
}
}
}
generateConnections(circlePaths);
// ---------------------------------------------
function metaball(ball1, ball2, v, handle_len_rate, maxDistance, debug) {
var center1 = ball1.position;
var center2 = ball2.position;
var radius1 = ball1.bounds.width / 2;
var radius2 = ball2.bounds.width / 2;
var pi2 = Math.PI / 2;
var d = center1.getDistance(center2);
var u1, u2;
if (radius1 == 0 || radius2 == 0)
return;
if (d > maxDistance || d <= Math.abs(radius1 - radius2)) {
return;
} else if (d < radius1 + radius2) { // case circles are overlapping
u1 = Math.acos((radius1 * radius1 + d * d - radius2 * radius2) /
(2 * radius1 * d));
u2 = Math.acos((radius2 * radius2 + d * d - radius1 * radius1) /
(2 * radius2 * d));
} else {
u1 = 0;
u2 = 0;
}
var angle1 = (center2 - center1).getAngleInRadians();
var angle2 = Math.acos((radius1 - radius2) / d);
var angle1a = angle1 + u1 + (angle2 - u1) * v;
var angle1b = angle1 - u1 - (angle2 - u1) * v;
var angle2a = angle1 + Math.PI - u2 - (Math.PI - u2 - angle2) * v;
var angle2b = angle1 - Math.PI + u2 + (Math.PI - u2 - angle2) * v;
var p1a = center1 + getVector(angle1a, radius1);
var p1b = center1 + getVector(angle1b, radius1);
var p2a = center2 + getVector(angle2a, radius2);
var p2b = center2 + getVector(angle2b, radius2);
// define handle length by the distance between
// both ends of the curve to draw
var totalRadius = (radius1 + radius2);
var d2 = Math.min(v * handle_len_rate, (p1a - p2a).length / totalRadius);
// case circles are overlapping:
d2 *= Math.min(1, d * 2 / (radius1 + radius2));
radius1 *= d2;
radius2 *= d2;
var path = new Path({
segments: [p1a, p2a, p2b, p1b],
style: ball1.style,
closed: true
});
if (debug) { path.fullySelected = true; }
var segments = path.segments;
segments[0].handleOut = getVector(angle1a - pi2, radius1);
segments[1].handleIn = getVector(angle2a + pi2, radius2);
segments[2].handleOut = getVector(angle2b - pi2, radius2);
segments[3].handleIn = getVector(angle1b + pi2, radius1);
return path;
}
// ------------------------------------------------
function getVector(radians, length) {
return new Point({
// Convert radians to degrees:
angle: radians * 180 / Math.PI,
length: length
});
}
</script>
<canvas resize="true" id="meta-ball"></canvas> body { margin: 0; background: #fee1e1; overflow: hidden; }
canvas {
background-color: #fee1e1;
-webkit-user-drag: none;
user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
width: 100%;
height: 600px;
} In this blog post I am going break down the steps required to generate the metaball. We are going to go through a function called metaball which generates the black shaded path that you see below. This consists of the connector plus a part of the second circle.
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<g stroke-width="6" fill="#fff" stroke="#333">
<circle id="js-circle1" cx="400" cy="600" r="96" />
<circle id="js-circle2" cx="400" cy="600" r="64" stroke="none" />
<path id="js-connector" d="" fill="#333" />
</g>
</svg>
</main> // Dom Nodes
const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
CIRCLE1: 96,
CIRCLE2: 64,
};
const circle1$ = Rx.Observable.of([600, 600])
.do(loc => { moveTo(loc, circle1); });
const circle2$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 200 * Math.sin(frame / 500))
.map(x => [600 + x, 600])
// Rx.Observable.of([712.5, 600])
.do(loc => { moveTo(loc, circle2); });
Rx.Observable
.combineLatest(circle1$, circle2$, (circle1Loc, circle2Loc) =>
metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc),
)
.subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
connector.setAttribute('d', path);
});
/**
* Based on Metaball script by SATO Hiroyuki
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
handleLenRate = 2.4,
v = 0.5,
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0) {
return [''];
}
if (d > maxDist) {
return [''];
} else if (d <= Math.abs(radius1 - radius2)) {
return [''];
}
// if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
// return '';
// }
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angleBetweenCenters = angle(center2, center1);
const spread = Math.acos((radius1 - radius2) / d);
const angle1 = angleBetweenCenters + u1 + (spread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (spread - u1) * v;
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v;
const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - spread) * v;
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleLenRate, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
const path = metaballToPath(
p1, p2, p3, p4,
h1, h2, h3, h4,
d > radius1,
radius2,
);
return [path, p1, p2, p3, p4, h1, h2, h3, h4];
}
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
// prettier-ignore
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
/**
* Utils
*/
function moveTo([x, y] = [0, 0], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
element.setAttribute('x1', x1);
element.setAttribute('y1', y1);
element.setAttribute('x2', x2);
element.setAttribute('y2', y2);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
} Building the Metaball
To figure out where the connector touches the two circles we start by locating two tangents that touch both circles. This is the widest the connector can be. BTW I’m focusing on the case when the circles are not overlapping first.
We can calculate the maximum angle of spread using:
const maxSpread = Math.acos((radius1 - radius2) / d);
Why? This took me a while to figure out. I could attempt to explain here, but you are probably better of seeing the step-by-step illustration in this external tangents to two given circles guide.
This is the maximum possible spread that the connector can have. We can control spread amount by multiplying it with a factor called v. The Paper.js code has v = 0.5. That seems to work well.
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden code">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<g stroke-width="6" fill="#fff" stroke="#333">
<circle id="js-circle1" cx="400" cy="600" r="96" />
<circle id="js-circle2" cx="400" cy="600" r="64" />
</g>
<text x="505" y="605" font-size="20">spread</text>
<g id="js-debug" stroke-width="8">
<g stroke="#5E2CA5" stroke-linecap="round">
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
</g>
<g fill="#fff" stroke="#FF41B4">
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
</g>
</g>
</svg>
</main> // Dom Nodes
const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const debugPts = document.querySelectorAll('#js-debug circle');
const debugLines = document.querySelectorAll('#js-debug line');
const debugText = document.querySelector('#js-debug-text');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
CIRCLE1: 96,
CIRCLE2: 64,
};
const animation$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 1 - 0.5 * Math.abs(Math.sin(frame / 200)));
const circle1$ = Rx.Observable.of([487.5, 600])
.do(loc => { moveTo(loc, circle1); });
const circle2$ = Rx.Observable.of([712.5, 600])
.do(loc => { moveTo(loc, circle2); });
Rx.Observable
.combineLatest(circle1$, circle2$, animation$, (circle1Loc, circle2Loc, anim) =>
metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc, anim),
)
.subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
// connector.setAttribute('d', path);
line(p1, p3, debugLines[0]);
line(p2, p4, debugLines[1]);
line([487.5, 600], p1, debugLines[2]);
line([487.5, 600], p2, debugLines[3]);
line([712.5, 600], p3, debugLines[4]);
line([712.5, 600], p4, debugLines[5]);
moveTo(p1, debugPts[0]);
moveTo(p2, debugPts[1]);
moveTo(p3, debugPts[2]);
moveTo(p4, debugPts[3]);
});
/**
* Based on Metaball script by SATO Hiroyuki
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
v = 0.5,
handleLenRate = 2.4,
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
return [''];
}
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angleBetweenCenters = angle(center2, center1);
const spread = Math.acos((radius1 - radius2) / d);
const angle1 = angleBetweenCenters + u1 + (spread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (spread - u1) * v;
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v;
const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - spread) * v;
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleLenRate, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
const path = metaballToPath(
p1, p2, p3, p4,
h1, h2, h3, h4,
d > radius1,
radius2,
);
return [path, p1, p2, p3, p4, h1, h2, h3, h4];
}
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
// prettier-ignore
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
/**
* Utils
*/
function moveTo([x, y] = [0, 0], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
element.setAttribute('x1', x1);
element.setAttribute('y1', y1);
element.setAttribute('x2', x2);
element.setAttribute('y2', y2);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
} The spread for the smaller circle is (Math.PI - maxSpread) * v. This is because the sum of the opposite angles of a polygon is 180°.
Next we need to find the location of those four points. We know the centre of the circles (center1 & center2) and the radii (radius1 & radius2). Therefore, we will only be dealing in terms of angles and then use polar coordinates to convert it into (x, y) values later.
const angleBetweenCenters = angle(center2, center1);
const maxSpread = Math.acos((radius1 - radius2) / d);
// Circle 1 (left)
const angle1 = angleBetweenCenters + maxSpread * v;
const angle2 = angleBetweenCenters - maxSpread * v;
// Circle 2 (right)
const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - maxSpread) * v);
const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - maxSpread) * v);
The angles need to be measured clockwise. Therefore, for the second circle we take that into account by subtracting from Math.PI. We add angleBetweenCenters to all because the circles can be moving diagonally too. Then convert polar coords to cartesian.
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
To convert the trapezium shaped connector into a curved one we need to add handles to all four points. The next part of the process is to figure out the location of the handles.
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<g stroke-width="6" fill="#fff" stroke="#333">
<circle id="js-circle1" cx="400" cy="600" r="96" />
<circle id="js-circle2" cx="400" cy="600" r="64" stroke="none" />
<path id="js-connector" d="" />
</g>
<g id="js-debug" stroke-width="8">
<g stroke="#5E2CA5">
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
</g>
<g fill="#fff" stroke="#FF41B4">
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
</g>
</g>
</svg>
</main> // Dom Nodes
const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const debugPts = document.querySelectorAll('#js-debug circle');
const debugLines = document.querySelectorAll('#js-debug line');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
CIRCLE1: 96,
CIRCLE2: 64,
};
const animation$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 0.5 + 0.5 * Math.abs(Math.sin(frame / 400)));
const circle1$ = Rx.Observable.of([487.5, 600])
.do(loc => { moveTo(loc, circle1); });
const circle2$ = /* Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 200 * Math.sin(frame / 500))
.map(x => [600 + x, 600]) */
Rx.Observable.of([712.5, 600])
.do(loc => { moveTo(loc, circle2); });
Rx.Observable
.combineLatest(circle1$, circle2$, animation$, (circle1Loc, circle2Loc, anim) =>
metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc, 0.5, 2.4, anim),
)
.subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
connector.setAttribute('d', path);
line(p1, h1, debugLines[0]);
line(p2, h2, debugLines[1]);
line(p3, h3, debugLines[2]);
line(p4, h4, debugLines[3]);
moveTo(p1, debugPts[0]);
moveTo(p2, debugPts[1]);
moveTo(p3, debugPts[2]);
moveTo(p4, debugPts[3]);
moveTo(h1, debugPts[4]);
moveTo(h2, debugPts[5]);
moveTo(h3, debugPts[6]);
moveTo(h4, debugPts[7]);
});
/**
* Based on Metaball script by SATO Hiroyuki
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
v = 0.5,
handleLenRate = 2.4,
anim
) {
const HALF_PI = Math.PI / 2 * anim;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
return [''];
}
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angleBetweenCenters = angle(center2, center1);
const spread = Math.acos((radius1 - radius2) / d);
// const angle1 = angleBetweenCenters + spread * v;
// const angle2 = angleBetweenCenters - spread * v;
// const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - spread) * v);
// const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - spread) * v);
const angle1 = angleBetweenCenters + u1 + (spread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (spread - u1) * v;
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v;
const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - spread) * v;
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleLenRate, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
const path = metaballToPath(
p1, p2, p3, p4,
h1, h2, h3, h4,
d > radius1,
radius2,
);
return [path, p1, p2, p3, p4, h1, h2, h3, h4];
}
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
// prettier-ignore
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
/**
* Utils
*/
function moveTo([x, y] = [0, 0], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
element.setAttribute('x1', x1);
element.setAttribute('y1', y1);
element.setAttribute('x2', x2);
element.setAttribute('y2', y2);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
} The handle for a particular point should be aligned to the tangent to the circle at that point. Again we’ll use polar coords to locate the handle. This time however, it will be relative to the point itself.
The lines AB and BC are perpendicular because AB is radial and BC is a tangent to the circle. Therefore, the angle for the handle 1 is angle1 - Math.PI / 2. Similarly we can calculate the angles for the other three handles.
The length of the handle is relative to the radius of the circle they originate from times the factor d2. For example, the length of handle 1 is radius1 * d2. We can now calculate the location of the handles like so:
const totalRadius = radius1 + radius2;
// Handle length scaling factor
const d2 = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Handle lengths
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
We have all the points 🙌🏽 Time to construct the SVG path. The path is made of three sections: curve from point 1 to point 3, arc of radius2 from point 3 to point 4 and curve from point 4 to point 2.
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
Circle Overlap
We have a gooey metaball! But you’ll notice that path gets all weird and twisty when the circles start to overlapping. We are going to fix this by expanding the spread in proportion to how much the circles are overlapping.
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<g stroke-width="6" fill="#fff" stroke="#333">
<circle id="js-circle1" cx="400" cy="600" r="96" />
<circle id="js-circle2" cx="400" cy="600" r="64" stroke="none" />
<path id="js-connector" d="" />
</g>
</svg>
</main> // Dom Nodes
const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const debugPts = document.querySelectorAll('#js-debug circle');
const debugLines = document.querySelectorAll('#js-debug line');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
CIRCLE1: 96,
CIRCLE2: 64,
};
const animation$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 1 - 0.5 * Math.abs(Math.sin(frame / 200)));
const circle1$ = Rx.Observable.of([600, 600])
.do(loc => { moveTo(loc, circle1); });
const circle2$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 200 * Math.sin(frame / 500))
.map(x => [600 + x, 600])
.do(loc => { moveTo(loc, circle2); });
Rx.Observable
.combineLatest(circle1$, circle2$, animation$, (circle1Loc, circle2Loc) =>
metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc),
)
.subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
connector.setAttribute('d', path);
});
/**
* Based on Metaball script by SATO Hiroyuki
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
v = 0.5,
handleLenRate = 2.4,
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
return [''];
}
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angleBetweenCenters = angle(center2, center1);
const spread = Math.acos((radius1 - radius2) / d);
const angle1 = angleBetweenCenters + spread * v;
const angle2 = angleBetweenCenters - spread * v;
const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - spread) * v);
const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - spread) * v);
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleLenRate, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
const path = metaballToPath(
p1, p2, p3, p4,
h1, h2, h3, h4,
d > radius1,
radius2,
);
return [path, p1, p2, p3, p4, h1, h2, h3, h4];
}
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
// prettier-ignore
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
/**
* Utils
*/
function moveTo([x, y] = [0, 0], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
element.setAttribute('x1', x1);
element.setAttribute('y1', y1);
element.setAttribute('x2', x2);
element.setAttribute('y2', y2);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
} The spread expansion will be controlled using the angles u1 and u2. We can calculate these using the law of cosines.
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)
);
But what shall we do with these 🤔 To be honest I’m not entirely sure how this works. What I do know is that it expands the spread as the circles get closer and then collapses it once circle 2 is completely inside circle 1.
const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
const angle2 = angleBetweenCenters - (u1 + (maxSpread - u1) * v);
const angle3 =
angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle4 =
angleBetweenCenters - (Math.PI - u2 - (Math.PI - u2 - maxSpread) * v);
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<g stroke-width="6" fill="#fff" stroke="#333">
<circle id="js-circle1" cx="400" cy="600" r="96" />
<circle id="js-circle2" cx="400" cy="600" r="64" stroke="none" />
<path id="js-connector" d="" />
</g>
<g id="js-debug" stroke-width="8">
<g stroke="#5E2CA5">
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
<line x1="0" y1="0" x2="0" y2="0" />
</g>
<g fill="#fff" stroke="#FF41B4">
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
<circle cx="" cy="" r="8" />
</g>
</g>
</svg>
</main> // Dom Nodes
const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const debugPts = document.querySelectorAll('#js-debug circle');
const debugLines = document.querySelectorAll('#js-debug line');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
CIRCLE1: 96,
CIRCLE2: 64,
};
const animation$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 1 - 0.5 * Math.abs(Math.sin(frame / 200)));
const circle1$ = Rx.Observable.of([600, 600])
.do(loc => { moveTo(loc, circle1); });
const circle2$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 200 * Math.sin(frame / 500))
.map(x => [600 + x, 600])
.do(loc => { moveTo(loc, circle2); });
Rx.Observable
.combineLatest(circle1$, circle2$, animation$, (circle1Loc, circle2Loc) =>
metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc),
)
.subscribe(([path, p1, p2, p3, p4, h1, h2, h3, h4]) => {
connector.setAttribute('d', path);
line(p1, h1, debugLines[0]);
line(p2, h2, debugLines[1]);
line(p3, h3, debugLines[2]);
line(p4, h4, debugLines[3]);
moveTo(p1, debugPts[0]);
moveTo(p2, debugPts[1]);
moveTo(p3, debugPts[2]);
moveTo(p4, debugPts[3]);
moveTo(h1, debugPts[4]);
moveTo(h2, debugPts[5]);
moveTo(h3, debugPts[6]);
moveTo(h4, debugPts[7]);
});
/**
* Based on Metaball script by SATO Hiroyuki
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
v = 0.5,
handleLenRate = 2.4,
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
return [''];
}
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angleBetweenCenters = angle(center2, center1);
const spread = Math.acos((radius1 - radius2) / d);
// const angle1 = angleBetweenCenters + spread * v;
// const angle2 = angleBetweenCenters - spread * v;
// const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - spread) * v);
// const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - spread) * v);
const angle1 = angleBetweenCenters + u1 + (spread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (spread - u1) * v;
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v;
const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - spread) * v;
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleLenRate, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
const path = metaballToPath(
p1, p2, p3, p4,
h1, h2, h3, h4,
d > radius1,
radius2,
);
return [path, p1, p2, p3, p4, h1, h2, h3, h4];
}
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
// prettier-ignore
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
/**
* Utils
*/
function moveTo([x, y] = [0, 0], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
element.setAttribute('x1', x1);
element.setAttribute('y1', y1);
element.setAttribute('x2', x2);
element.setAttribute('y2', y2);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
} And one final change to account for overlapping circles. The length of the handles will also be proportional to the distance between the circles.
// Define handle length by the distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, (d * 2) / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
Conclusion
And here is the final result and the entire code snippet for metaball. Try forking it and playing around with different values of handleSize and v. See how they impact the shape of the connector. There are so many amazing little details in these 70 lines of code. Fascinating work by Hiroyuki Sato. I learnt so much from it!
<main class="overflow-hidden vh-100 flex items-stretch overflow-hidden">
<svg class="w-100 h-100" viewBox="0 0 1200 1200" preserveAspectRatio="xMidYMid slice">
<g stroke-width="6" fill="#fff" stroke="#333">
<circle id="js-circle1" cx="400" cy="600" r="96" />
<circle id="js-circle2" cx="400" cy="600" r="64" stroke="none" />
<path id="js-connector" d="" />
</g>
</svg>
</main> // Dom Nodes
const circle1 = document.querySelector('#js-circle1');
const circle2 = document.querySelector('#js-circle2');
const connector = document.querySelector('#js-connector');
const VIEWBOX_SIZE = { W: 1200, H: 1200 };
const SIZES = {
CIRCLE1: 96,
CIRCLE2: 64,
};
const circle1$ = Rx.Observable.of([600, 600])
.do(loc => { moveTo(loc, circle1); });
const circle2$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame)
.map(frame => 200 * Math.sin(frame / 500))
.map(x => [600 + x, 600])
.do(loc => { moveTo(loc, circle2); });
Rx.Observable
.combineLatest(circle1$, circle2$, (circle1Loc, circle2Loc) =>
metaball(SIZES.CIRCLE1, SIZES.CIRCLE2, circle1Loc, circle2Loc),
)
.subscribe(path => {
connector.setAttribute('d', path);
});
/**
* Based on Metaball script by SATO Hiroyuki
* http://shspage.com/aijs/en/#metaball
*/
function metaball(radius1, radius2, center1, center2, handleSize = 2.4, v = 0.5) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
return '';
}
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);
} else {
u1 = 0;
u2 = 0;
}
// All the angles
const angleBetweenCenters = angle(center2, center1);
const maxSpread = Math.acos((radius1 - radius2) / d);
const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v;
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - maxSpread) * v;
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the
// distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
return metaballToPath(
p1, p2, p3, p4,
h1, h2, h3, h4,
d > radius1,
radius2,
);
}
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
/**
* Utils
*/
function moveTo([x, y] = [0, 0], element) {
element.setAttribute('cx', x);
element.setAttribute('cy', y);
}
function line([x1, y1] = [0, 0], [x2, y2] = [0, 0], element) {
element.setAttribute('x1', x1);
element.setAttribute('y1', y1);
element.setAttribute('x2', x2);
element.setAttribute('y2', y2);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function angle([x1, y1], [x2, y2]) {
return Math.atan2(y1 - y2, x1 - x2);
}
function getVector([cx, cy], a, r) {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
} /**
* Based on Metaball script by Hiroyuki Sato
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
handleSize = 2.4,
v = 0.5
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
// No blob if a radius is 0
// or if distance between the circles is larger than max-dist
// or if circle2 is completely inside circle1
if (
radius1 === 0 ||
radius2 === 0 ||
d > maxDist ||
d <= Math.abs(radius1 - radius2)
) {
return '';
}
// Calculate u1 and u2 if the circles are overlapping
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)
);
} else {
// Else set u1 and u2 to zero
u1 = 0;
u2 = 0;
}
// Calculate the max spread
const angleBetweenCenters = angle(center2, center1);
const maxSpread = Math.acos((radius1 - radius2) / d);
// Angles for the points
const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v;
const angle3 =
angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle4 =
angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - maxSpread) * v;
// Point locations
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, (d * 2) / (radius1 + radius2));
// Length of the handles
const r1 = radius1 * d2;
const r2 = radius2 * d2;
// Handle locations
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
// Generate the connector path
return metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, d > radius1, radius2);
}
// prettier-ignore
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}