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: `
+
+
+
+
`,
+ 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}`;
+ }
+}