From 66bc6f7f1ef403f73b4f556f3ff3877792f11ed9 Mon Sep 17 00:00:00 2001 From: Sheik Althaf Date: Fri, 23 Feb 2024 16:40:38 +0530 Subject: [PATCH] feat: add plugin support to ngu-flow (#8) --- package.json | 1 + projects/flow/src/lib/arrangements.spec.ts | 67 ----- projects/flow/src/lib/arrangements.ts | 225 ----------------- projects/flow/src/lib/connections.ts | 165 ------------ projects/flow/src/lib/flow-child.component.ts | 8 +- projects/flow/src/lib/flow-interface.ts | 5 - projects/flow/src/lib/flow.component.ts | 154 +++--------- projects/flow/src/lib/flow.service.ts | 26 +- .../flow/src/lib/plugins/arrangements.spec.ts | 47 ++++ projects/flow/src/lib/plugins/arrangements.ts | 135 ++++++++++ .../src/lib/{ => plugins}/connections.spec.ts | 22 +- projects/flow/src/lib/plugins/connections.ts | 236 ++++++++++++++++++ .../lib/{ => plugins}/fit-to-window.spec.ts | 26 +- .../src/lib/{ => plugins}/fit-to-window.ts | 107 +++++--- projects/flow/src/lib/plugins/plugin.ts | 14 ++ .../flow/src/lib/plugins/scroll-into-view.ts | 33 +++ projects/flow/src/public-api.ts | 11 +- src/app/demo/demo-one.component.ts | 52 +++- src/app/demo/demo-two.component.ts | 39 ++- src/app/demo/demo.service.ts | 13 +- src/app/demo/toolbar.component.ts | 15 +- 21 files changed, 705 insertions(+), 696 deletions(-) delete mode 100644 projects/flow/src/lib/arrangements.spec.ts delete mode 100644 projects/flow/src/lib/arrangements.ts delete mode 100644 projects/flow/src/lib/connections.ts create mode 100644 projects/flow/src/lib/plugins/arrangements.spec.ts create mode 100644 projects/flow/src/lib/plugins/arrangements.ts rename projects/flow/src/lib/{ => plugins}/connections.spec.ts (73%) create mode 100644 projects/flow/src/lib/plugins/connections.ts rename projects/flow/src/lib/{ => plugins}/fit-to-window.spec.ts (79%) rename projects/flow/src/lib/{ => plugins}/fit-to-window.ts (58%) create mode 100644 projects/flow/src/lib/plugins/plugin.ts create mode 100644 projects/flow/src/lib/plugins/scroll-into-view.ts diff --git a/package.json b/package.json index 298999b..7f1b1b2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "start": "ng serve --port 52666", "build": "ng build", "watch": "ng build --watch --configuration development", + "gh": "ng deploy --dir=dist/angular-flow/browser --base-href=/ngu-flow/", "test": "jest" }, "private": true, diff --git a/projects/flow/src/lib/arrangements.spec.ts b/projects/flow/src/lib/arrangements.spec.ts deleted file mode 100644 index 05f2d93..0000000 --- a/projects/flow/src/lib/arrangements.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Arrangements, Arrangements2 } from './arrangements'; -import { ChildInfo } from './flow-interface'; - -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: 600, y: 760, id: '8', deps: ['6', '7'] }, -]; - -describe('Arrangements', () => { - let arrangements: Arrangements; - - it('should be created', () => { - const childObj: ChildInfo[] = FLOW_LIST.map((x) => ({ - position: x, - elRect: { width: 200, height: 200 } as any, - })); - - arrangements = new Arrangements(childObj); - arrangements.verticalPadding = 20; - arrangements.groupPadding = 100; - const expected = { - '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: 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'] }, - }; - const actual = Object.fromEntries(arrangements.autoArrange()); - 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 deleted file mode 100644 index 20ac455..0000000 --- a/projects/flow/src/lib/arrangements.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { FlowOptions, ChildInfo, FlowDirection } from './flow-interface'; - -export class Arrangements { - 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(); - let currentX = 0; - let currentY = 0; - - // Handle both horizontal and vertical directions - const baseNodes = this.list.filter( - (node) => node.position.deps.length === 0 - ); - - for (const baseNode of baseNodes) { - if (this.direction === 'horizontal') { - this.positionDependents(baseNode, 0, currentY, newItems); - currentY += baseNode.elRect.height + this.verticalPadding; - } else { - // Vertical arrangement - this.positionDependents(baseNode, 0, currentX, newItems); - currentX += baseNode.elRect.width + this.verticalPadding; - } - } - - return newItems; - } - - private positionDependents( - baseNode: ChildInfo, - baseX: number, - baseY: number, - newItems: Map, - config: { first: boolean; gp: number; maxDepLength: number } = { - first: true, - gp: -this.groupPadding * 2, - maxDepLength: 0, - } - ): { consumedSpace: number; dep: boolean } { - const dependents = this.list.filter((child) => - child.position.deps.includes(baseNode.position.id) - ); - - const isV = this.direction === 'vertical'; - - let startY = baseY; - const { width: w, height: h } = baseNode.elRect; - let newX = baseX + (isV ? h : w) + this.horizontalPadding; - const height = isV ? w : h; - - 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 { consumedSpace, dep } = this.positionDependents( - dependent, - newX, - startY, - newItems, - childC - ); - - startY = 0; - - if (childC.maxDepLength > 1 && dep && !depLast) { - startY += this.groupPadding; - config.gp += this.groupPadding; - } - startY += consumedSpace + (!depLast ? this.verticalPadding : 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 firstDepId = dependents[0].position.id; - const lastDepId = dependents[dependents.length - 1].position.id; - const firstDep = newItems.get(firstDepId)!; - const lastDep = newItems.get(lastDepId)!; - // find the center of the first and last dependent - y = (isV ? firstDep.x + lastDep.x : firstDep.y + lastDep.y) / 2; - } else { - y = baseY + (dependents.length ? (startY - baseY) / 2 - height / 2 : 0); - - // TODO: This is not working as expected - // 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 / 2; - const halfVerticalPadding = (this.verticalPadding * len) / 2; - y -= baseNode.elRect.height * len - halfVerticalPadding; - } - } - newItems.set(baseNode.position.id, { - ...baseNode.position, - x: isV ? y : baseX, - y: isV ? baseX : y, - }); - // add groupPadding if there are more than one dependency - const groupPad = - dependents.length > 1 ? this.groupPadding - this.verticalPadding : 0; - const consumedSpace = startY + (dependents.length ? 0 : height) + groupPad; - 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/connections.ts b/projects/flow/src/lib/connections.ts deleted file mode 100644 index 52111ef..0000000 --- a/projects/flow/src/lib/connections.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ChildInfo, FlowOptions } from './flow-interface'; -import { FlowChildComponent } from './flow-child.component'; - -export class Connections { - // key = id of the item - // value = ids of the items that depend on it - reverseDepsMap = new Map(); - - // key = id of the item - // value = index of the closest dot - closestDots = new Map(); - - constructor( - private list: ChildInfo[], - private direction: 'horizontal' | 'vertical' = 'horizontal' - ) { - this.setReverseDepsMap(list.map((x) => x.position)); - // console.count('Connections'); - } - - public getClosestDotsSimplified( - item: ChildInfo, - dep: string - ): [number, number] { - const ids = [ - ...item.position.deps, - ...(this.reverseDepsMap.get(item.position.id) || []), - ]; - ids.forEach((x) => this.findClosestDot(x, item)); - // ids.forEach((x) => this.findClosestDot(x, item, childObj)); - - // Create unique keys for each dependency and retrieve the closest dots based on these keys - const closestDotIndices = ids.map((x) => { - const uniqueKey = `${item.position.id}-${x}`; - return this.closestDots.get(uniqueKey) as number; - }); - - // Remove duplicates - // const uniqueClosestDotIndices = Array.from(new Set(closestDotIndices)); - - return [ - this.closestDots.get(`${item.position.id}-${dep}`) as number, - this.closestDots.get(`${dep}-${item.position.id}`) as number, - ]; - // return dep - // ? [this.closestDots.get(`${item.id}-${dep}`) as number, this.closestDots.get(`${dep}-${item.id}`) as number] - // : closestDotIndices; - } - - private findClosestDot( - depId: string, - item: ChildInfo - // childObj: Record - ) { - const uniqueKey1 = `${item.position.id}-${depId}`; - const uniqueKey2 = `${depId}-${item.position.id}`; - - if (this.closestDots.has(uniqueKey1)) return; - - const dep = this.list.find((item) => item.position.id === depId); - if (dep) { - const [closestDotIndex1, closestDotIndex2] = - this._findClosestConnectionPoints(item, dep); - - this.closestDots.set(uniqueKey1, closestDotIndex1); - this.closestDots.set(uniqueKey2, closestDotIndex2); - } - } - - private computeDistance(dot1: DOMRect, dot2: DOMRect): number { - const dx = dot1.x - dot2.x; - const dy = dot1.y - dot2.y; - return Math.sqrt(dx * dx + dy * dy); - } - - public _findClosestConnectionPoints( - parent: ChildInfo, - child: ChildInfo - ): [number, number] { - // sides dot index order: [top, right, bottom, left] - const thresholdDistance = 10; // Example distance threshold. Adjust as needed. - let swapped = false; - const isV = this.direction === 'vertical'; - // correct the parent based on the deps - if (!child.position.deps.includes(parent.position.id)) { - const _t = child; - child = parent; - parent = _t; - swapped = true; - } - - const childDirection: 'right' | 'left' | 'bottom' | 'top' = (() => { - // consider width and height of the child - const { width, height } = child.elRect; - const { x, y } = child.position; - const { x: px, y: py } = parent.position; - - if (!isV) { - if (x + width < px) return 'left'; - if (x - width > px) return 'right'; - if (y + height < py) return 'top'; - if (y - height > py) return 'bottom'; - } else { - if (y + height < py) return 'top'; - if (y - height > py) return 'bottom'; - if (x + width < px) return 'left'; - if (x - width > px) return 'right'; - } - return 'right'; - })(); - - const parentIndex = (() => { - if (childDirection === 'right') return 1; - if (childDirection === 'left') return 3; - if (childDirection === 'bottom') return 2; - return 0; - })(); - const childIndex = (() => { - if (childDirection === 'right') return 3; - if (childDirection === 'left') return 1; - if (childDirection === 'bottom') return 0; - return 2; - })(); - - // console.log( - // `parentIndex ${parent.position.id}-${child.position.id}:`, - // `${parent.position.id}-${parentIndex}`, - // `${child.position.id}-${childIndex}`, - // [structuredClone(parent), structuredClone(child)] - // ); - - return swapped ? [childIndex, parentIndex] : [parentIndex, childIndex]; - } - - updateDotVisibility(childObj: Record) { - Object.keys(childObj).forEach((id) => { - const child = childObj[id]; - const position = child.position; - const dots = child.dots.toArray(); - - dots.forEach((dot, index) => { - // Check if the current dot is the closest for any dependency - const isClosestForAnyDep = Array.from(this.closestDots.keys()).some( - (key) => key.startsWith(id) && this.closestDots.get(key) === index - ); - - dot.nativeElement.style.visibility = isClosestForAnyDep - ? 'visible' - : 'hidden'; - // dot.nativeElement.style.visibility = 'hidden'; - }); - }); - } - - private setReverseDepsMap(list: FlowOptions[]) { - list.forEach((item) => { - item.deps.forEach((depId) => { - if (!this.reverseDepsMap.has(depId)) { - this.reverseDepsMap.set(depId, []); - } - this.reverseDepsMap.get(depId)!.push(item.id); - }); - }); - } -} diff --git a/projects/flow/src/lib/flow-child.component.ts b/projects/flow/src/lib/flow-child.component.ts index 2ca6455..d681c75 100644 --- a/projects/flow/src/lib/flow-child.component.ts +++ b/projects/flow/src/lib/flow-child.component.ts @@ -95,9 +95,7 @@ export class FlowChildComponent implements OnInit, OnChanges, OnDestroy { }); 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); + this.updatePosition(this.position.x, this.position.y); }); } @@ -135,8 +133,8 @@ export class FlowChildComponent implements OnInit, OnChanges, OnDestroy { (this.flow.gridSize * this.flow.scale) ) * this.flow.gridSize; - this.position.x = x - zRect.left; - this.position.y = y - zRect.top; + this.position.x = x; + this.position.y = y; this.positionChange.next(this.position); this.flow.arrowsChange.next(this.position); } diff --git a/projects/flow/src/lib/flow-interface.ts b/projects/flow/src/lib/flow-interface.ts index 5b697fe..335edc2 100644 --- a/projects/flow/src/lib/flow-interface.ts +++ b/projects/flow/src/lib/flow-interface.ts @@ -23,11 +23,6 @@ export interface DotOptions extends FlowOptions { dotIndex: number; } -export class FlowConfig { - Arrows = true; - ArrowSize = 20; -} - export type FlowDirection = 'horizontal' | 'vertical'; export type ArrowPathFn = ( diff --git a/projects/flow/src/lib/flow.component.ts b/projects/flow/src/lib/flow.component.ts index 2e37a3a..3c7eae1 100644 --- a/projects/flow/src/lib/flow.component.ts +++ b/projects/flow/src/lib/flow.component.ts @@ -10,21 +10,20 @@ import { ElementRef, NgZone, ChangeDetectionStrategy, + Input, + OnInit, } from '@angular/core'; import { startWith } from 'rxjs'; -import { Arrangements2 as Arrangements } from './arrangements'; -import { Connections } from './connections'; import { FlowChildComponent } from './flow-child.component'; import { FlowService } from './flow.service'; import { FlowOptions, ChildInfo, FlowDirection, - DotOptions, ArrowPathFn, } from './flow-interface'; -import { blendCorners, flowPath, bezierPath, blendCorners1 } from './svg'; -import { FitToWindow } from './fit-to-window'; +import { FlowConfig, FlowPlugin } from './plugins/plugin'; +import { Connections } from './plugins/connections'; const BASE_SCALE_AMOUNT = 0.05; @@ -125,10 +124,11 @@ const BASE_SCALE_AMOUNT = 0.05; ], }) export class FlowComponent - implements AfterContentInit, AfterViewInit, OnDestroy + implements OnInit, AfterContentInit, AfterViewInit, OnDestroy { - @ContentChildren(FlowChildComponent) children: QueryList = - new QueryList(); + @Input() config: FlowConfig = new FlowConfig(); + @ContentChildren(FlowChildComponent) children = + new QueryList(); // @ViewChildren('arrowPaths') arrowPaths: QueryList>; @ViewChild('zoomContainer') zoomContainer: ElementRef; @@ -138,12 +138,15 @@ export class FlowComponent @ViewChild('guideLines') guideLines: ElementRef; initialX = 0; initialY = 0; + defaultPlugins = [new Connections()]; constructor( public el: ElementRef, public flow: FlowService, private ngZone: NgZone - ) { + ) {} + + ngOnInit(): void { this.flow.zoomContainer = this.el.nativeElement; this.flow.arrowsChange.subscribe((e) => this.updateArrows(e)); this.ngZone.runOutsideAngular(() => { @@ -166,6 +169,19 @@ export class FlowComponent ngAfterViewInit(): void { this.createArrows(); + this.runPlugin((e) => e.afterInit?.(this)); + } + + private runPlugin(callback: (e: FlowPlugin) => void) { + for (const plug of this.defaultPlugins) { + callback(plug); + } + for (const key in this.config.Plugins) { + if (Object.prototype.hasOwnProperty.call(this.config.Plugins, key)) { + const element = this.config.Plugins[key]; + callback(element); + } + } } ngAfterContentInit() { @@ -173,7 +189,7 @@ export class FlowComponent .pipe(startWith(this.children)) .subscribe((children) => { this.flow.update(this.children.map((x) => x.position)); - this.arrangeChildren(); + this.runPlugin((e) => e.beforeUpdate?.(this)); this.createArrows(); }); requestAnimationFrame(() => this.updateArrows()); // this required for angular to render the dot @@ -189,7 +205,7 @@ export class FlowComponent updateDirection(direction: FlowDirection) { this.flow.direction = direction; - this.arrangeChildren(); + this.runPlugin((e) => e.beforeUpdate?.(this)); this.createArrows(); } @@ -281,38 +297,10 @@ export class FlowComponent return { scale: newScale, panX: newPanX, panY: newPanY }; } - fitToWindow() { - const ftw = new FitToWindow( - this.list, - this.zoomContainer.nativeElement.getBoundingClientRect(), - this.flow.scale, - this.flow.panX, - this.flow.panY - ); - const { scale, panX, panY } = ftw.fitToWindow(); - this.flow.scale = scale; - this.flow.panX = panX; - this.flow.panY = panY; - this.updateZoomContainer(); - } - - private updateZoomContainer() { + updateZoomContainer() { this.zoomContainer.nativeElement.style.transform = `translate3d(${this.flow.panX}px, ${this.flow.panY}px, 0) scale(${this.flow.scale})`; } - arrangeChildren() { - const arrangements = new Arrangements( - this.list, - this.flow.direction, - this.flow.horizontalPadding, - this.flow.verticalPadding, - this.flow.groupPadding - ); - const newList = arrangements.autoArrange(); - this.flow.update([...newList.values()]); - this.flow.layoutUpdated.next(); - } - get list() { return this.children.toArray().map((x) => { // calculate the width and height with scale @@ -397,101 +385,27 @@ export class FlowComponent } updateArrows(e?: FlowOptions) { - const gElement: SVGGElement = this.g.nativeElement; - const childObj = this.getChildInfo(); + this.runPlugin((e) => e.afterUpdate?.(this)); + // const gElement: SVGGElement = this.g.nativeElement; + // const childObj = this.getChildInfo(); // Handle reverse dependencies - this.flow.connections = new Connections(this.list, this.flow.direction); - - // 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 startDot = this.getDotByIndex( - childObj, - fromItem.position, - startDotIndex, - this.flow.scale, - this.flow.panX, - this.flow.panY - ); - const 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 = this.flow.arrowFn( - endDot, - startDot, - this.flow.config.ArrowSize, - 2 - ); - } - - // 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()); + // this.flow.connections = new Connections(this.list, this.flow.direction); } - private oldChildObj() { + oldChildObj() { return this.children.toArray().reduce((acc, curr) => { acc[curr.position.id] = curr; return acc; }, {} as Record); } - private getChildInfo() { + 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 - ): DotOptions { - const child = childObj[item.id]; - const childDots = child.dots as DOMRect[]; - // 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); - } - ngOnDestroy(): void { this.el.nativeElement.removeEventListener('wheel', this.zoomHandle); } diff --git a/projects/flow/src/lib/flow.service.ts b/projects/flow/src/lib/flow.service.ts index 699cf8f..e3317c5 100644 --- a/projects/flow/src/lib/flow.service.ts +++ b/projects/flow/src/lib/flow.service.ts @@ -1,13 +1,8 @@ import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Subject } from 'rxjs'; -import { Connections } from './connections'; -import { - ArrowPathFn, - FlowConfig, - FlowDirection, - FlowOptions, -} from './flow-interface'; +import { ArrowPathFn, FlowDirection, FlowOptions } from './flow-interface'; import { blendCorners } from './svg'; +import { FlowConfig } from './plugins/plugin'; @Injectable() export class FlowService { @@ -29,7 +24,6 @@ export class FlowService { gridSize = 1; arrows: Arrow[] = []; zoomContainer: HTMLElement; - connections: Connections; layoutUpdated = new Subject(); onMouse = new Subject(); @@ -47,7 +41,6 @@ export class FlowService { }; update(children: FlowOptions[]) { - // console.log('update', children); this.items.clear(); children.forEach((child) => { this.items.set(child.id, child); @@ -62,21 +55,6 @@ export class FlowService { }); } - // 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(); } diff --git a/projects/flow/src/lib/plugins/arrangements.spec.ts b/projects/flow/src/lib/plugins/arrangements.spec.ts new file mode 100644 index 0000000..a63cc84 --- /dev/null +++ b/projects/flow/src/lib/plugins/arrangements.spec.ts @@ -0,0 +1,47 @@ +import { Arrangements } from './arrangements'; +import { ChildInfo } from '../flow-interface'; +import { FlowComponent } from '../flow.component'; + +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: 600, y: 760, id: '8', deps: ['6', '7'] }, +]; + +describe('Arrangements', () => { + let arrangements: Arrangements; + + it('should be created', () => { + const childObj: ChildInfo[] = FLOW_LIST.map((x) => ({ + position: x, + elRect: { width: 200, height: 200 } as any, + })); + + arrangements = new Arrangements(); + arrangements.onInit({ + list: childObj, + flow: { + direction: 'vertical', + verticalPadding: 20, + groupPadding: 100, + }, + } as Partial as any); + 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/plugins/arrangements.ts b/projects/flow/src/lib/plugins/arrangements.ts new file mode 100644 index 0000000..e0feef6 --- /dev/null +++ b/projects/flow/src/lib/plugins/arrangements.ts @@ -0,0 +1,135 @@ +import { FlowOptions, ChildInfo, FlowDirection } from '../flow-interface'; +import { FlowComponent } from '../flow.component'; +import { FlowPlugin } from './plugin'; + +const ROOT_DATA = new Map(); +const ROOT_DEPS = new Map(); +const HORIZONTAL_PADDING = 100; +const VERTICAL_PADDING = 20; + +export class Arrangements implements FlowPlugin { + root: string[] = []; + data: FlowComponent; + private list: ChildInfo[]; + private direction: FlowDirection = 'vertical'; + public horizontalPadding = 100; + public verticalPadding = 20; + public groupPadding = 20; + + onInit(data: FlowComponent): void { + this.data = data; + } + + beforeUpdate(data: FlowComponent): void { + this.data = data; + this.runArrange(); + } + + private runArrange() { + const newList = this._autoArrange(); + this.data.flow.update([...newList.values()]); + this.data.flow.layoutUpdated.next(); + } + + arrange() { + this.runArrange(); + this.data.updateArrows(); + } + + public _autoArrange(): Map { + this.list = this.data.list; + this.direction = this.data.flow.direction; + this.horizontalPadding = this.data.flow.horizontalPadding; + this.verticalPadding = this.data.flow.verticalPadding; + this.groupPadding = this.data.flow.groupPadding; + + 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); + } + }); + + 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: number, y: number, 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: Coordinates = { x: 0, y: 0 }; + let last: Coordinates = { x: 0, y: 0 }; + 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/connections.spec.ts b/projects/flow/src/lib/plugins/connections.spec.ts similarity index 73% rename from projects/flow/src/lib/connections.spec.ts rename to projects/flow/src/lib/plugins/connections.spec.ts index 20835d6..2372ad7 100644 --- a/projects/flow/src/lib/connections.spec.ts +++ b/projects/flow/src/lib/plugins/connections.spec.ts @@ -1,4 +1,4 @@ -import { FlowOptions, ChildInfo } from './flow-interface'; +import { FlowOptions, ChildInfo } from '../flow-interface'; import { Connections } from './connections'; describe('Connections', () => { @@ -15,7 +15,8 @@ describe('Connections', () => { ]; check(list, [1, 3]); - check(list.reverse(), [3, 1]); + list.reverse(); + check(list, [3, 1]); list = [ t({ x: 300, y: -150, id: '2', deps: ['1'] }), @@ -36,7 +37,8 @@ describe('Connections', () => { check(list, [0, 2]); function check(list: ChildInfo[], expected: [number, number]) { - connections = new Connections(list); + connections = new Connections(); + connections.onInit({ list, flow: { direction: 'horizontal' } } as any); const actual = connections._findClosestConnectionPoints(list[0], list[1]); expect(actual).toEqual(expected); } @@ -49,21 +51,13 @@ describe('Connections', () => { ]; check(list, 0, '2', [1, 3]); - // connections = new Connections(list); - // let actual = connections.getClosestDotsSimplified(list[0], '2'); - // expect(actual).toEqual([1, 3]); check(list, 1, '1', [3, 1]); - // actual = connections.getClosestDotsSimplified(list[1], '1'); - // expect(actual).toEqual([3, 1]); list = [ t({ x: 40, y: 40, id: '1', deps: [] }), t({ x: 173.203125, y: -33, id: '2', deps: ['1'] }), ]; check(list, 0, '2', [1, 3]); - // connections = new Connections(list); - // actual = connections.getClosestDotsSimplified(list[0], '2'); - // expect(actual).toEqual([1, 3]); list = [ t({ x: 40, y: 40, id: '1', deps: [] }), @@ -71,16 +65,14 @@ describe('Connections', () => { ]; check(list, 0, '2', [1, 3]); - // actual = connections.getClosestDotsSimplified(list[0], '2'); - // expect(actual).toEqual([1, 3]); - function check( list: ChildInfo[], index: number, dep: string, expected: [number, number] ) { - connections = new Connections(list); + connections = new Connections(); + connections.onInit({ list, flow: { direction: 'horizontal' } } as any); const actual = connections.getClosestDotsSimplified(list[index], dep); expect(actual).toEqual(expected); } diff --git a/projects/flow/src/lib/plugins/connections.ts b/projects/flow/src/lib/plugins/connections.ts new file mode 100644 index 0000000..fed2f8d --- /dev/null +++ b/projects/flow/src/lib/plugins/connections.ts @@ -0,0 +1,236 @@ +import { ChildInfo, DotOptions, FlowOptions } from '../flow-interface'; +import { FlowChildComponent } from '../flow-child.component'; +import { FlowComponent } from '../flow.component'; +import { FlowPlugin } from './plugin'; + +export class Connections implements FlowPlugin { + // key = id of the item + // value = ids of the items that depend on it + reverseDepsMap = new Map(); + + // key = id of the item + // value = index of the closest dot + closestDots = new Map(); + + data: FlowComponent; + private list: ChildInfo[]; + private direction: 'horizontal' | 'vertical' = 'horizontal'; + + onInit(data: FlowComponent): void { + this.setData(data); + } + + afterUpdate(data: FlowComponent): void { + this.setData(data); + + const gElement: SVGGElement = this.data.g.nativeElement; + const childObj = this.data.getChildInfo(); + // Calculate new arrows + this.data.flow.arrows.forEach((arrow) => { + const [from, to] = arrow.deps; + const fromItem = childObj[from]; + const toItem = childObj[to]; + if (fromItem && toItem) { + const [endDotIndex, startDotIndex] = this.getClosestDotsSimplified( + toItem, + from + ); + + const startDot = this.getDotByIndex( + childObj, + fromItem.position, + startDotIndex, + this.data.flow.scale, + this.data.flow.panX, + this.data.flow.panY + ); + const endDot = this.getDotByIndex( + childObj, + toItem.position, + endDotIndex, + this.data.flow.scale, + this.data.flow.panX, + this.data.flow.panY + ); + + // we need to reverse the path because the arrow head is at the end + arrow.d = this.data.flow.arrowFn( + endDot, + startDot, + this.data.flow.config.ArrowSize, + 2 + ); + } + + // Update the SVG paths + this.data.flow.arrows.forEach((arrow) => { + const pathElement = gElement.querySelector( + `#${arrow.id}` + ) as SVGPathElement; + if (pathElement) { + pathElement.setAttribute('d', arrow.d); + } + }); + }); + + this.updateDotVisibility(this.data.oldChildObj()); + } + + private setData(data: FlowComponent) { + this.data = data; + this.list = data.list; + this.direction = data.flow.direction; + this.setReverseDepsMap(this.list.map((x) => x.position)); + } + + public getClosestDotsSimplified( + item: ChildInfo, + dep: string + ): [number, number] { + const ids = [ + ...item.position.deps, + ...(this.reverseDepsMap.get(item.position.id) || []), + ]; + ids.forEach((x) => this.findClosestDot(x, item)); + // ids.forEach((x) => this.findClosestDot(x, item, childObj)); + // Remove duplicates + // const uniqueClosestDotIndices = Array.from(new Set(closestDotIndices)); + + return [ + this.closestDots.get(`${item.position.id}-${dep}`) as number, + this.closestDots.get(`${dep}-${item.position.id}`) as number, + ]; + // return dep + // ? [this.closestDots.get(`${item.id}-${dep}`) as number, this.closestDots.get(`${dep}-${item.id}`) as number] + // : closestDotIndices; + } + + private findClosestDot( + depId: string, + item: ChildInfo + // childObj: Record + ) { + const uniqueKey1 = `${item.position.id}-${depId}`; + const uniqueKey2 = `${depId}-${item.position.id}`; + + if (this.closestDots.has(uniqueKey1)) return; + + const dep = this.list.find((item) => item.position.id === depId); + if (dep) { + const [closestDotIndex1, closestDotIndex2] = + this._findClosestConnectionPoints(item, dep); + + this.closestDots.set(uniqueKey1, closestDotIndex1); + this.closestDots.set(uniqueKey2, closestDotIndex2); + } + } + + public _findClosestConnectionPoints( + parent: ChildInfo, + child: ChildInfo + ): [number, number] { + // sides dot index order: [top, right, bottom, left] + let swapped = false; + const isV = this.direction === 'vertical'; + // correct the parent based on the deps + if (!child.position.deps.includes(parent.position.id)) { + const _t = child; + child = parent; + parent = _t; + swapped = true; + } + + const childDirection = this.getDirection(child, parent, isV); + + const parentIndex = (() => { + if (childDirection === 'right') return 1; + if (childDirection === 'left') return 3; + if (childDirection === 'bottom') return 2; + return 0; + })(); + const childIndex = (() => { + if (childDirection === 'right') return 3; + if (childDirection === 'left') return 1; + if (childDirection === 'bottom') return 0; + return 2; + })(); + + return swapped ? [childIndex, parentIndex] : [parentIndex, childIndex]; + } + + private getDirection( + child: ChildInfo, + parent: ChildInfo, + isV: boolean + ): 'right' | 'left' | 'bottom' | 'top' { + // consider width and height of the child + const { width, height } = child.elRect; + const { x, y } = child.position; + const { x: px, y: py } = parent.position; + + if (!isV) { + if (x + width < px) return 'left'; + if (x - width > px) return 'right'; + if (y + height < py) return 'top'; + if (y - height > py) return 'bottom'; + } else { + if (y + height < py) return 'top'; + if (y - height > py) return 'bottom'; + if (x + width < px) return 'left'; + if (x - width > px) return 'right'; + } + return 'right'; + } + + private updateDotVisibility(childObj: Record) { + Object.keys(childObj).forEach((id) => { + const child = childObj[id]; + const dots = child.dots.toArray(); + + dots.forEach((dot, index) => { + // Check if the current dot is the closest for any dependency + const isClosestForAnyDep = Array.from(this.closestDots.keys()).some( + (key) => key.startsWith(id) && this.closestDots.get(key) === index + ); + + dot.nativeElement.style.visibility = isClosestForAnyDep + ? 'visible' + : 'hidden'; + }); + }); + } + + private getDotByIndex( + childObj: Record, + item: FlowOptions, + dotIndex: number, + scale: number, + panX: number, + panY: number + ): DotOptions { + const child = childObj[item.id]; + const childDots = child.dots as DOMRect[]; + // 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.data.flow.zRect; + 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 }; + } + + private setReverseDepsMap(list: FlowOptions[]) { + list.forEach((item) => { + item.deps.forEach((depId) => { + if (!this.reverseDepsMap.has(depId)) { + this.reverseDepsMap.set(depId, []); + } + this.reverseDepsMap.get(depId)!.push(item.id); + }); + }); + } +} diff --git a/projects/flow/src/lib/fit-to-window.spec.ts b/projects/flow/src/lib/plugins/fit-to-window.spec.ts similarity index 79% rename from projects/flow/src/lib/fit-to-window.spec.ts rename to projects/flow/src/lib/plugins/fit-to-window.spec.ts index 3287679..1f9a7e8 100644 --- a/projects/flow/src/lib/fit-to-window.spec.ts +++ b/projects/flow/src/lib/plugins/fit-to-window.spec.ts @@ -80,11 +80,25 @@ describe('FitToWindow', () => { scale = 1; panX = 0; panY = 0; - fitToWindow = new FitToWindow(list, containerRect, scale, panX, panY); + fitToWindow = new FitToWindow(); + fitToWindow.onInit({ + list, + zoomContainer: { + nativeElement: { getBoundingClientRect: () => containerRect }, + }, + flow: { + scale, + panX, + panY, + zRect: containerRect, + }, + updateZoomContainer: () => {}, + } as any); + fitToWindow.run(list, containerRect, scale, panX, panY); }); it('should return positions', () => { - const positions = fitToWindow.getPositions(); + const positions = fitToWindow._getPositions(); expect(positions).toEqual([ { x: 121, y: 342.5, width: 400, height: 395 }, { x: 621, y: 342.5, width: 400, height: 395 }, @@ -98,7 +112,7 @@ describe('FitToWindow', () => { { x: 0, y: 0, width: 100, height: 100 }, { x: 100, y: 100, width: 100, height: 100 }, ]; - const { minX, maxX, minY, maxY } = fitToWindow.getBoundaries(positions); + const { minX, maxX, minY, maxY } = fitToWindow._getBoundaries(positions); expect(minX).toBe(0); expect(maxX).toBe(200); expect(minY).toBe(0); @@ -106,12 +120,12 @@ describe('FitToWindow', () => { }); it('should return new scale', () => { - const newScale = fitToWindow.getNewScale(100, 100); + const newScale = fitToWindow._getNewScale(100, 100); expect(newScale).toBe(6.28); }); it('should return pan values', () => { - const { panX, panY } = fitToWindow.getPanValues( + const { panX, panY } = fitToWindow._getPanValues( 1430, 840, 0.7476, @@ -123,7 +137,7 @@ describe('FitToWindow', () => { }); it('should return pan and scale values', () => { - const { scale, panX, panY } = fitToWindow.fitToWindow(); + const { scale, panX, panY } = fitToWindow._updateValue(); expect(scale).toBe(0.7476190476190476); expect(panX).toBe(-29.19047619047616); expect(panY).toBe(-38.876190476190494); diff --git a/projects/flow/src/lib/fit-to-window.ts b/projects/flow/src/lib/plugins/fit-to-window.ts similarity index 58% rename from projects/flow/src/lib/fit-to-window.ts rename to projects/flow/src/lib/plugins/fit-to-window.ts index abf87fe..88bd277 100644 --- a/projects/flow/src/lib/fit-to-window.ts +++ b/projects/flow/src/lib/plugins/fit-to-window.ts @@ -1,17 +1,57 @@ -import { ChildInfo } from './flow-interface'; - -export class FitToWindow { - cRect: CPosition; - containerPadding = 0; - - constructor( - private list: ChildInfo[], - private containerRect: DOMRect, - private scale: number, - private panX: number, - private panY: number +import { ChildInfo } from '../flow-interface'; +import { FlowComponent } from '../flow.component'; +import { FlowPlugin } from './plugin'; + +export class FitToWindow implements FlowPlugin { + private cRect: CPosition; + private containerPadding = 0; + private data: FlowComponent; + + private list: ChildInfo[]; + private containerRect: DOMRect; + private scale: number; + private panX: number; + private panY: number; + + constructor(private init = false) {} + + onInit(data: FlowComponent): void { + this.data = data; + } + + afterInit(data: FlowComponent): void { + this.data = data; + if (this.init) { + this.fitToWindow(); + } + } + + fitToWindow() { + this.run( + this.data.list, + this.data.zoomContainer.nativeElement.getBoundingClientRect(), + this.data.flow.scale, + this.data.flow.panX, + this.data.flow.panY + ); + } + + run( + list: ChildInfo[], + cRect: DOMRect, + scale: number, + panX: number, + panY: number ) { - const tt = { list, containerRect, scale, panX, panY }; + this.list = list; + this.containerRect = cRect; + this.scale = scale; + this.panX = panX; + this.panY = panY; + this._fitToWindowInternal(); + } + + private _fitToWindowInternal() { this.containerPadding = 30 / this.scale; this.cRect = { x: this.containerRect.x / this.scale - this.panX, @@ -19,15 +59,20 @@ export class FitToWindow { width: this.containerRect.width / this.scale, height: this.containerRect.height / this.scale, }; + const { scale, panX, panY } = this._updateValue(); + this.data.flow.scale = scale; + this.data.flow.panX = panX; + this.data.flow.panY = panY; + this.data.updateZoomContainer(); } - fitToWindow() { - const positions = this.getPositions(); - const { minX, maxX, minY, maxY } = this.getBoundaries(positions); + _updateValue() { + const positions = this._getPositions(); + const { minX, maxX, minY, maxY } = this._getBoundaries(positions); const adjMaxX = maxX - minX + this.containerPadding; const adjMaxY = maxY - minY + this.containerPadding; - const newScale = this.getNewScale(adjMaxX, adjMaxY); - const { panX, panY } = this.getPanValues( + const newScale = this._getNewScale(adjMaxX, adjMaxY); + const { panX, panY } = this._getPanValues( adjMaxX, adjMaxY, newScale, @@ -37,7 +82,7 @@ export class FitToWindow { return { scale: newScale, panX, panY }; } - getPositions() { + _getPositions() { return this.list.map((child) => { const scaledX = child.elRect.x / this.scale - this.panX; const scaledY = child.elRect.y / this.scale - this.panY; @@ -52,7 +97,7 @@ export class FitToWindow { }); } - getBoundaries(positions: CPosition[]) { + _getBoundaries(positions: CPosition[]) { 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)); @@ -60,31 +105,13 @@ export class FitToWindow { return { minX, maxX, minY, maxY }; } - getNewScale(adjMaxX: number, adjMaxY: number) { + _getNewScale(adjMaxX: number, adjMaxY: number) { const scaleX = this.cRect.width / adjMaxX; const scaleY = this.cRect.height / adjMaxY; return Math.min(scaleX, scaleY); } - // getPanValues( - // adjMaxX: number, - // adjMaxY: number, - // newScale: number, - // minX: number, - // minY: number - // ) { - // const panX = - // this.cRect.x + - // (this.cRect.width - (adjMaxX - this.containerPadding) * newScale) / 2 - - // minX * newScale; - // const panY = - // this.cRect.y + - // (this.cRect.height - (adjMaxY - this.containerPadding) * newScale) / 2 - - // minY * newScale; - // return { panX, panY }; - // } - // } - getPanValues( + _getPanValues( adjMaxX: number, adjMaxY: number, newScale: number, diff --git a/projects/flow/src/lib/plugins/plugin.ts b/projects/flow/src/lib/plugins/plugin.ts new file mode 100644 index 0000000..6a44350 --- /dev/null +++ b/projects/flow/src/lib/plugins/plugin.ts @@ -0,0 +1,14 @@ +import { FlowComponent } from '../flow.component'; + +export class FlowConfig { + Arrows = true; + ArrowSize = 20; + Plugins: { [x: string]: FlowPlugin } = {}; +} + +export interface FlowPlugin { + onInit?(data: FlowComponent): void; + afterInit?(data: FlowComponent): void; + beforeUpdate?(data: FlowComponent): void; + afterUpdate?(data: FlowComponent): void; +} diff --git a/projects/flow/src/lib/plugins/scroll-into-view.ts b/projects/flow/src/lib/plugins/scroll-into-view.ts new file mode 100644 index 0000000..d49e9ba --- /dev/null +++ b/projects/flow/src/lib/plugins/scroll-into-view.ts @@ -0,0 +1,33 @@ +import { FlowComponent } from '../flow.component'; +import { FlowPlugin } from './plugin'; + +export class ScrollIntoView implements FlowPlugin { + private data: FlowComponent; + + constructor(private id: string) {} + + afterInit(data: FlowComponent): void { + this.data = data; + this.focus(this.id); + } + + focus(id: string) { + const item = this.data.list.find((x) => x.position.id === id); + if (item) { + const { x, y } = item.position; + const { width, height } = item.elRect; + if (x + width * this.data.flow.scale > this.data.flow.zRect.width) { + this.data.flow.panX = + -x * this.data.flow.scale + (this.data.flow.zRect.width - width); + } else if ( + this.data.flow.panX + x * this.data.flow.scale < + this.data.flow.zRect.width + ) { + this.data.flow.panX = -x * this.data.flow.scale; + } + this.data.flow.panY = + -y * this.data.flow.scale + (this.data.flow.zRect.height - height) / 2; + this.data.updateZoomContainer(); + } + } +} diff --git a/projects/flow/src/public-api.ts b/projects/flow/src/public-api.ts index 7fbd8d6..af9b4d2 100644 --- a/projects/flow/src/public-api.ts +++ b/projects/flow/src/public-api.ts @@ -5,10 +5,9 @@ export * from './lib/flow.service'; export * from './lib/flow.component'; export * from './lib/flow-child.component'; -export { - ArrowPathFn, - FlowOptions, - FlowDirection, - FlowConfig, -} from './lib/flow-interface'; +export { ArrowPathFn, FlowOptions, FlowDirection } from './lib/flow-interface'; export * from './lib/svg'; +export { FitToWindow } from './lib/plugins/fit-to-window'; +export { ScrollIntoView } from './lib/plugins/scroll-into-view'; +export { Arrangements } from './lib/plugins/arrangements'; +export { FlowConfig, FlowPlugin } from './lib/plugins/plugin'; diff --git a/src/app/demo/demo-one.component.ts b/src/app/demo/demo-one.component.ts index 1976fd1..84af69d 100644 --- a/src/app/demo/demo-one.component.ts +++ b/src/app/demo/demo-one.component.ts @@ -5,10 +5,19 @@ import { ViewChild, inject, } from '@angular/core'; -import { FlowComponent, FlowChildComponent, FlowOptions } from '@ngu/flow'; +import { + FlowComponent, + FlowChildComponent, + FlowOptions, + FlowConfig, + FitToWindow, + ScrollIntoView, + Arrangements, +} from '@ngu/flow'; import { EditorComponent } from '../editor.component'; import { ToolbarComponent } from './toolbar.component'; import { DemoService } from './demo.service'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; @Component({ selector: 'app-demo-one', @@ -19,11 +28,25 @@ import { DemoService } from './demo.service'; FlowChildComponent, EditorComponent, ToolbarComponent, + ReactiveFormsModule, ], template: `
- - + + + @for (item of list; track item.id; let i = $index) {
('11', { nonNullable: true }); constructor() { this.list = structuredClone(FLOW_LIST); + this.selectedNode.valueChanges.subscribe((id) => { + this.plugins.scroll.focus(id); + }); } ngAfterViewInit(): void { this.demoService.flow = this.flowComponent; } + fitToWindow() { + this.plugins.fitWindow.fitToWindow(); + } + + autoArrange() { + this.plugins.arrange.arrange(); + } + deleteNode(id: string) { this.list = structuredClone(this.demoService.deleteNodeI(id, this.list)); } diff --git a/src/app/demo/demo-two.component.ts b/src/app/demo/demo-two.component.ts index 96df1d2..3c32d3a 100644 --- a/src/app/demo/demo-two.component.ts +++ b/src/app/demo/demo-two.component.ts @@ -5,7 +5,15 @@ import { ViewChild, inject, } from '@angular/core'; -import { FlowComponent, FlowChildComponent, FlowOptions } from '@ngu/flow'; +import { + FlowComponent, + FlowChildComponent, + FlowOptions, + FlowConfig, + ScrollIntoView, + FitToWindow, + Arrangements, +} from '@ngu/flow'; import { EditorComponent } from '../editor.component'; import { ToolbarComponent } from './toolbar.component'; import { DemoService } from './demo.service'; @@ -22,8 +30,15 @@ import { DemoService } from './demo.service'; ], template: `
- - + + @for (item of list; track item.id; let i = $index) {
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) + item.deps = item.deps.filter((dep) => !removeId.includes(dep)); + if (initialLength > 0 && item.deps.length === 0) { + removeId.push(item.id); + } else if ( + !removeId.includes(item.id) && + (item.deps.length === initialLength || item.deps.length > 0) + ) acc.push(item); return acc; }, [] as FlowOptions[]); diff --git a/src/app/demo/toolbar.component.ts b/src/app/demo/toolbar.component.ts index 708d7db..96e2c0b 100644 --- a/src/app/demo/toolbar.component.ts +++ b/src/app/demo/toolbar.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, EventEmitter, OnInit, Output, inject } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { DemoService } from './demo.service'; import { @@ -39,7 +39,7 @@ import { (click)="animatePathFn()" />Animate - +