CSSCamera.ts

import { mat4, vec3, quat } from 'gl-matrix';
import { getElement, applyCSS, getTransformMatrix, findIndex, getOffsetFromParent, getRotateOffset, assign } from './utils/helper';
import { quatToEuler } from './utils/math';
import * as DEFAULT from './constants/default';
import { Offset, UpdateOption, ValueOf, Options } from './types';

class CSSCamera {
  private _element: HTMLElement;
  private _viewportEl: HTMLElement;
  private _cameraEl: HTMLElement;
  private _worldEl: HTMLElement;

  private _position: vec3;
  private _scale: vec3;
  private _rotation: vec3;
  private _perspective: number;
  private _rotateOffset: number;
  private _updateTimer: number;

  /**
   * Current version of CSSCamera.
   * @example
   * console.log(CSSCamera.VERSION); // ex) 1.0.0
   * @type {string}
   */
  static get VERSION() { return '#__VERSION__#'; }

  /**
   * The element provided in the constructor.
   * @example
   * const camera = new CSSCamera(el);
   * console.log(camera.element === el); // true
   * @type {HTMLElement}
   */
  public get element() { return this._element; }

  /**
   * The reference of viewport DOM element.
   * @type {HTMLElement}
   */
  public get viewportEl() { return this._viewportEl; }

  /**
   * The reference of camera DOM element.
   * @type {HTMLElement}
   */
  public get cameraEl() { return this._cameraEl; }

  /**
   * The reference of world DOM element.
   * @type {HTMLElement}
   */
  public get worldEl() { return this._worldEl; }

  /**
   * The current position as number array([x, y, z]).
   * @example
   * const camera = new CSSCamera(el);
   * console.log(camera.position); // [0, 0, 0];
   * camera.position = [0, 0, 300];
   * console.log(camera.position); // [0, 0, 300];
   * @type {number[]}
   */
  public get position() { return [...this._position]; }

  /**
   * The current scale as number array([x, y, z]).
   * @example
   * const camera = new CSSCamera(el);
   * console.log(camera.scale); // [1, 1, 1];
   * camera.scale = [5, 1, 1];
   * console.log(camera.scale); // [5, 1, 1];
   * @type {number[]}
   */
  public get scale() { return [...this._scale]; }

  /**
   * The current Euler rotation angles in degree as number array([x, y, z]).
   * @example
   * const camera = new CSSCamera(el);
   * console.log(camera.rotation); // [0, 0, 0];
   * camera.rotation = [90, 0, 0];
   * console.log(camera.rotation); // [90, 0, 0];
   * @type {number[]}
   */
  public get rotation() { return [...this._rotation]; }

  /**
   * The current quaternion rotation as number array([x, y, z, w]).
   * @example
   * const camera = new CSSCamera(el);
   * console.log(camera.quaternion); // [0, 0, 0, 1];
   * camera.rotation = [90, 0, 0];
   * console.log(camera.quaternion); // [0.7071067690849304, 0, 0, 0.7071067690849304];
   * camera.quaternion = [0, 0, 0, 1];
   * console.log(camera.rotation); // [0, -0, 0];
   * @type {number[]}
   */
  public get quaternion() {
    const r = this._rotation;
    const quaternion = quat.fromEuler(quat.create(), r[0], r[1], r[2]);

    return [...quaternion];
  }

  /**
   * The current perspective value that will be applied to viewport element.
   * @example
   * const camera = new CSSCamera(el);
   * camera.perspective = 300;
   * console.log(camera.perspective); // 300
   * @type {number}
   */
  public get perspective() { return this._perspective; }

  /**
   * The current rotate offset value that will be applied to camera element.
   * The camera will be as far away from the focal point as this value.
   * |![rot0](https://woodneck.github.io/css-camera/asset/rot0.gif)|![rot150](https://woodneck.github.io/css-camera/asset/rot150.gif)|
   * |:---:|:---:|
   * @example
   * const camera = new CSSCamera(el);
   * camera.perspective = 300;
   * console.log(camera.cameraCSS); // scale3d(1, 1, 1) translateZ(300px) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
   * camera.rotateOffset = 100;
   * console.log(camera.cameraCSS); // scale3d(1, 1, 1) translateZ(400px) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
   * @type {number}
   */
  public get rotateOffset() { return this._rotateOffset; }

