diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 78ff481..2cee31c 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -2,8 +2,8 @@ display: flex; align-items: center; justify-content: center; - width: 200px; - height: 50px; + width: 180px; + height: 40px; box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3); border-radius: 5px; background-color: white; @@ -13,8 +13,8 @@ --scale: 2; left: 318px; top: 263px; - width: calc(10px + (10px * var(--scale))); - height: calc(10px + (10px * var(--scale))); + width: calc(5px + (5px * var(--scale))); + height: calc(5px + (5px * var(--scale))); position: fixed; background: blue; z-index: 124; @@ -22,5 +22,5 @@ } button { - @apply p-1 border; + @apply p-1; } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 40d8f11..0ada18c 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,752 +1,11 @@ -import { - Component, - AfterContentInit, - ContentChildren, - ElementRef, - Input, - QueryList, - OnInit, - Injectable, - NgZone, - ViewChildren, - ViewChild, - OnDestroy, - AfterViewInit, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { CommonModule, NgForOf } from '@angular/common'; +import { Component, ViewChild, inject } from '@angular/core'; +import { NgForOf } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { ContainerComponent } from './container.component'; -import { Subject, merge, startWith } from 'rxjs'; -import { ChildInfo, Connections } from './connections'; -import { Arrangements } from './arrangements'; - -export interface FlowOptions { - x: number; - y: number; - id: string; - deps: string[]; -} - -interface Arrow { - d: any; - deps: string[]; - id: string; - startDot: number; // Index of the starting dot - endDot: number; // Index of the ending dot -} - -@Injectable() -export class FlowService { - readonly items = new Map(); - arrowsChange = new Subject(); - deps = new Map(); - isDraggingZoomContainer: boolean; - isChildDragging: boolean; - 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[]) { - 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); - }); - }); - } - - get list() { - return Array.from(this.items.values()); - } - - get zRect() { - return this.zoomContainer.getBoundingClientRect(); - } -} - -@Component({ - standalone: true, - imports: [CommonModule], - selector: '[flowChild]', - template: ` -
-
-
-
`, - styles: [ - ` - .dot { - position: absolute; - width: 10px; - height: 10px; - background: red; - border-radius: 999px; - } - .dot-left { - top: calc(50% - 5px); - left: -5px; - } - .dot-right { - top: calc(50% - 5px); - right: -5px; - } - .dot-top { - left: 50%; - top: -5px; - } - .dot-bottom { - left: 50%; - bottom: -5px; - } - .invisible { - visibility: hidden; - } - `, - ], -}) -export class FlowChildComponent implements OnInit, OnDestroy { - private isDragging = false; - private offsetX = 0; - private offsetY = 0; - - @ViewChildren('dot') dots: QueryList>; - - @Input('flowChild') position: FlowOptions = { - x: 0, - y: 0, - id: '', - deps: [], - }; - - positionChange = new Subject(); - - 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.onMouse.pipe(takeUntilDestroyed()).subscribe(this.onMouseMove); - // mouse up event - this.el.nativeElement.addEventListener('mouseup', this.onMouseUp); - - // mouse down event - this.el.nativeElement.addEventListener('mousedown', this.onMouseDown); - }); - - this.flow.layoutUpdated.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; - // 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) => { - 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); - } - }; - - ngOnInit() { - 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.el.nativeElement.removeEventListener('mouseup', this.onMouseUp); - this.el.nativeElement.removeEventListener('mousedown', this.onMouseDown); - } -} - -@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.el.nativeElement.addEventListener('wheel', this.zoomHandle); - this.el.nativeElement.addEventListener( - 'mousedown', - this._startDraggingZoomContainer - ); - this.el.nativeElement.addEventListener( - 'mouseup', - this._stopDraggingZoomContainer - ); - this.el.nativeElement.addEventListener( - 'mousemove', - this._dragZoomContainer - ); - }); - - // this.flow.arrowsChange.subscribe((position) => { - // this.positionChange(position); - // const isAligned = this.checkAlignment(position); - // console.log(isAligned); - // this.hideGuideLines(); - // if (isAligned) { - // this.showGuideLines(position.x, position.y); - // } - // }); - } - - // checkAlignment(position: FlowOptions): boolean { - // const threshold = 10; // You can adjust this value - // let aligned = false; - // const x = position.x; - // const y = position.y; - - // for (let item of this.flow.list) { - // if (item.id !== position.id) { - // if ( - // Math.abs(item.x - x) <= threshold || - // Math.abs(item.y - y) <= threshold - // ) { - // aligned = true; - // } - // } - // } - - // return aligned; - // } - - // // New method to show guide lines - // showGuideLines(x: number, y: number) { - // const guideElement: SVGGElement = this.guideLines.nativeElement; - - // // Create horizontal guide - // const horizontalGuide = document.createElementNS( - // 'http://www.w3.org/2000/svg', - // 'line' - // ); - // horizontalGuide.setAttribute('x1', '0'); - // horizontalGuide.setAttribute('y1', y.toString()); - // horizontalGuide.setAttribute('x2', '100%'); // or specify an end value - // horizontalGuide.setAttribute('y2', y.toString()); - // horizontalGuide.setAttribute('stroke', 'green'); - // horizontalGuide.setAttribute('stroke-width', '2'); - // guideElement.appendChild(horizontalGuide); - - // // Create vertical guide - // const verticalGuide = document.createElementNS( - // 'http://www.w3.org/2000/svg', - // 'line' - // ); - // verticalGuide.setAttribute('x1', x.toString()); - // verticalGuide.setAttribute('y1', '0'); - // verticalGuide.setAttribute('x2', x.toString()); - // verticalGuide.setAttribute('y2', '100%'); // or specify an end value - // verticalGuide.setAttribute('stroke', 'green'); - // verticalGuide.setAttribute('stroke-width', '2'); - // guideElement.appendChild(verticalGuide); - // } - - // // New method to hide guide lines - // hideGuideLines() { - // const guideElement: SVGGElement = this.guideLines.nativeElement; - // while (guideElement.firstChild) { - // guideElement.removeChild(guideElement.firstChild); - // } - // } - - 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 scaleAmount = 0.01; - - // 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})`; - } - - ngAfterViewInit(): void { - this.createArrows(); - } - - ngAfterContentInit() { - this.children.changes - .pipe(startWith(this.children)) - .subscribe((children) => { - this.flow.update(this.children.map((x) => x.position)); - this.arrangeChildren(); - this.createArrows(); - }); - setTimeout(() => this.updateArrows()); // this required for angular to render the dot - } - - arrangeChildren() { - // this.flow.connections = new Connections(this.list); - const arrangements = new Arrangements(this.list); - const newList = arrangements.autoArrange(); - console.log('new list', Object.fromEntries(newList)); - 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 - ); - - arrow.d = this.calculatePath(startDot, endDot); - } - - // 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 - ); - } - - calculatePath(start: FlowOptions, end: FlowOptions) { - 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}`; - } - } - - ngOnDestroy(): void { - this.el.nativeElement.removeEventListener('wheel', this.zoomHandle); - } -} +import { FlowChildComponent } from './flow-child.component'; +import { FlowComponent } from './flow.component'; +import { FlowOptions } from './flow-interface'; +import { FlowService } from './flow.service'; @Component({ selector: 'app-root', @@ -760,16 +19,36 @@ export class FlowComponent ], template: ` -
+ + + +
- {{ item.id }} + + + {{ item.id }} + - +
@@ -780,6 +59,8 @@ export class FlowComponent export class AppComponent { title = 'angular-flow'; list: FlowOptions[] = []; + zooming = true; + childDragging = true; linkingFrom: number | null = null; // Store the index of the node that we start linking from @ViewChild(FlowComponent) flowComponent: FlowComponent; @@ -846,7 +127,9 @@ export class AppComponent { } addNode(item: FlowOptions) { - const newNodeId = (this.list.length + 1).toString(); + // find the highest id + const lastId = this.list.reduce((acc, cur) => Math.max(+cur.id, acc), 0); + const newNodeId = (lastId + 1).toString(); const newNode: FlowOptions = { x: 40 + this.list.length * 160, y: 40, @@ -856,14 +139,24 @@ export class AppComponent { this.list.push(newNode); } - deleteNode(index: number) { - if (index >= 0 && index < this.list.length) { - const deletedNode = this.list.splice(index, 1)[0]; + deleteNode(id: string) { + this.list = structuredClone(this.deleteNodeI(id, this.list)); + } + + deleteNodeI(id: string, list: FlowOptions[]) { + if (id && list.length > 0) { + const index = list.findIndex((x) => x.id == id); + const deletedNode = list.splice(index, 1)[0]; // Remove dependencies of the deleted node - this.list.forEach((item) => { + return list.reduce((acc, item) => { + const initialLength = item.deps.length; item.deps = item.deps.filter((dep) => dep !== deletedNode.id); - }); + if (item.deps.length === initialLength || item.deps.length > 0) + acc.push(item); + return acc; + }, [] as FlowOptions[]); } + return list; } startLinking(index: number) { @@ -885,16 +178,87 @@ export class AppComponent { this.flowComponent.arrangeChildren(); this.flowComponent.updateArrows(); } + + childDraggingFn() { + this.childDragging = !this.childDragging; + this.flowComponent.updateChildDragging(this.childDragging); + } + + zoomingFn() { + this.zooming = !this.zooming; + this.flowComponent.updateZooming(this.zooming); + } } export const FLOW_LIST = [ - { x: 40, y: 40, id: '1', deps: [] }, - { x: 200, y: 40, id: '2', deps: ['1'] }, - { x: 360, y: 40, id: '3', deps: ['2'] }, - { x: 520, y: 40, id: '4', deps: ['2'] }, - { x: 40, y: 200, id: '5', deps: ['1'] }, - { x: 200, y: 200, id: '6', deps: ['5'] }, - { x: 360, y: 200, id: '7', deps: ['5'] }, + // { x: 0, y: 380, id: '1', deps: [] }, + // { x: 0, y: 380, id: '2', deps: ['1'] }, + // { x: 0, y: 380, id: '3', deps: ['1'] }, + + { x: 0, y: 0, id: '1', deps: [] }, // Base node + + // First set of direct children of the base node + { x: 300, y: -400, id: '2', deps: ['1'] }, + { x: 300, y: -200, id: '3', deps: ['1'] }, + { x: 300, y: 0, id: '4', deps: ['1'] }, + { x: 300, y: 200, id: '5', deps: ['1'] }, + { x: 300, y: 400, id: '6', deps: ['1'] }, + + // Children for the second node + { x: 600, y: -500, id: '7', deps: ['2'] }, + { x: 600, y: -400, id: '8', deps: ['2'] }, + + // Children for the third node + { x: 600, y: -200, id: '9', deps: ['3'] }, + { x: 600, y: -100, id: '10', deps: ['3'] }, + + // Children for the fourth node + { x: 600, y: 0, id: '11', deps: ['4'] }, + { x: 600, y: 100, id: '12', deps: ['4'] }, + + // Children for the fifth node + { x: 600, y: 200, id: '13', deps: ['5'] }, + { x: 600, y: 300, id: '14', deps: ['5'] }, + + // Children for the sixth node + { x: 600, y: 400, id: '15', deps: ['6'] }, + { x: 600, y: 500, id: '16', deps: ['6'] }, + + // Further branching for some of the children nodes + { x: 900, y: -500, id: '17', deps: ['7'] }, + { x: 900, y: -400, id: '18', deps: ['8'] }, + { x: 900, y: -200, id: '19', deps: ['9'] }, + { x: 900, y: -100, id: '20', deps: ['10'] }, + { x: 900, y: 0, id: '21', deps: ['11'] }, + { x: 900, y: 100, id: '22', deps: ['12'] }, + { x: 900, y: 200, id: '23', deps: ['13'] }, + { x: 900, y: 300, id: '24', deps: ['14'] }, + { x: 900, y: 400, id: '25', deps: ['15'] }, + { x: 900, y: 500, id: '26', deps: ['16'] }, + + // And so on... you can continue this pattern to get to 40 nodes. + + // { x: 0, y: 380, id: '1', deps: [] }, + // { x: 300, y: 110, id: '2', deps: ['1'] }, + // { x: 600, y: 0, id: '3', deps: ['2'] }, + // { x: 600, y: 220, id: '4', deps: ['2'] }, + // { x: 300, y: 650, id: '5', deps: ['1'] }, + // { x: 600, y: 540, id: '6', deps: ['5'] }, + // { x: 600, y: 760, id: '7', deps: ['5'] }, + // { x: 600, y: 760, id: '8', deps: ['6', '7'] }, + + // { x: 40, y: 40, id: '1', deps: [] }, + // { x: 200, y: 40, id: '2', deps: ['1'] }, + // { x: 360, y: 40, id: '3', deps: ['2'] }, + // { x: 520, y: 40, id: '4', deps: ['2'] }, + // { x: 40, y: 200, id: '5', deps: ['1'] }, + // { x: 200, y: 200, id: '6', deps: ['5'] }, + // { x: 360, y: 200, id: '11', deps: ['5'] }, + // { x: 360, y: 200, id: '7', deps: ['5'] }, + // { x: 360, y: 200, id: '8', deps: ['4'] }, + // { x: 360, y: 200, id: '9', deps: ['6'] }, + // { x: 360, y: 200, id: '10', deps: ['11'] }, + // { x: 360, y: 200, id: '9', deps: ['4'] }, // { x: 520, y: 200, id: '8', deps: ['6', '7'] }, // { x: 40, y: 40, id: '1', deps: [] }, @@ -914,4 +278,58 @@ export const FLOW_LIST = [ // { x: 360, y: 520, id: '13', deps: ['10'] }, // { x: 520, y: 520, id: '14', deps: ['11'] }, // { x: 360, y: 680, id: '15', deps: ['12', '13', '14'] }, + + // { x: 40, y: 40, id: '1', deps: [] }, + // { x: 200, y: 40, id: '2', deps: ['1'] }, + // { x: 360, y: 40, id: '3', deps: ['2'] }, + // { x: 520, y: 40, id: '4', deps: ['2'] }, + // { x: 40, y: 200, id: '5', deps: ['1'] }, + // { x: 200, y: 200, id: '6', deps: ['5'] }, + // { x: 360, y: 200, id: '7', deps: ['5'] }, + // { x: 360, y: 200, id: '8', deps: ['5'] }, + // { x: 360, y: 200, id: '10', deps: ['5'] }, + // { x: 360, y: 200, id: '9', deps: ['6', '7', '8', '10'] }, + + // new deps + + // { x: 600, y: 0, id: '3', deps: ['2'] }, + // { x: 900, y: 70, id: '8', deps: ['4'] }, + // { x: 600, y: 70, id: '4', deps: ['2'] }, + // { x: 300, y: 35, id: '2', deps: ['1'] }, + // { x: 1200, y: 190, id: '17', deps: ['9'] }, + // { x: 1200, y: 260, id: '18', deps: ['9'] }, + // { x: 1200, y: 330, id: '19', deps: ['9'] }, + // { x: 1200, y: 400, id: '20', deps: ['9'] }, + // { x: 900, y: 295, id: '9', deps: ['6'] }, + // { x: 600, y: 295, id: '6', deps: ['5'] }, + // { x: 1200, y: 470, id: '13', deps: ['10'] }, + // { x: 1200, y: 540, id: '14', deps: ['10'] }, + // { x: 1200, y: 610, id: '15', deps: ['10'] }, + // { x: 1200, y: 680, id: '16', deps: ['10'] }, + // { x: 900, y: 575, id: '10', deps: ['11'] }, + // { x: 600, y: 575, id: '11', deps: ['5'] }, + // { x: 900, y: 750, id: '12', deps: ['7'] }, + // { x: 600, y: 750, id: '7', deps: ['5'] }, + // { x: 300, y: 470, id: '5', deps: ['1'] }, + // { x: 0, y: 350, id: '1', deps: [] }, + + // multi stage + // { x: 40, y: 40, id: '1', deps: [] }, + // { x: 40, y: 40, id: '2', deps: ['1'] }, + // // { x: 40, y: 40, id: '3', deps: ['2'] }, + // // { x: 40, y: 40, id: '4', deps: ['3'] }, + // // { x: 40, y: 40, id: '5', deps: ['4'] }, + // { x: 40, y: 40, id: '6', deps: ['1'] }, + // // { x: 40, y: 40, id: '7', deps: ['6'] }, + // // { x: 40, y: 40, id: '8', deps: ['7'] }, + // // { x: 40, y: 40, id: '9', deps: ['8'] }, + // { x: 40, y: 40, id: '10', deps: ['1'] }, + // { x: 40, y: 40, id: '11', deps: ['1'] }, + // // { x: 40, y: 40, id: '12', deps: ['11'] }, + // // { x: 40, y: 40, id: '13', deps: ['12'] }, + // // { x: 40, y: 40, id: '14', deps: ['1'] }, + // // { x: 40, y: 40, id: '15', deps: ['14'] }, + // // { x: 40, y: 40, id: '16', deps: ['15'] }, + // // { x: 40, y: 40, id: '17', deps: ['16'] }, + // { x: 40, y: 40, id: '19', deps: ['2', '6', '10', '11'] }, ]; diff --git a/src/app/arrangements.spec.ts b/src/app/arrangements.spec.ts index 81617bd..a860703 100644 --- a/src/app/arrangements.spec.ts +++ b/src/app/arrangements.spec.ts @@ -1,6 +1,5 @@ -import { FlowOptions } from './app.component'; import { Arrangements } from './arrangements'; -import { ChildInfo, Connections } from './connections'; +import { ChildInfo } from './connections'; export const FLOW_LIST = [ { x: 40, y: 40, id: '1', deps: [] }, @@ -10,72 +9,32 @@ export const FLOW_LIST = [ { x: 40, y: 200, id: '5', deps: ['1'] }, { x: 200, y: 200, id: '6', deps: ['5'] }, { x: 360, y: 200, id: '7', deps: ['5'] }, - // { x: 520, y: 200, id: '8', deps: ['6', '7'] }, + { x: 600, y: 760, id: '8', deps: ['6', '7'] }, ]; describe('Arrangements', () => { let arrangements: Arrangements; it('should be created', () => { - const list: ChildInfo[] = FLOW_LIST.map((x) => ({ + const childObj: ChildInfo[] = FLOW_LIST.map((x) => ({ position: x, - elRect: { - x: x.x + 59.796875, - y: x.y + 75, - width: 200, - height: 200, - top: x.y + 75, - right: x.x + 209.796875, - bottom: x.y + 275, - left: x.x + 59.796875, - toJSON: () => {}, - }, + elRect: { width: 200, height: 200 } as any, })); - const connections = new Connections(list); - const childObj: Record = { - '1': { - position: { x: 40, y: 40, id: '1', deps: [] }, - elRect: { width: 200, height: 200 }, - }, - '2': { - position: { x: 200, y: 40, id: '2', deps: ['1'] }, - elRect: { width: 200, height: 200 }, - }, - '3': { - position: { x: 360, y: 40, id: '3', deps: ['2'] }, - elRect: { width: 200, height: 200 }, - }, - '4': { - position: { x: 520, y: 40, id: '4', deps: ['2'] }, - elRect: { width: 200, height: 200 }, - }, - '5': { - position: { x: 40, y: 200, id: '5', deps: ['1'] }, - elRect: { width: 200, height: 200 }, - }, - '6': { - position: { x: 200, y: 200, id: '6', deps: ['5'] }, - elRect: { width: 200, height: 200 }, - }, - '7': { - position: { x: 360, y: 200, id: '7', deps: ['5'] }, - elRect: { width: 200, height: 200 }, - }, - // '8': { - // position: { x: 520, y: 200, id: '8', deps: ['6', '7'] }, - // elRect: { width: 150, height: 200 }, - // }, - }; - arrangements = new Arrangements(connections, childObj as any); + + arrangements = new Arrangements(childObj); + arrangements.verticalPadding = 20; + arrangements.groupPadding = 100; const expected = { - '1': { x: 0, y: 450, id: '1', deps: [] }, - '2': { x: 300, y: 150, id: '2', deps: ['1'] }, + '1': { x: 0, y: 370, id: '1', deps: [] }, + '2': { x: 300, y: 110, id: '2', deps: ['1'] }, '3': { x: 600, y: 0, id: '3', deps: ['2'] }, - '4': { x: 600, y: 300, id: '4', deps: ['2'] }, - '5': { x: 300, y: 750, id: '5', deps: ['1'] }, - '6': { x: 600, y: 600, id: '6', deps: ['5'] }, - '7': { x: 600, y: 900, id: '7', deps: ['5'] }, + '4': { x: 600, y: 220, id: '4', deps: ['2'] }, + '5': { x: 300, y: 630, id: '5', deps: ['1'] }, + '6': { x: 600, y: 520, id: '6', deps: ['5'] }, + '7': { x: 600, y: 740, id: '7', deps: ['5'] }, + '8': { x: 900, y: 550, id: '8', deps: ['6', '7'] }, }; - expect(Object.fromEntries(arrangements.newList)).toEqual(expected); + const actual = Object.fromEntries(arrangements.autoArrange()); + expect(actual).toEqual(expected); }); }); diff --git a/src/app/arrangements.ts b/src/app/arrangements.ts index 6342b4b..626e4bd 100644 --- a/src/app/arrangements.ts +++ b/src/app/arrangements.ts @@ -1,13 +1,14 @@ -import { FlowOptions } from './app.component'; -import { ChildInfo, Connections } from './connections'; +import { FlowOptions } from './flow-interface'; +import { ChildInfo } from './connections'; export class Arrangements { - direction: 'horizontal' | 'vertical' = 'horizontal'; - horizontalPadding = 100; - verticalPadding = 20; - groupPadding = 100; - - constructor(private list: ChildInfo[]) {} + constructor( + private list: ChildInfo[], + private direction: 'horizontal' | 'vertical' = 'horizontal', + public horizontalPadding = 100, + public verticalPadding = 20, + public groupPadding = 20 + ) {} public autoArrange(): Map { const newItems = new Map(); @@ -24,7 +25,7 @@ export class Arrangements { for (const baseNode of baseNodes) { const consumedHeight = this.positionDependents( baseNode, - currentX - baseNode.elRect.width - this.horizontalPadding, + 0, 0, newItems ); @@ -34,8 +35,7 @@ export class Arrangements { // x: currentX, // y: centerY - baseNode.elRect.height / 2, // }); - currentX += - baseNode.elRect.width + this.horizontalPadding + this.groupPadding; + currentX += baseNode.elRect.width + this.horizontalPadding; } } else { // direction === 'vertical' @@ -72,44 +72,77 @@ export class Arrangements { baseX: number, baseY: number, newItems: Map, - isLast = false - ): { consumedHeight: number } { + config: { first: boolean; gp: number; maxDepLength: number } = { + first: true, + gp: -this.groupPadding * 2, + maxDepLength: 0, + } + ): { consumedHeight: number; dep: boolean } { const dependents = this.list.filter((child) => child.position.deps.includes(baseNode.position.id) ); - // Sort children by their original Y-coordinate to preserve order - dependents.sort((a, b) => a.position.y - b.position.y); - let startY = baseY; let newX = baseX + baseNode.elRect.width + this.horizontalPadding; const height = baseNode.elRect.height; + const childC: { first: boolean; gp: number; maxDepLength: number } = { + first: true, + gp: 0, + maxDepLength: 0, + }; for (let i = 0; i < dependents.length; i++) { const depLast = i === dependents.length - 1; + childC.first = i === 0; const dependent = dependents[i]; - const { consumedHeight } = this.positionDependents( + const { consumedHeight, dep } = this.positionDependents( dependent, newX, startY, newItems, - depLast + childC ); - startY = consumedHeight + (!depLast ? this.verticalPadding : 0); + + startY = 0; + + if (childC.maxDepLength > 1 && dep && !depLast) { + startY += this.groupPadding; + config.gp += this.groupPadding; + } + startY += consumedHeight + (!depLast ? this.verticalPadding : 0); } - const y = - baseY + (dependents.length ? (startY - baseY) / 2 - height / 2 : 0); + // baseY += childC.gp; + config.maxDepLength = Math.max(config.maxDepLength, childC.maxDepLength); + + let y = 0; + if (dependents.length > 1) { + // find the first and last dependent and there y position + const firstDep = dependents[0]; + const lastDep = dependents[dependents.length - 1]; + const firstDepY = newItems.get(firstDep.position.id)!.y; + const lastDepY = newItems.get(lastDep.position.id)!.y; + // find the center of the first and last dependent + y = (firstDepY + lastDepY) / 2; + } else { + y = baseY + (dependents.length ? (startY - baseY) / 2 - height / 2 : 0); + // If there are more than one dependency, We need to center the node based on the parents + if (baseNode.position.deps.length > 1) { + const len = baseNode.position.deps.length - 1; + const halfVerticalPadding = (this.verticalPadding * len) / 2; + y -= baseNode.elRect.height * len - halfVerticalPadding; + } + } newItems.set(baseNode.position.id, { ...baseNode.position, - x: newX, + x: baseX, y: y, }); - if (!isLast && dependents.length > 1) { - startY += this.groupPadding; - } - const consumedHeight = startY + (dependents.length ? 0 : height); - return { consumedHeight }; + // add groupPadding if there are more than one dependency + const groupPad = + dependents.length > 1 ? this.groupPadding - this.verticalPadding : 0; + const consumedHeight = startY + (dependents.length ? 0 : height) + groupPad; + return { consumedHeight, dep: dependents.length > 0 }; } } diff --git a/src/app/connections.spec.ts b/src/app/connections.spec.ts index 4a3a6be..6bbe288 100644 --- a/src/app/connections.spec.ts +++ b/src/app/connections.spec.ts @@ -1,4 +1,4 @@ -import { FlowOptions } from './app.component'; +import { FlowOptions } from './flow-interface'; import { ChildInfo, Connections } from './connections'; describe('Connections', () => { diff --git a/src/app/connections.ts b/src/app/connections.ts index 6565a4a..844bd23 100644 --- a/src/app/connections.ts +++ b/src/app/connections.ts @@ -1,4 +1,5 @@ -import { FlowChildComponent, FlowOptions } from './app.component'; +import { FlowOptions } from './flow-interface'; +import { FlowChildComponent } from './flow-child.component'; export class Connections { // key = id of the item @@ -135,6 +136,7 @@ export class Connections { dot.nativeElement.style.visibility = isClosestForAnyDep ? 'visible' : 'hidden'; + // dot.nativeElement.style.visibility = 'hidden'; }); }); } diff --git a/src/app/flow-child.component.spec.ts b/src/app/flow-child.component.spec.ts index 2379b93..33030f9 100644 --- a/src/app/flow-child.component.spec.ts +++ b/src/app/flow-child.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FlowChildComponent, FlowService } from './app.component'; +import { FlowChildComponent } from './flow-child.component'; +import { FlowService } from './flow.service'; describe('FlowChildComponent', () => { let component: FlowChildComponent; diff --git a/src/app/flow-child.component.ts b/src/app/flow-child.component.ts new file mode 100644 index 0000000..ade2dba --- /dev/null +++ b/src/app/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-interface.ts b/src/app/flow-interface.ts new file mode 100644 index 0000000..d716769 --- /dev/null +++ b/src/app/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.component.spec.ts index 82bd43e..bb79b38 100644 --- a/src/app/flow.component.spec.ts +++ b/src/app/flow.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FlowComponent, FlowService } from './app.component'; +import { FlowComponent } from './flow.component'; +import { FlowService } from './flow.service'; describe('FlowComponent', () => { let component: FlowComponent; @@ -59,9 +60,9 @@ describe('FlowComponent', () => { const val = component._setZoom(1, 1, 1, 10, 11, 1.1); const d = JSON.stringify(val); expect(val).toEqual({ - scale: 1.11, - panX: 10.081818181818182, - panY: 11.090909090909092, + scale: 1.122, + panX: 10.18, + panY: 11.2, }); }); @@ -71,7 +72,7 @@ describe('FlowComponent', () => { }); component.zoomHandle(ev); expect(component.zoomContainer.nativeElement.style.transform).toEqual( - 'translate(0px, 0px) scale(1.01)' + 'translate(0px, 0px) scale(1.02)' ); }); }); diff --git a/src/app/flow.component.ts b/src/app/flow.component.ts new file mode 100644 index 0000000..ac2bd33 --- /dev/null +++ b/src/app/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.service.spec.ts index efce903..fbf8951 100644 --- a/src/app/flow.service.spec.ts +++ b/src/app/flow.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { FlowOptions, FlowService } from './app.component'; +import { FlowService } from './flow.service'; +import { FlowOptions } from './flow-interface'; describe('FlowService', () => { let service: FlowService; diff --git a/src/app/flow.service.ts b/src/app/flow.service.ts new file mode 100644 index 0000000..0a5d9ab --- /dev/null +++ b/src/app/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/svg.spec.ts b/src/app/svg.spec.ts new file mode 100644 index 0000000..0885cb9 --- /dev/null +++ b/src/app/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/svg.ts b/src/app/svg.ts new file mode 100644 index 0000000..0163433 --- /dev/null +++ b/src/app/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}`; + } +} diff --git a/src/styles.scss b/src/styles.scss index 68d75bd..dd8e51b 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -6,3 +6,18 @@ body { margin: 0; } + +// @keyframes dash { +// to { +// stroke-dashoffset: 100; /* Adjust this value to control the speed of the animation */ +// } +// } + +// path { +// fill: none; +// stroke: blue; +// stroke-width: 2; +// stroke-dasharray: 5, 5; /* You can adjust these values to control the appearance of the dashes (dots) */ +// stroke-dashoffset: 5; +// animation: dash 4s linear reverse infinite; /* The 'infinite' value will make the animation repeat indefinitely */ +// }