ssi-patterns / ssi-pattern-connections-visualization / src / core / captors / mouse.ts
mouse.ts
Raw
/**
 * Sigma.js Mouse Captor
 * ======================
 *
 * Sigma's captor dealing with the user's mouse.
 * @module
 */
import { CameraState, MouseCoords, WheelCoords } from "../../types";
import Sigma from "../../sigma";
import Captor, { getWheelDelta, getMouseCoords, getPosition, getWheelCoords } from "./captor";

/**
 * Constants.
 */
const DRAG_TIMEOUT = 100;
const DRAGGED_EVENTS_TOLERANCE = 3;
const MOUSE_INERTIA_DURATION = 200;
const MOUSE_INERTIA_RATIO = 3;
const MOUSE_ZOOM_DURATION = 250;
const ZOOMING_RATIO = 1.7;
const DOUBLE_CLICK_TIMEOUT = 300;
const DOUBLE_CLICK_ZOOMING_RATIO = 2.2;
const DOUBLE_CLICK_ZOOMING_DURATION = 200;

/**
 * Event types.
 */
export type MouseCaptorEvents = {
  click(coordinates: MouseCoords): void;
  rightClick(coordinates: MouseCoords): void;
  doubleClick(coordinates: MouseCoords): void;
  mouseup(coordinates: MouseCoords): void;
  mousedown(coordinates: MouseCoords): void;
  mousemove(coordinates: MouseCoords): void;
  mousemovebody(coordinates: MouseCoords): void;
  wheel(coordinates: WheelCoords): void;
};

/**
 * Mouse captor class.
 *
 * @constructor
 */
export default class MouseCaptor extends Captor<MouseCaptorEvents> {
  // State
  enabled = true;
  draggedEvents = 0;
  downStartTime: number | null = null;
  lastMouseX: number | null = null;
  lastMouseY: number | null = null;
  isMouseDown = false;
  isMoving = false;
  movingTimeout: number | null = null;
  startCameraState: CameraState | null = null;
  clicks = 0;
  doubleClickTimeout: number | null = null;

  currentWheelDirection: -1 | 0 | 1 = 0;
  lastWheelTriggerTime?: number;

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

    // Binding methods
    this.handleClick = this.handleClick.bind(this);
    this.handleRightClick = this.handleRightClick.bind(this);
    this.handleDown = this.handleDown.bind(this);
    this.handleUp = this.handleUp.bind(this);
    this.handleMove = this.handleMove.bind(this);
    this.handleWheel = this.handleWheel.bind(this);
    this.handleOut = this.handleOut.bind(this);

    // Binding events
    container.addEventListener("click", this.handleClick, false);
    container.addEventListener("contextmenu", this.handleRightClick, false);
    container.addEventListener("mousedown", this.handleDown, false);
    container.addEventListener("wheel", this.handleWheel, false);
    container.addEventListener("mouseout", this.handleOut, false);

