From 9ec802b31325bf42e58c24c7c7a59edaa5afc613 Mon Sep 17 00:00:00 2001 From: Sheik Althaf Date: Tue, 30 Jan 2024 14:06:25 +0530 Subject: [PATCH] refactor: add new auto arrangement method and improved demo changes (#5) --- projects/flow/README.md | 2 +- projects/flow/package.json | 2 +- projects/flow/src/lib/arrangements.spec.ts | 29 +- projects/flow/src/lib/arrangements.ts | 117 +++++++- projects/flow/src/lib/flow-child.component.ts | 5 +- projects/flow/src/lib/flow-interface.ts | 20 ++ projects/flow/src/lib/flow.component.ts | 141 ++------- projects/flow/src/lib/flow.service.ts | 12 +- projects/flow/src/lib/svg.spec.ts | 10 +- projects/flow/src/lib/svg.ts | 274 +++++++++--------- projects/flow/src/public-api.ts | 8 +- src/app/app.component.ts | 19 +- src/app/app.routes.ts | 9 +- .../demo-one.component.ts} | 111 +------ src/app/demo/demo-two.component.ts | 155 ++++++++++ src/app/demo/demo.service.ts | 31 +- src/app/demo/toolbar.component.ts | 33 ++- 17 files changed, 590 insertions(+), 388 deletions(-) rename src/app/{flow-demo.component.ts => demo/demo-one.component.ts} (76%) create mode 100644 src/app/demo/demo-two.component.ts diff --git a/projects/flow/README.md b/projects/flow/README.md index 998395e..ed83b04 100644 --- a/projects/flow/README.md +++ b/projects/flow/README.md @@ -1,7 +1,7 @@ # Angular Flow Angular Flow is a component that allows you to create a flow diagram using Angular. -Live Demo [link](https://sheikalthaf.github.io/flow/) +Live Demo [link](https://uiuniversal.github.io/flow/) Stackblitz Demo [link](https://stackblitz.com/edit/ngu-flow) diff --git a/projects/flow/package.json b/projects/flow/package.json index 4f4e329..7eb5bb6 100644 --- a/projects/flow/package.json +++ b/projects/flow/package.json @@ -1,6 +1,6 @@ { "name": "@ngu/flow", - "version": "0.0.3", + "version": "0.0.4", "peerDependencies": { "@angular/common": "^14.0.0", "@angular/core": "^14.0.0" diff --git a/projects/flow/src/lib/arrangements.spec.ts b/projects/flow/src/lib/arrangements.spec.ts index 22a3a0f..05f2d93 100644 --- a/projects/flow/src/lib/arrangements.spec.ts +++ b/projects/flow/src/lib/arrangements.spec.ts @@ -1,4 +1,4 @@ -import { Arrangements } from './arrangements'; +import { Arrangements, Arrangements2 } from './arrangements'; import { ChildInfo } from './flow-interface'; export const FLOW_LIST = [ @@ -38,3 +38,30 @@ describe('Arrangements', () => { expect(actual).toEqual(expected); }); }); + +describe('Arrangements2', () => { + let arrangements: Arrangements2; + + it('should be created', () => { + const childObj: ChildInfo[] = FLOW_LIST.map((x) => ({ + position: x, + elRect: { width: 200, height: 200 } as any, + })); + + arrangements = new Arrangements2(childObj); + arrangements.verticalPadding = 20; + arrangements.groupPadding = 100; + const expected = { + '1': { x: 330, y: 0, id: '1', deps: [] }, + '2': { x: 110, y: 300, id: '2', deps: ['1'] }, + '3': { x: 0, y: 600, id: '3', deps: ['2'] }, + '4': { x: 220, y: 600, id: '4', deps: ['2'] }, + '5': { x: 550, y: 300, id: '5', deps: ['1'] }, + '6': { x: 440, y: 600, id: '6', deps: ['5'] }, + '7': { x: 660, y: 600, id: '7', deps: ['5'] }, + '8': { x: 660, y: 900, id: '8', deps: ['6', '7'] }, + }; + const actual = Object.fromEntries(arrangements.autoArrange()); + expect(actual).toEqual(expected); + }); +}); diff --git a/projects/flow/src/lib/arrangements.ts b/projects/flow/src/lib/arrangements.ts index 542d0e2..20ac455 100644 --- a/projects/flow/src/lib/arrangements.ts +++ b/projects/flow/src/lib/arrangements.ts @@ -1,4 +1,4 @@ -import { FlowOptions, ChildInfo } from './flow-interface'; +import { FlowOptions, ChildInfo, FlowDirection } from './flow-interface'; export class Arrangements { constructor( @@ -21,12 +21,12 @@ export class Arrangements { for (const baseNode of baseNodes) { if (this.direction === 'horizontal') { - this.positionDependents(baseNode, currentX, 0, newItems); - currentX += baseNode.elRect.width + this.horizontalPadding; + this.positionDependents(baseNode, 0, currentY, newItems); + currentY += baseNode.elRect.height + this.verticalPadding; } else { // Vertical arrangement - this.positionDependents(baseNode, 0, currentY, newItems); - currentY += baseNode.elRect.height + this.horizontalPadding; + this.positionDependents(baseNode, 0, currentX, newItems); + currentX += baseNode.elRect.width + this.verticalPadding; } } @@ -116,3 +116,110 @@ export class Arrangements { return { consumedSpace, dep: dependents.length > 0 }; } } + +const ROOT_DATA = new Map(); +const ROOT_DEPS = new Map(); +const HORIZONTAL_PADDING = 100; +const VERTICAL_PADDING = 20; + +export class Arrangements2 { + root: string[] = []; + + constructor( + private list: ChildInfo[], + private direction: FlowDirection = 'vertical', + public horizontalPadding = 100, + public verticalPadding = 20, + public groupPadding = 20 + ) { + ROOT_DATA.clear(); + ROOT_DEPS.clear(); + this.list.forEach((item) => { + ROOT_DATA.set( + item.position.id, + new ArrangeNode(item.position, item.elRect) + ); + item.position.deps.forEach((dep) => { + let d = ROOT_DEPS.get(dep) || []; + d.push(item.position.id); + ROOT_DEPS.set(dep, d); + }); + + if (item.position.deps.length === 0) { + this.root.push(item.position.id); + } + }); + } + + public autoArrange(): Map { + this.root.forEach((id) => { + const node = ROOT_DATA.get(id)!; + node.arrange(0, 0, this.direction); + }); + + const newItems = new Map(); + + for (const item of this.list) { + newItems.set(item.position.id, item.position); + } + return newItems; + } +} + +interface Coordinates { + x: number; + y: number; +} + +export class ArrangeNode { + constructor(public position: FlowOptions, public elRect: DOMRect) {} + + get deps() { + return ROOT_DEPS.get(this.position.id) || []; + } + + // we need to recursively call this method to get all the dependents of the node + // and then we need to position them + arrange(x = 0, y = 0, direction: FlowDirection): Coordinates { + const dependents = ROOT_DEPS.get(this.position.id) || []; + let startX = x; + let startY = y; + let len = dependents.length; + + if (len) { + if (direction === 'horizontal') { + startX += this.elRect.width + HORIZONTAL_PADDING; + } else { + startY += this.elRect.height + HORIZONTAL_PADDING; + } + let first, last: Coordinates; + for (let i = 0; i < len; i++) { + const dep = dependents[i]; + const dependent = ROOT_DATA.get(dep)!; + const { x, y } = dependent.arrange(startX, startY, direction); + // capture the first and last dependent + if (i === 0) first = dependent.position; + if (i === len - 1) last = dependent.position; + + if (direction === 'horizontal') { + startY = y + VERTICAL_PADDING; + } else { + startX = x + VERTICAL_PADDING; + } + } + if (direction === 'horizontal') { + startY -= VERTICAL_PADDING + this.elRect.height; + y = first!.y + (last!.y - first!.y) / 2; + } else { + startX -= VERTICAL_PADDING + this.elRect.width; + x = first!.x + (last!.x - first!.x) / 2; + } + } + this.position.x = x; + this.position.y = y; + + return direction === 'horizontal' + ? { x: startX, y: startY + this.elRect.height } + : { x: startX + this.elRect.width, y: startY }; + } +} diff --git a/projects/flow/src/lib/flow-child.component.ts b/projects/flow/src/lib/flow-child.component.ts index 576ca36..2ca6455 100644 --- a/projects/flow/src/lib/flow-child.component.ts +++ b/projects/flow/src/lib/flow-child.component.ts @@ -12,7 +12,6 @@ import { SimpleChanges, ChangeDetectionStrategy, } 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'; @@ -70,6 +69,7 @@ export class FlowChildComponent implements OnInit, OnChanges, OnDestroy { private positionChange = new Subject(); private mouseMoveSubscription: Subscription; + private layoutSubscribe: Subscription; constructor( public el: ElementRef, @@ -89,7 +89,7 @@ export class FlowChildComponent implements OnInit, OnChanges, OnDestroy { }); }); - this.flow.layoutUpdated.pipe(takeUntilDestroyed()).subscribe((x) => { + this.layoutSubscribe = this.flow.layoutUpdated.subscribe((x) => { this.position = this.flow.items.get(this.position.id) as FlowOptions; this.positionChange.next(this.position); }); @@ -171,6 +171,7 @@ export class FlowChildComponent implements OnInit, OnChanges, OnDestroy { ngOnDestroy() { this.disableDragging(); + this.layoutSubscribe.unsubscribe(); // remove the FlowOptions from the flow service // this.flow.delete(this.position); // console.log('ngOnDestroy', this.position.id); diff --git a/projects/flow/src/lib/flow-interface.ts b/projects/flow/src/lib/flow-interface.ts index ff2ef18..5b697fe 100644 --- a/projects/flow/src/lib/flow-interface.ts +++ b/projects/flow/src/lib/flow-interface.ts @@ -12,7 +12,27 @@ export interface FlowOptions { deps: string[]; } +export interface DotOptions extends FlowOptions { + /** + * The index of the dot + * top = 0 + * right = 1 + * bottom = 2 + * left = 3 + */ + dotIndex: number; +} + export class FlowConfig { Arrows = true; ArrowSize = 20; } + +export type FlowDirection = 'horizontal' | 'vertical'; + +export type ArrowPathFn = ( + start: DotOptions, + end: DotOptions, + arrowSize: number, + strokeWidth: number +) => string; diff --git a/projects/flow/src/lib/flow.component.ts b/projects/flow/src/lib/flow.component.ts index b15377e..2e37a3a 100644 --- a/projects/flow/src/lib/flow.component.ts +++ b/projects/flow/src/lib/flow.component.ts @@ -12,12 +12,18 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { startWith } from 'rxjs'; -import { Arrangements } from './arrangements'; +import { Arrangements2 as Arrangements } from './arrangements'; import { Connections } from './connections'; import { FlowChildComponent } from './flow-child.component'; import { FlowService } from './flow.service'; -import { FlowOptions, ChildInfo } from './flow-interface'; -import { SvgHandler } from './svg'; +import { + FlowOptions, + ChildInfo, + FlowDirection, + DotOptions, + ArrowPathFn, +} from './flow-interface'; +import { blendCorners, flowPath, bezierPath, blendCorners1 } from './svg'; import { FitToWindow } from './fit-to-window'; const BASE_SCALE_AMOUNT = 0.05; @@ -160,14 +166,12 @@ export class FlowComponent 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(); @@ -183,10 +187,20 @@ export class FlowComponent this.flow.enableZooming.next(enable); } + updateDirection(direction: FlowDirection) { + this.flow.direction = direction; + this.arrangeChildren(); + this.createArrows(); + } + + updateArrowFn(fn: ArrowPathFn) { + this.flow.arrowFn = fn; + this.createArrows(); + } + 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; }; @@ -258,11 +272,8 @@ export class FlowComponent ) { // Make scaleAmount proportional to the current scale const scaleAmount = BASE_SCALE_AMOUNT * 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; @@ -285,73 +296,11 @@ export class FlowComponent this.updateZoomContainer(); } - // fitToWindow() { - // // The amount of padding to leave around the edges of the container and it should be scale independent - // const containerPadding = 30 / this.flow.scale; - // // Step 1: Get the positions of all nodes and dimensions of the container - // const positions = this.list.map((child) => { - // const scaledX = child.elRect.x / this.flow.scale - this.flow.panX; - // const scaledY = child.elRect.y / this.flow.scale - this.flow.panY; - // const scaledWidth = child.elRect.width; - // const scaledHeight = child.elRect.height; - // return { - // x: scaledX, - // y: scaledY, - // width: scaledWidth, - // height: scaledHeight, - // }; - // }); - // const containerRect = - // this.zoomContainer.nativeElement.getBoundingClientRect(); - - // // Step 2: Calculate the boundaries (min and max coordinates) of the nodes - // const minX = Math.min(...positions.map((p) => p.x)); - // const maxX = Math.max(...positions.map((p) => p.x + p.width)); - // const minY = Math.min(...positions.map((p) => p.y)); - // const maxY = Math.max(...positions.map((p) => p.y + p.height)); - - // // Step 3: Determine the scaling factor to fit nodes within the container - // const adjMaxX = maxX - minX + containerPadding; - // const adjMaxY = maxY - minY + containerPadding; - - // // find the actual width and height of the container after scale - // const cRect = { - // x: containerRect.x / this.flow.scale - this.flow.panX, - // y: containerRect.y / this.flow.scale - this.flow.panY, - // width: containerRect.width / this.flow.scale, - // height: containerRect.height / this.flow.scale, - // }; - - // const scaleX = cRect.width / adjMaxX; - // const scaleY = cRect.height / adjMaxY; - // const newScale = Math.min(scaleX, scaleY); - - // // Step 4: Determine the panning values to center the content within the container - // // These are now calculated relative to the unscaled positions - // const panX = - // cRect.x + - // (cRect.width - (adjMaxX - containerPadding) * newScale) / 2 - - // minX * newScale; - // const panY = - // cRect.y + - // (cRect.height - (adjMaxY - containerPadding) * newScale) / 2 - - // minY * newScale; - - // // Apply the calculated scale and pan values - // this.flow.scale = newScale; - // this.flow.panX = panX; - // this.flow.panY = panY; - - // // Update the zoomContainer's transform property to apply the new scale and pan - // this.updateZoomContainer(); - // } - private updateZoomContainer() { this.zoomContainer.nativeElement.style.transform = `translate3d(${this.flow.panX}px, ${this.flow.panY}px, 0) scale(${this.flow.scale})`; } arrangeChildren() { - // this.flow.connections = new Connections(this.list); const arrangements = new Arrangements( this.list, this.flow.direction, @@ -360,12 +309,7 @@ export class FlowComponent 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(); } @@ -400,12 +344,10 @@ export class FlowComponent // 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) => { @@ -455,18 +397,9 @@ export class FlowComponent } 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, this.flow.direction); // Calculate new arrows @@ -476,16 +409,6 @@ export class FlowComponent 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); const startDot = this.getDotByIndex( childObj, @@ -505,10 +428,11 @@ export class FlowComponent ); // we need to reverse the path because the arrow head is at the end - arrow.d = new SvgHandler().blendCorners( + arrow.d = this.flow.arrowFn( endDot, startDot, - this.flow.config.ArrowSize + this.flow.config.ArrowSize, + 2 ); } @@ -550,8 +474,6 @@ export class FlowComponent ): DotOptions { 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}`); @@ -567,25 +489,10 @@ export class FlowComponent } public getClosestDots(item: ChildInfo, dep?: string): number[] { - return this.flow.connections.getClosestDotsSimplified( - item, - dep as string - // newObj - ); + return this.flow.connections.getClosestDotsSimplified(item, dep as string); } ngOnDestroy(): void { this.el.nativeElement.removeEventListener('wheel', this.zoomHandle); } } - -export interface DotOptions extends FlowOptions { - /** - * The index of the dot - * top = 0 - * right = 1 - * bottom = 2 - * left = 3 - */ - dotIndex: number; -} diff --git a/projects/flow/src/lib/flow.service.ts b/projects/flow/src/lib/flow.service.ts index 7e04984..699cf8f 100644 --- a/projects/flow/src/lib/flow.service.ts +++ b/projects/flow/src/lib/flow.service.ts @@ -1,7 +1,13 @@ import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Subject } from 'rxjs'; import { Connections } from './connections'; -import { FlowConfig, FlowOptions } from './flow-interface'; +import { + ArrowPathFn, + FlowConfig, + FlowDirection, + FlowOptions, +} from './flow-interface'; +import { blendCorners } from './svg'; @Injectable() export class FlowService { @@ -13,7 +19,7 @@ export class FlowService { isChildDragging: boolean; enableChildDragging = new BehaviorSubject(true); enableZooming = new BehaviorSubject(true); - direction: 'horizontal' | 'vertical' = 'horizontal'; + direction: FlowDirection = 'horizontal'; horizontalPadding = 100; verticalPadding = 20; groupPadding = 40; @@ -27,6 +33,8 @@ export class FlowService { layoutUpdated = new Subject(); onMouse = new Subject(); + arrowFn: ArrowPathFn = blendCorners; + constructor(private ngZone: NgZone) { this.ngZone.runOutsideAngular(() => { // mouse move event diff --git a/projects/flow/src/lib/svg.spec.ts b/projects/flow/src/lib/svg.spec.ts index 0885cb9..b9c8b8d 100644 --- a/projects/flow/src/lib/svg.spec.ts +++ b/projects/flow/src/lib/svg.spec.ts @@ -1,10 +1,12 @@ -import { SvgHandler } from './svg'; +import { bezierPath } 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: [] } + const val = bezierPath( + { x: 0, y: 0, id: '1', deps: [], dotIndex: 0 }, + { x: 10, y: 10, id: '2', deps: [], dotIndex: 0 }, + 10, + 2 ); expect(val).toEqual('M0 0 C5 1.1785113019775793 5 8.82148869802242 10 10'); }); diff --git a/projects/flow/src/lib/svg.ts b/projects/flow/src/lib/svg.ts index 6775440..1cff950 100644 --- a/projects/flow/src/lib/svg.ts +++ b/projects/flow/src/lib/svg.ts @@ -1,143 +1,143 @@ -import { FlowOptions } from './flow-interface'; -import { DotOptions } from './flow.component'; - -export class SvgHandler { - // 'controlPointDistance' is a new property to be defined. It determines how 'curvy' the path should be. - // Adjust this value to increase or decrease the curvature of the Bezier path. - controlPointDistance = 50; // Example value, adjust as needed - - 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}`; +import { ArrowPathFn } from './flow-interface'; + +export const bezierPath: ArrowPathFn = (start, end, arrowSize, strokeWidth) => { + 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 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}`; } - - blendCorners1( - start: FlowOptions, - end: FlowOptions, - arrowSize: number - ): string { - // include the arrow size - let { x: startX, y: startY } = start; - let { x: endX, y: endY } = end; - endX -= 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}`; +}; + +// get the svg path similar to flow chart path +// -- +// | +// -- +// like above, no curves +export const flowPath: ArrowPathFn = (start, end, arrowSize, strokeWidth) => { + // 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}`; } - - blendCorners(start: DotOptions, end: DotOptions, arrowSize: number): string { - let { x: startX, y: startY, dotIndex: startDotIndex } = start; - let { x: endX, y: endY, dotIndex: endDotIndex } = end; - - // Determine the direction from the dotIndex and adjust the start and end points - let startAdjustment = this.getDirectionAdjustment(startDotIndex, arrowSize); - let endAdjustment = this.getDirectionAdjustment(endDotIndex, arrowSize); - - startX += startAdjustment.x; - startY += startAdjustment.y; - endX += endAdjustment.x; - endY += endAdjustment.y; - - // Calculate control points based on the directionality - let cp1 = { - x: startX + startAdjustment.cpX, - y: startY + startAdjustment.cpY, - }; - let cp2 = { x: endX + endAdjustment.cpX, y: endY + endAdjustment.cpY }; - - // Create the path using the cubic Bezier curve - const path = `M${startX - startAdjustment.x} ${ - startY - startAdjustment.y - } C${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${endX} ${endY}`; - return path; + // 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}`; } - getDirectionAdjustment( - dotIndex: number, - arrowSize: number - ): { - x: number; - y: number; - cpX: number; - cpY: number; - } { - switch (dotIndex) { - case 0: // top - return { - x: 0, - y: -arrowSize, - cpX: 0, - cpY: -this.controlPointDistance, - }; - case 1: // right - return { - x: arrowSize, - y: 0, - cpX: this.controlPointDistance, - cpY: 0, - }; - case 2: // bottom - return { - x: 0, - y: arrowSize, - cpX: 0, - cpY: this.controlPointDistance, - }; - case 3: // left - return { - x: -arrowSize, - y: 0, - cpX: -this.controlPointDistance, - cpY: 0, - }; - default: - return { x: 0, y: 0, cpX: 0, cpY: 0 }; - } + // 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}`; +}; + +export const blendCorners1: ArrowPathFn = ( + start, + end, + arrowSize, + strokeWidth +) => { + // include the arrow size + let { x: startX, y: startY } = start; + let { x: endX, y: endY } = end; + endX -= 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}`; +}; + +export const blendCorners: ArrowPathFn = ( + start, + end, + arrowSize, + strokeWidth +) => { + let { x: startX, y: startY, dotIndex: startDotIndex } = start; + let { x: endX, y: endY, dotIndex: endDotIndex } = end; + + // Determine the direction from the dotIndex and adjust the start and end points + let startAdjustment = getDirectionAdjustment(startDotIndex, arrowSize); + let endAdjustment = getDirectionAdjustment(endDotIndex, arrowSize); + + startX += startAdjustment.x - strokeWidth / 2; + startY += startAdjustment.y; + endX += endAdjustment.x - strokeWidth / 2; + endY += endAdjustment.y; + + // Calculate control points based on the directionality + let cp1 = { + x: startX + startAdjustment.cpX, + y: startY + startAdjustment.cpY, + }; + let cp2 = { x: endX + endAdjustment.cpX, y: endY + endAdjustment.cpY }; + + // Create the path using the cubic Bezier curve + const path = `M${startX - startAdjustment.x} ${startY - startAdjustment.y} C${ + cp1.x + } ${cp1.y} ${cp2.x} ${cp2.y} ${endX} ${endY}`; + return path; +}; + +function getDirectionAdjustment( + dotIndex: number, + arrowSize: number +): { + x: number; + y: number; + cpX: number; + cpY: number; +} { + // 'controlPointDistance' is a new property to be defined. It determines how 'curvy' the path should be. + // Adjust this value to increase or decrease the curvature of the Bezier path. + let controlPointDistance = 50; + switch (dotIndex) { + case 0: // top + return { + x: 0, + y: -arrowSize, + cpX: 0, + cpY: -controlPointDistance, + }; + case 1: // right + return { + x: arrowSize, + y: 0, + cpX: controlPointDistance, + cpY: 0, + }; + case 2: // bottom + return { + x: 0, + y: arrowSize, + cpX: 0, + cpY: controlPointDistance, + }; + case 3: // left + return { + x: -arrowSize, + y: 0, + cpX: -controlPointDistance, + cpY: 0, + }; + default: + return { x: 0, y: 0, cpX: 0, cpY: 0 }; } } diff --git a/projects/flow/src/public-api.ts b/projects/flow/src/public-api.ts index 7ab20f0..7fbd8d6 100644 --- a/projects/flow/src/public-api.ts +++ b/projects/flow/src/public-api.ts @@ -5,4 +5,10 @@ export * from './lib/flow.service'; export * from './lib/flow.component'; export * from './lib/flow-child.component'; -export * from './lib/flow-interface'; +export { + ArrowPathFn, + FlowOptions, + FlowDirection, + FlowConfig, +} from './lib/flow-interface'; +export * from './lib/svg'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f704380..3ec52c8 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,15 +1,22 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterLink, RouterOutlet } from '@angular/router'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterOutlet, RouterLink], - template: ` + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` `, }) export class AppComponent { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 57d00c6..1db4c85 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,8 +1,11 @@ import { Routes } from '@angular/router'; -import { FlowDemoComponent } from './flow-demo.component'; +import { DemoOneComponent } from './demo/demo-one.component'; import { SvgComponent } from './svg.component'; +import { DemoTwoComponent } from './demo/demo-two.component'; export const routes: Routes = [ - { path: '', component: FlowDemoComponent }, - { path: 'svg', component: SvgComponent } + { path: 'one', component: DemoOneComponent }, + { path: 'two', component: DemoTwoComponent }, + { path: 'svg', component: SvgComponent }, + { path: '', redirectTo: '/one', pathMatch: 'full' }, ]; diff --git a/src/app/flow-demo.component.ts b/src/app/demo/demo-one.component.ts similarity index 76% rename from src/app/flow-demo.component.ts rename to src/app/demo/demo-one.component.ts index 422a440..1976fd1 100644 --- a/src/app/flow-demo.component.ts +++ b/src/app/demo/demo-one.component.ts @@ -6,12 +6,12 @@ import { inject, } from '@angular/core'; import { FlowComponent, FlowChildComponent, FlowOptions } from '@ngu/flow'; -import { EditorComponent } from './editor.component'; -import { ToolbarComponent } from './demo/toolbar.component'; -import { DemoService } from './demo/demo.service'; +import { EditorComponent } from '../editor.component'; +import { ToolbarComponent } from './toolbar.component'; +import { DemoService } from './demo.service'; @Component({ - selector: 'app-root', + selector: 'app-demo-one', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ @@ -36,7 +36,7 @@ import { DemoService } from './demo/demo.service'; > {{ item.id }} - + @@ -47,21 +47,9 @@ import { DemoService } from './demo/demo.service'; styles: [ ` .card { - // display: flex; - // align-items: center; - // justify-content: center; - // width: 250px; - // min-height: 60px; - // border: 1px solid blue; - // box-shadow: 0 0 5px 0 rgb(0 0 255 / 36%); box-shadow: 0 0 5px 0 rgb(142 142 142 / 37%); border-radius: 5px; } - app-flow { - --flow-dot-color: gray; - --flow-path-color: gray; - --dot-size: 1px; - } button { @apply p-1; @@ -69,7 +57,7 @@ import { DemoService } from './demo/demo.service'; `, ], }) -export class FlowDemoComponent implements AfterViewInit { +export class DemoOneComponent implements AfterViewInit { title = 'angular-flow'; list: FlowOptions[] = []; linkingFrom: number | null = null; // Store the index of the node that we start linking from @@ -78,99 +66,14 @@ export class FlowDemoComponent implements AfterViewInit { constructor() { this.list = structuredClone(FLOW_LIST); - this.list = [ - { - x: 40, - y: 40, - id: '1', - deps: [], - }, - { - x: 249.09375, - y: -39, - id: '2', - deps: ['1'], - }, - { - x: 476.09375, - y: 67, - id: '3', - deps: ['2'], - }, - { - x: 662.09375, - y: 12, - id: '4', - deps: ['2'], - }, - { - x: -58.90625, - y: 349, - id: '5', - deps: ['1'], - }, - { - x: 381.09375, - y: 416, - id: '6', - deps: ['5'], - }, - { - x: 164.09375, - y: 175, - id: '7', - deps: ['5'], - }, - { - x: 164.09375, - y: 175, - id: '8', - deps: ['5'], - }, - { - x: 688.09375, - y: 255, - id: '9', - deps: ['6', '7', '8'], - }, - ]; } ngAfterViewInit(): void { this.demoService.flow = this.flowComponent; } - addNode(item: FlowOptions) { - // 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, - id: newNodeId, - deps: [item.id], - }; - this.list.push(newNode); - } - 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 - 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; + this.list = structuredClone(this.demoService.deleteNodeI(id, this.list)); } startLinking(index: number) { diff --git a/src/app/demo/demo-two.component.ts b/src/app/demo/demo-two.component.ts new file mode 100644 index 0000000..96df1d2 --- /dev/null +++ b/src/app/demo/demo-two.component.ts @@ -0,0 +1,155 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ViewChild, + inject, +} from '@angular/core'; +import { FlowComponent, FlowChildComponent, FlowOptions } from '@ngu/flow'; +import { EditorComponent } from '../editor.component'; +import { ToolbarComponent } from './toolbar.component'; +import { DemoService } from './demo.service'; + +@Component({ + selector: 'app-demo-two', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FlowComponent, + FlowChildComponent, + EditorComponent, + ToolbarComponent, + ], + template: ` +
+ + + @for (item of list; track item.id; let i = $index) { +
+
+
+ {{ item.id }} +
+
+

+ Lorem ipsum dolor sit amet consectetur, adipisicing elit. In alias + porro ratione quis dolore laboriosam eveniet vel hic beatae eaque + iste, neque quos, odit, explicabo nulla corporis iusto asperiores + sequi! +

+
+ + +
+
+ } +
+
+ `, + styles: [ + ` + .card { + box-shadow: 0 0 5px 0 rgb(142 142 142 / 37%); + } + ngu-flow { + --flow-dot-color: gray; + --flow-path-color: gray; + --dot-size: 1px; + } + + button { + @apply p-1; + } + `, + ], +}) +export class DemoTwoComponent implements AfterViewInit { + title = 'angular-flow'; + list: FlowOptions[] = []; + linkingFrom: number | null = null; // Store the index of the node that we start linking from + @ViewChild(FlowComponent) flowComponent: FlowComponent; + demoService = inject(DemoService); + + constructor() { + this.list = [ + { 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: ['1'] }, + { x: 40, y: 40, id: '5', deps: ['3'] }, + { x: 40, y: 40, id: '6', deps: ['2'] }, + { x: 40, y: 40, id: '7', deps: ['4'] }, + { x: 40, y: 40, id: '9', deps: ['7'] }, + ]; + // generate a random list of nodes with random dependencies min 100 nodes + // this.list = [ + // { x: 40, y: 40, id: '1', deps: [] }, + // { x: 40, y: 40, id: '2', deps: ['1'] }, + // { x: 40, y: 40, id: '3', deps: ['1'] }, + // { x: 40, y: 40, id: '4', deps: ['2'] }, + // { x: 40, y: 40, id: '5', deps: ['2'] }, + // { x: 40, y: 40, id: '6', deps: ['3'] }, + // { x: 40, y: 40, id: '7', deps: ['3'] }, + // { x: 40, y: 40, id: '8', deps: ['4'] }, + // { x: 40, y: 40, id: '9', deps: ['4'] }, + // { x: 40, y: 40, id: '10', deps: ['8'] }, + // { x: 40, y: 40, id: '11', deps: ['8'] }, + // { x: 40, y: 40, id: '12', deps: ['8'] }, + // { x: 40, y: 40, id: '13', deps: ['9'] }, + // { x: 40, y: 40, id: '14', deps: ['9'] }, + // { x: 40, y: 40, id: '15', deps: ['9'] }, + // { x: 40, y: 40, id: '16', deps: ['9'] }, + // { x: 40, y: 40, id: '17', deps: ['10'] }, + // { x: 40, y: 40, id: '18', deps: ['10'] }, + // { x: 40, y: 40, id: '19', deps: ['10'] }, + // { x: 40, y: 40, id: '20', deps: ['11'] }, + // { x: 40, y: 40, id: '21', deps: ['11'] }, + // { x: 40, y: 40, id: '22', deps: ['11'] }, + // { x: 40, y: 40, id: '23', deps: ['12'] }, + // { x: 40, y: 40, id: '24', deps: ['12'] }, + // { x: 40, y: 40, id: '25', deps: ['12'] }, + // { x: 40, y: 40, id: '26', deps: ['12'] }, + // { x: 40, y: 40, id: '27', deps: ['12'] }, + // { x: 40, y: 40, id: '28', deps: ['12'] }, + // { x: 40, y: 40, id: '29', deps: ['12'] }, + // { x: 40, y: 40, id: '30', deps: ['12'] }, + // { x: 40, y: 40, id: '31', deps: ['12'] }, + // { x: 40, y: 40, id: '32', deps: ['12'] }, + // { x: 40, y: 40, id: '33', deps: ['12'] }, + // ]; + } + + ngAfterViewInit(): void { + this.demoService.flow = this.flowComponent; + } + + deleteNode(id: string) { + this.list = structuredClone(this.demoService.deleteNodeI(id, this.list)); + } + + startLinking(index: number) { + if (this.linkingFrom === null) { + this.linkingFrom = index; + } else { + // Complete the linking + if (this.linkingFrom !== index) { + const fromNode = this.list[this.linkingFrom]; + const toNode = this.list[index]; + fromNode.deps.push(toNode.id); + } + this.linkingFrom = null; + } + } +} diff --git a/src/app/demo/demo.service.ts b/src/app/demo/demo.service.ts index d34d21a..4e829d0 100644 --- a/src/app/demo/demo.service.ts +++ b/src/app/demo/demo.service.ts @@ -1,9 +1,38 @@ import { Injectable } from '@angular/core'; -import { FlowComponent } from '@ngu/flow'; +import { FlowComponent, FlowOptions } from '@ngu/flow'; @Injectable({ providedIn: 'root' }) export class DemoService { flow: FlowComponent; constructor() {} + + addNode(item: FlowOptions, list: FlowOptions[]) { + // find the highest id + const lastId = list.reduce((acc, cur) => Math.max(+cur.id, acc), 0); + const newNodeId = (lastId + 1).toString(); + const newNode: FlowOptions = { + x: 40 + list.length * 160, + y: 40, + id: newNodeId, + deps: [item.id], + }; + list.push(newNode); + } + + 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 + 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; + } } diff --git a/src/app/demo/toolbar.component.ts b/src/app/demo/toolbar.component.ts index 277aa10..708d7db 100644 --- a/src/app/demo/toolbar.component.ts +++ b/src/app/demo/toolbar.component.ts @@ -1,13 +1,20 @@ import { Component, OnInit, inject } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { DemoService } from './demo.service'; +import { + FlowDirection, + flowPath, + bezierPath, + blendCorners, + blendCorners1, +} from '@ngu/flow'; @Component({ selector: 'app-toolbar', standalone: true, imports: [ReactiveFormsModule], template: `
- +