diff --git a/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.html b/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.html new file mode 100644 index 00000000..32a83278 --- /dev/null +++ b/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.html @@ -0,0 +1,99 @@ + + + + + +
+
Node A
+
+
+
Node B
+
+ + + + +
+
Node C
+
+
+
Node D
+
+ + + + +
+
Source
+
+
+
Target
+
+
+
diff --git a/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.scss b/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.scss new file mode 100644 index 00000000..3438187b --- /dev/null +++ b/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.scss @@ -0,0 +1,18 @@ +@use '../../flow-common'; + +::ng-deep f-flow { + @include flow-common.connection; +} + +.f-node { + @include flow-common.node; +} + +.f-node-content { + padding: 12px 20px; + background: white; + border: 2px solid #3b82f6; + border-radius: 8px; + font-weight: 500; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} diff --git a/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.ts b/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.ts new file mode 100644 index 00000000..4e9d21ef --- /dev/null +++ b/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { FCanvasComponent, FFlowModule } from '@foblex/flow'; + +@Component({ + selector: 'custom-path-control-points', + styleUrls: ['./custom-path-control-points.component.scss'], + templateUrl: './custom-path-control-points.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [FFlowModule], +}) +export class CustomPathControlPointsComponent { + @ViewChild(FCanvasComponent, { static: true }) + public fCanvas!: FCanvasComponent; + + // Example 1: Simple L-shaped path + public controlPoints1 = [{ x: 150, y: 50 }]; + + // Example 2: Z-shaped path with multiple control points + public controlPoints2 = [ + { x: 150, y: 200 }, + { x: 150, y: 250 }, + { x: 300, y: 250 }, + ]; + + // Example 3: Complex industrial-style path + public controlPoints3 = [ + { x: 100, y: 400 }, + { x: 100, y: 450 }, + { x: 200, y: 450 }, + { x: 200, y: 500 }, + { x: 300, y: 500 }, + ]; + + public onLoaded(): void { + this.fCanvas.resetScaleAndCenter(false); + } +} diff --git a/projects/f-flow/src/f-connection/common/domain/f-custom-path.builder.spec.ts b/projects/f-flow/src/f-connection/common/domain/f-custom-path.builder.spec.ts new file mode 100644 index 00000000..3bdaed32 --- /dev/null +++ b/projects/f-flow/src/f-connection/common/domain/f-custom-path.builder.spec.ts @@ -0,0 +1,143 @@ +import { FCustomPathBuilder } from './f-custom-path.builder'; +import { + IFConnectionBuilderRequest, + IFConnectionBuilderResponse, +} from '../../f-connection-builder'; +import { EFConnectableSide } from '../../../f-connectors'; + +describe('FCustomPathBuilder', () => { + let builder: FCustomPathBuilder; + + beforeEach(() => { + builder = new FCustomPathBuilder(); + }); + + it('should create a straight path when no control points are provided', () => { + const request: IFConnectionBuilderRequest = { + source: { x: 0, y: 0 }, + target: { x: 100, y: 100 }, + sourceSide: EFConnectableSide.AUTO, + targetSide: EFConnectableSide.AUTO, + radius: 0, + offset: 0, + controlPoints: [], + }; + + const response: IFConnectionBuilderResponse = builder.handle(request); + + expect(response.path).toBeDefined(); + expect(response.path).toContain('M0 0'); + expect(response.path).toContain('L100.0002 100.0002'); + expect(response.connectionCenter).toBeDefined(); + expect(response.points?.length).toBe(2); + }); + + it('should create a path with one control point', () => { + const request: IFConnectionBuilderRequest = { + source: { x: 0, y: 0 }, + target: { x: 100, y: 100 }, + sourceSide: EFConnectableSide.AUTO, + targetSide: EFConnectableSide.AUTO, + radius: 0, + offset: 0, + controlPoints: [{ x: 50, y: 0 }], + }; + + const response: IFConnectionBuilderResponse = builder.handle(request); + + expect(response.path).toBeDefined(); + expect(response.path).toContain('M0 0'); + expect(response.path).toContain('L50 0'); + expect(response.path).toContain('L100.0002 100.0002'); + expect(response.points?.length).toBe(3); + expect(response.points).toEqual([ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 100, y: 100 }, + ]); + }); + + it('should create a path with multiple control points', () => { + const request: IFConnectionBuilderRequest = { + source: { x: 0, y: 0 }, + target: { x: 100, y: 100 }, + sourceSide: EFConnectableSide.AUTO, + targetSide: EFConnectableSide.AUTO, + radius: 0, + offset: 0, + controlPoints: [ + { x: 50, y: 0 }, + { x: 50, y: 50 }, + { x: 100, y: 50 }, + ], + }; + + const response: IFConnectionBuilderResponse = builder.handle(request); + + expect(response.path).toBeDefined(); + expect(response.points?.length).toBe(5); + expect(response.points).toEqual([ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 50 }, + { x: 100, y: 50 }, + { x: 100, y: 100 }, + ]); + }); + + it('should apply border radius to corners', () => { + const request: IFConnectionBuilderRequest = { + source: { x: 0, y: 0 }, + target: { x: 100, y: 100 }, + sourceSide: EFConnectableSide.AUTO, + targetSide: EFConnectableSide.AUTO, + radius: 10, + offset: 0, + controlPoints: [{ x: 50, y: 0 }], + }; + + const response: IFConnectionBuilderResponse = builder.handle(request); + + expect(response.path).toBeDefined(); + // Should contain Q (quadratic bezier) for rounded corner + expect(response.path).toContain('Q'); + }); + + it('should calculate correct connection center', () => { + const request: IFConnectionBuilderRequest = { + source: { x: 0, y: 0 }, + target: { x: 100, y: 0 }, + sourceSide: EFConnectableSide.AUTO, + targetSide: EFConnectableSide.AUTO, + radius: 0, + offset: 0, + controlPoints: [], + }; + + const response: IFConnectionBuilderResponse = builder.handle(request); + + expect(response.connectionCenter).toBeDefined(); + expect(response.connectionCenter.x).toBeCloseTo(50, 0); + expect(response.connectionCenter.y).toBeCloseTo(0, 0); + }); + + it('should return correct penultimate and second points', () => { + const request: IFConnectionBuilderRequest = { + source: { x: 0, y: 0 }, + target: { x: 100, y: 100 }, + sourceSide: EFConnectableSide.AUTO, + targetSide: EFConnectableSide.AUTO, + radius: 0, + offset: 0, + controlPoints: [ + { x: 50, y: 0 }, + { x: 50, y: 50 }, + ], + }; + + const response: IFConnectionBuilderResponse = builder.handle(request); + + expect(response.secondPoint).toEqual({ x: 50, y: 0 }); + expect(response.penultimatePoint).toEqual({ x: 50, y: 50 }); + }); +}); diff --git a/projects/f-flow/src/f-connection/common/domain/f-custom-path.builder.ts b/projects/f-flow/src/f-connection/common/domain/f-custom-path.builder.ts new file mode 100644 index 00000000..f11a9caa --- /dev/null +++ b/projects/f-flow/src/f-connection/common/domain/f-custom-path.builder.ts @@ -0,0 +1,125 @@ +import { IPoint } from '@foblex/2d'; +import { + IFConnectionBuilder, + IFConnectionBuilderRequest, + IFConnectionBuilderResponse, +} from '../../f-connection-builder'; +import { + CalculateConnectionCenterHandler, + CalculateConnectionCenterRequest, +} from './calculate-connection-center'; + +/** + * Custom path builder that allows users to define connection paths using draggable control points. + * This builder is useful for industrial diagrams like P&ID, circuit diagrams, and process flow diagrams + * where precise manual positioning of connection paths is required. + */ +export class FCustomPathBuilder implements IFConnectionBuilder { + public handle(request: IFConnectionBuilderRequest): IFConnectionBuilderResponse { + const { source, target, controlPoints, radius } = request; + + // If no control points are provided, create a simple straight path + const points = this._getPathPoints(source, target, controlPoints || []); + + // Build the path with rounded corners based on radius + const path = this._buildPath(points, radius); + + // Calculate the center point for the connection + const center = new CalculateConnectionCenterHandler().handle( + new CalculateConnectionCenterRequest(points), + ); + + // Get penultimate and second points for drag handles + const penultimatePoint = points.length > 1 ? points[points.length - 2] : source; + const secondPoint = points.length > 1 ? points[1] : target; + + return { + path, + connectionCenter: center, + penultimatePoint, + secondPoint, + points, + }; + } + + private _getPathPoints(source: IPoint, target: IPoint, controlPoints: IPoint[]): IPoint[] { + // Combine source, control points, and target into a single array + return [source, ...controlPoints, target]; + } + + private _buildPath(points: IPoint[], borderRadius: number): string { + if (points.length < 2) { + return ''; + } + + let path = ''; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + let segment = ''; + + if (i > 0 && i < points.length - 1 && borderRadius > 0) { + // Apply rounded corner at intermediate points + segment = this._getBend(points[i - 1], p, points[i + 1], borderRadius); + } else if (i === points.length - 1) { + // Last point - add small offset to ensure proper rendering + segment = this._buildLastLineSegment(i, p); + } else { + // First point or no border radius + segment = this._buildMoveOrLineSegment(i, p); + } + path += segment; + } + + return path; + } + + private _distance(a: IPoint, b: IPoint): number { + return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)); + } + + private _getBend(a: IPoint, b: IPoint, c: IPoint, size: number): string { + const bendSize = Math.min(this._distance(a, b) / 2, this._distance(b, c) / 2, size); + const { x, y } = b; + + // Check if points are collinear (no bend needed) + if ((a.x === x && x === c.x) || (a.y === y && y === c.y)) { + return `L${x} ${y}`; + } + + // Calculate the direction vectors + const dx1 = x - a.x; + const dy1 = y - a.y; + const dx2 = c.x - x; + const dy2 = c.y - y; + + // Normalize the vectors + const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); + const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + + if (len1 === 0 || len2 === 0) { + return `L${x} ${y}`; + } + + const ndx1 = dx1 / len1; + const ndy1 = dy1 / len1; + const ndx2 = dx2 / len2; + const ndy2 = dy2 / len2; + + // Calculate control points for the curve + const p1x = x - ndx1 * bendSize; + const p1y = y - ndy1 * bendSize; + const p2x = x + ndx2 * bendSize; + const p2y = y + ndy2 * bendSize; + + return `L ${p1x},${p1y}Q ${x},${y} ${p2x},${p2y}`; + } + + private _buildMoveOrLineSegment(index: number, point: IPoint): string { + return `${index === 0 ? 'M' : 'L'}${point.x} ${point.y}`; + } + + private _buildLastLineSegment(index: number, point: IPoint): string { + // Add small offset to ensure proper rendering + return `L${point.x + 0.0002} ${point.y + 0.0002}`; + } +} diff --git a/projects/f-flow/src/f-connection/common/domain/index.ts b/projects/f-flow/src/f-connection/common/domain/index.ts index cfeb7794..d7293218 100644 --- a/projects/f-flow/src/f-connection/common/domain/index.ts +++ b/projects/f-flow/src/f-connection/common/domain/index.ts @@ -9,3 +9,5 @@ export * from './f-bezier.path-builder'; export * from './f-segment.path-builder'; export * from './f-straight.path-builder'; + +export * from './f-custom-path.builder'; diff --git a/projects/f-flow/src/f-connection/common/e-f-connection-type.ts b/projects/f-flow/src/f-connection/common/e-f-connection-type.ts index 68e17d98..cb58ff69 100644 --- a/projects/f-flow/src/f-connection/common/e-f-connection-type.ts +++ b/projects/f-flow/src/f-connection/common/e-f-connection-type.ts @@ -6,4 +6,6 @@ export enum EFConnectionType { BEZIER = 'bezier', ADAPTIVE_CURVE = 'adaptive-curve', + + CUSTOM_PATH = 'custom-path', } diff --git a/projects/f-flow/src/f-connection/common/f-connection-base.ts b/projects/f-flow/src/f-connection/common/f-connection-base.ts index 5a379a8e..0af6db99 100644 --- a/projects/f-flow/src/f-connection/common/f-connection-base.ts +++ b/projects/f-flow/src/f-connection/common/f-connection-base.ts @@ -70,6 +70,8 @@ export abstract class FConnectionBase public abstract fOffset: number; + public abstract fControlPoints: IPoint[]; + protected path: string = ''; public line = LineExtensions.initialize(); @@ -128,6 +130,11 @@ export abstract class FConnectionBase private _penultimatePoint = PointExtensions.initialize(); private _secondPoint = PointExtensions.initialize(); + private _pathPoints: IPoint[] = []; + + public get pathPoints(): IPoint[] { + return this._pathPoints; + } protected constructor() { super(inject(ElementRef).nativeElement); @@ -151,6 +158,7 @@ export abstract class FConnectionBase this.path = pathResult.path; this._penultimatePoint = pathResult.penultimatePoint || point1; this._secondPoint = pathResult.secondPoint || point2; + this._pathPoints = pathResult.points || []; new ConnectionContentLayoutEngine().layout(this.line, pathResult, this._contents()); @@ -177,6 +185,7 @@ export abstract class FConnectionBase targetSide: this._targetSide, radius, offset, + controlPoints: this.fControlPoints, }, }); } diff --git a/projects/f-flow/src/f-connection/common/f-drag-handle/f-connection-drag-handle-control-point.component.ts b/projects/f-flow/src/f-connection/common/f-drag-handle/f-connection-drag-handle-control-point.component.ts new file mode 100644 index 00000000..e657a7d8 --- /dev/null +++ b/projects/f-flow/src/f-connection/common/f-drag-handle/f-connection-drag-handle-control-point.component.ts @@ -0,0 +1,39 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + input, +} from '@angular/core'; +import { IPoint } from '@foblex/2d'; +import { IHasHostElement } from '../../../i-has-host-element'; +import { F_CSS_CLASS } from '../../../domain/css-cls'; + +@Component({ + selector: 'circle[f-connection-drag-handle-control-point]', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class]': 'class', + '[attr.data-index]': 'index()', + }, +}) +export class FConnectionDragHandleControlPointComponent implements IHasHostElement { + private readonly _elementReference = inject(ElementRef); + + protected readonly class: string = F_CSS_CLASS.CONNECTION.DRAG_HANDLE; + + public readonly index = input.required(); + + public point!: IPoint; + + public get hostElement(): SVGCircleElement { + return this._elementReference.nativeElement; + } + + public redraw(point: IPoint): void { + this.point = point; + this.hostElement.setAttribute('cx', point.x.toString()); + this.hostElement.setAttribute('cy', point.y.toString()); + } +} diff --git a/projects/f-flow/src/f-connection/common/f-drag-handle/index.ts b/projects/f-flow/src/f-connection/common/f-drag-handle/index.ts index 7ac93ea5..839376b3 100644 --- a/projects/f-flow/src/f-connection/common/f-drag-handle/index.ts +++ b/projects/f-flow/src/f-connection/common/f-drag-handle/index.ts @@ -1,3 +1,5 @@ export * from './f-connection-drag-handle-start.component'; export * from './f-connection-drag-handle-end.component'; + +export * from './f-connection-drag-handle-control-point.component'; diff --git a/projects/f-flow/src/f-connection/f-connection-builder/f-connection-factory.ts b/projects/f-flow/src/f-connection/f-connection-builder/f-connection-factory.ts index b24a1e97..b1ae2b86 100644 --- a/projects/f-flow/src/f-connection/f-connection-builder/f-connection-factory.ts +++ b/projects/f-flow/src/f-connection/f-connection-builder/f-connection-factory.ts @@ -4,6 +4,7 @@ import { AdaptiveCurveBuilder, EFConnectionType, FBezierPathBuilder, + FCustomPathBuilder, FSegmentPathBuilder, FStraightPathBuilder, } from '../common'; @@ -23,6 +24,8 @@ export class FConnectionFactory { [EFConnectionType.SEGMENT]: new FSegmentPathBuilder(), + [EFConnectionType.CUSTOM_PATH]: new FCustomPathBuilder(), + ...(inject(F_CONNECTION_BUILDERS, { optional: true }) || {}), }; diff --git a/projects/f-flow/src/f-connection/f-connection-builder/i-f-connection-builder-request.ts b/projects/f-flow/src/f-connection/f-connection-builder/i-f-connection-builder-request.ts index 3c132561..31d54618 100644 --- a/projects/f-flow/src/f-connection/f-connection-builder/i-f-connection-builder-request.ts +++ b/projects/f-flow/src/f-connection/f-connection-builder/i-f-connection-builder-request.ts @@ -2,7 +2,6 @@ import { IPoint } from '@foblex/2d'; import { EFConnectableSide } from '../../f-connectors'; export interface IFConnectionBuilderRequest { - source: IPoint; sourceSide: EFConnectableSide; @@ -14,4 +13,6 @@ export interface IFConnectionBuilderRequest { radius: number; offset: number; + + controlPoints?: IPoint[]; } diff --git a/projects/f-flow/src/f-connection/f-connection-for-create/f-connection-for-create.component.ts b/projects/f-flow/src/f-connection/f-connection-for-create/f-connection-for-create.component.ts index de82ac1c..3ca54bf2 100644 --- a/projects/f-flow/src/f-connection/f-connection-for-create/f-connection-for-create.component.ts +++ b/projects/f-flow/src/f-connection/f-connection-for-create/f-connection-for-create.component.ts @@ -64,6 +64,8 @@ export class FConnectionForCreateComponent @Input() public override fType: EFConnectionType | string = EFConnectionType.STRAIGHT; + public override fControlPoints: { x: number; y: number }[] = []; + public override fInputSide = input(EFConnectionConnectableSide.DEFAULT, { transform: (x) => { return castToEnum(x, 'fInputSide', EFConnectionConnectableSide); diff --git a/projects/f-flow/src/f-connection/f-connection/f-connection.component.html b/projects/f-flow/src/f-connection/f-connection/f-connection.component.html index 8c6edd12..02d1befe 100644 --- a/projects/f-flow/src/f-connection/f-connection/f-connection.component.html +++ b/projects/f-flow/src/f-connection/f-connection/f-connection.component.html @@ -10,6 +10,11 @@ } + @if (fType === 'custom-path' && !fDraggingDisabled()) { + @for (point of controlPointsToRender(); track $index) { + + } + } @if (fText) { diff --git a/projects/f-flow/src/f-connection/f-connection/f-connection.component.ts b/projects/f-flow/src/f-connection/f-connection/f-connection.component.ts index 236e19e6..a6eff64d 100644 --- a/projects/f-flow/src/f-connection/f-connection/f-connection.component.ts +++ b/projects/f-flow/src/f-connection/f-connection/f-connection.component.ts @@ -2,6 +2,7 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, + computed, inject, input, Input, @@ -9,8 +10,14 @@ import { OnChanges, OnDestroy, OnInit, + viewChildren, } from '@angular/core'; -import { EFConnectionBehavior, EFConnectionConnectableSide, EFConnectionType } from '../common'; +import { + EFConnectionBehavior, + EFConnectionConnectableSide, + EFConnectionType, + FConnectionDragHandleControlPointComponent, +} from '../common'; import { NotifyDataChangedRequest } from '../../f-storage'; import { F_CONNECTION } from '../common/f-connection.injection-token'; //TODO: Need to deal with cyclic dependencies, since in some cases an error occurs when importing them ../common @@ -69,6 +76,9 @@ export class FConnectionComponent extends FConnectionBase implements OnInit, OnC @Input() public override fType: EFConnectionType | string = EFConnectionType.STRAIGHT; + @Input() + public override fControlPoints: { x: number; y: number }[] = []; + public override fSelectionDisabled = input(false, { transform: booleanAttribute }); public override fReassignableStart = input(false, { transform: booleanAttribute }); @@ -96,6 +106,37 @@ export class FConnectionComponent extends FConnectionBase implements OnInit, OnC private readonly _mediator = inject(FMediator); + public readonly fDragHandleControlPoints = viewChildren( + FConnectionDragHandleControlPointComponent, + ); + + public readonly controlPointsToRender = computed(() => { + // Only render control points for custom-path connections + if (this.fType !== EFConnectionType.CUSTOM_PATH) { + return []; + } + return this.fControlPoints; + }); + + public override redraw(): void { + super.redraw(); + // Redraw control point handles for custom-path connections + if (this.fType === EFConnectionType.CUSTOM_PATH) { + this._redrawControlPointHandles(); + } + } + + private _redrawControlPointHandles(): void { + const handles = this.fDragHandleControlPoints(); + const controlPoints = this.fControlPoints; + + handles.forEach((handle, index) => { + if (index < controlPoints.length) { + handle.redraw(controlPoints[index]); + } + }); + } + public ngOnInit(): void { this._mediator.execute(new AddConnectionToStoreRequest(this)); } diff --git a/projects/f-flow/src/f-connection/f-snap-connection/f-snap-connection.component.ts b/projects/f-flow/src/f-connection/f-snap-connection/f-snap-connection.component.ts index 0ffe656c..4add858b 100644 --- a/projects/f-flow/src/f-connection/f-snap-connection/f-snap-connection.component.ts +++ b/projects/f-flow/src/f-connection/f-snap-connection/f-snap-connection.component.ts @@ -64,6 +64,8 @@ export class FSnapConnectionComponent @Input() public override fType: EFConnectionType | string = EFConnectionType.STRAIGHT; + public override fControlPoints: { x: number; y: number }[] = []; + public override fInputSide = input(EFConnectionConnectableSide.DEFAULT, { transform: (x) => { return castToEnum(x, 'fInputSide', EFConnectionConnectableSide); diff --git a/projects/f-flow/src/f-connection/providers.ts b/projects/f-flow/src/f-connection/providers.ts index 7a241fd4..174bd6bc 100644 --- a/projects/f-flow/src/f-connection/providers.ts +++ b/projects/f-flow/src/f-connection/providers.ts @@ -1,6 +1,10 @@ import { - FConnectionDragHandleEndComponent, FConnectionDragHandleStartComponent, - FConnectionGradientComponent, FConnectionPathComponent, FConnectionSelectionComponent, + FConnectionDragHandleEndComponent, + FConnectionDragHandleStartComponent, + FConnectionDragHandleControlPointComponent, + FConnectionGradientComponent, + FConnectionPathComponent, + FConnectionSelectionComponent, FConnectionTextComponent, FConnectionTextPathDirective, } from './common'; @@ -16,6 +20,7 @@ export const F_CONNECTION_PROVIDERS = [ FConnectionTextPathDirective, FConnectionDragHandleStartComponent, FConnectionDragHandleEndComponent, + FConnectionDragHandleControlPointComponent, FConnectionGradientComponent, FConnectionPathComponent, FConnectionSelectionComponent, diff --git a/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/f-drag-control-point-preparation.execution.ts b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/f-drag-control-point-preparation.execution.ts new file mode 100644 index 00000000..f805e1a7 --- /dev/null +++ b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/f-drag-control-point-preparation.execution.ts @@ -0,0 +1,100 @@ +import { inject, Injectable, Injector } from '@angular/core'; +import { FDragControlPointPreparationRequest } from './f-drag-control-point-preparation.request'; +import { IPoint, ITransformModel, Point } from '@foblex/2d'; +import { FComponentsStore } from '../../../../f-storage'; +import { FDraggableDataContext } from '../../../f-draggable-data-context'; +import { isValidEventTrigger, UpdateItemAndChildrenLayersRequest } from '../../../../domain'; +import { FExecutionRegister, FMediator, IExecution } from '@foblex/mediator'; +import { FConnectionBase } from '../../../../f-connection'; +import { FDragControlPointDragHandler } from '../f-drag-control-point.drag-handler'; +import { isPointerInsideControlPoint } from './is-pointer-inside-control-point'; + +@Injectable() +@FExecutionRegister(FDragControlPointPreparationRequest) +export class FDragControlPointPreparationExecution + implements IExecution +{ + private readonly _fMediator = inject(FMediator); + private readonly _store = inject(FComponentsStore); + private readonly _dragContext = inject(FDraggableDataContext); + private readonly _injector = inject(Injector); + + private _fConnection: FConnectionBase | undefined; + private _controlPointIndex: number = -1; + + private get _transform(): ITransformModel { + return this._store.fCanvas!.transform; + } + + private get _fHost(): HTMLElement { + return this._store.fFlow!.hostElement; + } + + private get _fConnections(): FConnectionBase[] { + return this._store.fConnections; + } + + public handle(request: FDragControlPointPreparationRequest): void { + const position = this._getPointInFlow(request); + if (!this._isValid(position) || !this._isValidTrigger(request)) { + return; + } + + this._dragContext.onPointerDownScale = this._transform.scale; + this._dragContext.onPointerDownPosition = Point.fromPoint(request.event.getPosition()) + .elementTransform(this._fHost) + .div(this._transform.scale); + + this._dragContext.draggableItems = [ + new FDragControlPointDragHandler( + this._injector, + this._fConnection!, + this._controlPointIndex, + ), + ]; + + setTimeout(() => this._updateConnectionLayer()); + } + + private _isValid(position: IPoint): boolean { + const result = this._getConnectionWithControlPoint(position); + this._fConnection = result.connection; + this._controlPointIndex = result.controlPointIndex; + + return !!this._fConnection && this._controlPointIndex >= 0 && !this._dragContext.draggableItems.length; + } + + private _isValidTrigger(request: FDragControlPointPreparationRequest): boolean { + return isValidEventTrigger(request.event.originalEvent, request.fTrigger); + } + + private _getPointInFlow(request: FDragControlPointPreparationRequest): IPoint { + return Point.fromPoint(request.event.getPosition()) + .elementTransform(this._fHost) + .sub(this._transform.scaledPosition) + .sub(this._transform.position) + .div(this._transform.scale); + } + + private _getConnectionWithControlPoint( + position: IPoint, + ): { connection: FConnectionBase | undefined; controlPointIndex: number } { + for (const connection of this._fConnections) { + const result = isPointerInsideControlPoint(connection, position); + if (result.isInside) { + return { connection, controlPointIndex: result.controlPointIndex }; + } + } + + return { connection: undefined, controlPointIndex: -1 }; + } + + private _updateConnectionLayer(): void { + this._fMediator.execute( + new UpdateItemAndChildrenLayersRequest( + this._fConnection!, + this._store.fCanvas!.fConnectionsContainer().nativeElement, + ), + ); + } +} diff --git a/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/f-drag-control-point-preparation.request.ts b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/f-drag-control-point-preparation.request.ts new file mode 100644 index 00000000..642e4f98 --- /dev/null +++ b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/f-drag-control-point-preparation.request.ts @@ -0,0 +1,10 @@ +import { FEventTrigger } from '../../../../domain'; +import { IPointerEvent } from '../../../../drag-toolkit'; + +export class FDragControlPointPreparationRequest { + static readonly fToken = Symbol('FDragControlPointPreparationRequest'); + constructor( + public event: IPointerEvent, + public fTrigger: FEventTrigger, + ) {} +} diff --git a/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/index.ts b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/index.ts new file mode 100644 index 00000000..7933bbea --- /dev/null +++ b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/index.ts @@ -0,0 +1,3 @@ +export * from './f-drag-control-point-preparation.request'; +export * from './f-drag-control-point-preparation.execution'; +export * from './is-pointer-inside-control-point'; diff --git a/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/is-pointer-inside-control-point.ts b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/is-pointer-inside-control-point.ts new file mode 100644 index 00000000..df8bf6af --- /dev/null +++ b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/drag-control-point-preparation/is-pointer-inside-control-point.ts @@ -0,0 +1,28 @@ +import { IPoint } from '@foblex/2d'; +import { FConnectionBase } from '../../../../f-connection'; +import { EFConnectionType } from '../../../../f-connection/common'; + +export function isPointerInsideControlPoint( + connection: FConnectionBase, + position: IPoint, +): { isInside: boolean; controlPointIndex: number } { + // Only check for custom-path connections + if (connection.fType !== EFConnectionType.CUSTOM_PATH || connection.fDraggingDisabled()) { + return { isInside: false, controlPointIndex: -1 }; + } + + const controlPoints = connection.fControlPoints; + + // Check each control point + for (let i = 0; i < controlPoints.length; i++) { + if (_isPointInsideCircle(position, controlPoints[i])) { + return { isInside: true, controlPointIndex: i }; + } + } + + return { isInside: false, controlPointIndex: -1 }; +} + +function _isPointInsideCircle(point: IPoint, circleCenter: IPoint): boolean { + return (point.x - circleCenter.x) ** 2 + (point.y - circleCenter.y) ** 2 <= 8 ** 2; +} diff --git a/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/f-drag-control-point.drag-handler.ts b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/f-drag-control-point.drag-handler.ts new file mode 100644 index 00000000..a26e6225 --- /dev/null +++ b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/f-drag-control-point.drag-handler.ts @@ -0,0 +1,56 @@ +import { IPoint, PointExtensions } from '@foblex/2d'; +import { IFDragHandler } from '../../f-drag-handler'; +import { FConnectionBase } from '../../../f-connection'; +import { Injector } from '@angular/core'; +import { FComponentsStore } from '../../../f-storage'; +import { FDragHandlerResult } from '../../f-drag-handler'; + +export class FDragControlPointDragHandler implements IFDragHandler { + public fEventType = 'drag-control-point'; + public fData: unknown; + + private readonly _result: FDragHandlerResult; + private readonly _store: FComponentsStore; + private readonly _startPosition: IPoint; + + constructor( + private readonly _injector: Injector, + private readonly _connection: FConnectionBase, + private readonly _controlPointIndex: number, + ) { + this._result = _injector.get(FDragHandlerResult); + this._store = _injector.get(FComponentsStore); + + this.fData = { + fConnectionId: this._connection.fId(), + controlPointIndex: this._controlPointIndex, + }; + + // Store the initial position of the control point + this._startPosition = { ...this._connection.fControlPoints[this._controlPointIndex] }; + } + + public prepareDragSequence(): void { + // Mark the connection as being edited + this._connection.hostElement.classList.add('control-point-dragging'); + } + + public onPointerMove(difference: IPoint): void { + // Calculate the new position based on the difference + const newPosition = PointExtensions.sum(this._startPosition, difference); + + // Update the control point in place + this._connection.fControlPoints[this._controlPointIndex] = newPosition; + + // Trigger a redraw of the connection + this._store.fCanvas?.redraw(); + } + + public onPointerUp(): void { + // Remove the dragging class + this._connection.hostElement.classList.remove('control-point-dragging'); + + // Emit the connection change event if needed + this._store.fCanvas?.emitCanvasChangeEvent(); + } +} diff --git a/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/index.ts b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/index.ts new file mode 100644 index 00000000..a0124e1c --- /dev/null +++ b/projects/f-flow/src/f-draggable/f-connection/f-drag-control-point/index.ts @@ -0,0 +1,2 @@ +export * from './f-drag-control-point.drag-handler'; +export * from './drag-control-point-preparation'; diff --git a/projects/f-flow/src/f-draggable/f-connection/index.ts b/projects/f-flow/src/f-draggable/f-connection/index.ts index eae4fbac..b68d143a 100644 --- a/projects/f-flow/src/f-draggable/f-connection/index.ts +++ b/projects/f-flow/src/f-draggable/f-connection/index.ts @@ -2,4 +2,6 @@ export * from './f-create-connection'; export * from './f-reassign-connection'; +export * from './f-drag-control-point'; + export * from './providers'; diff --git a/projects/f-flow/src/f-draggable/f-connection/providers.ts b/projects/f-flow/src/f-draggable/f-connection/providers.ts index 00b344c2..7c8b38b0 100644 --- a/projects/f-flow/src/f-draggable/f-connection/providers.ts +++ b/projects/f-flow/src/f-draggable/f-connection/providers.ts @@ -10,6 +10,7 @@ import { FReassignConnectionFinalizeExecution, FReassignConnectionPreparationExecution, } from './f-reassign-connection'; +import { FDragControlPointPreparationExecution } from './f-drag-control-point'; export const CONNECTIONS_PROVIDERS = [ FCreateConnectionFinalizeExecution, @@ -27,4 +28,6 @@ export const CONNECTIONS_PROVIDERS = [ FReassignConnectionFinalizeExecution, FReassignConnectionPreparationExecution, + + FDragControlPointPreparationExecution, ]; diff --git a/projects/f-flow/src/f-draggable/f-draggable.directive.ts b/projects/f-flow/src/f-draggable/f-draggable.directive.ts index 9357cef8..23a6b64c 100644 --- a/projects/f-flow/src/f-draggable/f-draggable.directive.ts +++ b/projects/f-flow/src/f-draggable/f-draggable.directive.ts @@ -27,6 +27,7 @@ import { FReassignConnectionEvent, FReassignConnectionFinalizeRequest, FReassignConnectionPreparationRequest, + FDragControlPointPreparationRequest, } from './f-connection'; import { FSelectionChangeEvent } from './f-selection-change-event'; import { FMediator } from '@foblex/mediator'; @@ -246,6 +247,10 @@ export class FDraggableDirective this._mediator.execute(new FSingleSelectRequest(event, this.fMultiSelectTrigger)); + this._mediator.execute( + new FDragControlPointPreparationRequest(event, this.fReassignConnectionTrigger), + ); + this._mediator.execute( new FReassignConnectionPreparationRequest(event, this.fReassignConnectionTrigger), ); diff --git a/public/markdown/examples/custom-path-control-points.md b/public/markdown/examples/custom-path-control-points.md new file mode 100644 index 00000000..72cdee96 --- /dev/null +++ b/public/markdown/examples/custom-path-control-points.md @@ -0,0 +1,92 @@ +# Custom Path with Control Points + +## Description + +This feature allows you to create custom connection paths with multiple control points, ideal for industrial diagrams like P&ID (Piping and Instrumentation Diagrams), circuit diagrams, and process flow diagrams where precise manual positioning of connection paths is required. + +Instead of relying on automatic path calculation, you can define exactly where the connection should route by providing an array of intermediate control points between the source and target nodes. + +## Key Features + +- **Multiple Control Points**: Define as many intermediate points as needed for complex paths +- **Rounded Corners**: Automatically applies smooth corners at control points using quadratic bezier curves +- **Flexible Routing**: Perfect for industrial diagrams requiring specific routing patterns +- **Easy to Use**: Simply provide an array of points through the `fControlPoints` input + +## Use Cases + +- **P&ID Diagrams**: Route pipes and flow lines around equipment +- **Circuit Diagrams**: Position wires and traces precisely +- **Process Flow Diagrams**: Control flow paths through different process stages +- **Architectural Plans**: Route connections along specific paths + +## Example + +::: ng-component [height]="700" +[component.html] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.html +[component.ts] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.ts +[component.scss] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component.scss +[common.scss] <<< https://raw.githubusercontent.com/Foblex/f-flow/main/projects/f-examples/_flow-common.scss +::: + +## How to Use + +To create a custom path connection, set the connection type to `custom-path` and provide an array of control points: + +```html + + +``` + +### Parameters + +- **fType**: Set to `"custom-path"` to use the custom path builder +- **fControlPoints**: Array of `{ x: number, y: number }` points defining the intermediate path +- **fRadius**: (Optional) Controls the roundness of corners at control points (default: 8) + +## Advanced Usage + +You can dynamically update control points in your component: + +```typescript +export class MyComponent { + public controlPoints = [ + { x: 100, y: 50 }, + { x: 100, y: 100 }, + { x: 200, y: 100 } + ]; + + public updatePath() { + // Modify control points based on user interaction + this.controlPoints = [ + { x: 150, y: 75 }, + { x: 150, y: 125 } + ]; + } +} +``` + +## Future Enhancements + +While the current implementation allows you to specify control points programmatically, future enhancements may include: + +- Interactive drag handles for each control point +- Visual controls for adding/removing control points +- Snap-to-grid functionality for control points +- Automatic control point optimization + +## Tips + +1. **Start Simple**: Begin with 1-2 control points and add more as needed +2. **Use Rounded Corners**: Set `fRadius` to create smoother, more professional-looking paths +3. **Plan Your Routes**: Sketch out your desired path before defining control points +4. **Store Paths**: Save control point arrays in your data model for persistence diff --git a/src/app/examples.config.ts b/src/app/examples.config.ts index 1de8d767..8cc33464 100644 --- a/src/app/examples.config.ts +++ b/src/app/examples.config.ts @@ -157,6 +157,13 @@ export const EXAMPLES_CONFIGURATION = { '../../projects/f-examples/connections/custom-connection-type/custom-connection-type.component' ), ), + defineLazyComponent( + 'custom-path-control-points', + () => + import( + '../../projects/f-examples/connections/custom-path-control-points/custom-path-control-points.component' + ), + ), defineLazyComponent( 'connection-behaviours', () => @@ -699,6 +706,22 @@ function connectionGroup() { image_type: 'image/png', date: new Date('2024-10-02 20:12:01'), }, + { + link: 'custom-path-control-points', + text: 'Custom Path with Control Points', + description: + 'Create custom connection paths with multiple control points for industrial diagrams like P&ID, circuit diagrams, or process flows. Manually position paths with precise control.', + image: './previews/examples/custom-path-control-points.light.png', + image_dark: './previews/examples/custom-path-control-points.dark.png', + image_width: 791, + image_height: 600, + image_type: 'image/png', + date: new Date('2025-11-11 11:00:00'), + badge: { + text: 'New', + type: 'success', + }, + }, { link: 'connection-behaviours', text: 'Connection Behaviours',