diff --git a/src/app/app.component.html b/src/app/app.component.html deleted file mode 100644 index 9b37b02..0000000 --- a/src/app/app.component.html +++ /dev/null @@ -1,3 +0,0 @@ - -
-
diff --git a/src/app/app.component.scss b/src/app/app.component.scss deleted file mode 100644 index 2cee31c..0000000 --- a/src/app/app.component.scss +++ /dev/null @@ -1,26 +0,0 @@ -.card { - display: flex; - align-items: center; - justify-content: center; - width: 180px; - height: 40px; - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3); - border-radius: 5px; - background-color: white; -} - -.doting { - --scale: 2; - left: 318px; - top: 263px; - width: calc(5px + (5px * var(--scale))); - height: calc(5px + (5px * var(--scale))); - position: fixed; - background: blue; - z-index: 124; - pointer-events: none; -} - -button { - @apply p-1; -} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 0ada18c..31be05d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,22 +1,14 @@ import { Component, ViewChild, inject } from '@angular/core'; import { NgForOf } from '@angular/common'; import { RouterOutlet } from '@angular/router'; -import { ContainerComponent } from './container.component'; -import { FlowChildComponent } from './flow-child.component'; -import { FlowComponent } from './flow.component'; -import { FlowOptions } from './flow-interface'; -import { FlowService } from './flow.service'; +import { FlowChildComponent } from './flow/flow-child.component'; +import { FlowComponent } from './flow/flow.component'; +import { FlowOptions } from './flow/flow-interface'; @Component({ selector: 'app-root', standalone: true, - imports: [ - NgForOf, - RouterOutlet, - FlowComponent, - ContainerComponent, - FlowChildComponent, - ], + imports: [NgForOf, RouterOutlet, FlowComponent, FlowChildComponent], template: ` @@ -54,7 +46,36 @@ import { FlowService } from './flow.service'; `, - styleUrls: ['./app.component.scss'], + styles: [ + ` + .card { + display: flex; + align-items: center; + justify-content: center; + width: 180px; + height: 40px; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3); + border-radius: 5px; + background-color: white; + } + + .doting { + --scale: 2; + left: 318px; + top: 263px; + width: calc(5px + (5px * var(--scale))); + height: calc(5px + (5px * var(--scale))); + position: fixed; + background: blue; + z-index: 124; + pointer-events: none; + } + + button { + @apply p-1; + } + `, + ], }) export class AppComponent { title = 'angular-flow'; diff --git a/src/app/container.component.ts b/src/app/container.component.ts deleted file mode 100644 index 310e9f3..0000000 --- a/src/app/container.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -@Component({ - standalone: true, - selector: 'app-container', - template: `
`, - styles: [ - ` - div { - display: flex; - width: 150px; - height: 300px; - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3); - border-radius: 5px; - } - `, - ], -}) -export class ContainerComponent implements OnInit { - constructor() {} - - ngOnInit() {} -} diff --git a/src/app/arrangements.spec.ts b/src/app/flow/arrangements.spec.ts similarity index 100% rename from src/app/arrangements.spec.ts rename to src/app/flow/arrangements.spec.ts diff --git a/src/app/arrangements.ts b/src/app/flow/arrangements.ts similarity index 100% rename from src/app/arrangements.ts rename to src/app/flow/arrangements.ts diff --git a/src/app/connections.spec.ts b/src/app/flow/connections.spec.ts similarity index 100% rename from src/app/connections.spec.ts rename to src/app/flow/connections.spec.ts diff --git a/src/app/connections.ts b/src/app/flow/connections.ts similarity index 100% rename from src/app/connections.ts rename to src/app/flow/connections.ts diff --git a/src/app/flow-child.component.spec.ts b/src/app/flow/flow-child.component.spec.ts similarity index 100% rename from src/app/flow-child.component.spec.ts rename to src/app/flow/flow-child.component.spec.ts diff --git a/src/app/flow/flow-child.component.ts b/src/app/flow/flow-child.component.ts new file mode 100644 index 0000000..ade2dba --- /dev/null +++ b/src/app/flow/flow-child.component.ts @@ -0,0 +1,180 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + OnInit, + OnDestroy, + ViewChildren, + QueryList, + ElementRef, + Input, + NgZone, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, Subscription } from 'rxjs'; +import { FlowService } from './flow.service'; +import { FlowOptions } from './flow-interface'; + +@Component({ + standalone: true, + imports: [CommonModule], + selector: '[flowChild]', + template: ` +
+
+
+
`, + styles: [ + ` + .dot { + --dot-size: 10px; + --dot-half-size: -5px; + position: absolute; + width: var(--dot-size); + height: var(--dot-size); + background: red; + border-radius: 999px; + } + .dot-left { + top: calc(50% + var(--dot-half-size)); + left: var(--dot-half-size); + } + .dot-right { + top: calc(50% + var(--dot-half-size)); + right: var(--dot-half-size); + } + .dot-top { + left: 50%; + top: var(--dot-half-size); + } + .dot-bottom { + left: 50%; + bottom: var(--dot-half-size); + } + .invisible { + visibility: hidden; + } + `, + ], +}) +export class FlowChildComponent implements OnInit, OnChanges, OnDestroy { + private isDragging = false; + private offsetX = 0; + private offsetY = 0; + + @ViewChildren('dot') dots: QueryList>; + + @Input('flowChild') position: FlowOptions; + + private positionChange = new Subject(); + private mouseMoveSubscription: Subscription; + + constructor( + public el: ElementRef, + private flow: FlowService, + private ngZone: NgZone + ) { + this.el.nativeElement.style.position = 'absolute'; + this.el.nativeElement.style.transformOrigin = '0, 0'; + // track mouse move outside angular + this.ngZone.runOutsideAngular(() => { + this.flow.enableChildDragging.subscribe((x) => { + if (x) { + this.enableDragging(); + } else { + this.disableDragging(); + } + }); + }); + + this.flow.layoutUpdated.pipe(takeUntilDestroyed()).subscribe((x) => { + this.position = this.flow.items.get(this.position.id) as FlowOptions; + this.positionChange.next(this.position); + }); + + this.positionChange.subscribe((x) => { + const { left, top } = this.flow.zRect; + if (!this.position) console.log(this.position); + this.updatePosition(this.position.x + left, this.position.y + top); + }); + } + + private onMouseUp = (event: MouseEvent) => { + event.stopPropagation(); + this.isDragging = false; + this.flow.isChildDragging = false; + }; + + private onMouseDown = (event: MouseEvent) => { + event.stopPropagation(); + this.isDragging = true; + this.flow.isChildDragging = true; + const rect = this.el.nativeElement.getBoundingClientRect(); + this.offsetX = event.clientX - rect.x; + this.offsetY = event.clientY - rect.y; + }; + + private onMouseMove = (event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + if (this.isDragging) { + event.stopPropagation(); + const zRect = this.flow.zRect; + const cx = event.clientX - zRect.left; + const cy = event.clientY - zRect.top; + const x = + Math.round( + (cx - this.flow.panX - this.offsetX) / + (this.flow.gridSize * this.flow.scale) + ) * this.flow.gridSize; + const y = + Math.round( + (cy - this.flow.panY - this.offsetY) / + (this.flow.gridSize * this.flow.scale) + ) * this.flow.gridSize; + + this.position.x = x - zRect.left; + this.position.y = y - zRect.top; + this.positionChange.next(this.position); + this.flow.arrowsChange.next(this.position); + } + }; + + private enableDragging() { + this.mouseMoveSubscription = this.flow.onMouse.subscribe(this.onMouseMove); + // mouse up event + this.el.nativeElement.addEventListener('mouseup', this.onMouseUp); + + // mouse down event + this.el.nativeElement.addEventListener('mousedown', this.onMouseDown); + } + + private disableDragging() { + this.mouseMoveSubscription?.unsubscribe(); + this.el.nativeElement.removeEventListener('mouseup', this.onMouseUp); + this.el.nativeElement.removeEventListener('mousedown', this.onMouseDown); + } + + ngOnInit() { + this.updatePosition(this.position.x, this.position.y); + } + + ngOnChanges(changes: SimpleChanges): void { + console.log(`ngOnChanges ${this.position.id}`, changes); + // if (changes['position']) { + // this.updatePosition(this.position.x, this.position.y); + // } + } + + private updatePosition(x: number, y: number) { + this.el.nativeElement.style.transform = `translate(${x}px, ${y}px)`; + } + + ngOnDestroy() { + this.disableDragging(); + // remove the FlowOptions from the flow service + // this.flow.delete(this.position); + // console.log('ngOnDestroy', this.position.id); + } +} diff --git a/src/app/flow/flow-interface.ts b/src/app/flow/flow-interface.ts new file mode 100644 index 0000000..d716769 --- /dev/null +++ b/src/app/flow/flow-interface.ts @@ -0,0 +1,6 @@ +export interface FlowOptions { + x: number; + y: number; + id: string; + deps: string[]; +} diff --git a/src/app/flow.component.spec.ts b/src/app/flow/flow.component.spec.ts similarity index 100% rename from src/app/flow.component.spec.ts rename to src/app/flow/flow.component.spec.ts diff --git a/src/app/flow/flow.component.ts b/src/app/flow/flow.component.ts new file mode 100644 index 0000000..ac2bd33 --- /dev/null +++ b/src/app/flow/flow.component.ts @@ -0,0 +1,510 @@ +import { NgForOf } from '@angular/common'; +import { + Component, + AfterContentInit, + AfterViewInit, + OnDestroy, + ContentChildren, + QueryList, + ViewChild, + ElementRef, + NgZone, +} from '@angular/core'; +import { startWith } from 'rxjs'; +import { Arrangements } from './arrangements'; +import { ChildInfo, Connections } from './connections'; +import { FlowChildComponent } from './flow-child.component'; +import { FlowService } from './flow.service'; +import { FlowOptions } from './flow-interface'; +import { SvgHandler } from './svg'; + +@Component({ + standalone: true, + imports: [NgForOf, FlowChildComponent], + providers: [FlowService], + selector: 'app-flow', + template: ` +
+ + + + + + + + + + + + + + Follow me + + + Follow me + + + + +
`, + styles: [ + ` + :host { + --grid-size: 20px; + display: block; + height: 100%; + width: 100%; + position: relative; + overflow: hidden; + } + + .flow-pattern { + position: absolute; + width: 100%; + height: 100%; + top: 0px; + left: 0px; + } + + .zoom-container { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + transform-origin: 0 0; + } + + svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + } + `, + ], +}) +export class FlowComponent + implements AfterContentInit, AfterViewInit, OnDestroy +{ + @ContentChildren(FlowChildComponent) children: QueryList = + new QueryList(); + + // @ViewChildren('arrowPaths') arrowPaths: QueryList>; + @ViewChild('zoomContainer') zoomContainer: ElementRef; + @ViewChild('svg') svg: ElementRef; + @ViewChild('g') g: ElementRef; + // New SVG element for guide lines + @ViewChild('guideLines') guideLines: ElementRef; + initialX = 0; + initialY = 0; + + constructor( + private el: ElementRef, + public flow: FlowService, + private ngZone: NgZone + ) { + this.flow.zoomContainer = this.el.nativeElement; + this.flow.arrowsChange.subscribe((e) => this.updateArrows(e)); + this.ngZone.runOutsideAngular(() => { + this.flow.enableZooming.subscribe((enable) => { + if (enable) { + this.el.nativeElement.addEventListener('wheel', this.zoomHandle); + } else { + this.el.nativeElement.removeEventListener('wheel', this.zoomHandle); + } + }); + this.el.nativeElement.addEventListener( + 'mousedown', + this._startDraggingZoomContainer + ); + this.el.nativeElement.addEventListener( + 'mouseup', + this._stopDraggingZoomContainer + ); + this.el.nativeElement.addEventListener( + 'mousemove', + this._dragZoomContainer + ); + }); + } + + ngAfterViewInit(): void { + this.createArrows(); + // this.updateZoomContainer(); + } + + ngAfterContentInit() { + this.children.changes + .pipe(startWith(this.children)) + .subscribe((children) => { + // console.log('children changed', children); + this.flow.update(this.children.map((x) => x.position)); + this.arrangeChildren(); + this.createArrows(); + }); + requestAnimationFrame(() => this.updateArrows()); // this required for angular to render the dot + } + + updateChildDragging(enable = true) { + this.flow.enableChildDragging.next(enable); + } + + updateZooming(enable = true) { + this.flow.enableZooming.next(enable); + } + + public _startDraggingZoomContainer = (event: MouseEvent) => { + event.stopPropagation(); + this.flow.isDraggingZoomContainer = true; + // const containerRect = this.el.nativeElement.getBoundingClientRect(); + this.initialX = event.clientX - this.flow.panX; + this.initialY = event.clientY - this.flow.panY; + }; + + public _stopDraggingZoomContainer = (event: MouseEvent) => { + event.stopPropagation(); + this.flow.isDraggingZoomContainer = false; + }; + + public _dragZoomContainer = (event: MouseEvent) => { + if (this.flow.isDraggingZoomContainer) { + event.stopPropagation(); + this.flow.panX = event.clientX - this.initialX; + this.flow.panY = event.clientY - this.initialY; + this.updateZoomContainer(); + } + }; + + public zoomHandle = (event: WheelEvent) => { + if (this.flow.isDraggingZoomContainer || this.flow.isChildDragging) return; + event.stopPropagation(); + event.preventDefault(); + const scaleDirection = event.deltaY < 0 ? 1 : -1; + // if it is zoom out and the scale is less than 0.2, then return + if (scaleDirection === -1 && this.flow.scale < 0.2) return; + + this.setZoom1(event.clientX, event.clientY, scaleDirection); + }; + + private setZoom1(clientX: number, clientY: number, scaleDirection: number) { + const { left, top } = this.flow.zRect; + const { scale, panX, panY } = this._setZoom( + clientX - left, + clientY - top, + scaleDirection, + this.flow.panX, + this.flow.panY, + this.flow.scale + ); + this.flow.scale = scale; + this.flow.panX = panX; + this.flow.panY = panY; + + // Apply the zoom and the pan + this.updateZoomContainer(); + } + + public _setZoom( + wheelClientX: number, + wheelClientY: number, + scaleDirection: number, + panX: number, + panY: number, + scale: number + ) { + const baseScaleAmount = 0.02; // You can adjust this base scale amount + + // Make scaleAmount proportional to the current scale + const scaleAmount = baseScaleAmount * scale; + // const scaleAmount = 0.02; + + // Calculate new scale + const newScale = scale + scaleDirection * scaleAmount; + + // Calculate new pan values to keep the zoom point in the same position on the screen + const newPanX = wheelClientX + ((panX - wheelClientX) * newScale) / scale; + const newPanY = wheelClientY + ((panY - wheelClientY) * newScale) / scale; + + return { scale: newScale, panX: newPanX, panY: newPanY }; + } + + private updateZoomContainer() { + this.zoomContainer.nativeElement.style.transform = `translate(${this.flow.panX}px, ${this.flow.panY}px) scale(${this.flow.scale})`; + } + + arrangeChildren() { + // this.flow.connections = new Connections(this.list); + const arrangements = new Arrangements( + this.list, + this.flow.direction, + this.flow.horizontalPadding, + this.flow.verticalPadding, + this.flow.groupPadding + ); + const newList = arrangements.autoArrange(); + // console.log('new list', Object.fromEntries(newList)); + this.flow.update([...newList.values()]); + // this.flow.items.clear(); + // newList.forEach((value, key) => { + // this.flow.items.set(key, value); + // }); + this.flow.layoutUpdated.next(); + } + + get list() { + return this.children.toArray().map((x) => { + // calculate the width and height with scale + const elRect = x.el.nativeElement.getBoundingClientRect(); + const width = elRect.width / this.flow.scale; + const height = elRect.height / this.flow.scale; + const newElRect = { ...elRect, width, height }; + return { + position: x.position, + elRect: newElRect, + dots: x.dots.map((y) => y.nativeElement.getBoundingClientRect()), + } as ChildInfo; + }); + } + + createArrows() { + if (!this.g) { + return; + } + // Clear existing arrows + this.flow.arrows = []; + const gElement: SVGGElement = this.g.nativeElement; + + // Remove existing paths + while (gElement.firstChild) { + gElement.removeChild(gElement.firstChild); + } + + // Calculate new arrows + this.list.forEach((item) => { + item.position.deps.forEach((depId) => { + const dep = this.list.find((dep) => dep.position.id === depId); + if (dep) { + const arrow = { + d: `M${item.position.x},${item.position.y} L${dep.position.x},${dep.position.y}`, + deps: [item.position.id, dep.position.id], + startDot: 0, + endDot: 0, + id: `arrow${item.position.id}-to-${dep.position.id}`, + }; + + // Create path element and set attributes + const pathElement = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path' + ); + pathElement.setAttribute('d', arrow.d); + pathElement.setAttribute('id', arrow.id); + pathElement.setAttribute('stroke', 'blue'); + pathElement.setAttribute('stroke-width', '2'); + pathElement.setAttribute('fill', 'none'); + pathElement.setAttribute('marker-end', 'url(#arrowhead)'); + + // Append path to element + gElement.appendChild(pathElement); + + this.flow.arrows.push(arrow); + } + }); + }); + this.updateArrows(); + } + + positionChange(position: FlowOptions) { + // Find the item in the list + const item = this.list.find((item) => item.position.id === position.id); + + // Update item position + if (!item) return; + item.position.x = position.x; + item.position.y = position.y; + + // Update arrows + this.updateArrows(); + } + + updateArrows(e?: FlowOptions) { + const containerRect = this.el.nativeElement.getBoundingClientRect(); + const gElement: SVGGElement = this.g.nativeElement; + // Clear existing arrows + // const childObj = this.children.toArray().reduce((acc, curr) => { + // acc[curr.position.id] = curr; + // return acc; + // }, {} as Record); + const childObj = this.getChildInfo(); + + // Handle reverse dependencies + // this.closestDots.clear(); + // this.reverseDepsMap.clear(); + this.flow.connections = new Connections(this.list); + + // Calculate new arrows + this.flow.arrows.forEach((arrow) => { + const [from, to] = arrow.deps; + const fromItem = childObj[from]; + const toItem = childObj[to]; + if (fromItem && toItem) { + const [endDotIndex, startDotIndex] = this.getClosestDots(toItem, from); + // const toClosestDots = this.getClosestDots( + // toItem.position, + // from, + // childObj + // ); + + // Assuming 0 is a default value, replace it with actual logic + // const startDotIndex = fromClosestDots[0] || 0; + // const endDotIndex = toClosestDots[0] || 0; + // console.log('startDotIndex', startDotIndex, endDotIndex); + + let startDot: FlowOptions = undefined as any; + let endDot: FlowOptions = undefined as any; + startDot = this.getDotByIndex( + childObj, + fromItem.position, + startDotIndex, + this.flow.scale, + this.flow.panX, + this.flow.panY + ); + endDot = this.getDotByIndex( + childObj, + toItem.position, + endDotIndex, + this.flow.scale, + this.flow.panX, + this.flow.panY + ); + + // we need to reverse the path because the arrow head is at the end + arrow.d = new SvgHandler().blendCorners(endDot, startDot); + } + + // Update the SVG paths + this.flow.arrows.forEach((arrow) => { + const pathElement = gElement.querySelector( + `#${arrow.id}` + ) as SVGPathElement; + if (pathElement) { + pathElement.setAttribute('d', arrow.d); + } + }); + }); + + this.flow.connections.updateDotVisibility(this.oldChildObj()); + } + + private oldChildObj() { + return this.children.toArray().reduce((acc, curr) => { + acc[curr.position.id] = curr; + return acc; + }, {} as Record); + } + + private getChildInfo() { + return this.list.reduce((acc, curr) => { + acc[curr.position.id] = curr; + return acc; + }, {} as Record); + } + + private getDotByIndex( + childObj: Record, + item: FlowOptions, + dotIndex: number, + scale: number, + panX: number, + panY: number + ) { + const child = childObj[item.id]; + const childDots = child.dots as DOMRect[]; + // console.log('childDots', childDots, dotIndex, item.id); + + // Make sure the dot index is within bounds + if (dotIndex < 0 || dotIndex >= childDots.length) { + throw new Error(`Invalid dot index: ${dotIndex}`); + } + + const rect = childDots[dotIndex]; + const { left, top } = this.flow.zRect; + // const rect = dotEl.nativeElement.getBoundingClientRect(); + const x = (rect.x + rect.width / 2 - panX - left) / scale; + const y = (rect.y + rect.height / 2 - panY - top) / scale; + + return { ...item, x, y, dotIndex }; + } + + public getClosestDots(item: ChildInfo, dep?: string): number[] { + return this.flow.connections.getClosestDotsSimplified( + item, + dep as string + // newObj + ); + } + + ngOnDestroy(): void { + this.el.nativeElement.removeEventListener('wheel', this.zoomHandle); + } +} diff --git a/src/app/flow.service.spec.ts b/src/app/flow/flow.service.spec.ts similarity index 100% rename from src/app/flow.service.spec.ts rename to src/app/flow/flow.service.spec.ts diff --git a/src/app/flow/flow.service.ts b/src/app/flow/flow.service.ts new file mode 100644 index 0000000..0a5d9ab --- /dev/null +++ b/src/app/flow/flow.service.ts @@ -0,0 +1,82 @@ +import { Injectable, NgZone } from '@angular/core'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { Connections } from './connections'; +import { FlowOptions } from './flow-interface'; + +@Injectable() +export class FlowService { + readonly items = new Map(); + arrowsChange = new Subject(); + deps = new Map(); + isDraggingZoomContainer: boolean; + isChildDragging: boolean; + enableChildDragging = new BehaviorSubject(true); + enableZooming = new BehaviorSubject(true); + direction: 'horizontal' | 'vertical' = 'horizontal'; + horizontalPadding = 100; + verticalPadding = 20; + groupPadding = 20; + scale = 1; + panX = 0; + panY = 0; + gridSize = 1; + arrows: Arrow[] = []; + zoomContainer: HTMLElement; + connections: Connections; + layoutUpdated = new Subject(); + onMouse = new Subject(); + + constructor(private ngZone: NgZone) { + this.ngZone.runOutsideAngular(() => { + // mouse move event + document.addEventListener('mousemove', this.onMouseMove); + }); + } + + private onMouseMove = (event: MouseEvent) => { + this.onMouse.next(event); + }; + + update(children: FlowOptions[]) { + console.log('update', children); + this.items.clear(); + children.forEach((child) => { + this.items.set(child.id, child); + child.deps.forEach((dep) => { + let d = this.deps.get(dep); + if (!d) { + d = []; + } + d.push(child.id); + this.deps.set(dep, d); + }); + }); + } + + // delete(option: FlowOptions) { + // this.items.delete(option.id); + // this.deps.delete(option.id); + // this.deps.forEach((v, k) => { + // const index = v.indexOf(option.id); + // if (index > -1) { + // v.splice(index, 1); + // } + // }); + // } + + get list() { + return Array.from(this.items.values()); + } + + get zRect() { + return this.zoomContainer.getBoundingClientRect(); + } +} + +interface Arrow { + d: any; + deps: string[]; + id: string; + startDot: number; // Index of the starting dot + endDot: number; // Index of the ending dot +} diff --git a/src/app/flow/svg.spec.ts b/src/app/flow/svg.spec.ts new file mode 100644 index 0000000..0885cb9 --- /dev/null +++ b/src/app/flow/svg.spec.ts @@ -0,0 +1,11 @@ +import { SvgHandler } from './svg'; + +describe('SvgHandler', () => { + it('should calc the path', () => { + const val = new SvgHandler().bezierPath( + { x: 0, y: 0, id: '1', deps: [] }, + { x: 10, y: 10, id: '2', deps: [] } + ); + expect(val).toEqual('M0 0 C5 1.1785113019775793 5 8.82148869802242 10 10'); + }); +}); diff --git a/src/app/flow/svg.ts b/src/app/flow/svg.ts new file mode 100644 index 0000000..0163433 --- /dev/null +++ b/src/app/flow/svg.ts @@ -0,0 +1,65 @@ +import { FlowOptions } from './flow-interface'; + +export class SvgHandler { + arrowSize = 20; + bezierPath(start: FlowOptions, end: FlowOptions) { + let { x: startX, y: startY } = start; + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const offset = dist / 12; // Adjust this value to change the "tightness" of the curve + + // Check if start and end points are on the same X-axis (within +/- 5 range) + if (Math.abs(dy) <= 5) { + return `M${start.x} ${start.y} L${end.x} ${end.y}`; + } else { + // const startX = start.x; + // const startY = start.y; + const endX = end.x; + const endY = end.y; + const cp1x = start.x + dx / 2; + const cp2x = end.x - dx / 2; + + // Adjust control points based on the relative positions of the start and end nodes + const cp1y = end.y > start.y ? startY + offset : startY - offset; + const cp2y = end.y > start.y ? endY - offset : endY + offset; + + return `M${startX} ${startY} C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${endX} ${endY}`; + } + } + + // get the svg path similar to flow chart path + // -- + // | + // -- + // like above, no curves + flowPath(start: FlowOptions, end: FlowOptions): string { + // If the start and end are aligned vertically: + if (Math.abs(start.x - end.x) <= 5) { + return `M${start.x} ${start.y} L${end.x} ${end.y}`; + } + // If the start and end are aligned horizontally: + if (Math.abs(start.y - end.y) <= 5) { + return `M${start.x} ${start.y} L${end.x} ${end.y}`; + } + + // Determine the midpoint of the x coordinates + const midX = (start.x + end.x) / 2; + + // Create the path + return `M${start.x} ${start.y} L${midX} ${start.y} L${midX} ${end.y} L${end.x} ${end.y}`; + } + + blendCorners(start: FlowOptions, end: FlowOptions): string { + // include the arrow size + let { x: startX, y: startY } = start; + let { x: endX, y: endY } = end; + endX -= this.arrowSize; + // Define two control points for the cubic Bezier curve + const cp1 = { x: startX + (endX - startX) / 3, y: startY }; + const cp2 = { x: endX - (endX - startX) / 3, y: endY }; + + // Create the path using the cubic Bezier curve + return `M${startX} ${startY} C${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${endX} ${endY}`; + } +}