  /**
   * CSS string can be applied to camera element based on current transform.
   * @example
   * const camera = new CSSCamera(el);
   * camera.perspective = 300;
   * console.log(camera.cameraCSS); // scale3d(1, 1, 1) translateZ(300px) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
   * @type {string}
   */
  public get cameraCSS() {
    const perspective = this._perspective;
    const rotateOffset = this._rotateOffset;
    const rotation = this._rotation;
    const scale = this._scale;

    // Rotate in order of Z - Y - X
    // tslint:disable-next-line: max-line-length
    return `scale3d(${scale[0]}, ${scale[1]}, ${scale[2]}) translateZ(${perspective - rotateOffset}px) rotateX(${rotation[0]}deg) rotateY(${rotation[1]}deg) rotateZ(${rotation[2]}deg)`;
  }

  /**
   * CSS string can be applied to world element based on current transform.
   * ```
   * const camera = new CSSCamera(el);
   * console.log(camera.worldCSS); // "translate3d(0px, 0px, 0px)";
   * camera.translate(0, 0, 300);
   * console.log(camera.worldCSS); // "translate3d(0px, 0px, -300px)";
   * ```
   * @type {string}
   */
  public get worldCSS() {
    const position = this._position;

    return `translate3d(${-position[0]}px, ${-position[1]}px, ${-position[2]}px)`;
  }

  public set position(val: number[]) { this._position = vec3.fromValues(val[0], val[1], val[2]); }
  public set scale(val: number[]) { this._scale = vec3.fromValues(val[0], val[1], val[2]); }
  public set rotation(val: number[]) { this._rotation = vec3.fromValues(val[0], val[1], val[2]); }
  public set quaternion(val: number[]) { this._rotation = quatToEuler(quat.fromValues(val[0], val[1], val[2], val[3])); }
  public set perspective(val: number) { this._perspective = val; }
  public set rotateOffset(val: number) { this._rotateOffset = val; }

  /**
   * Create new CSSCamera with given element / selector.
   * @param - The element to apply camera. Can be HTMLElement or CSS selector.
   * @param {Partial<Options>} [options] Camera options
   * @param {number[]} [options.position=[0, 0, 0]] Initial position of the camera.
   * @param {number[]} [options.scale=[1, 1, 1]] Initial scale of the camera.
   * @param {number[]} [options.rotation=[0, 0, 0]] Initial Euler rotation angles(x, y, z) of the camera in degree.
   * @param {number} [options.perspective=0] Initial perspective of the camera.
   * @param {number} [options.rotateOffset=0] Initial rotate offset of the camera.
   * @example
   * const camera = new CSSCamera("#el", {
   *   position: [0, 0, 150], // Initial pos(x, y, z)
   *   rotation: [90, 0, 0],  // Initial rotation(x, y, z, in degree)
   *   perspective: 300       // CSS "perspective" value to apply
   * });
   */
  constructor(el: string | HTMLElement, options: Partial<Options> = {}) {
    this._element = getElement(el);

    const op = assign(assign({}, DEFAULT.OPTIONS), options) as Options;

    this._position = vec3.fromValues(op.position[0], op.position[1], op.position[2]);
    this._scale = vec3.fromValues(op.scale[0], op.scale[1], op.scale[2]);
    this._rotation = vec3.fromValues(op.rotation[0], op.rotation[1], op.rotation[2]);
    this._perspective = op.perspective;
    this._rotateOffset = op.rotateOffset;
    this._updateTimer = -1;

    const element = this._element;
    const viewport = document.createElement('div');
    const camera = viewport.cloneNode() as HTMLElement;
    const world = viewport.cloneNode() as HTMLElement;

    viewport.className = DEFAULT.CLASS.VIEWPORT;
    camera.className = DEFAULT.CLASS.CAMERA;
    world.className = DEFAULT.CLASS.WORLD;

    applyCSS(viewport, DEFAULT.STYLE.VIEWPORT);
    applyCSS(camera, DEFAULT.STYLE.CAMERA);
    applyCSS(world, DEFAULT.STYLE.WORLD);

    camera.appendChild(world);
    viewport.appendChild(camera);

    this._viewportEl = viewport;
    this._cameraEl = camera;
    this._worldEl = world;

    // EL's PARENT -> VIEWPORT -> CAMERA -> WORLD -> EL
    element.parentElement!.insertBefore(viewport, element);
    world.appendChild(element);

    this.update(0);
  }

