File

src/app/graphs/issue-graph/issue-graph.component.ts

Description

This component creates nodes and edges in the embedded MICO GraphEditor (html tag: ) to reflect the data for the current project. This data consists of the project's interfaces, components, issues and their relations and is stored in this.graphData. The key method for this purpose is drawGraph(). This component is also responsible for registering event listeners with the GraphEditor.

Implements

OnInit OnDestroy AfterViewInit

Metadata

selector app-issue-graph
styleUrls ./issue-graph.component.css
templateUrl ./issue-graph.component.html

Index

Properties
Methods
Inputs

Constructor

constructor(dialog: MatDialog, gs: IssueGraphStateService, ss: StateService, router: Router, activatedRoute: ActivatedRoute, componentStoreService: ComponentStoreService, interfaceStoreService: InterfaceStoreService, componentContextMenuService: componentContextMenuComponent.ComponentContextMenuService, breakPointObserver: BreakpointObserver, issueGraphClassSettersService: IssueGraphClassSettersService, issueGraphLinkHandlesService: IssueGraphLinkHandlesService, issueGraphDynamicTemplateRegistryService: IssueGraphDynamicTemplateRegistryService)
Parameters :
Name Type Optional
dialog MatDialog No
gs IssueGraphStateService No
ss StateService No
router Router No
activatedRoute ActivatedRoute No
componentStoreService ComponentStoreService No
interfaceStoreService InterfaceStoreService No
componentContextMenuService componentContextMenuComponent.ComponentContextMenuService No
breakPointObserver BreakpointObserver No
issueGraphClassSettersService IssueGraphClassSettersService No
issueGraphLinkHandlesService IssueGraphLinkHandlesService No
issueGraphDynamicTemplateRegistryService IssueGraphDynamicTemplateRegistryService No

Inputs

projectId
Type : string

Methods

Private addInterfaceToComponent
addInterfaceToComponent(offeredById: string, position: issueGraphNodes.Position)

Opens the interface creation dialog. If the user actually creates the interface it is added to the providing component at the position where the dragged edge was dropped by the user (before opening the interface creation dialog).

Parameters :
Name Type Optional Description
offeredById string No

Id of the component that will provide the interface.

position issueGraphNodes.Position No

Position of the interface.

Returns : void
Private addIssueFolderNodes
addIssueFolderNodes(node: issueGraphNodes.IssueNode)

This method presumes that node has the 4 'Grouping Manager Objects' depicted on the the upper levels in the graph_structure_documentation.png. correctly setup.

Parameters :
Name Type Optional Description
node issueGraphNodes.IssueNode No

Interface / component that is handled.

Returns : void
Private addIssueFolders
addIssueFolders(node: issueGraphNodes.IssueNode)

Adds the issue folders with counts for each IssueCategory (currently 3) to the component or interface represented by node. The first methods call sets up invisible nodes in the graph to make the folders display properly. The second method takes care of actually adding the visible folders with the correct counts.

Parameters :
Name Type Optional Description
node issueGraphNodes.IssueNode No
  • Interface / component that is handled.
Returns : void
Private addIssueGroupContainer
addIssueGroupContainer(node: issueGraphNodes.IssueNode)

Creates the node groups necessary for displaying issue folders attached to a node. A Node represents a component or an interface. It also gets an issue group of IssueGroupContainerParentBehaviour, issueGroupContainerNode with IssueGroupContainerBehaviour gets added to it. This corresponds to the 4 'Grouping Manager' object on the upper two levels in the graph_structure_documentation.png.

Parameters :
Name Type Optional Description
node issueGraphNodes.IssueNode No
  • Node (component or interface) which can have issue folders attached.
Returns : void
calculateBoundingBox
calculateBoundingBox()

Calculates the bounding box of the view.

Returns : Rect

The calculated bounding box.

Private closeComponentActions
closeComponentActions(reload: boolean)

Closes the component context menu, if one is open

Parameters :
Name Type Optional Default value Description
reload boolean No true

If true, reloads the graph if a context menu was closed

Returns : boolean
connectConsumingComponents
connectConsumingComponents(interfaceNode: issueGraphNodes.InterfaceNode)

Adds an edge from each connected component to the interface.

Parameters :
Name Type Optional Description
interfaceNode issueGraphNodes.InterfaceNode No
  • Interface (visualized by lollipop notation) that is handled.
Returns : void
connectToOfferingComponent
connectToOfferingComponent(node: issueGraphNodes.InterfaceNode)

Creates and adds an edge between the node representing a component an the node representing the interface itself.

Parameters :
Name Type Optional Description
node issueGraphNodes.InterfaceNode No
  • Interface that is handled.
Returns : void
Private contextMenuTypeForNodeType
contextMenuTypeForNodeType(node: Node)

Sets the context menu type.

Parameters :
Name Type Optional Description
node Node No

Node that is handled.

Returns : NodeDetailsType
Private drawFolderRelations
drawFolderRelations(node: issueGraphNodes.IssueNode)

Draws folder relations originating from the issue folder represented by node.

Parameters :
Name Type Optional Description
node issueGraphNodes.IssueNode No
  • Issue folder (for issues of a certain type) that is handled.
Returns : void
drawGraph
drawGraph()

Responsible for drawing the graph based on this.graphData. Takes care of adding interfaces and components, and their connections. Additionally adds issue folders attached to each component and the dashed edges between them based on this.graphData.relatedFolders

Returns : void
Private extractIssueId
extractIssueId(issueList, category: string)

Extracts the id of an issue in a given issue list.

Parameters :
Name Type Optional Description
issueList No

Ids of the issues that are handled.

category string No

Category of issues that are handled.

Returns : string

Id of the first issue (in the issue list) with matching category.

findIdealComponentPosition
findIdealComponentPosition(id: string, boundingBox: Rect)

Finds the ideal component position if none is saved.

Parameters :
Name Type Optional Description
id string No

Id of component that is handled.

boundingBox Rect No

Bounding box of the component that is handled.

Returns : Point
fitGraphInView
fitGraphInView()

Fits the graph into view.

Returns : void
initGraph
initGraph()

Sets up the graph and register event listeners

Returns : void
layoutGraph
layoutGraph()

Attempts to automatically lay-out the graph in a reasonable manner

Returns : void
Private loadSavedPositions
loadSavedPositions()

Loads positions of graph elements from the local storage.

Returns : Positions

Parsed positions

Private manageDragBehaviour
manageDragBehaviour(graph: GraphEditor)

Manages the edge drag behaviour of given GraphEditor instance.

Parameters :
Name Type Optional Description
graph GraphEditor No

Reference to the GraphEditor instance of the graph that is handled.

Returns : void
Private manageEventListeners
manageEventListeners(graph: GraphEditor, minimap: GraphEditor)

Adds event listeners to a given GraphEditor instance.

Parameters :
Name Type Optional Description
graph GraphEditor No

Reference to the GraphEditor instance of the graph that is handled.

minimap GraphEditor No

Reference to the GraphEditor instance of the minimap that is handled.

Returns : void
Private nodeClickContextMenuHasType
nodeClickContextMenuHasType(node: Node, event: CustomEvent, contextMenuType: NodeDetailsType)

Open the component context menu

Parameters :
Name Type Optional Description
node Node No

Node that is handled

event CustomEvent No

Event that is handled

contextMenuType NodeDetailsType No

Type of the context menu that is handled

Returns : void
Private nodeClickIssueFolder
nodeClickIssueFolder(node: Node)

Handles the case in which an issue folder is clicked. Determines the number of issues in the issue folder and opens the corresponding issue page.

Parameters :
Name Type Optional Description
node Node No

Issue folder that is handled.

Returns : void
Private nodeClickManyIssues
nodeClickManyIssues(rootNode: Node)

Handles the case in which the clicked issue folder contains many issues.

Parameters :
Name Type Optional Description
rootNode Node No

Root node that is handled.

Returns : void
Private nodeClickOneIssue
nodeClickOneIssue(rootId: string, rootNode: Node, node: Node)

Handles the case in which the clicked issue folder contains only one issues.

Parameters :
Name Type Optional Description
rootId string No

