ssi-patterns / ssi-pattern-connections-visualization / src / core / captors / touch.ts
touch.ts
Raw
/**
 * Sigma.js Touch Captor
 * ======================
 *
 * Sigma's captor dealing with touch.
 * @module
 */
import { CameraState, Coordinates, Dimensions, TouchCoords } from "../../types";
import Captor, { getPosition, getTouchCoords, getTouchesArray } from "./captor";
import Sigma from "../../sigma";

const DRAG_TIMEOUT = 200;
const TOUCH_INERTIA_RATIO = 3;
const TOUCH_INERTIA_DURATION = 200;

export type FakeSigmaMouseEvent = MouseEvent & { isFakeSigmaMouseEvent?: true };

/**
 * Event types.
 */
export type TouchCaptorEvents = {
  touchdown(coordinates: TouchCoords): void;
  touchup(coordinates: TouchCoords): void;
  touchmove(coordinates: TouchCoords): void;
};

/**
 * Touch captor class.
 *
 * @constructor
 */
export default class TouchCaptor extends Captor<TouchCaptorEvents> {
  enabled = true;
  isMoving = false;
  hasMoved = false;
  startCameraState?: CameraState;
  touchMode = 0; // number of touches down
  movingTimeout?: number;

  startTouchesAngle?: number;
  startTouchesDistance?: number;
  startTouchesPositions: Coordinates[] = [];
  lastTouchesPositions?: Coordinates[];
  lastTouches?: Touch[];

  constructor(container: HTMLElement, renderer: Sigma) {
    super(container, renderer);

    // Binding methods:
    this.handleStart = this.handleStart.bind(this);
    this.handleLeave = this.handleLeave.bind(this);
    this.handleMove = this.handleMove.bind(this);

    // Binding events
    container.addEventListener("touchstart", this.handleStart, false);
    container.addEventListener("touchend", this.handleLeave, false);
    container.addEventListener("touchcancel", this.handleLeave, false);
    container.addEventListener("touchmove", this.handleMove, false);
  }

  kill(): void {
    const container = this.container;

    container.removeEventListener("touchstart", this.handleStart);
    container.removeEventListener("touchend", this.handleLeave);
    container.removeEventListener("touchcancel", this.handleLeave);
    container.removeEventListener("touchmove", this.handleMove);
  }

  getDimensions(): Dimensions {
    return {
      width: this.container.offsetWidth,
      height: this.container.offsetHeight,
    };
  }

  dispatchRelatedMouseEvent(type: string, e: TouchEvent, touch?: Touch, emitter?: EventTarget): void {
    const mousePosition = touch || e.touches[0];
    const mouseEvent = new MouseEvent(type, {
      clientX: mousePosition.clientX,
      clientY: mousePosition.clientY,
      altKey: e.altKey,
      ctrlKey: e.ctrlKey,
    });

    (mouseEvent as FakeSigmaMouseEvent).isFakeSigmaMouseEvent = true;

    (emitter || this.container).dispatchEvent(mouseEvent);
  }

  handleStart(e: TouchEvent): void {
    if (!this.enabled) return;

    // Prevent default to avoid default browser behaviors...
    e.preventDefault();
    // ...but simulate mouse behavior anyway, to get the MouseCaptor working as well:
    if (e.touches.length === 1) this.dispatchRelatedMouseEvent("mousedown", e);

    const touches = getTouchesArray(e.touches);
    this.touchMode = touches.length;

    this.startCameraState = this.renderer.getCamera().getState();
    this.startTouchesPositions = touches.map((touch) => getPosition(touch, this.container));
    this.lastTouches = touches;
    this.lastTouchesPositions = this.startTouchesPositions;

    // When there are two touches down, let's record distance and angle as well:
    if (this.touchMode === 2) {
      const [{ x: x0, y: y0 }, { x: x1, y: y1 }] = this.startTouchesPositions;
      this.startTouchesAngle = Math.atan2(y1 - y0, x1 - x0);
      this.startTouchesDistance = Math.sqrt(Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2));
    }