  /**
   * Focus a camera to given element.
   * After focus, element will be in front of camera with no rotation applied.
   * Also, it will have original width / height if neither [scale](#scale) nor [perspectiveOffset](#perspectiveOffset) is applied.
   * This method won't work if any of element's parent except camera element has scale applied.
   * @param - The element to focus. Can be HTMLElement or CSS selector.
   * @return {CSSCamera} The instance itself
   */
  public focus(el: string | HTMLElement): this {
    const element = getElement(el);
    const focusMatrix = this._getFocusMatrix(element);

    const rotation = quat.create();
    const translation = vec3.create();
    mat4.getRotation(rotation, focusMatrix);
    mat4.getTranslation(translation, focusMatrix);

    const eulerAngle = quatToEuler(rotation);

    vec3.negate(eulerAngle, eulerAngle);

    this._rotation = eulerAngle;
    this._position = translation;
    return this;
  }

  /**
   * Translate a camera in its local coordinate space.
   * For example, `camera.translateLocal(0, 0, -300)` will always move camera to direction where it's seeing.
   * @param - Amount of horizontal translation, in px.
   * @param - Amount of vertical translation, in px.
   * @param - Amount of translation in view direction, in px.
   * @return {CSSCamera} The instance itself
   */
  public translateLocal(x: number = 0, y: number = 0, z: number = 0): this {
    const position = this._position;
    const rotation = this._rotation;

    const transVec = vec3.fromValues(x, y, z);
    const rotQuat = quat.create();
    quat.fromEuler(rotQuat, -rotation[0], -rotation[1], -rotation[2]);
    vec3.transformQuat(transVec, transVec, rotQuat);

    vec3.add(position, position, transVec);
    return this;
  }

  /**
   * Translate a camera in world(absolute) coordinate space.
   * @param - Amount of translation in x axis, in px.
   * @param - Amount of translation in y axis, in px.
   * @param - Amount of translation in z axis, in px.
   * @return {CSSCamera} The instance itself
   */
  public translate(x: number = 0, y: number = 0, z: number = 0): this {
    vec3.add(this._position, this._position, vec3.fromValues(x, y, z));

    return this;
  }

  /**
   * Rotate a camera in world(absolute) coordinate space.
   * @param - Amount of rotation in x axis, in degree.
   * @param - Amount of rotation in y axis, in degree.
   * @param - Amount of rotation in z axis, in degree.
   * @return {CSSCamera} The instance itself
   */
  public rotate(x: number = 0, y: number = 0, z: number = 0): this {
    vec3.add(this._rotation, this._rotation, vec3.fromValues(x, y, z));

    return this;
  }

  /**
   * Updates a camera CSS with given duration.
   * Every other camera transforming properties / methods will be batched until this method is called.
   * @example
   * const camera = new CSSCamera(el);
   * console.log(camera.cameraEl.style.transform); // ''
   *
   * camera.perspective = 300;
   * camera.translate(0, 0, 300);
   * camera.rotate(0, 90, 0);
   * console.log(camera.cameraEl.style.transform); // '', Not changed!
   *
   * await camera.update(1000); // Camera style is updated.
   * console.log(camera.cameraEl.style.transform); // scale3d(1, 1, 1) translateZ(300px) rotateX(0deg) rotateY(90deg) rotateZ(0deg)
   *
   * // When if you want to apply multiple properties
   * camera.update(1000, {
   *   property: "transform, background-color",
   *   timingFunction: "ease-out, ease-out", // As same with CSS, you should assign values to each property
   *   delay: "0ms, 100ms"
   * });
   * @param - Transition duration in ms.
   * @param {Partial<UpdateOption>} [options] Transition options.
   * @param {string} [options.property="transform"] CSS [transition-property](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-property) to apply.
   * @param {string} [options.timingFunction="ease-out"] CSS [transition-timing-function](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function) to apply.
   * @param {string} [options.delay="0ms"] CSS [transition-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-delay) to apply.
   * @return {Promise<CSSCamera>} A promise resolving instance itself
   */
  public async update(duration: number = 0, options: Partial<UpdateOption> = {}): Promise<this> {
    applyCSS(this._viewportEl, { perspective: `${this.perspective}px` });
    applyCSS(this._cameraEl, { transform: this.cameraCSS });
    applyCSS(this._worldEl, { transform: this.worldCSS });

    const updateOptions = assign(assign({}, DEFAULT.UPDATE_OPTIONS), options) as UpdateOption;

    if (duration > 0) {
      if (this._updateTimer > 0) {
        window.clearTimeout(this._updateTimer);
      }

      const transitionDuration = `${duration}ms`;
      const updateOption = Object.keys(updateOptions).reduce((option: {[key: string]: ValueOf<UpdateOption>}, key) => {
        option[`transition${key.charAt(0).toUpperCase() + key.slice(1)}`] = updateOptions[key as keyof UpdateOption]!;
        return option;
      }, {});

      const finalOption = {
        transitionDuration,
        ...updateOption,
      };

      [this._viewportEl, this._cameraEl, this._worldEl].forEach(el => {
        applyCSS(el, finalOption);
      });
    }

    return new Promise(resolve => {
      // Make sure to use requestAnimationFrame even if duration is 0
      // To make sure DOM is updated, for successive update() calls.
      if (duration > 0) {
        this._updateTimer = window.setTimeout(() => {
          // Reset transition values
          [this._viewportEl, this._cameraEl, this._worldEl].forEach(el => {
            applyCSS(el, { transition: '' });
          });
          this._updateTimer = -1;
          resolve();
        }, duration);
      } else {
        requestAnimationFrame(() => {
          resolve();
        });
      }
    });
  }