Root id that is handled.

rootNode Node No

Root node that is handled.

node Node No

Clicked node that is handled.

Returns : void
Private onMinimapRender
onMinimapRender(minimap: GraphEditor)

Method gets triggered when the minimap renders.

Parameters :
Name Type Optional Description
minimap GraphEditor No

Minimap that is handled.

Returns : EventListenerOrEventListenerObject
Public openCreateComponentDialog
openCreateComponentDialog()

Opens create component dialog and triggers reload of data after the dialog is closed.

Returns : void
Public reload
reload()

Issues a redraw of the graph. ?

Returns : void
resetGraph
resetGraph()

Resets graph state. Called at start of draw(). Enables logic in draw() to assume a 'blank sheet' state avoiding complex updating logic.

Returns : void
Private setGraphToLastView
setGraphToLastView()

Sets the view and the bounding box of the graph to how it was when the user left the graph with the help of localStorage. When theres no previous session available set the view to the optimized bounding box for the graph.

Returns : void
setRelationVisibility
setRelationVisibility(showRelations: boolean)

Sets --show-relations css variable to initial or none. It is the value of the display attribute of the edges. If we set it to none the edges disappear.

Parameters :
Name Type Optional Description
showRelations boolean No
  • Boolean derived from the setting of the switch slider for relation edges above the graph.
Returns : void
Private subscribeToSubject
subscribeToSubject()

Subscribes to the subject emitting node positions.

Returns : void

Properties

Private componentContextMenu
Type : componentContextMenuComponent.ComponentContextMenuComponent
Private componentContextMenuNodeId
Type : number | string
currentVisibleArea
Type : Rect
Default value : {x: 0, y: 0, width: 1, height: 1}
Private destroy$
Default value : new ReplaySubject(1)
Private graph
Type : GraphEditor
Public graphData
Type : GraphData
Private graphFirstRender
Default value : true
Private graphInitialized
Default value : false
graphWrapper
Type : literal type
Decorators :
@ViewChild('graph', {static: true})
Private isHandset
Default value : false
Private issueGroupParents
Type : Node[]
Default value : []
minimap
Type : literal type
Decorators :
@ViewChild('minimap', {static: true})
Private onCreateEdge
Default value : () => {...}

Method gets triggered after an edge gets created, it can either be of type provider or consumer.

Parameters :
Name Description
edge

Edge that is handled.

Private onDraggedEdgeTargetChanged
Default value : () => {...}

Method gets triggered after an edge gets dragged and its target is changed: ex. consumer edge gets moved away from the provider edge.

Parameters :
Name Description
edge

Edge that is handled.

sourceNode

Source of the handled edge.

targetNode

Target of the handled edge.

Private onEdgeAdd
Default value : () => {...}

Method gets triggered after an edge gets added.

Parameters :
Name Description
event

Event that is handled.

Private onEdgeDrop
Default value : () => {...}

Method gets triggered after an edge gets dropped.

Parameters :
Name Description
event

Event that is handled.

Private onEdgeRemove
Default value : () => {...}

Method gets triggered after an edge gets removed.

Parameters :
Name Description
event

Event that is handled.

Private onNodeClick
Default value : () => {...}

Method gets triggered after a node is clicked.

Parameters :
Name Description
event

Event that is handled.

Private onNodeDragEnd
Default value : () => {...}

Called when the user lets go of a node

Parameters :
Name Description
e

The event

