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

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/tachyons/4.6.2/tachyons.min.css

External JavaScript

  1. https://unpkg.com/@reactivex/rxjs@5.4.3/dist/global/Rx.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js
  3. https://codepen.io/steveg3003/pen/MmqOpb/.js