extensions/interactions/GraphInput/directives/graph-viz.component.ts

Summary

Maintainability
F
6 days
Test Coverage
// Copyright 2019 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview Directive for the graph-viz.
 *
 * IMPORTANT NOTE: The naming convention for customization args that are passed
 * into the directive is: the name of the parameter, followed by 'With',
 * followed by the name of the arg.
 */

import {
  AfterViewInit,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  HostListener,
  Input,
  OnInit,
  Output,
} from '@angular/core';
import {isNumber} from 'angular';
import {GraphAnswer} from 'interactions/answer-defs';
import {InteractionsExtensionsConstants} from 'interactions/interactions-extension.constants';
import {PlayerPositionService} from 'pages/exploration-player-page/services/player-position.service';
import {Subscription} from 'rxjs';
import {DeviceInfoService} from 'services/contextual/device-info.service';
import {FocusManagerService} from 'services/stateful/focus-manager.service';
import {UtilsService} from 'services/utils.service';
import {EdgeCentre, GraphDetailService} from './graph-detail.service';
import {downgradeComponent} from '@angular/upgrade/static';
import {I18nLanguageCodeService} from 'services/i18n-language-code.service';

const debounce = (delay: number = 5): MethodDecorator => {
  return function (
    target: string,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const original = descriptor.value;
    const key = `__timeout__${propertyKey}`;

    descriptor.value = function (...args) {
      clearTimeout(this[key]);
      this[key] = setTimeout(() => original.apply(this, args), delay);
    };
    return descriptor;
  };
};

interface GraphButton {
  text: string;
  description: string;
  mode: number;
}

interface GraphOption {
  text: string;
  option: string;
}

@Component({
  selector: 'graph-viz',
  templateUrl: './graph-viz.component.html',
  styleUrls: [],
})
export class GraphVizComponent implements OnInit, AfterViewInit {
  @Input() graph: GraphAnswer;
  @Input() canAddVertex: boolean;
  @Input() canDeleteVertex: boolean;
  @Input() canMoveVertex: boolean;
  @Input() canEditVertexLabel: boolean;
  @Input() canAddEdge: boolean;
  @Input() canDeleteEdge: boolean;
  @Input() canEditEdgeWeight: boolean;
  @Input() interactionIsActive: boolean;
  @Input() canEditOptions: boolean;

  @Output() graphChange: EventEmitter<GraphAnswer> = new EventEmitter();
  isMobile: boolean = false;
  helpText: string = '';
  _MODES = {
    MOVE: 0,
    ADD_EDGE: 1,
    ADD_VERTEX: 2,
    DELETE: 3,
  };

  // Styling functions.
  DELETE_COLOR = 'red';
  HOVER_COLOR = 'aqua';
  SELECT_COLOR = 'orange';
  DEFAULT_COLOR = 'black';
  state = {
    currentMode: this._MODES.MOVE,
    // Vertex, edge, mode button, label currently being hovered over.
    hoveredVertex: null,
    hoveredEdge: null,
    hoveredModeButton: null,
    // If in ADD_EDGE mode, source vertex of the new edge, if it
    // exists.
    addEdgeVertex: null,
    // Currently dragged vertex.
    currentlyDraggedVertex: null,
    // Selected vertex for editing label.
    selectedVertex: null,
    // Selected edge for editing weight.
    selectedEdge: null,
    // Mouse position in SVG coordinates.
    mouseX: 0,
    mouseY: 0,
    // Original position of dragged vertex.
    vertexDragStartX: 0,
    vertexDragStartY: 0,
    // Original position of mouse when dragging started.
    mouseDragStartX: 0,
    mouseDragStartY: 0,
  };

  selectedEdgeWeightValue: number | string;
  buttons: GraphButton[] = [];
  private vizContainer: SVGSVGElement[];
  componentSubscriptions: Subscription = new Subscription();
  shouldShowWrongWeightWarning: boolean;
  VERTEX_RADIUS: number;
  EDGE_WIDTH: number;
  graphOptions: GraphOption[];
  svgViewBox: string;
  constructor(
    private changeDetectorRef: ChangeDetectorRef,
    private deviceInfoService: DeviceInfoService,
    private element: ElementRef,
    private focusManagerService: FocusManagerService,
    private graphDetailService: GraphDetailService,
    private i18nLanguageCodeService: I18nLanguageCodeService,
    private playerPositionService: PlayerPositionService,
    private utilsService: UtilsService
  ) {}