Private projectStorageKey
Type : string
Private redrawByCloseOfComponentDetails
Default value : false
Public reload$
Type : BehaviorSubject<void>
Default value : new BehaviorSubject(null)
Private reloadOnMouseUp
Default value : false
Private savedPositions
Type : Positions
Default value : {nodes: {}, issueGroups: {}}
Private savePositionsSubject
Default value : new Subject<null>()
Readonly zeroPosition
Type : object
Default value : {x: 0, y: 0}
Private zoomOnRedraw
Default value : true
import {AfterViewInit, Component, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {MatDialog} from '@angular/material/dialog';
import {DraggedEdge, Edge, Point} from '@ustutt/grapheditor-webcomponent/lib/edge';
import GraphEditor from '@ustutt/grapheditor-webcomponent/lib/grapheditor';
import {Node} from '@ustutt/grapheditor-webcomponent/lib/node';
import {Rect} from '@ustutt/grapheditor-webcomponent/lib/util';
import {BehaviorSubject, ReplaySubject, Subject} from 'rxjs';
import {debounceTime, takeUntil} from 'rxjs/operators';
import {IssueGraphStateService} from '../../data/issue-graph/issue-graph-state.service';
import {IssueGroupContainerBehaviour, IssueGroupContainerParentBehaviour} from './group-behaviours';
import {CreateInterfaceDialogComponent} from '@app/dialogs/create-interface-dialog/create-interface-dialog.component';
import {StateService} from '@app/state.service';
import {CreateInterfaceData} from '../../dialogs/create-interface-dialog/create-interface-dialog.component';
import {GraphData} from '../../data/issue-graph/graph-data';
import {IssueCategory} from 'src/generated/graphql';
import * as issueGraphNodes from './issue-graph-nodes';
import {ActivatedRoute, Router} from '@angular/router';
import {CreateComponentDialogComponent} from '@app/dialogs/create-component-dialog/create-component-dialog.component';
import {ComponentStoreService} from '@app/data/component/component-store.service';
import {InterfaceStoreService} from '@app/data/interface/interface-store.service';
import * as componentContextMenuComponent from '@app/graphs/component-context-menu/component-context-menu.component';
import {NodeDetailsType} from '@app/node-details/node-details.component';
import {doGraphLayout, LayoutNode} from '@app/graphs/automatic-layout';
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
import {IssueGraphClassSettersService} from './class-setters/issue-graph-class-setters.service';
import {IssueGraphLinkHandlesService} from './link-handles/issue-graph-link-handles.service';
import {IssueGraphDynamicTemplateRegistryService} from './dynamic-template-registry/issue-graph-dynamic-template-registry.service';

/**
 * Interface specifying the content of the graph component local storage
 */
interface Positions {
  /** Positions of the nodes as the user arranged them */
  nodes: {[prop: string]: Point};
  /** Positions (north, south, east, west) of the issue groups */
  issueGroups: {[node: string]: string};
}

/**
 * This component creates nodes and edges in the embedded MICO GraphEditor
 * (html tag: <network-graph>) to reflect the data for the current project.
 * This data consists of the project's interfaces, components, issues and their relations and
 * is stored in this.graphData. The key method for this purpose is drawGraph().
 * This component is also responsible for registering event listeners with the GraphEditor.
 */
@Component({
  selector: 'app-issue-graph',
  templateUrl: './issue-graph.component.html',
  styleUrls: ['./issue-graph.component.css']
})
export class IssueGraphComponent implements OnInit, OnDestroy, AfterViewInit {
  constructor(
    private dialog: MatDialog,
    private gs: IssueGraphStateService,
    private ss: StateService,
    private router: Router,
    private activatedRoute: ActivatedRoute,
    private componentStoreService: ComponentStoreService,
    private interfaceStoreService: InterfaceStoreService,
    private componentContextMenuService: componentContextMenuComponent.ComponentContextMenuService,
    private breakPointObserver: BreakpointObserver,
    private issueGraphClassSettersService: IssueGraphClassSettersService,
    private issueGraphLinkHandlesService: IssueGraphLinkHandlesService,
    private issueGraphDynamicTemplateRegistryService: IssueGraphDynamicTemplateRegistryService
  ) {}

  // references the graph template
  @ViewChild('graph', {static: true}) graphWrapper: {
    nativeElement: GraphEditor;
  };

  // references the minimap template
  @ViewChild('minimap', {static: true}) minimap: {
    nativeElement: GraphEditor;
  };

  currentVisibleArea: Rect = {x: 0, y: 0, width: 1, height: 1};
  @Input() projectId: string;

  readonly zeroPosition = {x: 0, y: 0};

  private componentContextMenu: componentContextMenuComponent.ComponentContextMenuComponent;
  private componentContextMenuNodeId: number | string;
  private destroy$ = new ReplaySubject(1);

  // reference to the GraphEditor instance of the graph
  private graph: GraphEditor;

  // contains all data about the projects interfaces, components, issues and their relations
  // that is needed in order to create nodes and edges in the grapheditor to visualize the project
  public graphData: GraphData;
  private graphFirstRender = true;

  // indicates whether graph is initialized
  private graphInitialized = false;
  private isHandset = false;

  // contains nodes representing interfaces and components which utilize node groups for display of issue folders
  private issueGroupParents: Node[] = [];

  // local storage key for positions of graph elements
  private projectStorageKey: string;

  // The component details page moves the graph sometimes a bit,
  // so dont move back when closing the component details page
  private redrawByCloseOfComponentDetails = false;

  // when a new graph state arrives it is passed to the graph
  // and a graph redraw is issued
  // (check IssueGraphControlsComponents ngAfterViewInit for more information)
  public reload$: BehaviorSubject<void> = new BehaviorSubject(null);
  private reloadOnMouseUp = false;

  // Saved positions of the nodes and the issue groups
  private savedPositions: Positions = {nodes: {}, issueGroups: {}};
  // Responsible for saving the node positions to local storage
  private savePositionsSubject = new Subject<null>();

  // used in the drawGraph method true on first draw and after component creation, effects a zoom to bounding box
  private zoomOnRedraw = true;

  /**
   * Gets reference to the MICO GraphEditor instance of the graph and initializes it.
   */
  ngAfterViewInit(): void {
    this.graph = this.graphWrapper.nativeElement;
    this.initGraph();
  }

  /**
   * Sets up a local storage key for graph element positions.
   */
  ngOnInit(): void {
    this.projectStorageKey = `CCIMS-Project_${this.projectId}`;
    this.breakPointObserver.observe(Breakpoints.Handset).subscribe((r) => (this.isHandset = r.matches));
  }

  /**
   * Cancels all subscriptions on component destruction.
   */
  ngOnDestroy(): void {
    // saves the current zoom details of the graph for when the user comes back to the graph
    localStorage.setItem(`zoomTransform_${this.projectStorageKey}`, JSON.stringify(this.graph.currentZoomTransform));

    // saves the current bounding box of the graph for when the user comes back to the graph
    localStorage.setItem(`zoomBoundingBox_${this.projectStorageKey}`, JSON.stringify(this.graph.currentViewWindow));

    this.destroy$.next();
    this.closeComponentActions();
  }

  /**
   * Sets up the graph and register event listeners
   */
  initGraph(): void {
    // case: graph already initialized
    if (this.graphInitialized) {
      return;
    }

    // Subscribe to the subject emitting node positions
    this.savedPositions = this.loadSavedPositions();
    this.subscribeToSubject();

    this.graphInitialized = true;
    const graph: GraphEditor = this.graphWrapper.nativeElement;
    const minimap: GraphEditor = this.minimap.nativeElement;

    // Set up graph
    this.issueGraphClassSettersService.manageClassSetters(graph, minimap);
    this.issueGraphLinkHandlesService.manageLinkHandles(graph, minimap);
    this.manageDragBehaviour(graph);
    this.issueGraphDynamicTemplateRegistryService.manageDynamicTemplateRegistry(graph);

    // Register event listeners
    this.manageEventListeners(graph, minimap);
  }

  /**
   * Loads positions of graph elements from the local storage.
   * @returns Parsed positions
   */
  private loadSavedPositions(): Positions {
    // gets data from the local storage
    const data = localStorage.getItem(this.projectStorageKey);

    // case: there is no data
    if (data == null) {
      return {nodes: {}, issueGroups: {}};
    }

    return JSON.parse(data);
  }

  /**
   * Subscribes to the subject emitting node positions.
   */
  private subscribeToSubject(): void {
    this.savePositionsSubject.pipe(takeUntil(this.destroy$), debounceTime(300)).subscribe(() => {
      // case: there are saved positions
      if (this.savedPositions != null) {
        const newData = JSON.stringify(this.savedPositions);
        localStorage.setItem(this.projectStorageKey, newData);
      }
    });
  }

  /**
   * Manages the edge drag behaviour of given GraphEditor instance.
   * @param graph Reference to the GraphEditor instance of the graph that is handled.
   */
  private manageDragBehaviour(graph: GraphEditor): void {
    graph.onCreateDraggedEdge = this.onCreateEdge;
    graph.onDraggedEdgeTargetChange = this.onDraggedEdgeTargetChanged;
    graph.addEventListener('edgeadd', this.onEdgeAdd);
    graph.addEventListener('edgeremove', this.onEdgeRemove);
    graph.addEventListener('edgedrop', this.onEdgeDrop);
  }

  /**
   * Method gets triggered after an edge gets created,
   * it can either be of type provider or consumer.
   * @param edge Edge that is handled.
   */
  private onCreateEdge = (edge: DraggedEdge): DraggedEdge => {
    const graph: GraphEditor = this.graphWrapper.nativeElement;
    const sourceNode = graph.getNode(edge.source);

    // case: edge created from an existing edge
    // => allows deletion or dropping at the same node
    if (edge.createdFrom != null) {
      const original = graph.getEdge(edge.createdFrom);
      edge.validTargets.clear();
      edge.validTargets.add(original.target.toString());
      return edge;
    }

    // case: edge originates from a component
    if (sourceNode.type === issueGraphNodes.NodeType.Component) {
      // updates edge properties (no drag handles)
      edge.type = issueGraphNodes.NodeType.Interface;
      edge.dragHandles = [];

      // updates valid targets
      edge.validTargets.clear();

      // updates marker at the end of the edge
      edge.markerEnd = {
        template: 'interface-connector-initial',
        relativeRotation: 0,
        absoluteRotation: 0
      };

      // allows only interfaces as targets
      graph.nodeList.forEach((node) => {
        if (node.type === issueGraphNodes.NodeType.Interface) {
          edge.validTargets.add(node.id.toString());
        }
      });

      // allows only new targets
      graph.getEdgesBySource(sourceNode.id).forEach((existingEdge) => {
        edge.validTargets.delete(existingEdge.target.toString());
      });
    }

    return edge;
  };

  /**
   * Method gets triggered after an edge gets dragged
   * and its target is changed:
   * ex. consumer edge gets moved away from the provider edge.
   * @param edge Edge that is handled.
   * @param sourceNode Source of the handled edge.
   * @param targetNode Target of the handled edge.
   * @returns Edge that is handled.
   */
  private onDraggedEdgeTargetChanged = (edge: DraggedEdge, sourceNode: Node, targetNode: Node): DraggedEdge => {
    // case: edge originates from a component
    if (sourceNode.type === issueGraphNodes.NodeType.Component) {
      // case: target of edge is an interface
      // => handles edge as of type consumer
      if (targetNode?.type === issueGraphNodes.NodeType.Interface) {
        // updates edge properties (default drag handle)
        edge.type = issueGraphNodes.NodeType.InterfaceConsumer;
        delete edge.dragHandles;

        // updates marker at the end of the edge
        edge.markerEnd = {
          template: 'interface-connector',
          relativeRotation: 0
        };
      }
      // case: target of edge is not an interface (aka. null)
      // => handles edge as of type provider
      else {
        // updates edge properties (no drag handles)
        edge.type = issueGraphNodes.NodeType.Interface;
        edge.dragHandles = [];

        // updates marker at the end of the edge
        // ? delete edge.markerEnd; ?
        edge.markerEnd = {
          template: 'interface-connector-initial',
          relativeRotation: 0,
          absoluteRotation: 0
        };
      }
    }

    return edge;
  };

  /**
   * Method gets triggered after an edge gets added.
   * @param event Event that is handled.
   */
  private onEdgeAdd = (event: CustomEvent): void => {
    const edge: Edge = event.detail.edge;

    // case: source of event is the API
    if (event.detail.eventSource === 'API') {
      return;
    }

    // case: edge of type interface consumer
    if (edge.type === issueGraphNodes.NodeType.InterfaceConsumer) {
      // cancels edge creation
      event.preventDefault();

      // updates the graph via the API
      const sourceNode = this.graph.getNode(edge.source);
      const targetNode = this.graph.getNode(edge.target);

      // case: edge has source and target
      // => adds edge of type interface provider
      if (sourceNode != null && targetNode != null) {
        this.gs.addConsumedInterface(sourceNode.id.toString(), targetNode.id.toString()).subscribe(() => this.reload$.next(null));
      }
    }
  };

  /**
   * Method gets triggered after an edge gets dropped.
   * @param event Event that is handled.
   */
  private onEdgeDrop = (event: CustomEvent): void => {
    const edge: DraggedEdge = event.detail.edge;

    // case: source of event is the API
    if (event.detail.eventSource === 'API') {
      return;
    }

    // case: edge created from an existing edge
    if (edge.createdFrom != null) {
      return;
    }

    // case: edge of type interface
    // => opens the interface creation dialog
    if (edge.type === issueGraphNodes.NodeType.Interface) {
      this.addInterfaceToComponent(event.detail.sourceNode.id, event.detail.dropPosition);
    }
  };

  /**
   * Opens the interface creation dialog. If the user actually creates the interface
   * it is added to the providing component at the position
   * where the dragged edge was dropped by the user (before opening the interface creation dialog).
   * @param offeredById Id of the component that will provide the interface.
   * @param position Position of the interface.
   */
  private addInterfaceToComponent(offeredById: string, position: issueGraphNodes.Position): void {
    // interface data
    const data: CreateInterfaceData = {
      position,
      offeredById
    };

    // interface dialog reference
    const createInterfaceDialogRef = this.dialog.open(CreateInterfaceDialogComponent, {
      data
    });

    // subscribes ...
    createInterfaceDialogRef.afterClosed().subscribe((interfaceId) => {
      this.savedPositions.nodes[interfaceId] = {
        x: position.x,
        y: position.y
      };
      this.savePositionsSubject.next();
      this.reload$.next(null);
    });
  }

  /**
   * Method gets triggered after an edge gets removed.
   * @param event Event that is handled.
   */
  private onEdgeRemove = (event: CustomEvent): void => {
    const edge: Edge = event.detail.edge;

    // case: source of event is the API
    if (event.detail.eventSource === 'API') {
      return;
    }

    // case: edge of type interface consumer
    if (edge.type === issueGraphNodes.NodeType.InterfaceConsumer) {
      // cancels edge deletion
      event.preventDefault();

      // updates the graph via the API
      const graph: GraphEditor = this.graphWrapper.nativeElement;
      const sourceNode = graph.getNode(edge.source);
      const targetNode = graph.getNode(edge.target);

      // case: edge has source and target
      // => removes edge of type interface provider
      if (sourceNode != null && targetNode != null) {
        this.gs.removeConsumedInterface(sourceNode.id.toString(), targetNode.id.toString()).subscribe(() => this.reload$.next(null));
      }
    }
  };

  /**
   * Adds event listeners to a given GraphEditor instance.
   * @param graph Reference to the GraphEditor instance of the graph that is handled.
   * @param minimap Reference to the GraphEditor instance of the minimap that is handled.
   */
  private manageEventListeners(graph: GraphEditor, minimap: GraphEditor): void {
    graph.addEventListener('nodeclick', this.onNodeClick);

    graph.addEventListener('nodepositionchange', () => {
      if (this.closeComponentActions(false)) {
        this.reloadOnMouseUp = true;
      }
    });

    graph.addEventListener('nodedragend', this.onNodeDragEnd);

    graph.addEventListener('nodeadd', (event: CustomEvent) => {
      if (event.detail.node.type === issueGraphNodes.NodeType.IssueGroupContainer) {
        return;
      }
      const node = event.detail.node;
      minimap.addNode(node);
    });

    graph.addEventListener('noderemove', (event: CustomEvent) => {
      const node = event.detail.node;
      if (event.detail.node.type !== issueGraphNodes.NodeType.IssueGroupContainer) {
        minimap.removeNode(node);
      }
    });

    graph.addEventListener('edgeadd', (event: CustomEvent) => {
      minimap.addEdge(event.detail.edge);
    });

    graph.addEventListener('edgeremove', (event: CustomEvent) => {
      minimap.removeEdge(event.detail.edge);
    });

    graph.addEventListener('render', this.onMinimapRender(minimap));

    graph.addEventListener('click', () => this.closeComponentActions());

    graph.addEventListener('zoomchange', (event: CustomEvent) => {
      this.currentVisibleArea = event.detail.currentViewWindow;
      if (!this.componentContextMenu) {
        return;
      }

      // Update component context menu position when zoom changed
      const node = this.graph.getNode(this.componentContextMenuNodeId);
      const [x, y] = this.graph.currentZoomTransform.apply([node.x, node.y]);
      this.componentContextMenu.updatePosition(Math.max(x, 0), Math.max(y, 0));
    });
  }

  /**
   * Called when the user lets go of a node
   * @param e The event
   */
  private onNodeDragEnd = (e: CustomEvent): void => {
    const node = e.detail.node;
    // Store position of issue folders
    if (node.type === issueGraphNodes.NodeType.IssueGroupContainer) {
      this.savedPositions.issueGroups[node.id] = node.position;
    }

    // store node positioning information
    this.savedPositions.nodes[node.id] = {
      x: node.x,
      y: node.y
    };

    this.savePositionsSubject.next();
    if (this.reloadOnMouseUp) {
      this.reloadOnMouseUp = false;
      this.zoomOnRedraw = false;
      this.reload();
    }
  };

  /**
   * Method gets triggered after a node is clicked.
   * @param event Event that is handled.
   */
  private onNodeClick = (event: CustomEvent): void => {
    // cancels node selection
    event.preventDefault();

    const node: Node = event.detail.node;

    // Close existing context menu if the user clicked twice on the same node
    if (this.componentContextMenu && this.componentContextMenu.data.nodeId === node.id) {
      this.closeComponentActions();
      return;
    }

    // Close context menu if one is already open
    this.closeComponentActions();

    // doesn't allow the view of the graph to change after the Details page has been closed
    this.redrawByCloseOfComponentDetails = true;

    // Open the details in a new page if a phone is used or if shift is pressed
    if (event.detail.sourceEvent.shiftKey || this.isHandset) {
      // case: node of type Component
      // => opens View Component page
      if (node.type === issueGraphNodes.NodeType.Component) {
        this.router.navigate(['./component/', node.id], {
          relativeTo: this.activatedRoute.parent
        });
        return;
      }

      // case: node of type Interface
      // => opens View Interface page
      if (node.type === issueGraphNodes.NodeType.Interface) {
        this.router.navigate(['./interface/', node.id], {
          relativeTo: this.activatedRoute.parent
        });
        return;
      }
    } else {
      // sets the context menu type
      const contextMenuType = this.contextMenuTypeForNodeType(node);

      // case: context menu has a type
      if (contextMenuType != null) {
        this.nodeClickContextMenuHasType(node, event, contextMenuType);
        return;
      }
    }

    // case: clicked issue folder
    // => determines issue count, opens corresponding issue page
    this.nodeClickIssueFolder(node);
  };

  /**
   * Sets the context menu type.
   * @param node Node that is handled.
   */
  private contextMenuTypeForNodeType(node: Node): NodeDetailsType {
    // case: node of type Component
    // => sets the context menu type as Component
    if (node.type === issueGraphNodes.NodeType.Component) {
      return NodeDetailsType.Component;
    }

    // case: node of type Interface
    // => sets the context menu type as Interface
    if (node.type === issueGraphNodes.NodeType.Interface) {
      return NodeDetailsType.Interface;
    }

    return null;
  }

  /**
   * Open the component context menu
   * @param node Node that is handled
   * @param event Event that is handled
   * @param contextMenuType Type of the context menu that is handled
   */
  private nodeClickContextMenuHasType(node: Node, event: CustomEvent, contextMenuType: NodeDetailsType): void {
    // Transform the node graph coordinates to screen coordinates
    const [x, y] = this.graph.currentZoomTransform.apply([node.x, node.y]);

    // Only open the dialog if it will fit into view
    if (x >= 0 && y >= 0) {
      this.componentContextMenuNodeId = node.id;

      // Cancel the click event that would otherwise close the dialog again
      event.detail.sourceEvent.stopImmediatePropagation();

      this.componentContextMenu = this.componentContextMenuService.open(
        this.graphWrapper.nativeElement,
        x,
        y,
        this.projectId,
        node.id.toString(),
        contextMenuType,
        this
      );

      // Make sure that the context menu is visible if it extends over the right/bottom edge
      const visible = this.graph.currentViewWindow;
      const scale = this.graph.currentZoomTransform.k;
      // FIXME: this isn't ideal, as the padding is somewhat dependent on the aspect ratio
      const padding = 85 / scale;
      const edgeX = visible.width * scale;
      const edgeY = visible.height * scale;
      const moveX = Math.max(0, this.componentContextMenu.width + x - edgeX) / scale;
      const moveY = Math.max(0, this.componentContextMenu.height + y - edgeY) / scale;

      // case: Zoom has to change to make overlay visible
      if (moveX || moveY) {
        this.graph.zoomToBox({
          x: visible.x + moveX + padding,
          y: visible.y + moveY + padding,
          width: visible.width - 2 * padding,
          height: visible.height - 2 * padding
        });
      }
    }
  }

  /**
   * Handles the case in which an issue folder is clicked.
   * Determines the number of issues in the issue folder
   * and opens the corresponding issue page.
   * @param node Issue folder that is handled.
   */
  private nodeClickIssueFolder(node: Node): void {
    // case: clicked issue folder
    // => determines issue count, opens corresponding issue page
    if (node.type === 'BUG' || node.type === 'UNCLASSIFIED' || node.type === 'FEATURE_REQUEST') {
      // reference to the GraphEditor instance of the graph, the root ID and the root node
      const graph: GraphEditor = this.graphWrapper.nativeElement;
      const rootId = graph.groupingManager.getTreeRootOf(node.id);
      const rootNode = graph.getNode(rootId);

      // case: only one issue inside the clicked issue folder
      // => opens Issue Details page
      if (node.issueCount === 1) {
        this.nodeClickOneIssue(rootId, rootNode, node);
        return;
      }

      // case: many issues inside the clicked issue folder
      // => opens Component Issues / Interface Issues page
      else {
        this.nodeClickManyIssues(rootNode);
        return;
      }
    }
  }

  /**
   * Handles the case in which the clicked issue folder contains only one issues.
   * @param rootNode Root node that is handled.
   * @param rootId Root id that is handled.
   * @param node Clicked node that is handled.
   */
  private nodeClickOneIssue(rootId: string, rootNode: Node, node: Node): void {
    // case: root node of type Component
    // => handles a single component issue, opens its Issue Details page
    if (rootNode.type === issueGraphNodes.NodeType.Component) {
      this.componentStoreService.getFullComponent(rootId).subscribe((component) => {
        const currentIssueId = this.extractIssueId(component.node.issues.nodes, node.type);
        this.router.navigate(['./', 'issues', currentIssueId], {
          relativeTo: this.activatedRoute.parent
        });
      });
    }

    // case: root node of type Interface
    // => handles a single interface issue, opens its Issue Details page
    else if (rootNode.type === issueGraphNodes.NodeType.Interface) {
      this.interfaceStoreService.getInterface(rootId).subscribe((interfaceComponent) => {
        const currentIssueId = this.extractIssueId(interfaceComponent.node.issuesOnLocation.nodes, node.type);
        this.router.navigate(['./', 'issues', currentIssueId], {
          relativeTo: this.activatedRoute.parent
        });
      });
    }
  }

  /**
   * Extracts the id of an issue in a given issue list.
   * @param issueList Ids of the issues that are handled.
   * @param category Category of issues that are handled.
   * @returns Id of the first issue (in the issue list) with matching category.
   */
  private extractIssueId(issueList, category: string): string {
    for (const issue of issueList) {
      if (issue.category === category) {
        return issue.id;
      }
    }
  }

  /**
   * Handles the case in which the clicked issue folder contains many issues.
   * @param rootNode Root node that is handled.
   */
  private nodeClickManyIssues(rootNode: Node): void {
    // case: root node of type Component
    // => handles many component issues, opens their Component Issues page
    if (rootNode.type === issueGraphNodes.NodeType.Component) {
      this.router.navigate(['./component/', rootNode.id], {
        relativeTo: this.activatedRoute.parent
      });
    }

    // case: root node of type Interface
    // => handles many interface issues, opens their Interface Issues page
    if (rootNode.type === issueGraphNodes.NodeType.Interface) {
      this.router.navigate(['./interface/', rootNode.id], {
        relativeTo: this.activatedRoute.parent
      });
    }
  }

  /**
   * Closes the component context menu, if one is open
   * @param reload If true, reloads the graph if a context menu was closed
   */
  private closeComponentActions(reload: boolean = true): boolean {
    // case: there are actions to close
    if (this.componentContextMenu) {
      // case: redraw of the graph needed
      // => issues redraw
      if (reload) {
        this.reload();
      }

      // Close dialog
      this.componentContextMenu.close();
      this.componentContextMenu = null;
      this.componentContextMenuNodeId = null;

      return true;
    }

    return false;
  }

  /**
   * Issues a redraw of the graph. ?
   */
  public reload(): void {
    this.reload$.next(null);
  }

  /**
   * Method gets triggered when the minimap renders.
   * @param minimap Minimap that is handled.
   */
  private onMinimapRender(minimap: GraphEditor): EventListenerOrEventListenerObject {
    return (event: CustomEvent) => {
      // case: renders the minimap completely
      if (event.detail.rendered === 'complete') {
        minimap.completeRender();
        minimap.zoomToBoundingBox();
      }

      // case: renders texts
      else if (event.detail.rendered === 'text') {
        // irrelevant for the minimap
      }

      // case: renders node classes
      else if (event.detail.rendered === 'classes') {
        minimap.updateNodeClasses();
      }

      // case: renders node positions
      else if (event.detail.rendered === 'positions') {
        minimap.updateGraphPositions();
        minimap.zoomToBoundingBox();
      }
    };
  }

  /**
   * Responsible for drawing the graph based on this.graphData.
   * Takes care of adding interfaces and components, and their connections.
   * Additionally adds issue folders attached to each component and the dashed edges
   * between them based on this.graphData.relatedFolders
   */
  drawGraph(): void {
    const boundingBox = this.calculateBoundingBox();
    // reset graph and remove all elements, gives us clean slate
    this.resetGraph();

    const layoutGraph = Object.keys(this.savedPositions.nodes).length === 0;
    // create nodes corresponding to the components and interfaces of the project
    const componentNodes = Array.from(this.graphData.components.values()).map((component) =>
      issueGraphNodes.createComponentNode(component, this.findIdealComponentPosition(component.id, boundingBox))
    );
    const interfaceNodes = Array.from(this.graphData.interfaces.values()).map((intrface) =>
      issueGraphNodes.createInterfaceNode(intrface, this.savedPositions.nodes[intrface.id])
    );
    // issueNodes contains BOTH componentNodes and interfaceNodes
    const issueNodes = (componentNodes as issueGraphNodes.IssueNode[]).concat(interfaceNodes as issueGraphNodes.IssueNode[]);
    // For components AND interfaces: add the edges, issue folders and relations between folders
    issueNodes.forEach((node) => {
      this.graph.addNode(node);
      this.addIssueFolders(node);
      this.drawFolderRelations(node);
    });
    // ONLY for interfaces: create edges connecting interface to offering component and consuming components to interface
    interfaceNodes.forEach((interfaceNode) => {
      this.connectToOfferingComponent(interfaceNode);
      this.connectConsumingComponents(interfaceNode);
    });

    // render all changes
    this.graph.completeRender();
    this.setGraphToLastView();
    if (layoutGraph && issueNodes.length > 0) {
      this.layoutGraph();
      this.drawGraph();
    }
  }

  /**
   * Resets graph state. Called at start of draw(). Enables logic in draw()
   * to assume a 'blank sheet' state avoiding complex updating logic.
   */
  resetGraph(): void {
    this.graph.edgeList = [];
    this.graph.nodeList = [];
    this.issueGroupParents = [];
    this.graph.groupingManager.clearAllGroups();
  }

  /**
   * Finds the ideal component position if none is saved.
   * @param id Id of component that is handled.
   * @param boundingBox Bounding box of the component that is handled.
   */
  findIdealComponentPosition(id: string, boundingBox: Rect): Point {
    const saved = this.savedPositions.nodes[id];
    if (saved) {
      return saved;
    }

    // Next position is right to the current bounding box, approximately at half height
    const point = {x: 0, y: 0};
    if (boundingBox) {
      point.x = boundingBox.x + boundingBox.width + 60;
      point.y = boundingBox.y + boundingBox.height / 2;
    }

    this.savedPositions.nodes[id] = point;
    return point;
  }

  /**
   * Creates and adds an edge between the node representing a component
   * an the node representing the interface itself.
   * @param node - Interface that is handled.
   */
  connectToOfferingComponent(node: issueGraphNodes.InterfaceNode): void {
    this.graph.addEdge(issueGraphNodes.createInterfaceProvisionEdge(node.offeredById, node.id));
  }

  /**
   * Adds an edge from each connected component to the interface.
   * @param interfaceNode - Interface (visualized by lollipop notation) that is handled.
   */
  connectConsumingComponents(interfaceNode: issueGraphNodes.InterfaceNode): void {
    for (const consumerId of this.graphData.interfaces.get(interfaceNode.id).consumedBy) {
      this.graph.addEdge(issueGraphNodes.createConsumptionEdge(consumerId, interfaceNode.id));
    }
  }

  /**
   * Adds the issue folders with counts for each IssueCategory (currently 3)
   * to the component or interface represented by node. The first methods call
   * sets up invisible nodes in the graph to make the folders display properly.
   * The second method takes care of actually adding the visible folders with
   * the correct counts.
   * @param node - Interface / component that is handled.
   */
  private addIssueFolders(node: issueGraphNodes.IssueNode): void {
    this.addIssueGroupContainer(node);
    this.addIssueFolderNodes(node);
  }

  /**
   * Creates the node groups necessary for displaying issue folders attached to a node.
   * A Node represents a component or an interface.
   * It also gets an issue group of IssueGroupContainerParentBehaviour,
   * issueGroupContainerNode with IssueGroupContainerBehaviour gets added to it.
   * This corresponds to the 4 'Grouping Manager' object
   * on the upper two levels in the graph_structure_documentation.png.
   * @param node - Node (component or interface) which can have issue folders attached.
   */
  private addIssueGroupContainer(node: issueGraphNodes.IssueNode): void {
    const gm = this.graph.groupingManager;
    gm.markAsTreeRoot(node.id);
    const issueGroupContainerNode = issueGraphNodes.createIssueGroupContainerNode(node);
    const initialPosition = this.savedPositions.issueGroups[issueGroupContainerNode.id];
    gm.setGroupBehaviourOf(node.id, new IssueGroupContainerParentBehaviour(initialPosition));

    // the issueGroupContainerNode has no visual representation but contains the visible issue folders
    node.issueGroupContainer = issueGroupContainerNode;
    this.graph.addNode(issueGroupContainerNode);
    gm.addNodeToGroup(node.id, issueGroupContainerNode.id);
    gm.setGroupBehaviourOf(issueGroupContainerNode.id, new IssueGroupContainerBehaviour());
    this.issueGroupParents.push(node);
  }

  /**
   * This method presumes that node has the 4 'Grouping Manager Objects'
   * depicted on the the upper levels in the graph_structure_documentation.png.
   * correctly setup.
   * @param node Interface / component that is handled.
   */
  private addIssueFolderNodes(node: issueGraphNodes.IssueNode): void {
    // get mapping from IssueCategory to number for the component or interface represented by node
    const issueCounts = this.graphData.graphLocations.get(node.id).issues;
    // iterate over issue categories and create a node if there is at least one issue of it
    Object.keys(IssueCategory).forEach((key) => {
      const issueCategory = IssueCategory[key];
      if (issueCounts.has(issueCategory)) {
        const count = issueCounts.get(issueCategory);
        // only show folders for issue categories with at least one issue
        if (count > 0) {
          const issueFolderNode = issueGraphNodes.createIssueFolderNode(node, issueCategory, count.toString());
          this.graph.addNode(issueFolderNode);
          this.graph.groupingManager.addNodeToGroup(node.issueGroupContainer.id, issueFolderNode.id);
        }
      }
    });
  }

  /**
   * Draws folder relations originating from the issue folder represented by node.
   * @param node - Issue folder (for issues of a certain type) that is handled.
   */
  private drawFolderRelations(node: issueGraphNodes.IssueNode): void {
    // @ts-ignore
    const folderNodes: IssueFolderNode[] = Array.from(node.issueGroupContainer.issueGroupNodeIds).map((id: string) =>
      this.graph.getNode(id)
    );
    for (const folderNode of folderNodes) {
      const relatedFolders = this.graphData.relatedFolders.getValue([node.id.toString(), folderNode.type]);
      for (const relatedFolder of relatedFolders) {
        const [issueNodeId, category] = relatedFolder;
        const edge = issueGraphNodes.createRelationEdge(folderNode.id, issueGraphNodes.getIssueFolderId(issueNodeId, category));
        this.graph.addEdge(edge);
      }
    }
  }

  /**
   * Sets the view and the bounding box of the graph to how it was when the user left the graph with the help of localStorage.
   * When theres no previous session available set the view to the optimized bounding box for the graph.
   */
  private setGraphToLastView() {
    const previousBoundingBoxAsString = localStorage.getItem(`zoomBoundingBox_${this.projectStorageKey}`);
    const zoomTransformAsString = localStorage.getItem(`zoomTransform_${this.projectStorageKey}`);
    // Only set the bounding box to the optimized bounding box for the graph when creating the first component
    const firstComponent = this.graphData.components.size === 1;

    // Set the bounding box to the bounding box of the last session or to the optimized bounding box if there wasn't a last session
    if (
      JSON.parse(previousBoundingBoxAsString) !== null &&
      JSON.parse(zoomTransformAsString) !== null &&
      this.graphFirstRender &&
      !this.redrawByCloseOfComponentDetails &&
      !firstComponent
    ) {
      const previousBoundingBox = JSON.parse(previousBoundingBoxAsString);
      /*
      These calculations are necessary because of how GraphEditor.zoomToBox(box: Rect) works.
      GraphEditor.zoomToBox zooms to the given box and adds some padding.
      These calculations get rid of the padding. Otherwise the padding would be added to the graph with every
      execution of the setGraphToLastView() method.
      */
      previousBoundingBox.width = previousBoundingBox.width * 0.9;
      previousBoundingBox.height = previousBoundingBox.height * 0.9;
      // Difference between Rect.x that is given into the GraphEdit.zoomToBox(box: Rect) method and the resulting Rect.x
      const paddingX = 57.75 / JSON.parse(zoomTransformAsString).k;
      // Difference between Rect.y that is given into the GraphEdit.zoomToBox(box: Rect) method and the resulting Rect.y
      const paddingY = 17.2 / JSON.parse(zoomTransformAsString).k;
      previousBoundingBox.x = previousBoundingBox.x + paddingX;
      previousBoundingBox.y = previousBoundingBox.y + paddingY;
      this.graph.zoomToBox(previousBoundingBox);
      this.graphFirstRender = false;
    } else if ((this.zoomOnRedraw && !this.redrawByCloseOfComponentDetails) || firstComponent) {
      // Zoom to the optimized bounding box if no graph view is stored from the last session or when the first component is created
      this.fitGraphInView();
      this.zoomOnRedraw = false;
    }
  }

  /**
   * Fits the graph into view.
   */
  fitGraphInView(): void {
    // calculates the bounding box of the view
    const rect = this.calculateBoundingBox();

    // case: bounding box is calculated
    // => zoom to bounding box
    if (rect) {
      this.graph.zoomToBox(rect);
    }
  }

  /**
   * Calculates the bounding box of the view.
   * @returns The calculated bounding box.
   */
  calculateBoundingBox(): Rect {
    const componentSize = {width: 100, height: 60};
    const interfaceSize = {width: 14, height: 14};
    const issueContainerSize = {width: 40, height: 30};

    // calculates bounding box
    let rect = null;
    for (const node of this.graph.nodeList) {
      let size;
      if (node.type === issueGraphNodes.NodeType.Component) {
        size = componentSize;
      } else if (node.type === issueGraphNodes.NodeType.Interface || node.type === issueGraphNodes.NodeType.InterfaceConsumer) {
        size = interfaceSize;
      } else if (node.type === issueGraphNodes.NodeType.IssueGroupContainer) {
        if (node.issueGroupNodeIds.size === 0) {
          // irrelevant for empty issue group containers
          continue;
        }

        size = issueContainerSize;
      } else {
        continue;
      }

      const nodeX = node.x - size.width / 2;
      const nodeY = node.y - size.height / 2;

      if (rect === null) {
        rect = {
          xMin: nodeX,
          yMin: nodeY,
          xMax: nodeX + size.width,
          yMax: nodeY + size.height
        };
      } else {
        rect.xMin = Math.min(nodeX, rect.xMin);
        rect.yMin = Math.min(nodeY, rect.yMin);

        rect.xMax = Math.max(nodeX + size.width, rect.xMax);
        rect.yMax = Math.max(nodeY + size.height, rect.yMax);
      }
    }

    return rect
      ? {
          x: rect.xMin,
          y: rect.yMin,
          width: rect.xMax - rect.xMin,
          height: rect.yMax - rect.yMin
        }
      : null;
  }

  /**
   * Attempts to automatically lay-out the graph in a reasonable manner
   */
  layoutGraph(): void {
    const nodes = new Map<string | number, LayoutNode>();

    for (const node of this.graph.nodeList) {
      if (node.type === issueGraphNodes.NodeType.Component || node.type === issueGraphNodes.NodeType.Interface) {
        nodes.set(node.id, new LayoutNode(node.id, node.x, node.y, node.type));
      }
    }

    for (const edge of this.graph.edgeList) {
      if (nodes.has(edge.source) && nodes.has(edge.target)) {
        nodes.get(edge.source).connectTo(nodes.get(edge.target));
        nodes.get(edge.target).connectTo(nodes.get(edge.source));
      }
    }

    const nodeList = Array.from(nodes.values());
    doGraphLayout(nodeList);

    for (const node of nodeList) {
      const layoutNode = nodes.get(node.id);
      this.savedPositions.nodes[layoutNode.id] = {
        x: layoutNode.position.x,
        y: layoutNode.position.y
      };
    }

    this.savePositionsSubject.next();
  }

  /**
   * Sets --show-relations css variable to initial or none. It is the value
   * of the display attribute of the edges. If we set it to none the edges disappear.
   * @param showRelations - Boolean derived from the setting of the switch slider for relation edges above the graph.
   */
  setRelationVisibility(showRelations: boolean): void {
    this.graph.getSVG().style('--show-relations', showRelations ? 'initial' : 'none');
  }

  /**
   * Opens create component dialog and triggers reload of data after the dialog is closed.
   */
  public openCreateComponentDialog(): void {
    const createComponentDialogRef = this.dialog.open(CreateComponentDialogComponent, {
      data: {projectId: this.projectId}
    });
    createComponentDialogRef.afterClosed().subscribe(() => {
      this.zoomOnRedraw = false;
      this.reload$.next(null);
    });
  }
}
<div class="graph-container">
  <network-graph
    class="graph"
    mode="layout"
    classes="component interface issue bug done interface-connect component-connect issue-relation related-to duplicate dependency"
    #graph
  >
    <style slot="style">
      svg {
        width: 100%;
        height: 100%;
      }
    </style>
    <svg slot="graph">
      <style>
        /* The show-relations variable is used to show/hide dashed issue relation edges */
        /* It is toggled by the IssueGraphComponent when the user changes the dashed edges switch */
        :root {
          --show-relations: initial;
        }

        .ghost {
          opacity: 0.5;
        }

        .link-handle {
          display: none;
          fill: black;
          opacity: 0.1;
        }

        .link-handle > * {
          transition: transform 0.25s ease-out;
        }

        .edge-group .link-handle {
          display: initial;
        }

        .link-handle:hover {
          opacity: 0.7;
        }

        .link-handle:hover > * {
          transform: scale(1.5);
        }

        .text {
          text-overflow: ellipsis;
          word-break: break-word;
        }

        .text.title {
          font-size: 12pt;
          text-overflow: ellipsis;
          word-break: break-all;
        }

        .badge-background {
          fill: white;
          opacity: 0.85;
        }

        .text.badge {
          font-size: 6pt;
          text-anchor: middle;
          text-overflow: clip;
        }

        .background {
          opacity: 0;
        }

        .dropzone {
          stroke-dasharray: 2 1;
          opacity: 0.3;
          transition: opacity 0.2s ease-out;
        }

        .dropzone:hover {
          opacity: 1;
        }

        .component.hovered .link-handle {
          display: initial;
        }

        .node.component rect {
          fill: #096dd9;
          stroke: black;
          stroke-width: 1.5;
          fill-opacity: 0.5;
        }

        .component-name {
          font-size: 8pt;
          font-family: 'Arial Narrow', Arial, sans-serif;
          word-break: break-word;
          user-select: none;
        }

        .interface-name {
          font-size: 8pt;
          font-family: 'Arial Narrow', Arial, sans-serif;
          word-break: break-word;
          user-select: none;
        }

        .edge-group.issue-relation {
          opacity: 0.7;
        }

        /* issue relation edge styling (use '.related-to', '.duplicate' and '.dependency' to style the individual types) */
        .edge-group.issue-relation .edge {
          stroke: grey;
          stroke-dasharray: 3 5;
          stroke-width: 3px;
          stroke-linecap: round;
        }

        /* coloring of issue relation edges when issue folder is hovered */
        .edge-group.issue-relation.highlight-outgoing .edge {
          stroke: purple;
        }

        .edge-group.issue-relation.highlight-incoming .edge {
          stroke: #008b8b;
        }

        .related-to {
          display: var(--show-relations);
        }

        .related-to.edge-group.issue-relation.highlight-incoming,
        .related-to.edge-group.issue-relation.highlight-outgoing {
          display: initial;
        }

        .edge-group.issue-relation .marker {
          fill: grey;
        }

        .edge-group.issue-relation.highlight-outgoing .marker {
          fill: purple;
        }

        .edge-group.issue-relation.highlight-incoming .marker {
          fill: #008b8b;
        }
      </style>
      <defs class="templates">
        <path id="icon-folder" d="M 36,29 l3,-24 h -6 M 0,3 l 3,26 h 33 l -3,-26 h -23 l -1,-3 h -8 z"></path>
        <path
          id="icon-undecided"
          d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
        ></path>
        <path
          id="icon-bug"
          d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"
        ></path>
        <path
          id="icon-feature"
          d="M12,3c-0.46,0-0.93,0.04-1.4,0.14C7.84,3.67,5.64,5.9,5.12,8.66c-0.48,2.61,0.48,5.01,2.22,6.56C7.77,15.6,8,16.13,8,16.69 V19c0,1.1,0.9,2,2,2h0.28c0.35,0.6,0.98,1,1.72,1s1.38-0.4,1.72-1H14c1.1,0,2-0.9,2-2v-2.31c0-0.55,0.22-1.09,0.64-1.46 C18.09,13.95,19,12.08,19,10C19,6.13,15.87,3,12,3z M14,19h-4v-1h4V19z M14,17h-4v-1h4V17z M12.5,11.41V14h-1v-2.59L9.67,9.59 l0.71-0.71L12,10.5l1.62-1.62l0.71,0.71L12.5,11.41z"
        ></path>

        <!-- Containers to group SVG elements -->

        <!-- Component container -->
        <g id="component" data-template-type="node">
          <rect width="100" height="60" rx="10" ry="10" x="-50" y="-30" data-link-handles="edges"></rect>
          <text
            class="component-name text"
            data-content="title"
            data-click="title"
            dominant-baseline="middle"
            text-anchor="middle"
            data-width="90"
            data-height="38"
            text-overflow="hidden"
          ></text>
          <title data-content="title"></title>
        </g>

        <!-- ? container -->
        <clipPath id="clip1">
          <rect x="5" y="5" width="57" height="90" />
        </clipPath>

        <!-- Interface container (for interface provision edge) -->
        <g id="interface" data-template-type="node">
          <circle cx="0" cy="0" r="7"></circle>
          <text
            class="interface-name text"
            data-content="title"
            data-click="title"
            dominant-baseline="middle"
            text-anchor="middle"
            data-width="90"
            data-height="38"
            y="-20"
            text-overflow="hidden"
          ></text>
          <title data-content="title"></title>
        </g>

        <!-- Issue Group container -->
        <g id="issue-group-container" data-template-type="node">
          <rect class="background" x="-18" y="-14" width="36" height="28" data-link-handles="edges"></rect>
          <use href="#icon-folder" class="dropzone" stroke="black" fill="white" x="-19" y="-15"></use>
        </g>

        <!-- Unclassified container -->
        <g id="UNCLASSIFIED" data-template-type="node">
          <!-- first entry for link handle calculation only -->
          <rect class="background" x="-18" y="-14" width="36" height="28" data-link-handles="edges"></rect>
          <use href="#icon-folder" stroke="black" fill="white" x="-19" y="-15"></use>
          <use href="#icon-undecided" fill="black" x="-12" y="-11"></use>
          <rect class="badge-background" x="5" y="8" width="20" height="12" rx="6"></rect>
          <text class="text badge" data-content="issueCount" x="15" y="18" width="21"></text>
        </g>

        <!-- Feature Request container -->
        <g id="FEATURE_REQUEST" data-template-type="node">
          <rect class="background" x="-18" y="-14" width="36" height="28" data-link-handles="edges"></rect>
          <use href="#icon-folder" stroke="black" fill="white" x="-19" y="-15"></use>
          <use href="#icon-feature" fill="#005eff" x="-12" y="-11"></use>
          <rect class="badge-background" x="5" y="8" width="20" height="12" rx="6"></rect>
          <text class="text badge" data-content="issueCount" x="15" y="18" width="21"></text>
        </g>

        <!-- Bug container -->
        <g id="BUG" data-template-type="node">
          <rect class="background" x="-18" y="-14" width="36" height="28" data-link-handles="edges"></rect>
          <use href="#icon-folder" stroke="black" fill="white" x="-19" y="-15"></use>
          <use href="#icon-bug" fill="red" x="-12" y="-11"></use>
          <rect class="badge-background" x="5" y="8" width="20" height="12" rx="6"></rect>
          <text class="text badge" data-content="issueCount" x="15" y="18" width="21"></text>
        </g>

        <!-- Arrow container (for issue relation edge) -->
        <g id="arrow" data-template-type="marker" data-line-attachement-point="-9 0">
          <path d="M -9 -4 L 0 0 L -9 4 z" />
        </g>

        <!-- Interface (Consumption) Connector container (for interface consumption edge) -->
        <g id="interface-connector" data-template-type="marker" data-line-attachement-point="-1 0">
          <path d="M 7 -8 a8,8 0 0,0 0,16 l 0,1 a9,9 0 0,1 0,-18 z" />
        </g>

        <!--Interface (Provision) Connector container (for interface provision edge, before being created) -->
        <g id="interface-connector-initial" data-template-type="marker" data-line-attachement-point="-1 0">
          <text
            class="interface-name text"
            dominant-baseline="middle"
            text-anchor="middle"
            data-width="90"
            data-height="38"
            y="20"
            text-overflow="hidden"
            fill="red"
          >
            Create Interface / Connect
          </text>
        </g>
      </defs>
    </svg>
  </network-graph>

  <network-graph class="minimap" mode="display" classes="component interface issue bug done" #minimap>
    <style slot="style">
      svg {
        width: 100%;
        height: 100%;
      }
    </style>
    <svg slot="graph">
      <style>
        .view-box {
          fill: orange;
          opacity: 0.5;
        }

        .link-handle {
          display: none;
        }
      </style>
      <defs class="templates">
        <path id="icon-folder" d="M 36,29 l3,-24 h -6 M 0,3 l 3,26 h 33 l -3,-26 h -23 l -1,-3 h -8 z"></path>
        <path
          id="icon-undecided"
          d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
        ></path>
        <path
          id="icon-bug"
          d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"
        ></path>
        <path
          id="icon-feature"
          d="M12,3c-0.46,0-0.93,0.04-1.4,0.14C7.84,3.67,5.64,5.9,5.12,8.66c-0.48,2.61,0.48,5.01,2.22,6.56C7.77,15.6,8,16.13,8,16.69 V19c0,1.1,0.9,2,2,2h0.28c0.35,0.6,0.98,1,1.72,1s1.38-0.4,1.72-1H14c1.1,0,2-0.9,2-2v-2.31c0-0.55,0.22-1.09,0.64-1.46 C18.09,13.95,19,12.08,19,10C19,6.13,15.87,3,12,3z M14,19h-4v-1h4V19z M14,17h-4v-1h4V17z M12.5,11.41V14h-1v-2.59L9.67,9.59 l0.71-0.71L12,10.5l1.62-1.62l0.71,0.71L12.5,11.41z"
        ></path>
        <g id="component" data-template-type="node">
          <rect width="100" height="60" x="-50" y="-30" data-link-handles="edges"></rect>
        </g>
        <g id="interface" data-template-type="node">
          <circle cx="0" cy="0" r="7"></circle>
        </g>
        <g id="issue" data-template-type="node">
          <rect width="30" height="30" x="-15" y="-15" data-link-handles="edges"></rect>
        </g>
        <g id="bug" data-template-type="node">
          <rect width="30" height="30" x="-15" y="-15" data-link-handles="edges"></rect>
        </g>
        <g id="UNCLASSIFIED" data-template-type="node">
          <use href="#icon-folder" stroke="black" fill="white" x="-18" y="-13" data-link-handles="edges"></use>
          <use href="#icon-undecided" fill="black" x="-12" y="-9"></use>
        </g>
        <g id="FEATURE_REQUEST" data-template-type="node">
          <use href="#icon-folder" stroke="black" fill="white" x="-18" y="-13" data-link-handles="edges"></use>
          <use href="#icon-feature" fill="#005eff" x="-12" y="-9"></use>
        </g>
        <g id="BUG" data-template-type="node">
          <use href="#icon-folder" stroke="black" fill="white" x="-18" y="-13" data-link-handles="edges"></use>
          <use href="#icon-bug" fill="red" x="-12" y="-9"></use>
        </g>
        <g id="arrow" data-template-type="marker" data-line-attachement-point="-9 0">
          <path d="M -9 -4 L 0 0 L -9 4 z" />
        </g>
        <g id="interface-connector" data-template-type="marker" data-line-attachement-point="-1 0">
          <path d="M 7 -8 a8,8 0 0,0 0,16 l 0,1 a9,9 0 0,1 0,-18 z" />
        </g>
      </defs>
      <g class="zoom-group">
        <g class="edges"></g>
        <g class="nodes"></g>
        <rect
          class="view-box"
          [attr.x]="currentVisibleArea?.x"
          [attr.y]="currentVisibleArea?.y"
          [attr.width]="currentVisibleArea?.width"
          [attr.height]="currentVisibleArea?.height"
        ></rect>
      </g>
    </svg>
  </network-graph>
</div>

./issue-graph.component.css

.graph-container {
  position: relative;
  width: 100%;
  height: 100%;
}

.graph {
  position: absolute;
  width: 100%;
  height: 100%;
}

.minimap {
  top: 1rem;
  left: 1rem;
}

.minimap,
.minimap::before,
.minimap::after {
  position: absolute;
  width: 8rem;
  height: 6rem;
}

.minimap::before,
.minimap::after {
  content: "";
  opacity: 0.5;
}

.minimap::before {
  background-color: white;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""