Skip to content

Instantly share code, notes, and snippets.

@dianjuar
Last active October 7, 2022 17:03
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dianjuar/1fb69f3cd80af2a54915f87be8d421ef to your computer and use it in GitHub Desktop.
Save dianjuar/1fb69f3cd80af2a54915f87be8d421ef to your computer and use it in GitHub Desktop.
Create connecting lines between boxes with Angular
import {
AfterViewInit,
Directive,
ElementRef,
Inject,
Input,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { MapPathsDirective } from './map-path.directive';
type Point = {
x: number;
y: number;
};
@Directive({
selector: '[connectingLines]',
})
export class ConnectingLinesDirective implements AfterViewInit {
/**
* When it's on, add some styles to the svg canvas
*/
@Input()
public debugMode: boolean = false;
private svgCanvas: SVGElement | undefined = undefined;
constructor(
@Inject(DOCUMENT) private document: Document,
private host: ElementRef,
private paths: MapPathsDirective
) {}
ngAfterViewInit(): void {
const hostNativeElement: HTMLElement = this.host.nativeElement;
hostNativeElement.style.position = 'relative';
// insert canvas into host
const canvas = this.generateSVGCanvas();
this.svgCanvas = hostNativeElement.insertAdjacentElement(
'afterbegin',
canvas
) as SVGElement;
this.drawLines();
}
/**
* Generates the svg canvas element with the necessary styles
*/
private generateSVGCanvas(): SVGElement {
const svgCanvas = this.document.createElementNS(
'http://www.w3.org/2000/svg',
'svg'
);
svgCanvas.style.height = '100%';
svgCanvas.style.width = '100%';
svgCanvas.style.position = 'absolute';
if (this.debugMode) {
svgCanvas.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
svgCanvas.style.zIndex = '1';
}
return svgCanvas;
}
private drawLines() {
const allStartCards = this.paths.getStartElements();
Array.from(allStartCards).forEach(startElement => {
const endElement = this.paths.getEndElement(startElement);
this.drawLinesBetweenElements(startElement, endElement);
});
}
private drawLinesBetweenElements(startElement: Element, endElement: Element) {
if (this.areElementsInRectLine(startElement, endElement)) {
this.drawRectLineBetween(startElement, endElement);
} else {
this.drawCurveLineBetween(startElement, endElement);
}
}
private areElementsInRectLine(
startElement: Element,
endElement: Element
): boolean {
return (
startElement.getBoundingClientRect().y ===
endElement.getBoundingClientRect().y
);
}
private drawRectLineBetween(startElement: Element, endElement: Element) {
const canvasRect = this.svgCanvas!.getBoundingClientRect();
const startRect = startElement.getBoundingClientRect();
const endRect = endElement.getBoundingClientRect();
const startPoint: Point = {
x: this.getXPoint(startRect, canvasRect, 'start'),
y: this.getYPoint(startRect, canvasRect, 'horizontal'),
};
const endPoint: Point = {
x: this.getXPoint(endRect, canvasRect, 'end'),
y: this.getYPoint(endRect, canvasRect, 'horizontal'),
};
const path = this.createPath(startPoint, endPoint);
this.svgCanvas!.appendChild(path);
}
private drawCurveLineBetween(startElement: Element, endElement: Element) {
const canvasRect = this.svgCanvas!.getBoundingClientRect();
const startRect = startElement.getBoundingClientRect();
const endRect = endElement.getBoundingClientRect();
const startPointHorizontal: Point = {
x: this.getXPoint(startRect, canvasRect, 'start'),
y: this.getYPoint(startRect, canvasRect, 'horizontal'),
};
const endPointHorizontal: Point = {
x: this.getXPoint(endRect, canvasRect, 'vertical'),
y: this.getYPoint(startRect, canvasRect, 'horizontal'),
};
const startPointVertical: Point = {
x: this.getXPoint(endRect, canvasRect, 'vertical'),
y: this.getYPoint(startRect, canvasRect, 'horizontal'),
};
const endPointVertical: Point = {
x: this.getXPoint(endRect, canvasRect, 'vertical'),
y: this.getYPoint(endRect, canvasRect, 'vertical'),
};
const pathHorizontal = this.createPath(
startPointHorizontal,
endPointHorizontal
);
const pathVertical = this.createPath(
startPointVertical,
endPointVertical,
this.paths.wantsOffset(startElement)
);
this.svgCanvas!.appendChild(pathHorizontal);
this.svgCanvas!.appendChild(pathVertical);
}
private getXPoint(
rect: DOMRect,
offset: DOMRect,
endOrStart: 'end' | 'start' | 'vertical'
): number {
let x: number;
switch (endOrStart) {
case 'start':
x = rect.right;
break;
case 'end':
x = rect.left;
break;
case 'vertical':
x = rect.left + rect.width / 2;
break;
}
return x - offset.x;
}
private getYPoint(
rect: DOMRect,
offset: DOMRect,
direction: 'horizontal' | 'vertical'
) {
let y: number;
if (direction === 'horizontal') {
y = rect.y + rect.height / 2;
} else {
y = rect.bottom;
}
return y - offset.y;
}
private createPath(start: Point, end: Point, withOffset?: boolean) {
const path = this.document.createElementNS(
'http://www.w3.org/2000/svg',
'line'
);
path.setAttribute('x1', start.x.toString());
path.setAttribute('y1', start.y.toString());
path.setAttribute('x2', end.x.toString());
path.setAttribute('y2', end.y.toString());
// Styles
path.setAttribute('stroke', 'lightgray');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-dasharray', '2 2');
path.setAttribute('stroke-dashoffset', withOffset ? '2' : '0');
return path;
}
}
<div class="process-map-container" connectingLines>
<kendo-stacklayout
[gap]="30"
orientation="horizontal"
[align]="{ horizontal: 'start' }"
>
<val-section-panel title="load" orientation="vertical">
<!-- statements -->
<process-map-card-statement-files [mapPaths]="paths.statementFiles">
</process-map-card-statement-files>
<!-- checks -->
<process-map-card-check-files [mapPaths]="paths.checkFiles">
</process-map-card-check-files>
<!-- account extracts -->
<process-map-card-account-extracts [mapPaths]="paths.accountExtracts">
</process-map-card-account-extracts>
</val-section-panel>
<val-section-panel title="refine" orientation="horizontal">
<!-- reconcile -->
<process-map-card-reconcile [mapPaths]="paths.reconcile">
</process-map-card-reconcile>
<!-- transaction -->
<process-map-card-transactions [mapPaths]="paths.transaction">
</process-map-card-transactions>
<!-- match -->
<process-map-card-match [mapPaths]="paths.match">
</process-map-card-match>
<!-- check bank match -->
<process-map-card-check-bank-match [mapPaths]="paths.checkBankMatch">
</process-map-card-check-bank-match>
</val-section-panel>
<val-section-panel title="analyze" orientation="vertical">
<!-- output -->
<process-map-card-outputs [mapPaths]="paths.outputs">
</process-map-card-outputs>
</val-section-panel>
</kendo-stacklayout>
</div>
import { DOCUMENT } from '@angular/common';
import { Directive, ElementRef, Inject, Input, OnInit } from '@angular/core';
export type MapIds = {
start?: string;
end?: string[];
/**
* Used to align dots
*/
withOffset?: boolean;
};
@Directive({
selector: '[mapPaths]',
})
export class MapPathsDirective implements OnInit {
private readonly START_METADATA_ATTR = 'data-map-lines-start';
private readonly END_METADATA_ATTR = 'data-map-lines-end';
private readonly WITH_OFFSET_METADATA_ATTR =
'data-map-lines-withOffset';
@Input('mapPaths')
pathsIds!: MapIds;
constructor(
private host: ElementRef,
@Inject(DOCUMENT) private document: Document
) {}
ngOnInit(): void {
this.setStartIds(this.pathsIds.start);
this.setEndIds(this.pathsIds.end);
this.setOffset(this.pathsIds.withOffset);
}
getStartElements() {
return this.document.querySelectorAll(`[${this.START_METADATA_ATTR}]`);
}
getEndElement(startElement: Element): Element {
const connectionId = startElement.attributes.getNamedItem(
this.START_METADATA_ATTR
)!.value;
const endElement = this.document.querySelector(
`[${this.buildEndConnectionId(connectionId)}]`
)!;
return endElement;
}
wantsOffset(startElement: Element): boolean {
return !!startElement.attributes.getNamedItem(
this.WITH_OFFSET_METADATA_ATTR
);
}
private buildEndConnectionId(id: string) {
return `${this.END_METADATA_ATTR}-${id}`;
}
private setStartIds(startId: MapIds['start']) {
if (!startId) {
return;
}
const nativeElement = this.host.nativeElement as Element;
nativeElement.setAttribute(this.START_METADATA_ATTR, startId);
}
private setEndIds(endIds: MapIds['end']) {
if (!endIds) {
return;
}
endIds.forEach(endId => {
const nativeElement = this.host.nativeElement as Element;
nativeElement.setAttribute(this.buildEndConnectionId(endId), '');
});
}
private setOffset(withOffset: MapIds['withOffset']) {
if (!withOffset) {
return;
}
const nativeElement = this.host.nativeElement as Element;
nativeElement.setAttribute(this.WITH_OFFSET_METADATA_ATTR, '');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment