<main class="overflow-hidden vh-100 flex items-stretch">
    <svg
      class="w-50 h-100"
      viewBox="0 0 300 600"
      preserveAspectRatio="xMidYMid slice"
    >
      <defs>
        <pattern id="pyramids" width="60" height="60" patternUnits="userSpaceOnUse">
          <path class="js-pyramid" fill="#6A9ED0" />
          <path class="js-pyramid" fill="#fff" />
        </pattern>
      </defs>
      <!-- pyramids -->
      <rect x="0" y="0" width="300" height="100%" fill="#0259BF" />
      <rect x="0" y="0" width="300" height="100%" fill="url('#pyramids')" />
    </svg>
    <svg
      class="w-50 h-100"
      viewBox="0 0 300 600"
      preserveAspectRatio="xMidYMid slice"
    >
      <g stroke-width="3">
        <!-- controls -->
        <rect width="300" height="100%" fill="#231D1F" />
        <!-- Grid -->
        <g id="js-grid">
          <!-- Horizontal -->
          <line x1="100" x2="100" y1="0" y2="600"  stroke="#fff" />
          <line x1="200" x2="200" y1="0" y2="600" stroke="#fff" />
          <!-- Vertical -->
          <line x1="0" x2="300" y1="200" y2="200" stroke="#fff" />
          <line x1="0" x2="300" y1="400" y2="400" stroke="#fff" />
        </g>
        <!-- Guides -->
        <g id="js-guide" stroke-width="4" stroke="#fff" stroke-linecap="round" stroke-dasharray="0.25, 8">
          <!-- Top -->
          <line x1="125" x2="125" y1="0" y2="180" />
          <line x1="175" x2="175" y1="0" y2="180" />
          <!-- Bottom -->
          <line x1="125" x2="125" y1="420" y2="600" />
          <line x1="175" x2="175" y1="420" y2="600" />
        </g>
        <!-- curve -->
        <path id="js-curve" d="M100,200 C150,200 150,400 200,400" fill="none" stroke="#1E5EB3" />
        <!-- curve points -->
        <g>
          <!-- connector 2 -->
          <line class="js-connector" x1="" x2="" y1="" y2="" stroke="#6A9ED0" />
          <!-- stop -->
          <circle class="move" id="js-stop" cx="" cy="" r="4" fill="#fff" stroke="#6A9ED0" />
          <!-- cp2 -->
          <circle class="ew-resize" id="js-cp2" cx="" cy="" r="4" fill="#6A9ED0" />
          <!-- connector 1 -->
          <line class="js-connector" x1="" x2="" y1="" y2="" stroke="#6A9ED0" />
          <!-- cp1 -->
          <circle class="ew-resize" id="js-cp1" cx="" cy="" r="4" fill="#6A9ED0" />
          <!-- start -->
          <circle class="move" id="js-start" cx="" cy="" r="4" fill="#fff" stroke="#6A9ED0" />
        </g>
      </g>
    </svg>
  </main>
.move {
  cursor: move;
}
.ew-resize {
  cursor: ew-resize;
}
// Dom Nodes
const pyramidEls = document.querySelectorAll('.js-pyramid');
const ptEls = document.querySelectorAll('.js-c');
const connectorEls = document.querySelectorAll('.js-connector');
const curveEl = document.querySelector('#js-curve');
const startEl = document.querySelector('#js-start');
const cp1El = document.querySelector('#js-cp1');
const cp2El = document.querySelector('#js-cp2');
const stopEl = document.querySelector('#js-stop');
const [y1El, y2El, x1El, x2El] = document.querySelectorAll('#js-grid line');
const [tlEl, trEl, blEl, brEl] = document.querySelectorAll('#js-guide line');

const animationFrame$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame);

// Drag Handler
const drag = (domNode, pan$) =>
  pan$.filter(e => e.type === 'panstart').switchMap(pd => {
    const start = {
      x: +domNode.getAttribute('cx'),
      y: +domNode.getAttribute('cy'),
    };
    const w = document.body.clientWidth;
    const h = document.body.clientHeight;
    domNode.setAttribute('r', 8);

    const svgW = w > h ? 300 : 300 * w / h;
    const svgH = w > h ? 600 * h / w : 600;

    const move$ = pan$
      .filter(e => e.type === 'panmove')
      .map(pm => {
        return {
          x: start.x + linInterp(pm.deltaX, 0, w / 2, 0, svgW),
          y: start.y + linInterp(pm.deltaY, 0, h, 0, svgH),
        };
      })
      .takeUntil(pan$.filter(e => e.type === 'panend'));

    move$.subscribe(null, null, () => domNode.setAttribute('r', 4));

    return move$;
  });

const handleDrag = domNode => {
  const hammerPan = new Hammer(domNode, {
    direction: Hammer.DIRECTION_ALL,
  });

  hammerPan.get('pan').set({ direction: Hammer.DIRECTION_ALL });

  const pan$ = Rx.Observable.fromEventPattern(h =>
    hammerPan.on('panstart panup pandown panmove panend', h),
  );

  const drag$ = drag(domNode, pan$);

  return animationFrame$
    .withLatestFrom(drag$, (_, p) => p)
    .scan(RxCSS.lerp(0.1))
    .map(p => [p.x, p.y]);
};