  ngOnInit(): void {
    this.componentSubscriptions.add(
      this.playerPositionService.onNewCardAvailable.subscribe(
        () => (this.state.currentMode = null)
      )
    );
    this.VERTEX_RADIUS = this.graphDetailService.VERTEX_RADIUS;
    this.EDGE_WIDTH = this.graphDetailService.EDGE_WIDTH;
    this.selectedEdgeWeightValue = 0;
    this.shouldShowWrongWeightWarning = false;

    this.isMobile = false;
    if (this.deviceInfoService.isMobileDevice()) {
      this.isMobile = true;
    }
  }

  ngAfterViewInit(): void {
    this.vizContainer = this.element.nativeElement.querySelectorAll(
      '.oppia-graph-viz-svg'
    );

    this.graphOptions = [
      {
        text: 'Labeled',
        option: 'isLabeled',
      },
      {
        text: 'Directed',
        option: 'isDirected',
      },
      {
        text: 'Weighted',
        option: 'isWeighted',
      },
    ];
    this.helpText = null;
    const svgContainer = this.element.nativeElement.querySelectorAll(
      '.oppia-graph-viz-svg'
    )[0];
    const boundingBox = svgContainer.getBBox();
    const viewBoxHeight = Math.max(
      boundingBox.height + boundingBox.y,
      svgContainer.getAttribute('height')
    );
    this.svgViewBox = `0 0 ${svgContainer.width.baseVal.value} ${viewBoxHeight}`;

    // Initial value of SVG view box.
    if (this.interactionIsActive) {
      this.init();
    }
    this.changeDetectorRef.detectChanges();
  }

  getEdgeColor(index: number): string {
    if (!this.interactionIsActive) {
      return this.DEFAULT_COLOR;
    }
    if (
      this.state.currentMode === this._MODES.DELETE &&
      index === this.state.hoveredEdge &&
      this.canDeleteEdge
    ) {
      return this.DELETE_COLOR;
    } else if (index === this.state.hoveredEdge) {
      return this.HOVER_COLOR;
    } else if (this.state.selectedEdge === index) {
      return this.SELECT_COLOR;
    } else {
      return this.DEFAULT_COLOR;
    }
  }

  isLanguageRTL(): boolean {
    return this.i18nLanguageCodeService.isCurrentLanguageRTL();
  }

  getVertexColor(index: number): string {
    if (!this.interactionIsActive) {
      return this.DEFAULT_COLOR;
    }
    if (
      this.state.currentMode === this._MODES.DELETE &&
      index === this.state.hoveredVertex &&
      this.canDeleteVertex
    ) {
      return this.DELETE_COLOR;
    } else if (index === this.state.currentlyDraggedVertex) {
      return this.HOVER_COLOR;
    } else if (index === this.state.hoveredVertex) {
      return this.HOVER_COLOR;
    } else if (this.state.selectedVertex === index) {
      return this.SELECT_COLOR;
    } else {
      return this.DEFAULT_COLOR;
    }
  }

  getDirectedEdgeArrowPoints(index: number): string {
    return this.graphDetailService.getDirectedEdgeArrowPoints(
      this.graph,
      index
    );
  }

  getEdgeCentre(index: number): EdgeCentre {
    return this.graphDetailService.getEdgeCentre(this.graph, index);
  }

  mousemoveGraphSVG(event: MouseEvent): void {
    if (!this.interactionIsActive) {
      return;
    }
    // Note: Transform client (X, Y) to SVG (X, Y). This has to be
    // done so that changes due to viewBox attribute are
    // propagated nicely.
    const pt = this.vizContainer[0].createSVGPoint();
    pt.x = event.clientX;
    pt.y = event.clientY;
    const svgp = pt.matrixTransform(
      this.vizContainer[0].getScreenCTM().inverse()
    );
    this.state.mouseX = svgp.x;
    this.state.mouseY = svgp.y;
    // We use vertexDragStartX/Y and mouseDragStartX/Y to make
    // mouse-dragging by label more natural, by moving the vertex
    // according to the difference from the original position.
    // Otherwise, mouse-dragging by label will make the vertex
    // awkwardly jump to the mouse.
    if (
      this.state.currentlyDraggedVertex !== null &&
      this.state.mouseX >
        InteractionsExtensionsConstants.GRAPH_INPUT_LEFT_MARGIN
    ) {
      this.graph.vertices[this.state.currentlyDraggedVertex].x =
        this.state.vertexDragStartX +
        (this.state.mouseX - this.state.mouseDragStartX);
      this.graph.vertices[this.state.currentlyDraggedVertex].y =
        this.state.vertexDragStartY +
        (this.state.mouseY - this.state.mouseDragStartY);
    }
  }