    this.emit("touchdown", getTouchCoords(e, this.container));
  }

  handleLeave(e: TouchEvent): void {
    if (!this.enabled) return;

    // Prevent default to avoid default browser behaviors...
    e.preventDefault();
    // ...but simulate mouse behavior anyway, to get the MouseCaptor working as well:
    if (e.touches.length === 0 && this.lastTouches && this.lastTouches.length) {
      this.dispatchRelatedMouseEvent("mouseup", e, this.lastTouches[0], document);
      // ... and only click if no move was made
      if (!this.hasMoved) {
        this.dispatchRelatedMouseEvent("click", e, this.lastTouches[0]);
      }
    }

    if (this.movingTimeout) {
      this.isMoving = false;
      clearTimeout(this.movingTimeout);
    }

    switch (this.touchMode) {
      case 2:
        if (e.touches.length === 1) {
          this.handleStart(e);

          e.preventDefault();
          break;
        }
      /* falls through */
      case 1:
        // TODO
        // Dispatch event

        if (this.isMoving) {
          const camera = this.renderer.getCamera();
          const cameraState = camera.getState(),
            previousCameraState = camera.getPreviousState() || { x: 0, y: 0 };

          camera.animate(
            {
              x: cameraState.x + TOUCH_INERTIA_RATIO * (cameraState.x - previousCameraState.x),
              y: cameraState.y + TOUCH_INERTIA_RATIO * (cameraState.y - previousCameraState.y),
            },
            {
              duration: TOUCH_INERTIA_DURATION,
              easing: "quadraticOut",
            },
          );
        }

        this.hasMoved = false;
        this.isMoving = false;
        this.touchMode = 0;
        break;
    }

    this.emit("touchup", getTouchCoords(e, this.container));
  }

  handleMove(e: TouchEvent): void {
    if (!this.enabled) return;

    // Prevent default to avoid default browser behaviors...
    e.preventDefault();
    // ...but simulate mouse behavior anyway, to get the MouseCaptor working as well:
    if (e.touches.length === 1) this.dispatchRelatedMouseEvent("mousemove", e);

    const touches = getTouchesArray(e.touches);
    const touchesPositions = touches.map((touch) => getPosition(touch, this.container));
    this.lastTouches = touches;
    this.lastTouchesPositions = touchesPositions;

    // If a move was initiated at some point and we get back to startpoint,
    // we should still consider that we did move (which also happens after a
    // multiple touch when only one touch remains in which case handleStart
    // is recalled within handleLeave).
    // Now, some mobile browsers report zero-distance moves so we also check that
    // one of the touches did actually move from the origin position.
    this.hasMoved ||= touchesPositions.some((position, idx) => {
      const startPosition = this.startTouchesPositions[idx];

      return position.x !== startPosition.x || position.y !== startPosition.y;
    });

    // If there was no move, do not trigger touch moves behavior
    if (!this.hasMoved) {
      return;
    }

    this.isMoving = true;

    if (this.movingTimeout) clearTimeout(this.movingTimeout);

    this.movingTimeout = window.setTimeout(() => {
      this.isMoving = false;
    }, DRAG_TIMEOUT);

    const camera = this.renderer.getCamera();
    const startCameraState = this.startCameraState as CameraState;

    switch (this.touchMode) {
      case 1: {
        const { x: xStart, y: yStart } = this.renderer.viewportToFramedGraph(
          (this.startTouchesPositions || [])[0] as Coordinates,
        );
        const { x, y } = this.renderer.viewportToFramedGraph(touchesPositions[0]);

        camera.setState({
          x: startCameraState.x + xStart - x,
          y: startCameraState.y + yStart - y,
        });
        break;
      }
      case 2: {
        /**
         * Here is the thinking here:
         *
         * 1. We can find the new angle and ratio, by comparing the vector from "touch one" to "touch two" at the start
         *    of the d'n'd and now
         *
         * 2. We can use `Camera#viewportToGraph` inside formula to retrieve the new camera position, using the graph
         *    position of a touch at the beginning of the d'n'd (using `startCamera.viewportToGraph`) and the viewport
         *    position of this same touch now
         */
        const newCameraState: Partial<CameraState> = {};

        const { x: x0, y: y0 } = touchesPositions[0];
        const { x: x1, y: y1 } = touchesPositions[1];

        const angleDiff = Math.atan2(y1 - y0, x1 - x0) - (this.startTouchesAngle as number);
        const ratioDiff = Math.hypot(y1 - y0, x1 - x0) / (this.startTouchesDistance as number);

        // 1.
        const newRatio = camera.getBoundedRatio(startCameraState.ratio / ratioDiff);
        newCameraState.ratio = newRatio;
        newCameraState.angle = startCameraState.angle + angleDiff;

        // 2.
        const dimensions = this.getDimensions();
        const touchGraphPosition = this.renderer.viewportToFramedGraph(
          (this.startTouchesPositions || [])[0] as Coordinates,
          { cameraState: startCameraState },
        );
        const smallestDimension = Math.min(dimensions.width, dimensions.height);

        const dx = smallestDimension / dimensions.width;
        const dy = smallestDimension / dimensions.height;
        const ratio = newRatio / smallestDimension;

        // Align with center of the graph:
        let x = x0 - smallestDimension / 2 / dx;
        let y = y0 - smallestDimension / 2 / dy;

        // Rotate:
        [x, y] = [
          x * Math.cos(-newCameraState.angle) - y * Math.sin(-newCameraState.angle),
          y * Math.cos(-newCameraState.angle) + x * Math.sin(-newCameraState.angle),
        ];

        newCameraState.x = touchGraphPosition.x - x * ratio;
        newCameraState.y = touchGraphPosition.y + y * ratio;

        camera.setState(newCameraState);

        break;
      }
    }

    this.emit("touchmove", getTouchCoords(e, this.container));
  }
}