src/app/components/graph/graph.component.ts
The interactive graph component.
AfterViewInit
OnChanges
OnDestroy
AfterViewChecked
selector | apollo-graph[graph][allowEditing] |
styleUrls | ./graph.component.scss |
templateUrl | ./graph.component.html |
Properties |
|
Methods |
|
Inputs |
Outputs |
HostListeners |
Public
constructor(store: Store<State>, translate: TranslateService, dialog: MatDialog, bottomSheet: MatBottomSheet)
|
|||||||||||||||
Parameters :
|
allowEditing | |
Type : boolean
|
|
Allows editing if set to true. Should not be changed after initialization. |
config | |
Type : GraphConfiguration
|
|
Default value : DEFAULT_GRAPH_CONFIGURATION
|
|
Configuration for visuals of the graph. Should not be changed after initialization. |
graph | |
Type : D3Graph | null | undefined
|
|
Default value : new D3Graph()
|
|
The graph that will be displayed. Can be changed after initialization. For proper usage see GraphEditorComponent and ModelCheckerPage. |
graphExportRequests | |
Type : Observable<void>
|
|
Graph will be exported when export-requests are received. |
graphExported | |
Type : EventEmitter
|
|
linkDeleted | |
Type : EventEmitter
|
|
linkSelected | |
Type : EventEmitter
|
|
nodeDeleted | |
Type : EventEmitter
|
|
nodeSelected | |
Type : EventEmitter
|
|
saveRequested | |
Type : EventEmitter
|
|
window:resize |
Arguments : '$event'
|
window:resize()
|
Redraws the entire graph with new sizes. |
Public cleanInitGraph |
cleanInitGraph(width?: number, height?: number)
|
Returns :
void
|
Private createDrag |
createDrag()
|
Returns :
Drag
|
Public createLink |
createLink(source: D3Node, target: D3Node)
|
Returns :
Promise<void>
|
Public Async createNode | ||||||||||||
createNode(x: number, y: number)
|
||||||||||||
Parameters :
Returns :
Promise<void>
|
Private createSimulation |
createSimulation()
|
Returns :
Simulation
|
Public exportGraph |
exportGraph()
|
Returns :
void
|
Private initGraph | ||||||||||||
initGraph(width: number, height: number)
|
||||||||||||
Parameters :
Returns :
void
|
Private isBidirectional |
isBidirectional(source: D3Node, target: D3Node)
|
Returns :
boolean
|
Public ngAfterViewChecked |
ngAfterViewChecked()
|
Decorators :
@HostListener('window:resize', ['$event'])
|
Redraws the entire graph with new sizes.
Returns :
void
|
Public ngAfterViewInit |
ngAfterViewInit()
|
Returns :
void
|
Public ngOnChanges | ||||||
ngOnChanges(changes: SimpleChanges)
|
||||||
Parameters :
Returns :
void
|
Public ngOnDestroy |
ngOnDestroy()
|
Returns :
void
|
Private onDrag | |||||||||
onDrag(event: NodeDragEvent, node: D3Node)
|
|||||||||
Parameters :
Returns :
void
|
Private onDragEnd | |||||||||
onDragEnd(event: NodeDragEvent, node: D3Node)
|
|||||||||
Parameters :
Returns :
void
|
Private onDragStart | |||||||||
onDragStart(event: NodeDragEvent, node: D3Node)
|
|||||||||
Parameters :
Returns :
void
|
Private onPointerDown | |||||||||
onPointerDown(event: PointerEvent, node: D3Node)
|
|||||||||
Parameters :
Returns :
void
|
Private onPointerMoved | ||||||
onPointerMoved(event: PointerEvent)
|
||||||
Parameters :
Returns :
void
|
Private onPointerUp | ||||||
onPointerUp(event: PointerEvent)
|
||||||
Parameters :
Returns :
void
|
Private onSettingsChanged | ||||||
onSettingsChanged(settings: GraphSettings)
|
||||||
Parameters :
Returns :
void
|
Private onTick |
onTick()
|
Returns :
void
|
Private onZoom | ||||||
onZoom(event: D3ZoomEvent<E | D>)
|
||||||
Type parameters :
|
||||||
Parameters :
Returns :
void
|
Public removeLink | ||||||
removeLink(link: D3Link)
|
||||||
Parameters :
Returns :
void
|
Public removeNode | ||||||
removeNode(node: D3Node)
|
||||||
Parameters :
Returns :
void
|
Private resetDraggableLink |
resetDraggableLink()
|
Returns :
void
|
Public resetGraph |
resetGraph()
|
Returns :
void
|
Private resetView |
resetView()
|
Returns :
void
|
Public restart | ||||||||||
restart(alpha: number)
|
||||||||||
Creates, updates and deletes the displayed nodes and links.
Parameters :
Returns :
void
|
Public saveGraph |
saveGraph()
|
Returns :
void
|
Public toggleLabels |
toggleLabels()
|
Returns :
void
|
Public toggleSimulation |
toggleSimulation()
|
Returns :
void
|
Private updateDraggableLinkPath |
updateDraggableLinkPath()
|
Returns :
void
|
Private Optional canvas |
Type : Canvas
|
Private Optional drag |
Type : Drag
|
Private Optional draggableLink |
Type : DraggableLink
|
Private Optional draggableLinkEnd |
Type : [number, number]
|
Private Optional draggableLinkSourceNode |
Type : D3Node
|
Private Optional draggableLinkTargetNode |
Type : D3Node
|
Private enableSimulation |
Default value : true
|
Private Optional graphExportRequestsSubscription |
Type : Subscription
|
Private Readonly graphHost |
Type : ElementRef<HTMLDivElement>
|
Decorators :
@ViewChild('graphHost')
|
Public Readonly graphSettings |
Default value : this.store.select('graphSettings')
|
Private Optional graphSettingsSubscription |
Type : Subscription
|
Private height |
Type : number
|
Default value : 0
|
Private Optional linkSelection |
Type : LinkSelection
|
Private Optional nodeSelection |
Type : NodeSelection
|
Private scale |
Type : number
|
Default value : 1
|
Private showLabels |
Default value : true
|
Private Optional simulation |
Type : Simulation
|
Private width |
Type : number
|
Default value : 0
|
Private xOffset |
Type : number
|
Default value : 0
|
Private yOffset |
Type : number
|
Default value : 0
|
Private Optional zoom |
Type : Zoom
|
import { AfterViewChecked, AfterViewInit, Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core';
import { MatBottomSheet } from '@angular/material/bottom-sheet';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import * as d3 from 'd3';
import { D3ZoomEvent } from 'd3';
import { concat, firstValueFrom, forkJoin, Observable, of, Subscription } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';
import { ExportGraphBottomSheet } from 'src/app/bottom-sheets/export-graph/export-graph.bottom-sheet';
import { DEFAULT_GRAPH_CONFIGURATION, GraphConfiguration } from 'src/app/configurations/graph.configuration';
import { SaveGraphDialog } from 'src/app/dialogs/save-graph/save-graph.dialog';
import D3Graph from 'src/app/model/d3/d3.graph';
import { D3Link } from 'src/app/model/d3/d3.link';
import { D3Node } from 'src/app/model/d3/d3.node';
import { FOLGraph } from 'src/app/model/domain/fol.graph';
import { enableSimulation, toggleLabels, toggleSimulation } from 'src/app/store/actions';
import { GraphSettings, State } from 'src/app/store/state';
import { Canvas, createCanvas } from 'src/app/utils/d3/canvas';
import { createDrag, Drag, NodeDragEvent } from 'src/app/utils/d3/drag';
import { createDraggableLink, DraggableLink } from 'src/app/utils/d3/draggable-link';
import { createLinkSelection, LinkSelection } from 'src/app/utils/d3/link-selection';
import { initMarkers } from 'src/app/utils/d3/markers';
import { createNodeSelection, NodeSelection } from 'src/app/utils/d3/node-selection';
import {
bidirectionalLinkTextTransform,
directLinkTextTransform,
linePath,
paddedArcPath,
paddedLinePath,
paddedReflexivePath,
reflexiveLinkTextTransform,
} from 'src/app/utils/d3/paths';
import { createSimulation, Simulation } from 'src/app/utils/d3/simulation';
import { createZoom, Zoom } from 'src/app/utils/d3/zoom';
import { terminate } from 'src/app/utils/events';
/**
* The interactive graph component.
*/
@Component({
selector: 'apollo-graph[graph][allowEditing]',
templateUrl: './graph.component.html',
styleUrls: ['./graph.component.scss'],
})
export class GraphComponent implements AfterViewInit, OnChanges, OnDestroy, AfterViewChecked {
/**
* The graph that will be displayed.
* Can be changed after initialization.
* For proper usage see GraphEditorComponent and ModelCheckerPage.
*/
@Input() public graph: D3Graph | null | undefined = new D3Graph();
/**
* Allows editing if set to true.
* Should not be changed after initialization.
*/
@Input() public allowEditing!: boolean;
/**
* Configuration for visuals of the graph.
* Should not be changed after initialization.
*/
@Input() public config: GraphConfiguration = DEFAULT_GRAPH_CONFIGURATION;
/**
* Graph will be exported when export-requests are received.
*/
@Input() public graphExportRequests?: Observable<void>;
@Output() public readonly linkSelected = new EventEmitter<D3Link>();
@Output() public readonly nodeSelected = new EventEmitter<D3Node>();
@Output() public readonly linkDeleted = new EventEmitter<D3Link>();
@Output() public readonly nodeDeleted = new EventEmitter<D3Node>();
@Output() public readonly saveRequested = new EventEmitter<FOLGraph>();
@Output() public readonly graphExported = new EventEmitter<FOLGraph>();
@ViewChild('graphHost') private readonly graphHost!: ElementRef<HTMLDivElement>;
// TranslateService::stream and TranslateService::onLangChange do not properly emit upon first subscription.
// As a workaround, we emit a dummy value froma second observable to trigger the forkJoin and call TranslateService::get instead.
public readonly controlsTooltipText: Observable<string> = concat(of(true), this.translate.onLangChange).pipe(
mergeMap(() => forkJoin([this.translate.get('graph.controls.view'), this.translate.get('graph.controls.graph')])),
map(([view, controls]) => (this.allowEditing ? view + '\n' + controls : view)),
);
public readonly graphSettings = this.store.select('graphSettings');
private graphSettingsSubscription?: Subscription;
private graphExportRequestsSubscription?: Subscription;
private enableSimulation = true;
private showLabels = true;
private width = 0;
private height = 0;
private xOffset = 0;
private yOffset = 0;
private scale = 1;
private simulation?: Simulation;
private canvas?: Canvas;
private linkSelection?: LinkSelection;
private nodeSelection?: NodeSelection;
private zoom?: Zoom;
private drag?: Drag;
private draggableLink?: DraggableLink;
private draggableLinkSourceNode?: D3Node;
private draggableLinkTargetNode?: D3Node;
private draggableLinkEnd?: [number, number];
public constructor(
private readonly store: Store<State>,
private readonly translate: TranslateService,
private readonly dialog: MatDialog,
private readonly bottomSheet: MatBottomSheet,
) {}
/**
* Redraws the entire graph with new sizes.
*/
@HostListener('window:resize', ['$event'])
public ngAfterViewChecked(): void {
const newWidth = this.graphHost.nativeElement.offsetWidth;
const newHeight = this.graphHost.nativeElement.offsetHeight;
const widthDiffers = this.width.toFixed(2) !== newWidth.toFixed(2);
const heightDiffers = this.height.toFixed(2) !== newHeight.toFixed(2);
if (!widthDiffers && !heightDiffers) {
return;
} else if (window.innerWidth <= 800 && !this.enableSimulation) {
this.store.dispatch(enableSimulation());
} else {
this.cleanInitGraph(newWidth, newHeight);
}
}
public ngAfterViewInit(): void {
this.initGraph();
this.graphSettingsSubscription = this.graphSettings.subscribe((settings) => this.onSettingsChanged(settings));
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes.graph.currentValue && this.graphHost !== undefined) {
// Perform clean init of the Graph. Enable the simulation for proper layouting.
this.cleanInitGraph();
if (!this.enableSimulation) {
// This will trigger a restart, so immediate restarting is not neccessary.
this.store.dispatch(enableSimulation());
} else {
this.restart(1);
}
} else if (changes.graph.currentValue === null) {
// Graph input not provided or not yet present (async). Use fallback.
this.graph = new D3Graph();
}
if (changes.graphExportRequests) {
this.graphExportRequestsSubscription?.unsubscribe();
this.graphExportRequestsSubscription = this.graphExportRequests?.subscribe(() => this.graphExported.emit(this.graph!.toDomainGraph()));
}
}
public ngOnDestroy(): void {
this.graphSettingsSubscription?.unsubscribe();
this.graphExportRequestsSubscription?.unsubscribe();
}
public saveGraph(): void {
firstValueFrom(
this.dialog
.open(SaveGraphDialog, {
data: this.graph!.toDomainGraph(),
})
.afterClosed(),
).then((domainGraph) => {
if (domainGraph !== undefined) {
this.saveRequested.emit(domainGraph);
}
});
}
public exportGraph(): void {
this.bottomSheet.open(ExportGraphBottomSheet, {
data: this.graph!.toDomainGraph(),
});
}
public toggleLabels(): void {
this.store.dispatch(toggleLabels());
}
public toggleSimulation(): void {
this.store.dispatch(toggleSimulation());
}
public resetGraph(): void {
this.cleanInitGraph();
this.store.dispatch(enableSimulation());
}
public cleanInitGraph(width?: number, height?: number): void {
this.resetView();
this.initGraph(width, height);
}
/**
* Creates, updates and deletes the displayed nodes and links.
*
* @param alpha Alpha value (heat, activity) of the simulation
*/
public restart(alpha = 0.5): void {
this.linkSelection = this.linkSelection!.data(this.graph!.links, (d: D3Link) => `${d.source.id}-${d.target.id}`).join((enter) => {
const linkGroup = enter.append('g');
linkGroup.append('path').classed('link', true).style('marker-end', 'url(#link-arrow');
linkGroup
.append('path')
.classed('clickbox', true)
.on('contextmenu', (event: MouseEvent, d) => {
terminate(event);
this.linkSelected.emit(d);
});
linkGroup.append('text').classed('link-details', true);
return linkGroup;
});
this.nodeSelection = this.nodeSelection!.data(this.graph!.nodes, (d) => d.id).join((enter) => {
const nodeGroup = enter
.append('g')
.call(this.drag!)
.on('contextmenu', (event: MouseEvent, d) => {
terminate(event);
this.nodeSelected.emit(d);
});
nodeGroup
.append('circle')
.classed('node', true)
.attr('r', this.config.nodeRadius)
.on('mouseenter', (_, d: D3Node) => (this.draggableLinkTargetNode = d))
.on('mouseout', () => (this.draggableLinkTargetNode = undefined))
.on('pointerdown', (event: PointerEvent, d) => this.onPointerDown(event, d))
.on('pointerup', (event: PointerEvent) => this.onPointerUp(event));
nodeGroup
.append('text')
.text((d) => d.id)
.classed('node-id', true)
.attr('dy', `0.33em`);
nodeGroup.append('text').classed('node-details', true).attr('dy', `-2em`);
return nodeGroup;
});
this.linkSelection
.select('.link-details')
.attr('opacity', this.showLabels ? 1 : 0)
.text((d) => [...d.relations, ...d.functions].join(', '));
this.nodeSelection
.select('.node-details')
.attr('opacity', this.showLabels ? 1 : 0)
.text((d) => [...d.relations, ...d.constants].join(', '));
this.simulation!.nodes(this.graph!.nodes);
this.simulation!.alpha(alpha).restart();
}
public async createNode(x: number = this.width / 2 - this.xOffset, y: number = this.height / 2 - this.yOffset): Promise<void> {
if (!this.allowEditing) {
return Promise.reject('Graph is not in edit mode.');
}
const node = await this.graph!.createNodeWithGeneratedId(x, y);
this.restart();
this.nodeSelected.emit(node);
}
public removeNode(node: D3Node): void {
if (!this.allowEditing) {
return;
}
this.resetDraggableLink();
this.graph!.removeNode(node).then(([deletedNode, deletedLinks]) => {
this.restart();
this.nodeDeleted.emit(deletedNode);
deletedLinks.forEach((deletedLink) => this.linkDeleted.emit(deletedLink));
});
}
public createLink(source: D3Node, target: D3Node): Promise<void> {
if (!this.allowEditing) {
return Promise.reject('Graph is not in edit mode.');
}
return this.graph!.createLink(source.id, target.id)
.then((newLink) => {
this.restart();
this.linkSelected.emit(newLink);
})
.catch((existingLink: D3Link) => this.linkSelected.emit(existingLink));
}
public removeLink(link: D3Link): void {
if (!this.allowEditing) {
return;
}
this.graph!.removeLink(link).then((deletedLink) => {
this.restart();
this.linkDeleted.emit(deletedLink);
});
}
private initGraph(width: number = this.graphHost.nativeElement.clientWidth, height: number = this.graphHost.nativeElement.clientHeight): void {
this.width = width;
this.height = height;
this.zoom = createZoom((event) => this.onZoom(event));
this.canvas = createCanvas(
d3.select(this.graphHost.nativeElement),
this.zoom,
(event: PointerEvent) => this.onPointerMoved(event),
(event: PointerEvent) => this.onPointerUp(event),
(event) => this.createNode(d3.pointer(event, this.canvas!.node())[0], d3.pointer(event, this.canvas!.node())[1]),
);
initMarkers(this.canvas, this.config);
if (this.allowEditing) {
this.draggableLink = createDraggableLink(this.canvas);
}
this.linkSelection = createLinkSelection(this.canvas);
this.nodeSelection = createNodeSelection(this.canvas);
this.simulation = this.createSimulation();
this.drag = this.createDrag();
this.restart();
}
private createSimulation(): Simulation {
return createSimulation(this.graph!, this.config, this.width, this.height, this.enableSimulation, () => this.onTick());
}
private onTick(): void {
this.nodeSelection!.attr('transform', (d) => `translate(${d.x},${d.y})`);
this.linkSelection!.selectAll<SVGPathElement, D3Link>('path').attr('d', (d: D3Link) => {
if (d.source.id === d.target.id) {
return paddedReflexivePath(d.source, [this.width / 2, this.height / 2], this.config);
} else if (this.isBidirectional(d.source, d.target)) {
return paddedArcPath(d.source, d.target, this.config);
} else {
return paddedLinePath(d.source, d.target, this.config);
}
});
this.linkSelection!.select('.link-details').attr('transform', (d: D3Link) => {
if (d.source.id === d.target.id) {
return reflexiveLinkTextTransform(d.source, [this.width / 2, this.height / 2], this.config);
} else if (this.isBidirectional(d.source, d.target)) {
return bidirectionalLinkTextTransform(d.source, d.target, this.config);
} else {
return directLinkTextTransform(d.source, d.target);
}
});
this.updateDraggableLinkPath();
}
private updateDraggableLinkPath(): void {
const source = this.draggableLinkSourceNode;
if (source !== undefined) {
const target = this.draggableLinkTargetNode;
if (target !== undefined) {
this.draggableLink!.attr('d', () => {
if (source.id === target.id) {
return paddedReflexivePath(source, [this.width / 2, this.height / 2], this.config);
} else if (this.isBidirectional(source, target)) {
return paddedLinePath(source, target, this.config);
} else {
return paddedArcPath(source, target, this.config);
}
});
} else if (this.draggableLinkEnd !== undefined) {
const from: [number, number] = [source.x!, source.y!];
this.draggableLink!.attr('d', linePath(from, this.draggableLinkEnd));
}
}
}
private createDrag(): Drag {
return createDrag(
(event, node) => this.onDragStart(event, node),
(event, node) => this.onDrag(event, node),
(event, node) => this.onDragEnd(event, node),
);
}
private onDragStart(event: NodeDragEvent, node: D3Node) {
terminate(event.sourceEvent);
if (event.active === 0) {
this.simulation?.alphaTarget(0.5).restart();
}
node.fx = node.x;
node.fy = node.y;
}
private onDrag(event: NodeDragEvent, node: D3Node) {
node.fx = event.x;
node.fy = event.y;
}
private onDragEnd(event: NodeDragEvent, node: D3Node) {
if (event.active === 0) {
this.simulation?.alphaTarget(0);
}
node.fx = undefined;
node.fy = undefined;
}
private onPointerDown(event: PointerEvent, node: D3Node): void {
if (!this.allowEditing || event.button !== 0) {
return;
}
terminate(event);
const coordinates: [number, number] = [node.x!, node.y!];
this.draggableLinkEnd = coordinates;
this.draggableLinkSourceNode = node;
this.draggableLink!.style('marker-end', 'url(#draggable-link-arrow').classed('hidden', false).attr('d', linePath(coordinates, coordinates));
this.restart();
}
private onPointerMoved(event: PointerEvent): void {
terminate(event);
if (this.draggableLinkSourceNode !== undefined) {
const pointer = d3.pointers(event, this.graphHost.nativeElement)[0];
const point: [number, number] = [(pointer[0] - this.xOffset) / this.scale, (pointer[1] - this.yOffset) / this.scale];
if (event.pointerType === 'touch') {
point[1] = point[1] - 4 * this.config.nodeRadius;
// PointerEvents are not firing correctly for touch input.
// So for TouchEvents, we have to manually detect Nodes within range and set them as the current target node.
this.draggableLinkTargetNode = this.graph!.nodes.find((node) => Math.sqrt(Math.pow(node.x! - point[0], 2) + Math.pow(node.y! - point[1], 2)) < this.config.nodeRadius);
}
this.draggableLinkEnd = point;
this.updateDraggableLinkPath();
}
}
private onPointerUp(event: PointerEvent): void {
const source = this.draggableLinkSourceNode;
const target = this.draggableLinkTargetNode;
this.resetDraggableLink();
if (!this.allowEditing || source === undefined || target === undefined) {
return;
}
terminate(event);
this.createLink(source, target);
}
private resetDraggableLink(): void {
this.draggableLink?.classed('hidden', true).style('marker-end', '');
this.draggableLinkSourceNode = undefined;
this.draggableLinkTargetNode = undefined;
this.draggableLinkEnd = undefined;
}
private resetView(): void {
this.simulation?.stop();
d3.select(this.graphHost.nativeElement).selectChildren().remove();
this.zoom = undefined;
this.xOffset = 0;
this.yOffset = 0;
this.scale = 1;
this.canvas = undefined;
this.draggableLink = undefined;
this.linkSelection = undefined;
this.nodeSelection = undefined;
this.simulation = undefined;
this.resetDraggableLink();
}
private onZoom<E extends Element, D>(event: D3ZoomEvent<E, D>): void {
this.xOffset = event.transform.x;
this.yOffset = event.transform.y;
this.scale = event.transform.k;
this.canvas!.attr('transform', `translate(${this.xOffset},${this.yOffset})scale(${this.scale})`);
}
private isBidirectional(source: D3Node, target: D3Node): boolean {
return (
source.id !== target.id &&
this.graph!.links.some((l) => l.target.id === source.id && l.source.id === target.id) &&
this.graph!.links.some((l) => l.target.id === target.id && l.source.id === source.id)
);
}
private onSettingsChanged(settings: GraphSettings): void {
const simulationChanged = this.enableSimulation !== settings.enableSimulation;
const labelsChanged = this.showLabels !== settings.showLabels;
this.enableSimulation = settings.enableSimulation;
this.showLabels = settings.showLabels;
if (simulationChanged) {
this.simulation?.stop();
this.simulation = this.createSimulation();
}
if (simulationChanged || labelsChanged) {
this.restart(1);
}
}
}
<div class="graph-tool-container">
<button mat-icon-button color="primary" (click)="saveGraph()" matTooltip="{{ 'actions.save' | translate }}">
<mat-icon>save</mat-icon>
</button>
<button mat-icon-button (click)="exportGraph()" color="primary" matTooltip="{{ 'actions.export' | translate }}">
<mat-icon>get_app</mat-icon>
</button>
<button mat-icon-button color="primary" (click)="resetGraph()" matTooltip="{{ 'graph.reset-graph' | translate }}">
<mat-icon>location_searching</mat-icon>
</button>
<mat-icon class="help-icon" color="primary" [matTooltip]="(controlsTooltipText | async) || ''" matTooltipClass="multiline-tooltip">help_outlined</mat-icon>
<mat-slide-toggle color="accent" class="automatic-layout-toggle" [checked]="(graphSettings | async)!.enableSimulation" (change)="toggleSimulation()">
{{ "graph.simulation" | translate }}
</mat-slide-toggle>
<mat-slide-toggle color="accent" [checked]="(graphSettings | async)!.showLabels" (change)="toggleLabels()">
{{ "graph.labels" | translate }}
</mat-slide-toggle>
</div>
<button
*ngIf="allowEditing"
mat-mini-fab
color="primary"
(click)="createNode()"
class="create-node-button"
matTooltip="{{ 'graph.create-node' | translate }}"
matTooltipPosition="left"
>
<mat-icon>add</mat-icon>
</button>
<div #graphHost class="graph"></div>
./graph.component.scss
@use "@angular/material" as mat;
:host {
height: 100%;
}
:host,
.graph,
.graph > svg {
display: block;
min-height: 400px;
}
@mixin graph-theme($theme) {
$primary: map-get($theme, primary);
$accent: map-get($theme, accent);
$foreground: map-get($theme, foreground);
.graph {
width: 100%;
height: 100%;
touch-action: none;
}
.graph-tool-container {
position: absolute;
left: 0.5rem;
top: 0.5rem;
right: 0.5rem;
text-align: right;
display: flex;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
pointer-events: none;
* {
pointer-events: all;
}
> mat-slide-toggle {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.help-icon {
padding: 8px;
}
}
@media screen and (max-width: 800px) {
.graph-tool-container {
position: absolute;
left: 0.25rem;
top: 0.2rem;
right: 0.25rem;
.automatic-layout-toggle {
display: none;
}
}
}
.create-node-button {
position: absolute;
right: 1rem;
bottom: 1rem;
}
.link {
stroke: mat.get-color-from-palette($accent);
stroke-width: 4px;
fill: none;
&.hidden {
stroke-width: 0;
}
&.draggable {
stroke: mat.get-color-from-palette($accent, darker);
stroke-dasharray: 8px 2px;
pointer-events: none;
}
}
.clickbox {
stroke: rgba($color: #000000, $alpha: 0);
stroke-width: 16px;
fill: none;
cursor: pointer;
}
.arrow {
fill: mat.get-color-from-palette($accent);
&.draggable {
fill: mat.get-color-from-palette($accent, darker);
}
}
.node {
fill: mat.get-color-from-palette($primary);
stroke: none;
cursor: pointer;
}
.link-details,
.node-details {
fill: mat.get-color-from-palette($foreground, text);
text-anchor: middle;
}
.node-id {
pointer-events: none;
fill: mat.get-color-from-palette($primary, default-contrast);
text-anchor: middle;
}
}