  onClickGraphSVG(): void {
    if (!this.interactionIsActive) {
      return;
    }
    if (
      this.state.currentMode === this._MODES.ADD_VERTEX &&
      this.canAddVertex
    ) {
      this.graph.vertices.push({
        x: this.state.mouseX,
        y: this.state.mouseY,
        label: '',
      });
      this.graphChange.emit(this.graph);
    }
    if (this.state.hoveredVertex === null) {
      this.state.selectedVertex = null;
    }
    if (this.state.hoveredEdge === null) {
      this.state.selectedEdge = null;
    }
  }

  initButtons(): void {
    this.buttons = [];
    if (this.canMoveVertex) {
      this.buttons.push({
        text: '\uF0B2',
        description: 'I18N_INTERACTIONS_GRAPH_MOVE',
        mode: this._MODES.MOVE,
      });
    }
    if (this.canAddEdge) {
      this.buttons.push({
        text: '\uF0C1',
        description: 'I18N_INTERACTIONS_GRAPH_ADD_EDGE',
        mode: this._MODES.ADD_EDGE,
      });
    }
    if (this.canAddVertex) {
      this.buttons.push({
        text: '\uF067',
        description: 'I18N_INTERACTIONS_GRAPH_ADD_NODE',
        mode: this._MODES.ADD_VERTEX,
      });
    }
    if (this.canDeleteVertex || this.canDeleteEdge) {
      this.buttons.push({
        text: '\uF068',
        description: 'I18N_INTERACTIONS_GRAPH_DELETE',
        mode: this._MODES.DELETE,
      });
    }
  }

  init(): void {
    this.initButtons();
    this.state.currentMode = this.buttons[0].mode;
    if (this.isMobile) {
      if (this.state.currentMode === this._MODES.ADD_EDGE) {
        this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT';
      } else if (this.state.currentMode === this._MODES.MOVE) {
        this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_INITIAL_HELPTEXT';
      } else {
        this.helpText = '';
      }
    } else {
      this.helpText = '';
    }
  }

  toggleGraphOption(option: string): void {
    // Handle the case when we have two edges s -> d and d -> s.
    if (option === 'isDirected' && this.graph[option]) {
      this._deleteRepeatedUndirectedEdges();
    }
    this.graph[option] = !this.graph[option];
    this.graphChange.emit(this.graph);
  }

  setMode(mode: number): void {
    this.state.currentMode = mode;
    if (this.isMobile) {
      if (this.state.currentMode === this._MODES.ADD_EDGE) {
        this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT';
      } else if (this.state.currentMode === this._MODES.MOVE) {
        this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_INITIAL_HELPTEXT';
      } else {
        this.helpText = null;
      }
    } else {
      this.helpText = null;
    }
    this.state.addEdgeVertex = null;
    this.state.selectedVertex = null;
    this.state.selectedEdge = null;
    this.state.currentlyDraggedVertex = null;
    this.state.hoveredVertex = null;
  }

  onClickModeButton(mode: number, event: Event): void {
    event.preventDefault();
    event.stopPropagation();
    if (this.interactionIsActive) {
      this.setMode(mode);
    }
  }

  // TODO(#12104): Consider if there's a neat way to write a reset()
  // function to clear bits of this.state
  // (e.g. currentlyDraggedVertex, addEdgeVertex).