  private _getFocusMatrix(element: HTMLElement): mat4 {
    const elements: HTMLElement[] = [];
    while (element) {
      elements.push(element);
      if (element === this._element) break;
      element = element.parentElement!;
    }

    // Order by shallow to deep
    elements.reverse();

    const elStyles = elements.map(el => window.getComputedStyle(el));

    // Find first element that transform-style is not preserve-3d
    // As all childs of that element is affected by its matrix
    const firstFlatIndex = findIndex(elStyles, style => style.transformStyle !== 'preserve-3d');
    if (firstFlatIndex > 0) { // el doesn't have to be preserve-3d'ed
      elStyles.splice(firstFlatIndex + 1);
    }

    let parentOffset: Offset = {
      left: 0,
      top: 0,
      width: this.viewportEl.offsetWidth,
      height: this.viewportEl.offsetHeight,
    };

    // Accumulated rotation
    const accRotation = quat.identity(quat.create());
    // Assume center of screen as (0, 0, 0)
    const centerPos = vec3.fromValues(0, 0, 0);

    elStyles.forEach((style, idx) => {
      const el = elements[idx];
      const currentOffset = {
        left: el.offsetLeft,
        top: el.offsetTop,
        width: el.offsetWidth,
        height: el.offsetHeight,
      };
      const transformMat = getTransformMatrix(style);
      const offsetFromParent = getOffsetFromParent(currentOffset, parentOffset);
      vec3.transformQuat(offsetFromParent, offsetFromParent, accRotation);

      vec3.add(centerPos, centerPos, offsetFromParent);

      const rotateOffset = getRotateOffset(style, currentOffset);
      vec3.transformQuat(rotateOffset, rotateOffset, accRotation);

      const transformOrigin = vec3.clone(centerPos);
      vec3.add(transformOrigin, transformOrigin, rotateOffset);

      const centerFromOrigin = vec3.create();
      vec3.sub(centerFromOrigin, centerPos, transformOrigin);

      const invAccRotation = quat.invert(quat.create(), accRotation);
      vec3.transformQuat(centerFromOrigin, centerFromOrigin, invAccRotation);
      vec3.transformMat4(centerFromOrigin, centerFromOrigin, transformMat);
      vec3.transformQuat(centerFromOrigin, centerFromOrigin, accRotation);

      const newCenterPos = vec3.add(vec3.create(), transformOrigin, centerFromOrigin);
      const rotation = mat4.getRotation(quat.create(), transformMat);

      vec3.copy(centerPos, newCenterPos);
      quat.mul(accRotation, accRotation, rotation);
      parentOffset = currentOffset;
    });

    const perspective = vec3.fromValues(0, 0, this.perspective);
    vec3.transformQuat(perspective, perspective, accRotation);
    vec3.add(centerPos, centerPos, perspective);

    const matrix = mat4.create();
    mat4.fromRotationTranslation(matrix, accRotation, centerPos);

    return matrix;
  }
}

export default CSSCamera;