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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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',