  // ---- Vertex events ----
  onClickVertex(index: number): void {
    if (this.state.currentMode === this._MODES.DELETE) {
      if (this.canDeleteVertex) {
        this.deleteVertex(index);
      }
    }
    if (
      this.state.currentMode !== this._MODES.DELETE &&
      this.graph.isLabeled &&
      this.canEditVertexLabel
    ) {
      this.beginEditVertexLabel(index);
    }
    if (this.isMobile) {
      this.state.hoveredVertex = index;
      if (
        this.state.addEdgeVertex === null &&
        this.state.currentlyDraggedVertex === null
      ) {
        this.onTouchInitialVertex(index);
      } else {
        if (this.state.addEdgeVertex === index) {
          this.state.hoveredVertex = null;
          this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT';
          this.state.addEdgeVertex = null;
          return;
        }
        this.onTouchFinalVertex(index);
      }
    }
  }

  onTouchInitialVertex(index: number): void {
    if (this.state.currentMode === this._MODES.ADD_EDGE) {
      if (this.canAddEdge) {
        this.beginAddEdge(index);
        this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_FINAL_HELPTEXT';
      }
    } else if (this.state.currentMode === this._MODES.MOVE) {
      if (this.canMoveVertex) {
        this.beginDragVertex(index);
        this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_FINAL_HELPTEXT';
      }
    }
  }

  onTouchFinalVertex(index: number): void {
    if (this.state.currentMode === this._MODES.ADD_EDGE) {
      this.tryAddEdge(this.state.addEdgeVertex, index);
      this.endAddEdge();
      this.state.hoveredVertex = null;
      this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT';
    } else if (this.state.currentMode === this._MODES.MOVE) {
      if (this.state.currentlyDraggedVertex !== null) {
        this.endDragVertex();
        this.state.hoveredVertex = null;
        this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_INITIAL_HELPTEXT';
      }
    }
  }

  onMousedownVertex(index: number): void {
    if (this.isMobile) {
      return;
    }
    if (this.state.currentMode === this._MODES.ADD_EDGE) {
      if (this.canAddEdge) {
        this.beginAddEdge(index);
      }
    } else if (this.state.currentMode === this._MODES.MOVE) {
      if (this.canMoveVertex) {
        this.beginDragVertex(index);
      }
    }
  }

  onMouseleaveVertex(index: number): void {
    if (this.isMobile) {
      return;
    }
    this.state.hoveredVertex =
      index === this.state.hoveredVertex ? null : this.state.hoveredVertex;
  }

  onClickVertexLabel(index: number): void {
    if (this.graph.isLabeled && this.canEditVertexLabel) {
      this.beginEditVertexLabel(index);
    }
  }

  // ---- Edge events ----
  onClickEdge(index: number): void {
    if (this.state.currentMode === this._MODES.DELETE) {
      if (this.canDeleteEdge) {
        this.deleteEdge(index);
      }
    }
    if (
      this.state.currentMode !== this._MODES.DELETE &&
      this.graph.isWeighted &&
      this.canEditEdgeWeight
    ) {
      this.beginEditEdgeWeight(index);
    }
  }

  onClickEdgeWeight(index: number): void {
    if (this.graph.isWeighted && this.canEditEdgeWeight) {
      this.beginEditEdgeWeight(index);
    }
  }

  // ---- Document event ----
  @HostListener('document:mouseup', ['$event'])
  @debounce()
  onMouseupDocument(): void {
    if (this.isMobile) {
      return;
    }
    if (this.state.currentMode === this._MODES.ADD_EDGE) {
      if (this.state.hoveredVertex !== null) {
        this.tryAddEdge(this.state.addEdgeVertex, this.state.hoveredVertex);
      }
      this.endAddEdge();
    } else if (this.state.currentMode === this._MODES.MOVE) {
      if (this.state.currentlyDraggedVertex !== null) {
        this.endDragVertex();
      }
    }
  }

  // ---- Actions ----
  beginAddEdge(startIndex: number): void {
    this.state.addEdgeVertex = startIndex;
  }

  endAddEdge(): void {
    this.state.addEdgeVertex = null;
  }

