// 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),
};
};
}
View Compiled