const points$ = Rx.Observable
  .combineLatest(
    handleDrag(startEl).startWith([200, 400]),
    handleDrag(cp1El).startWith([150, 400]),
    handleDrag(cp2El).startWith([150, 200]),
    handleDrag(stopEl).startWith([100, 200]),
  )
  .map(([start, cp1, cp2, stop]) => [
    start,
    [cp1[0], start[1]],
    [cp2[0], stop[1]],
    stop,
  ]);

const svgGeometry$ = points$.map(([start, cp1, cp2, stop]) => {
  const t = stop[1];
  const r = start[0];
  const b = start[1];
  const l = stop[0];
  const cpDelta = Math.abs(cp1[0] - cp2[0]);

  return {
    t,
    r,
    b,
    l,
    rot: linInterp(cpDelta, 0, 300, -12, 12),
    pts: { start, cp1, cp2, stop },
    connectors: [[stop, cp2], [start, cp1]],
    curve: makeCurve(start, cp1, cp2, stop),
    faces: makePyramid(Math.abs(t - b), Math.abs(l - r)),
  };
});

svgGeometry$.subscribe(({ pts, curve, connectors, t, r, b, l, faces, rot }) => {
  // Guides
  tlEl.setAttribute('y2', t - 16);
  trEl.setAttribute('y2', t - 16);
  blEl.setAttribute('y1', b + 16);
  brEl.setAttribute('y1', b + 16);
  // Grid
  moveY(t, x1El);
  moveY(b, x2El);
  moveX(l, y1El);
  moveX(r, y2El);
  // Curve
  curveEl.setAttribute('d', curve);
  // Connectors
  line(connectors[0], connectorEls[0]);
  line(connectors[1], connectorEls[1]);
  // Points
  moveTo(pts.start, startEl);
  moveTo(pts.cp1, cp1El);
  moveTo(pts.cp2, cp2El);
  moveTo(pts.stop, stopEl);
  // Pyramid
  pyramidEls[0].setAttribute('d', faces[0]);
  pyramidEls[1].setAttribute('d', faces[1]);
  pyramidEls[0].setAttribute('transform', `rotate(${rot})`);
  pyramidEls[1].setAttribute('transform', `rotate(${rot})`);
});

/**
 * Geometry
 */
function makeCurve(start, cp1, cp2, stop) {
  return ['M', start, 'C', cp1, cp2, stop].join(' ');
}
function line([[x1, y1], [x2, y2]], node) {
  node.setAttribute('x1', x1);
  node.setAttribute('x2', x2);
  node.setAttribute('y1', y1);
  node.setAttribute('y2', y2);
}
function moveTo([x, y], node) {
  node.setAttribute('cx', x);
  node.setAttribute('cy', y);
}
function moveX(x, node) {
  node.setAttribute('x1', x);
  node.setAttribute('x2', x);
}
function moveY(y, node) {
  node.setAttribute('y1', y);
  node.setAttribute('y2', y);
}
function pyramidPts(heightMult, spanMult) {
  const cntr = [
    linInterp(19.6875, 0, 56, 0, 60),
    linInterp(10.5469, -32, 30, 0, 60),
  ];
  const pts = [
    // top
    {
      theta: -1.05165047,
      r: 49.00343208245725 * heightMult,
    },
    // right
    {
      theta: 0.491808641,
      r: 41.19491177147974 * spanMult,
    },
    // bottom
    {
      theta: 2.089943243589793,
      r: 15.494629903937685 * spanMult,
    },
    // left
    {
      theta: -2.649782490589793,
      r: 22.33460892561139 * spanMult,
    },
  ];
  return pts.map(pt => polarToCart(pt, cntr).map(round));
}
function makePyramid(deltaY, deltaX) {
  const heightMult = linInterp(deltaY, 0, 600, 0.1, 1);
  const spanMult = linInterp(deltaX, 0, 300, 0.1, 1);

  const [c1, c2, c3, c4] = pyramidPts(heightMult, spanMult);
  return [[c1, c4, c3], [c1, c2, c3]].map(makeFace);
}
function makeFace([p1, p2, p3]) {
  return ['M', p1, 'L', p2, 'L', p3].join('');
}
function polarToCart({ r, theta }, [cx, cy]) {
  return [cx + r * Math.cos(theta), cy + r * Math.sin(theta)];
}

/**
 * Utils
 */
function round(x) {
  return Math.round(x * 100) / 100;
}
function dist([ux, uy], [vx, vy]) {
  return Math.sqrt((ux - vx) * (ux - vx) + (uy - vy) * (uy - vy));
}
function same(a, b) {
  return Math.abs(a - b) <= 0.1;
}
function linInterp(x, x1, x2, y1, y2) {
  return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
}
View Compiled

External CSS

  1. https://unpkg.com/tachyons@4.7.0/css/tachyons.min.css

External JavaScript

  1. https://unpkg.com/@reactivex/rxjs@5.1.0/dist/global/Rx.min.js
  2. https://unpkg.com/rxcss@latest/dist/rxcss.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js