  tryAddEdge(startIndex: number, endIndex: number): void {
    if (
      startIndex === null ||
      endIndex === null ||
      startIndex === endIndex ||
      startIndex < 0 ||
      endIndex < 0 ||
      startIndex >= this.graph.vertices.length ||
      endIndex >= this.graph.vertices.length
    ) {
      return;
    }
    for (let i = 0; i < this.graph.edges.length; i++) {
      if (
        startIndex === this.graph.edges[i].src &&
        endIndex === this.graph.edges[i].dst
      ) {
        return;
      }
      if (!this.graph.isDirected) {
        if (
          startIndex === this.graph.edges[i].dst &&
          endIndex === this.graph.edges[i].src
        ) {
          return;
        }
      }
    }
    this.graph.edges.push({
      src: startIndex,
      dst: endIndex,
      weight: 1,
    });
    this.graphChange.emit(this.graph);
    return;
  }

  beginDragVertex(index: number): void {
    this.state.currentlyDraggedVertex = index;
    this.state.vertexDragStartX = this.graph.vertices[index].x;
    this.state.vertexDragStartY = this.graph.vertices[index].y;
    this.state.mouseDragStartX = this.state.mouseX;
    this.state.mouseDragStartY = this.state.mouseY;
  }

  endDragVertex(): void {
    this.state.currentlyDraggedVertex = null;
    this.state.vertexDragStartX = 0;
    this.state.vertexDragStartY = 0;
    this.state.mouseDragStartX = 0;
    this.state.mouseDragStartY = 0;
  }

  beginEditVertexLabel(index: number): void {
    this.state.selectedVertex = index;
    this.focusManagerService.setFocus('vertexLabelEditBegun');
  }

  beginEditEdgeWeight(index: number): void {
    this.state.selectedEdge = index;
    this.selectedEdgeWeightValue =
      this.graph.edges[this.state.selectedEdge].weight;
    this.shouldShowWrongWeightWarning = false;
    this.focusManagerService.setFocus('edgeWeightEditBegun');
  }

  deleteEdge(index: number): void {
    this.graph.edges.splice(index, 1);
    this.graphChange.emit(this.graph);
    this.state.hoveredEdge = null;
  }

  private _deleteRepeatedUndirectedEdges(): void {
    for (let i = 0; i < this.graph.edges.length; i++) {
      const edge1 = this.graph.edges[i];
      for (let j = i + 1; j < this.graph.edges.length; j++) {
        const edge2 = this.graph.edges[j];
        if (
          (edge1.src === edge2.src && edge1.dst === edge2.dst) ||
          (edge1.src === edge2.dst && edge1.dst === edge2.src)
        ) {
          this.deleteEdge(j);
          j--;
        }
      }
    }
  }

  deleteVertex(index: number): void {
    this.graph.edges = this.graph.edges.map(edge => {
      if (edge.src === index || edge.dst === index) {
        return null;
      }
      if (edge.src > index) {
        edge.src--;
      }
      if (edge.dst > index) {
        edge.dst--;
      }
      return edge;
    });
    this.graph.edges = this.graph.edges.filter(edge => edge !== null);
    this.graph.vertices.splice(index, 1);
    this.graphChange.emit(this.graph);
    this.state.hoveredVertex = null;
  }

  get selectedVertexLabel(): string {
    if (this.state.selectedVertex === null) {
      return '';
    }
    return this.graph.vertices[this.state.selectedVertex].label;
  }

  set selectedVertexLabel(label: string) {
    if (this.utilsService.isDefined(label)) {
      this.graph.vertices[this.state.selectedVertex].label = label;
    }
  }

  get selectedEdgeWeight(): string | number {
    if (this.state.selectedEdge === null) {
      return '';
    }
    return this.selectedEdgeWeightValue;
  }

  set selectedEdgeWeight(weight: number | string | null) {
    if (weight === null) {
      this.selectedEdgeWeightValue = '';
    }
    if (isNumber(weight)) {
      this.selectedEdgeWeightValue = weight;
    }
  }

  isValidEdgeWeight(): boolean {
    return typeof this.selectedEdgeWeightValue === 'number';
  }

  onUpdateEdgeWeight(): void {
    if (isNumber(this.selectedEdgeWeightValue)) {
      this.graph.edges[this.state.selectedEdge].weight =
        this.selectedEdgeWeightValue;
      this.graphChange.emit(this.graph);
    }
    this.state.selectedEdge = null;
  }
}
angular.module('oppia').directive(
  'graphViz',
  downgradeComponent({
    component: GraphVizComponent,
  })
);