    document.addEventListener("mousemove", this.handleMove, false);
    document.addEventListener("mouseup", this.handleUp, false);
  }

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

    container.removeEventListener("click", this.handleClick);
    container.removeEventListener("contextmenu", this.handleRightClick);
    container.removeEventListener("mousedown", this.handleDown);
    container.removeEventListener("wheel", this.handleWheel);
    container.removeEventListener("mouseout", this.handleOut);

    document.removeEventListener("mousemove", this.handleMove);
    document.removeEventListener("mouseup", this.handleUp);
  }

  handleClick(e: MouseEvent): void {
    if (!this.enabled) return;

    this.clicks++;

    if (this.clicks === 2) {
      this.clicks = 0;
      if (typeof this.doubleClickTimeout === "number") {
        clearTimeout(this.doubleClickTimeout);
        this.doubleClickTimeout = null;
      }
      return this.handleDoubleClick(e);
    }

    setTimeout(() => {
      this.clicks = 0;
      this.doubleClickTimeout = null;
    }, DOUBLE_CLICK_TIMEOUT);

    // NOTE: this is here to prevent click events on drag
    if (this.draggedEvents < DRAGGED_EVENTS_TOLERANCE) this.emit("click", getMouseCoords(e, this.container));
  }

  handleRightClick(e: MouseEvent): void {
    if (!this.enabled) return;

    this.emit("rightClick", getMouseCoords(e, this.container));
  }

  handleDoubleClick(e: MouseEvent): void {
    if (!this.enabled) return;

    e.preventDefault();
    e.stopPropagation();

    const mouseCoords = getMouseCoords(e, this.container);
    this.emit("doubleClick", mouseCoords);

    if (mouseCoords.sigmaDefaultPrevented) return;

    // default behavior
    const camera = this.renderer.getCamera();
    const newRatio = camera.getBoundedRatio(camera.getState().ratio / DOUBLE_CLICK_ZOOMING_RATIO);

    camera.animate(this.renderer.getViewportZoomedState(getPosition(e, this.container), newRatio), {
      easing: "quadraticInOut",
      duration: DOUBLE_CLICK_ZOOMING_DURATION,
    });
  }

  handleDown(e: MouseEvent): void {
    if (!this.enabled) return;

    // We only start dragging on left button
    if (e.button === 0) {
      this.startCameraState = this.renderer.getCamera().getState();

      const { x, y } = getPosition(e, this.container);
      this.lastMouseX = x;
      this.lastMouseY = y;

      this.draggedEvents = 0;

      this.downStartTime = Date.now();
      this.isMouseDown = true;
    }

    this.emit("mousedown", getMouseCoords(e, this.container));
  }

  handleUp(e: MouseEvent): void {
    if (!this.enabled || !this.isMouseDown) return;

    const camera = this.renderer.getCamera();
    this.isMouseDown = false;

    if (typeof this.movingTimeout === "number") {
      clearTimeout(this.movingTimeout);
      this.movingTimeout = null;
    }

    const { x, y } = getPosition(e, this.container);

    const cameraState = camera.getState(),
      previousCameraState = camera.getPreviousState() || { x: 0, y: 0 };

    if (this.isMoving) {
      camera.animate(
        {
          x: cameraState.x + MOUSE_INERTIA_RATIO * (cameraState.x - previousCameraState.x),
          y: cameraState.y + MOUSE_INERTIA_RATIO * (cameraState.y - previousCameraState.y),
        },
        {
          duration: MOUSE_INERTIA_DURATION,
          easing: "quadraticOut",
        },
      );
    } else if (this.lastMouseX !== x || this.lastMouseY !== y) {
      camera.setState({
        x: cameraState.x,
        y: cameraState.y,
      });
    }

    this.isMoving = false;
    setTimeout(() => {
      this.draggedEvents = 0;

      // NOTE: this refresh is here to make sure `hideEdgesOnMove` can work
      // when someone releases camera pan drag after having stopped moving.
      // See commit: https://github.com/jacomyal/sigma.js/commit/cfd9197f70319109db6b675dd7c82be493ca95a2
      // See also issue: https://github.com/jacomyal/sigma.js/issues/1290
      // It could be possible to render instead of scheduling a refresh but for
      // now it seems good enough.
      this.renderer.refresh();
    }, 0);
    this.emit("mouseup", getMouseCoords(e, this.container));
  }

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

    const mouseCoords = getMouseCoords(e, this.container);

    // Always trigger a "mousemovebody" event, so that it is possible to develop
    // a drag-and-drop effect that works even when the mouse is out of the
    // container:
    this.emit("mousemovebody", mouseCoords);

    // Only trigger the "mousemove" event when the mouse is actually hovering
    // the container, to avoid weirdly hovering nodes and/or edges when the
    // mouse is not hover the container:
    if (e.target === this.container) {
      this.emit("mousemove", mouseCoords);
    }

    if (mouseCoords.sigmaDefaultPrevented) return;

    // Handle the case when "isMouseDown" all the time, to allow dragging the
    // stage while the mouse is not hover the container:
    if (this.isMouseDown) {
      this.isMoving = true;
      this.draggedEvents++;

      if (typeof this.movingTimeout === "number") {
        clearTimeout(this.movingTimeout);
      }

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

      const camera = this.renderer.getCamera();

      const { x: eX, y: eY } = getPosition(e, this.container);

      const lastMouse = this.renderer.viewportToFramedGraph({
        x: this.lastMouseX as number,
        y: this.lastMouseY as number,
      });

      const mouse = this.renderer.viewportToFramedGraph({ x: eX, y: eY });

      const offsetX = lastMouse.x - mouse.x,
        offsetY = lastMouse.y - mouse.y;

      const cameraState = camera.getState();

      const x = cameraState.x + offsetX,
        y = cameraState.y + offsetY;

      camera.setState({ x, y });

      this.lastMouseX = eX;
      this.lastMouseY = eY;

      e.preventDefault();
      e.stopPropagation();
    }
  }

  handleWheel(e: WheelEvent): void {
    if (!this.enabled) return;

    e.preventDefault();
    e.stopPropagation();

    const delta = getWheelDelta(e);

    if (!delta) return;

    const wheelCoords = getWheelCoords(e, this.container);
    this.emit("wheel", wheelCoords);

    if (wheelCoords.sigmaDefaultPrevented) return;

    // Default behavior
    const ratioDiff = delta > 0 ? 1 / ZOOMING_RATIO : ZOOMING_RATIO;
    const camera = this.renderer.getCamera();
    const newRatio = camera.getBoundedRatio(camera.getState().ratio * ratioDiff);
    const wheelDirection = delta > 0 ? 1 : -1;
    const now = Date.now();

    // Cancel events that are too close too each other and in the same direction:
    if (
      this.currentWheelDirection === wheelDirection &&
      this.lastWheelTriggerTime &&
      now - this.lastWheelTriggerTime < MOUSE_ZOOM_DURATION / 5
    ) {
      return;
    }

    camera.animate(
      this.renderer.getViewportZoomedState(getPosition(e, this.container), newRatio),
      {
        easing: "quadraticOut",
        duration: MOUSE_ZOOM_DURATION,
      },
      () => {
        this.currentWheelDirection = 0;
      },
    );

    this.currentWheelDirection = wheelDirection;
    this.lastWheelTriggerTime = now;
  }

  handleOut(): void {
    // TODO: dispatch event
  }
}