diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 007e548..c33921b 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -6,7 +6,7 @@ import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; import { DragContext, DragDiff } from "../../../services/drag"; -import { PortState, TPortId } from "../../../store/connection/port/Port"; +import { PortState, TPort, TPortId } from "../../../store/connection/port/Port"; import { applyAlpha, getXY } from "../../../utils/functions"; import { EventedComponent } from "../EventedComponent/EventedComponent"; import { CursorLayerCursorTypes } from "../layers/cursorLayer"; @@ -117,6 +117,24 @@ export class GraphComponent< return this.ports.get(id); } + /** + * Get all ports of this component + * @returns Array of all port states + */ + public getPorts(): PortState[] { + return Array.from(this.ports.values()); + } + + /** + * Update port position and metadata + * @param id Port identifier + * @param portChanges port changes {x?, y?, meta?} + */ + public updatePort(id: TPortId, portChanges: Partial>): void { + const port = this.getPort(id); + port.updatePort(portChanges); + } + protected setAffectsUsableRect(affectsUsableRect: boolean) { this.setProps({ affectsUsableRect }); this.setContext({ affectsUsableRect }); diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index f88d9d2..5ef6fbb 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -35,6 +35,10 @@ export class Anchor extends GraphComponen public static CANVAS_HOVER_FACTOR = 1.8; public static DETAILED_HOVER_FACTOR = 1.2; + public getEntityId(): number | string { + return this.props.id; + } + public get zIndex() { // @ts-ignore this.__comp.parent instanceOf Block return this.__comp.parent.zIndex + 1; @@ -83,7 +87,7 @@ export class Anchor extends GraphComponen } protected willMount(): void { - this.props.port.addObserver(this); + this.props.port.setOwner(this); this.subscribeSignal(this.connectedState.$selected, (selected) => { this.setState({ selected }); }); @@ -102,6 +106,10 @@ export class Anchor extends GraphComponen this.setHitBox(x - this.shift, y - this.shift, x + this.shift, y + this.shift); }; + public override getPorts(): PortState[] { + return [this.props.port]; + } + /** * Get the position of the anchor. * Returns the position of the anchor in the coordinate system of the graph(ABSOLUTE). @@ -159,7 +167,7 @@ export class Anchor extends GraphComponen } protected unmount() { - this.props.port.removeObserver(this); + this.props.port.removeOwner(); super.unmount(); } diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 318d604..f56df6a 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -111,6 +111,7 @@ declare module "../../../../graphEvents" { * The layer renders on a separate canvas with a higher z-index and handles * all mouse interactions for connection creation. */ + export class ConnectionLayer extends Layer< ConnectionLayerProps, LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } @@ -176,13 +177,17 @@ export class ConnectionLayer extends Layer< if (!this.enabled) { return false; } - if (this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors")) { - return target instanceof Anchor; + const isTargetAllowed = + (target instanceof Anchor && this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors")) || + (isShiftKeyEvent(initEvent) && isBlock(target)); + + if (!isTargetAllowed) { + return false; } - if (isShiftKeyEvent(initEvent) && isBlock(target)) { - return true; + if (this.props.isConnectionAllowed && !this.props.isConnectionAllowed(target.connectedState)) { + return false; } - return false; + return true; } protected handleMouseDown = (nativeEvent: GraphMouseEvent) => { @@ -196,14 +201,6 @@ export class ConnectionLayer extends Layer< this.checkIsShouldStartCreationConnection(target as GraphComponent, initEvent) && (isBlock(target) || target instanceof Anchor) ) { - // Get the source component state - const sourceComponent = target.connectedState; - - // Check if connection is allowed using the validation function if provided - if (this.props.isConnectionAllowed && !this.props.isConnectionAllowed(sourceComponent)) { - return; - } - if (isGraphEvent(nativeEvent)) { nativeEvent.stopGraphEventPropagation(); } diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.md b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.md new file mode 100644 index 0000000..49a06f3 --- /dev/null +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.md @@ -0,0 +1,267 @@ +# PortConnectionLayer + +Port-based connection layer for creating connections between graph elements using ports as the primary abstraction. + +## Overview + +`PortConnectionLayer` is a new layer designed to work exclusively with ports instead of components (Block/Anchor). It provides a more unified and efficient approach to connection creation. + +## Key Differences from ConnectionLayer + +| Feature | ConnectionLayer | PortConnectionLayer | +|---------|----------------|---------------------| +| Primary abstraction | Components (Block/Anchor) | Ports (PortState) | +| Element detection | `getElementOverPoint` → Component | `getElementsOverPoint` → Components → Ports | +| Port lookup | Component → Port | Components under cursor → Filter ports by distance | +| Metadata structure | Direct `IPortSnapMeta` | `meta[PortMetaKey]` | +| Events | Standard events | New `port-*` events with port refs | +| Dependencies | Depends on Block/Anchor classes | Only depends on PortState | + +## Usage + +### Basic Setup + +```typescript +import { PortConnectionLayer } from "@gravity-ui/graph"; + +const GraphApp = () => { + const { graph, addLayer } = useGraph({ + settings: { + canCreateNewConnections: true, + useBlocksAnchors: true, + }, + }); + + useLayoutEffect(() => { + const layer = addLayer(PortConnectionLayer, { + searchRadius: 30, // Port detection radius in pixels + createIcon: { /* icon config */ }, + point: { /* point icon config */ }, + drawLine: (start, end) => { /* custom line renderer */ } + }); + + return () => layer.detachLayer(); + }, []); +}; +``` + +### Configuring Port Snapping + +```typescript +class MagneticBlock extends CanvasBlock { + protected override willMount(): void { + super.willMount(); + + this.state.anchors?.forEach((anchor) => { + const portId = createAnchorPortId(this.state.id, anchor.id); + + // Configure port metadata using PortMetaKey + this.updatePort(portId, undefined, undefined, { + [PortConnectionLayer.PortMetaKey]: { + snappable: true, + snapCondition: (ctx) => { + // Custom validation logic + const sameBlock = ctx.sourcePort.owner === ctx.targetPort.owner; + return !sameBlock; + } + } + }); + }); + } +} +``` + +## Port Metadata API + +### PortMetaKey + +Unique symbol key for storing layer-specific metadata in ports: + +```typescript +PortConnectionLayer.PortMetaKey // Symbol.for("PortConnectionLayer.PortMeta") +``` + +### IPortConnectionMeta + +```typescript +interface IPortConnectionMeta { + snappable?: boolean; + snapCondition?: TPortSnapCondition; +} + +type TPortSnapCondition = (context: { + sourcePort: PortState; + targetPort: PortState; + cursorPosition: TPoint; + distance: number; +}) => boolean; +``` + +## Performance Optimization + +PortConnectionLayer uses several optimizations for efficient port snapping: + +### Viewport-based Filtering + +The snapping system only considers ports from components visible in the current viewport (with padding). This significantly improves performance on large graphs: + +- **RBush spatial index**: Fast nearest-neighbor search for ports +- **Viewport filtering**: Only includes ports from visible components +- **Lazy rebuild**: Snapping tree is rebuilt only when needed: + - When ports change (new ports added, removed, or updated) + - When camera moves (viewport changes) + - When connection creation starts + +### Memory Efficiency + +The spatial index is automatically rebuilt when: +1. Components enter or leave the viewport +2. Port metadata changes +3. Ports are added or removed + +This ensures the snapping system stays accurate while minimizing memory usage. + +## Events + +PortConnectionLayer emits new events with extended parameters: + +### port-connection-create-start + +Fired when connection creation starts from a port. + +```typescript +graph.on("port-connection-create-start", (event) => { + const { blockId, anchorId, sourcePort } = event.detail; + console.log("Starting connection from port:", sourcePort.id); + console.log("Port metadata:", sourcePort.meta); +}); +``` + +### port-connection-create-hover + +Fired when hovering over a potential target port. + +```typescript +graph.on("port-connection-create-hover", (event) => { + const { sourcePort, targetPort } = event.detail; + if (targetPort) { + console.log("Hovering over target port:", targetPort.id); + } +}); +``` + +### port-connection-created + +Fired when a connection is successfully created. + +```typescript +graph.on("port-connection-created", (event) => { + const { sourcePort, targetPort, sourceBlockId, targetBlockId } = event.detail; + console.log("Connection created between ports:", sourcePort.id, "->", targetPort.id); + + // Access port metadata + const sourceMeta = sourcePort.meta?.[PortConnectionLayer.PortMetaKey]; + const targetMeta = targetPort.meta?.[PortConnectionLayer.PortMetaKey]; +}); +``` + +### port-connection-create-drop + +Fired when the mouse is released (regardless of success). + +```typescript +graph.on("port-connection-create-drop", (event) => { + const { sourcePort, targetPort, point } = event.detail; + console.log("Connection dropped at:", point); +}); +``` + +## Advanced Examples + +### Data Type Validation + +```typescript +class TypedBlock extends CanvasBlock { + protected override willMount(): void { + super.willMount(); + + this.state.anchors?.forEach((anchor) => { + const portId = createAnchorPortId(this.state.id, anchor.id); + const dataType = anchor.type === EAnchorType.IN ? "number" : "string"; + + this.updatePort(portId, undefined, undefined, { + [PortConnectionLayer.PortMetaKey]: { + snappable: true, + snapCondition: (ctx) => { + // Check data types match + const sourceMeta = ctx.sourcePort.meta as { dataType?: string }; + const targetMeta = ctx.targetPort.meta as { dataType?: string }; + + return sourceMeta?.dataType === targetMeta?.dataType; + } + }, + dataType: dataType + }); + }); + } +} +``` + +### Distance-Based Validation + +```typescript +const snapMeta: IPortConnectionMeta = { + snappable: true, + snapCondition: (ctx) => { + // Only snap if very close (within 10px) + return ctx.distance <= 10; + } +}; +``` + +## Benefits + +1. **Unified API**: Work with ports directly, no component type checks needed +2. **Better Performance**: + - First finds components under cursor using `getElementsOverPoint` + - Then checks only their ports instead of all ports in the graph + - More efficient for graphs with many ports +3. **Namespace Safety**: Symbol-based metadata keys prevent conflicts +4. **Enhanced Events**: Direct access to port objects and metadata +5. **Type Safety**: Better TypeScript inference with port-first approach +6. **Backward Compatible**: Existing ConnectionLayer continues to work + +## Migration from ConnectionLayer + +PortConnectionLayer is a drop-in replacement for ConnectionLayer: + +```typescript +// Old way +addLayer(ConnectionLayer, { /* props */ }); + +// New way +addLayer(PortConnectionLayer, { /* same props */ }); +``` + +Update your blocks to use the new metadata structure: + +```typescript +// Old metadata structure (ConnectionLayer) +this.updatePort(portId, undefined, undefined, { + snappable: true, + snapCondition: (ctx) => { /* ... */ } +}); + +// New metadata structure (PortConnectionLayer) +this.updatePort(portId, undefined, undefined, { + [PortConnectionLayer.PortMetaKey]: { + snappable: true, + snapCondition: (ctx) => { /* ... */ } + } +}); +``` + +## See Also + +- [ConnectionLayer](../connectionLayer/ConnectionLayer.md) - Original component-based connection layer +- [Port System](../../../../store/connection/port/Port.ts) - Port state management diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts new file mode 100644 index 0000000..8d94461 --- /dev/null +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -0,0 +1,725 @@ +import RBush from "rbush"; + +import { GraphMouseEvent, extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; +import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; +import { ESelectionStrategy } from "../../../../services/selection"; +import { EAnchorType } from "../../../../store/anchor/Anchor"; +import { TBlockId } from "../../../../store/block/Block"; +import { PortState } from "../../../../store/connection/port/Port"; +import { vectorDistance } from "../../../../utils/functions"; +import { render } from "../../../../utils/renderers/render"; +import { renderSVG } from "../../../../utils/renderers/svgPath"; +import { Point, TPoint } from "../../../../utils/types/shapes"; +import { Anchor } from "../../../canvas/anchors"; +import { Block } from "../../../canvas/blocks/Block"; +import { GraphComponent } from "../../GraphComponent"; + +/** + * Default search radius for port detection and snapping in pixels + */ +const PORT_SEARCH_RADIUS = 20; + +export type TPortSnapConditionContext = { + sourcePort: PortState; + targetPort: PortState; + cursorPosition: TPoint; + distance: number; +}; +export type TPortSnapCondition = (context: TPortSnapConditionContext) => boolean; + +/** + * Port metadata structure for PortConnectionLayer + * This structure is stored under PortConnectionLayer.PortMetaKey in port.meta + */ +export interface IPortConnectionMeta { + /** Enable snapping for this port. If false or undefined, port will not participate in snapping */ + snappable?: boolean; + /** Custom condition for snapping - validates if connection is allowed */ + snapCondition?: TPortSnapCondition; +} + +type TIcon = { + path: string; + fill?: string; + stroke?: string; + width: number; + height: number; + viewWidth: number; + viewHeight: number; +}; + +type LineStyle = { + color: string; + width?: number; + dash: number[]; +}; + +type DrawLineFunction = (start: TPoint, end: TPoint) => { path: Path2D; style: LineStyle }; + +type SnappingPortBox = { + minX: number; + minY: number; + maxX: number; + maxY: number; + port: PortState; +}; + +type PortConnectionLayerProps = LayerProps & { + createIcon?: TIcon; + point?: TIcon; + drawLine?: DrawLineFunction; + searchRadius?: number; + lineWidth?: number; + lineDash?: number[]; +}; + +declare module "../../../../graphEvents" { + interface GraphEventsDefinitions { + /** + * Port-based event fired when a user initiates a connection from a port. + * Extends connection-create-start with direct port reference. + */ + "port-connection-create-start": ( + event: CustomEvent<{ + blockId: TBlockId; + anchorId: string | undefined; + sourcePort: PortState; + }> + ) => void; + + /** + * Port-based event fired when the connection hovers over a potential target port. + * Extends connection-create-hover with direct port references. + */ + "port-connection-create-hover": ( + event: CustomEvent<{ + sourceBlockId: TBlockId; + sourceAnchorId: string | undefined; + targetBlockId: TBlockId | undefined; + targetAnchorId: string | undefined; + sourcePort: PortState; + targetPort?: PortState; + }> + ) => void; + + /** + * Port-based event fired when a connection is successfully created between two ports. + * Extends connection-created with direct port references. + */ + "port-connection-created": ( + event: CustomEvent<{ + sourceBlockId: TBlockId; + sourceAnchorId?: string; + targetBlockId: TBlockId; + targetAnchorId?: string; + sourcePort: PortState; + targetPort: PortState; + }> + ) => void; + + /** + * Port-based event fired when the user releases the mouse button. + * Extends connection-create-drop with direct port references. + */ + "port-connection-create-drop": ( + event: CustomEvent<{ + sourceBlockId: TBlockId; + sourceAnchorId: string; + targetBlockId?: TBlockId; + targetAnchorId?: string; + point: Point; + sourcePort: PortState; + targetPort?: PortState; + }> + ) => void; + } +} + +/** + * PortConnectionLayer - new layer for creating connections, working only with ports + * + * Key differences from ConnectionLayer: + * - Works only with ports, (use Block and Anchor components only to pass more info about ports in Event) + * - Uses findPortAtPoint to detect ports under cursor + * - Metadata is stored under unique PortConnectionLayer.PortMetaKey key + * - More efficient search through spatial index + * - Events extended with sourcePort and targetPort parameters + * + * @example + * ```typescript + * // Configure port for snapping + * port.updatePort({ + * meta: { + * [PortConnectionLayer.PortMetaKey]: { + * snappable: true, + * snapCondition: (ctx) => { + * // Custom validation logic + * return true; + * } + * } + * } + * }); + * ``` + */ +export class PortConnectionLayer extends Layer< + PortConnectionLayerProps, + LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } +> { + /** + * Unique key for port metadata + * Using a symbol prevents conflicts with other layers + */ + static readonly PortMetaKey = Symbol.for("PortConnectionLayer.PortMeta"); + + private startState: Point | null = null; + private endState: Point | null = null; + + private sourcePort?: PortState; + private targetPort?: PortState; + + private snappingPortsTree: RBush | null = null; + private isSnappingTreeOutdated = true; + private portsUnsubscribe?: () => void; + + protected enabled: boolean; + + constructor(props: PortConnectionLayerProps) { + super({ + canvas: { + zIndex: 4, + classNames: ["no-pointer-events"], + transformByCameraPosition: true, + ...props.canvas, + }, + ...props, + }); + + this.setContext({ + canvas: this.getCanvas(), + graphCanvas: props.graph.getGraphCanvas(), + ctx: this.getCanvas().getContext("2d"), + camera: props.camera, + constants: this.props.graph.graphConstants, + colors: this.props.graph.graphColors, + graph: this.props.graph, + }); + + this.enabled = Boolean(this.props.graph.rootStore.settings.getConfigFlag("canCreateNewConnections")); + + this.onSignal(this.props.graph.rootStore.settings.$settings, (value) => { + this.enabled = Boolean(value.canCreateNewConnections); + }); + } + + protected afterInit(): void { + this.onGraphEvent("mousedown", this.handleMouseDown, { capture: true }); + + // Subscribe to ports changes + const checkPortsChanged = () => { + this.isSnappingTreeOutdated = true; + }; + + this.portsUnsubscribe = this.onSignal(this.context.graph.rootStore.connectionsList.ports.$ports, checkPortsChanged); + + // Subscribe to camera changes to invalidate tree when viewport changes + this.onGraphEvent("camera-change", () => { + this.isSnappingTreeOutdated = true; + }); + + super.afterInit(); + } + + public enable = (): void => { + this.enabled = true; + }; + + public disable = (): void => { + this.enabled = false; + }; + + protected handleMouseDown = (nativeEvent: GraphMouseEvent): void => { + if (!this.enabled) { + return; + } + const initEvent = extractNativeGraphMouseEvent(nativeEvent); + const initialComponent = nativeEvent.detail.target as GraphComponent; + if (!initEvent || !this.root?.ownerDocument || !initialComponent) { + return; + } + if (!(initialComponent instanceof GraphComponent) || initialComponent.getPorts().length === 0) { + return; + } + if (isGraphEvent(nativeEvent)) { + nativeEvent.stopGraphEventPropagation(); + } + // DragService will provide world coordinates in callbacks + this.context.graph.dragService.startDrag( + { + onStart: (_event, coords) => { + const point = new Point(coords[0], coords[1]); + const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; + + const port = this.context.graph.rootStore.connectionsList.ports.findPortAtPointByComponent( + initialComponent, + point, + searchRadius + ); + if (port) { + this.onStartConnection(port, point); + } + }, + onUpdate: (event, coords) => this.onMoveNewConnection(event, new Point(coords[0], coords[1])), + onEnd: (_event, coords) => this.onEndNewConnection(new Point(coords[0], coords[1])), + }, + { cursor: "crosshair", initialEvent: initEvent } + ); + }; + + protected renderEndpoint(ctx: CanvasRenderingContext2D): void { + ctx.beginPath(); + + const scale = this.context.camera.getCameraScale(); + const iconSize = 24 / scale; + const iconOffset = 12 / scale; + + if (!this.targetPort && this.props.createIcon && this.endState) { + renderSVG( + { + path: this.props.createIcon.path, + width: this.props.createIcon.width, + height: this.props.createIcon.height, + iniatialWidth: this.props.createIcon.viewWidth, + initialHeight: this.props.createIcon.viewHeight, + }, + ctx, + { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize } + ); + } else if (this.props.point) { + ctx.fillStyle = this.props.point.fill || this.context.colors.canvas.belowLayerBackground; + if (this.props.point.stroke) { + ctx.strokeStyle = this.props.point.stroke; + } + + renderSVG( + { + path: this.props.point.path, + width: this.props.point.width, + height: this.props.point.height, + iniatialWidth: this.props.point.viewWidth, + initialHeight: this.props.point.viewHeight, + }, + ctx, + { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize } + ); + } + ctx.closePath(); + } + + protected render(): void { + this.resetTransform(); + if (!this.startState || !this.endState) { + return; + } + + if (this.props.drawLine) { + const { path, style } = this.props.drawLine(this.startState, this.endState); + + this.context.ctx.lineWidth = this.context.camera.limitScaleEffect(style.width || 3); + this.context.ctx.strokeStyle = style.color; + this.context.ctx.setLineDash(style.dash); + this.context.ctx.stroke(path); + } else { + this.context.ctx.beginPath(); + this.context.ctx.lineWidth = this.context.camera.limitScaleEffect(3); + this.context.ctx.strokeStyle = this.context.colors.connection.selectedBackground; + this.context.ctx.moveTo(this.startState.x, this.startState.y); + this.context.ctx.lineTo(this.endState.x, this.endState.y); + this.context.ctx.stroke(); + this.context.ctx.closePath(); + } + + render(this.context.ctx, (ctx) => { + this.renderEndpoint(ctx); + }); + } + + private onStartConnection(port: PortState, _worldCoords: Point): void { + if (!port) { + return; + } + + const params = this.getEventParams(port); + + this.sourcePort = port; + this.startState = new Point(port.x, port.y); + + this.context.graph.executеDefaultEventAction( + "port-connection-create-start", + { + blockId: params.blockId, + anchorId: params.anchorId, + sourcePort: port, + }, + () => { + this.selectPort(port, true); + } + ); + + this.performRender(); + } + + private onMoveNewConnection(event: MouseEvent, point: Point): void { + if (!this.startState || !this.sourcePort) { + return; + } + + // Try to snap to nearby port first + const snapResult = this.findNearestSnappingPort(point, this.sourcePort); + + let actualEndPoint = point; + let newTargetPort: PortState | undefined; + + if (snapResult) { + // Snap to port + actualEndPoint = new Point(snapResult.snapPoint.x, snapResult.snapPoint.y); + newTargetPort = snapResult.port; + } else { + // Try to find port at cursor without snapping + const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; + newTargetPort = this.context.graph.rootStore.connectionsList.ports.findPortAtPoint(point, searchRadius, (p) => { + return Boolean(p.owner) && p.id !== this.sourcePort?.id; + }); + } + + this.endState = new Point(actualEndPoint.x, actualEndPoint.y); + this.performRender(); + + // Handle target port change + if (newTargetPort !== this.targetPort) { + this.selectPort(this.targetPort, false); + + this.targetPort = newTargetPort; + + if (newTargetPort) { + const sourceParams = this.getEventParams(this.sourcePort); + const targetParams = this.getEventParams(newTargetPort); + + this.context.graph.executеDefaultEventAction( + "port-connection-create-hover", + { + sourceBlockId: sourceParams.blockId, + sourceAnchorId: sourceParams.anchorId, + targetBlockId: targetParams.blockId, + targetAnchorId: targetParams.anchorId, + sourcePort: this.sourcePort, + targetPort: newTargetPort, + }, + () => { + this.selectPort(newTargetPort, true); + } + ); + } + } + } + + protected selectPort(port: PortState, select: boolean): void { + if (!port) return; + const component = port.owner; + if (component instanceof GraphComponent) { + const bucket = this.context.graph.rootStore.selectionService.getBucketByElement(component); + if (!bucket) { + return; + } + if (select) { + bucket.select([component.getEntityId()], ESelectionStrategy.REPLACE); + } else { + bucket.deselect([component.getEntityId()]); + } + } + } + + private onEndNewConnection(point: Point): void { + if (!this.sourcePort || !this.startState || !this.endState) { + return; + } + + // Try to find target port at drop point using same logic as onMove + // First try snapping, then fallback to direct search + let targetPort: PortState | undefined; + + // Try snapping first + const snapResult = this.findNearestSnappingPort(point, this.sourcePort); + if (snapResult) { + targetPort = snapResult.port; + } else { + // Fallback: try to find port at drop point + const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; + targetPort = this.context.graph.rootStore.connectionsList.ports.findPortAtPoint(point, searchRadius, (p) => { + return Boolean(p.owner) && p.id !== this.sourcePort?.id; + }); + } + + this.startState = null; + this.endState = null; + this.performRender(); + + const sourceParams = this.getEventParams(this.sourcePort); + + if (!targetPort) { + // Drop without target + this.context.graph.executеDefaultEventAction( + "port-connection-create-drop", + { + sourceBlockId: sourceParams.blockId, + sourceAnchorId: sourceParams.anchorId, + point, + sourcePort: this.sourcePort, + }, + () => {} + ); + + // Cleanup + this.sourcePort = undefined; + this.targetPort = undefined; + return; + } + + // Determine port types to ensure correct connection direction (OUT -> IN) + const sourceType = this.getPortType(this.sourcePort); + const targetType = this.getPortType(targetPort); + + // Determine actual source and target based on port types + let actualSourcePort = this.sourcePort; + let actualTargetPort = targetPort; + + // If source is IN and target is OUT, swap them + if (sourceType === EAnchorType.IN && targetType === EAnchorType.OUT) { + actualSourcePort = targetPort; + actualTargetPort = this.sourcePort; + } + + const actualSourceParams = this.getEventParams(actualSourcePort); + const actualTargetParams = this.getEventParams(actualTargetPort); + + // Create connection + this.context.graph.executеDefaultEventAction( + "port-connection-created", + { + sourceBlockId: actualSourceParams.blockId, + sourceAnchorId: actualSourceParams.anchorId, + targetBlockId: actualTargetParams.blockId, + targetAnchorId: actualTargetParams.anchorId, + sourcePort: actualSourcePort, + targetPort: actualTargetPort, + }, + () => { + this.context.graph.rootStore.connectionsList.addConnection({ + sourceBlockId: actualSourceParams.blockId, + sourceAnchorId: actualSourceParams.anchorId, + targetBlockId: actualTargetParams.blockId, + targetAnchorId: actualTargetParams.anchorId, + }); + } + ); + + this.selectPort(this.sourcePort, false); + this.selectPort(targetPort, false); + + const targetParams = this.getEventParams(targetPort); + + // Drop event + this.context.graph.executеDefaultEventAction( + "port-connection-create-drop", + { + sourceBlockId: sourceParams.blockId, + sourceAnchorId: sourceParams.anchorId, + targetBlockId: targetParams.blockId, + targetAnchorId: targetParams.anchorId, + point, + sourcePort: this.sourcePort, + targetPort: targetPort, + }, + () => {} + ); + + // Cleanup + this.sourcePort = undefined; + this.targetPort = undefined; + } + + private findNearestSnappingPort( + point: TPoint, + sourcePort?: PortState + ): { port: PortState; snapPoint: TPoint } | null { + this.rebuildSnappingTree(); + + if (!this.snappingPortsTree) { + return null; + } + + const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; + const candidates = this.snappingPortsTree.search({ + minX: point.x - searchRadius, + minY: point.y - searchRadius, + maxX: point.x + searchRadius, + maxY: point.y + searchRadius, + }); + + if (candidates.length === 0) { + return null; + } + + let nearestPort: PortState | null = null; + let nearestDistance = Infinity; + + for (const candidate of candidates) { + const port = candidate.port; + + // Skip source port + if (sourcePort && port.id === sourcePort.id) { + continue; + } + + // Calculate vector distance + const distance = vectorDistance(point, port); + + // Check custom condition if provided + const meta = port.meta?.[PortConnectionLayer.PortMetaKey] as IPortConnectionMeta | undefined; + if (meta?.snapCondition && sourcePort) { + const canSnap = meta.snapCondition({ + sourcePort: sourcePort, + targetPort: port, + cursorPosition: point, + distance, + }); + + if (!canSnap) { + continue; + } + } + + // Update nearest port + if (distance < nearestDistance) { + nearestDistance = distance; + nearestPort = port; + } + } + + if (!nearestPort) { + return null; + } + + return { + port: nearestPort, + snapPoint: { x: nearestPort.x, y: nearestPort.y }, + }; + } + + /** + * Rebuild the RBush spatial index for snapping ports + * Optimization: Only includes ports from components visible in viewport + padding + */ + private rebuildSnappingTree(): void { + if (!this.isSnappingTreeOutdated) { + return; + } + + const snappingBoxes: SnappingPortBox[] = []; + const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; + + // Get only visible components in viewport (with padding already applied) + const visibleComponents = this.context.graph.getElementsInViewport([GraphComponent]); + + // Collect ports from visible components only + for (const component of visibleComponents) { + const ports = component.getPorts(); + + for (const port of ports) { + // Skip ports in lookup state (no valid coordinates) + if (port.lookup) continue; + + const meta = port.meta?.[PortConnectionLayer.PortMetaKey] as IPortConnectionMeta | undefined; + + if (meta?.snappable) { + snappingBoxes.push({ + minX: port.x - searchRadius, + minY: port.y - searchRadius, + maxX: port.x + searchRadius, + maxY: port.y + searchRadius, + port: port, + }); + } + } + } + + this.snappingPortsTree = new RBush(9); + if (snappingBoxes.length > 0) { + this.snappingPortsTree.load(snappingBoxes); + } + + this.isSnappingTreeOutdated = false; + } + + /** + * Determine the port type (IN or OUT) + * @param port Port to check + * @returns EAnchorType.IN, EAnchorType.OUT, or null if the port is a block point (no specific direction) + */ + private getPortType(port: PortState): EAnchorType | null { + const component = port.owner; + + if (!component) { + return null; + } + + // For Anchor components, get the anchor type + if (component instanceof Anchor) { + const anchorType = component.connectedState.state.type; + if (anchorType === EAnchorType.IN || anchorType === EAnchorType.OUT) { + return anchorType; + } + } + + // For Block points, return null (no specific direction) + return null; + } + + /** + * Get full event parameters from a port + * Includes both legacy parameters (blockId, anchorId) and new port reference + */ + private getEventParams(port: PortState): { + blockId: TBlockId; + anchorId?: string; + } { + const component = port.owner; + + if (!component) { + throw new Error("Port has no owner component"); + } + + if (component instanceof Anchor) { + return { + blockId: component.connectedState.blockId, + anchorId: component.connectedState.id, + }; + } + + if (component instanceof Block) { + return { + blockId: component.connectedState.id, + }; + } + + throw new Error("Port owner is not Block or Anchor"); + } + + public override unmount(): void { + if (this.portsUnsubscribe) { + this.portsUnsubscribe(); + this.portsUnsubscribe = undefined; + } + this.snappingPortsTree = null; + super.unmount(); + } +} diff --git a/src/components/canvas/layers/portConnectionLayer/index.ts b/src/components/canvas/layers/portConnectionLayer/index.ts new file mode 100644 index 0000000..7840a47 --- /dev/null +++ b/src/components/canvas/layers/portConnectionLayer/index.ts @@ -0,0 +1,2 @@ +export { PortConnectionLayer } from "./PortConnectionLayer"; +export type { IPortConnectionMeta, TPortSnapCondition } from "./PortConnectionLayer"; diff --git a/src/index.ts b/src/index.ts index 73628fa..d94979f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ export { EAnchorType } from "./store/anchor/Anchor"; export type { BlockState, TBlockId } from "./store/block/Block"; export type { ConnectionState, TConnection, TConnectionId } from "./store/connection/ConnectionState"; export type { AnchorState } from "./store/anchor/Anchor"; +export type { TPort, TPortId } from "./store/connection/port/Port"; +export { createAnchorPortId, createBlockPointPortId, createPortId } from "./store/connection/port/utils"; export { ECanChangeBlockGeometry, ECanDrag } from "./store/settings"; export { type TMeasureTextOptions, type TWrapText } from "./utils/functions/text"; export { ESchedulerPriority } from "./lib/Scheduler"; @@ -26,6 +28,7 @@ export { applyAlpha, clearColorCache } from "./utils/functions/color"; export * from "./components/canvas/groups"; +export * from "./components/canvas/layers/portConnectionLayer"; export * from "./components/canvas/layers/newBlockLayer/NewBlockLayer"; export * from "./components/canvas/layers/connectionLayer/ConnectionLayer"; export * from "./lib/Component"; diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts index 6b91006..0882d26 100644 --- a/src/services/drag/DragService.ts +++ b/src/services/drag/DragService.ts @@ -320,10 +320,15 @@ export class DragService { * ``` */ public startDrag(callbacks: DragOperationCallbacks, options: DragOperationOptions = {}): void { - const { document: doc, cursor, autopanning = true, stopOnMouseLeave, threshold } = options; + const { document: doc, cursor, autopanning = true, stopOnMouseLeave, threshold, initialEvent } = options; const { onStart, onUpdate, onEnd } = callbacks; const targetDocument = doc ?? this.graph.getGraphCanvas().ownerDocument; + let initialCoords: [number, number] | null = null; + if (threshold && initialEvent) { + const coords = this.getWorldCoords(initialEvent); + initialCoords = coords; + } dragListener(targetDocument, { graph: this.graph, @@ -333,8 +338,7 @@ export class DragService { threshold, }) .on(EVENTS.DRAG_START, (event: MouseEvent) => { - const coords = this.getWorldCoords(event); - onStart?.(event, coords); + onStart?.(event, initialCoords ?? this.getWorldCoords(event)); }) .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { const coords = this.getWorldCoords(event); diff --git a/src/services/drag/types.ts b/src/services/drag/types.ts index 556073b..4f6d44f 100644 --- a/src/services/drag/types.ts +++ b/src/services/drag/types.ts @@ -19,6 +19,11 @@ export type DragOperationOptions = { * If not set, uses graph's dragThreshold setting. */ threshold?: number; + /** + * Initial event to use for threshold calculation. + * If not set, uses the first mousemove event. + */ + initialEvent?: MouseEvent; }; /** diff --git a/src/store/connection/ConnectionList.ts b/src/store/connection/ConnectionList.ts index 12b1cb5..2cba204 100644 --- a/src/store/connection/ConnectionList.ts +++ b/src/store/connection/ConnectionList.ts @@ -48,7 +48,11 @@ export class ConnectionsStore { return this.connectionSelectionBucket.$selectedComponents.value as BaseConnection[]; }); - protected ports: PortsStore; + /** + * Ports store instance + * Note: Made public to allow subscription to port changes + */ + public ports: PortsStore; constructor( public rootStore: RootStore, @@ -128,6 +132,14 @@ export class ConnectionsStore { return this.ports.getPort(id); } + /** + * Get all ports + * @returns Array of all port states + */ + public getAllPorts(): PortState[] { + return this.ports.$ports.value; + } + /** * Check if a port exists * @param id Port identifier diff --git a/src/store/connection/port/Port.ts b/src/store/connection/port/Port.ts index f8e480f..cfc426b 100644 --- a/src/store/connection/port/Port.ts +++ b/src/store/connection/port/Port.ts @@ -11,7 +11,7 @@ export type TPortId = string | number | symbol; * Port data structure * Represents a connection point that can be attached to blocks, anchors, or custom components */ -export type TPort = { +export type TPort = { /** Unique identifier for the port */ id: TPortId; /** X coordinate of the port */ @@ -22,6 +22,8 @@ export type TPort = { component?: Component; /** Whether the port is waiting for position data from its component */ lookup?: boolean; + /** Arbitrary metadata (port doesn't know what's inside) */ + meta?: T; }; /** @@ -44,8 +46,8 @@ export type TPort = { * Tracks which components are listening to this port's changes. When no listeners * remain and no component owns the port, it can be safely garbage collected. */ -export class PortState { - public $state = signal(undefined); +export class PortState { + public $state = signal>(undefined); public owner?: Component; @@ -106,7 +108,16 @@ export class PortState { return this.$state.value.lookup; } - constructor(port: TPort) { + /** + * Get the port's metadata + * + * @returns {T | undefined} The metadata attached to this port + */ + public get meta(): T | undefined { + return this.$state.value.meta; + } + + constructor(port: TPort) { this.$state.value = { ...port }; // Initialize owner if component was provided in the constructor if (port.component) { @@ -167,8 +178,15 @@ export class PortState { * Update port state with partial data * @param port Partial port data to merge with current state */ - public updatePort(port: Partial): void { - this.$state.value = { ...this.$state.value, ...port }; + public updatePort(port: Partial>): void { + this.$state.value = { + ...this.$state.value, + ...port, + meta: { + ...this.$state.value.meta, + ...port.meta, + }, + }; } /** diff --git a/src/store/connection/port/PortList.ts b/src/store/connection/port/PortList.ts index 9aacf82..2f00d85 100644 --- a/src/store/connection/port/PortList.ts +++ b/src/store/connection/port/PortList.ts @@ -3,6 +3,8 @@ import { computed, signal } from "@preact/signals-core"; import { GraphComponent } from "../../../components/canvas/GraphComponent"; import { Graph } from "../../../graph"; import { Component, ESchedulerPriority } from "../../../lib"; +import { vectorDistance } from "../../../utils/functions"; +import { Point, TPoint } from "../../../utils/types/shapes"; import { debounce } from "../../../utils/utils/schedule"; import { RootStore } from "../../index"; @@ -105,4 +107,71 @@ export class PortsStore { public reset(): void { this.clearPorts(); } + + /** + * Find the nearest port at given world coordinates + * First finds components under cursor, then checks their ports + * + * @param point World coordinates to search from + * @param searchRadius Maximum search radius in pixels (default: 0 - exact match) + * @param filter Optional filter function to validate ports + * @returns Nearest port within radius, or undefined if none found + * + * @example + * ```typescript + * const port = portsStore.findPortAtPoint( + * { x: 100, y: 200 }, + * 30, + * (port) => !port.lookup && port.owner !== undefined + * ); + * ``` + */ + public findPortAtPoint( + point: TPoint, + searchRadius = 0, + filter?: (port: PortState) => boolean + ): PortState | undefined { + // Get all components under cursor (GraphComponent only) + const pointObj = new Point(point.x, point.y); + const component = this.graph.getElementOverPoint(pointObj, [GraphComponent]); + if (!component) { + return undefined; + } + return this.findPortAtPointByComponent(component, point, searchRadius, filter); + } + + /** + * Find the nearest port at given world coordinates for a specific component + * + * @param component Component to search in + * @param point World coordinates to search from + * @param searchRadius Maximum search radius in pixels (default: 0 - exact match) + * @param filter Optional filter function to validate ports + * @returns Nearest port within radius, or undefined if none found + */ + public findPortAtPointByComponent( + component: GraphComponent, + point: TPoint, + searchRadius = 0, + filter?: (port: PortState) => boolean + ): PortState | undefined { + const ports = component.getPorts(); + + for (const port of ports) { + // Skip ports in lookup state (no valid coordinates) + if (port.lookup) continue; + + // Calculate vector distance + const distance = vectorDistance(point, port); + + // Check if within radius and closer than previous + if (distance <= searchRadius) { + // Apply optional filter + if (filter && !filter(port)) continue; + return port; + } + } + + return undefined; + } } diff --git a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx new file mode 100644 index 0000000..754e4df --- /dev/null +++ b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; + +import { ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryFn } from "@storybook/react-webpack5"; + +import { Anchor, CanvasBlock, EAnchorType, ECanDrag, Graph } from "../../../"; +import { TBlock } from "../../../components/canvas/blocks/Block"; +import { + IPortConnectionMeta, + PortConnectionLayer, +} from "../../../components/canvas/layers/portConnectionLayer/PortConnectionLayer"; +import { GraphCanvas, useGraph } from "../../../react-components"; +import { createAnchorPortId } from "../../../store/connection/port/utils"; +import { BlockStory } from "../../main/Block"; + +import "@gravity-ui/uikit/styles/styles.css"; + +/** + * Helper function to check if two ports belong to the same block + */ +function isSameBlock(sourcePort: { owner?: unknown }, targetPort: { owner?: unknown }): boolean { + return sourcePort.owner === targetPort.owner; +} + +/** + * Helper function to check if connection is valid (IN↔OUT bidirectional) + */ +function isValidConnection(sourcePort: { owner?: unknown }, targetPort: { owner?: unknown }): boolean { + const sourceComponent = sourcePort.owner; + const targetComponent = targetPort.owner; + + if (!sourceComponent || !targetComponent) { + return true; + } + + const isSourceAnchor = sourceComponent instanceof Anchor; + const isTargetAnchor = targetComponent instanceof Anchor; + + if (isSourceAnchor && isTargetAnchor) { + const sourceType = sourceComponent.connectedState.state.type; + const targetType = targetComponent.connectedState.state.type; + return sourceType !== targetType; + } + + return true; +} + +/** + * Custom block with port-based snapping using PortConnectionLayer + * Demonstrates the new port-centric approach + * Rules: + * - Can connect IN to OUT or OUT to IN (bidirectional) + * - Cannot connect same types (IN to IN or OUT to OUT) + * - Cannot connect ports of the same block + */ +class PortBasedBlock extends CanvasBlock { + protected override willMount(): void { + super.willMount(); + + // Configure snapping for all anchors using new PortMetaKey + this.state.anchors?.forEach((anchor) => { + const portId = createAnchorPortId(this.state.id, anchor.id); + + const snapMeta: IPortConnectionMeta = { + snappable: true, + snapCondition: (ctx) => { + // Cannot connect to the same block + if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Can connect IN ↔ OUT (bidirectional) + return isValidConnection(ctx.sourcePort, ctx.targetPort); + }, + }; + + // Configure port for snapping + this.updatePort(portId, { + meta: { + [PortConnectionLayer.PortMetaKey]: snapMeta, + }, + }); + }); + } +} + +/** + * Custom block with conditional snapping and data types + * Demonstrates advanced port validation with metadata + * This block passes "number" type through - both IN and OUT have same type + */ +class ConditionalPortBlock extends CanvasBlock { + protected override willMount(): void { + super.willMount(); + + this.state.anchors?.forEach((anchor) => { + const portId = createAnchorPortId(this.state.id, anchor.id); + // Both IN and OUT have the same data type so they can connect + const dataType = "number"; + + const snapMeta: IPortConnectionMeta = { + snappable: true, + snapCondition: (ctx) => { + // Cannot connect to the same block + if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Can connect IN ↔ OUT (bidirectional) + if (!isValidConnection(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Check data types match + const sourceMeta = ctx.sourcePort.meta as Record | undefined; + const targetMeta = ctx.targetPort.meta as Record | undefined; + const sourceDataType = sourceMeta?.dataType; + const targetDataType = targetMeta?.dataType; + + if (!sourceDataType || !targetDataType) { + return true; + } + + return sourceDataType === targetDataType; + }, + }; + + // Store both snap metadata and data type + this.updatePort(portId, { + meta: { + [PortConnectionLayer.PortMetaKey]: snapMeta, + dataType: dataType, + }, + }); + }); + } +} + +const generatePortBlocks = (): TBlock[] => { + return [ + { + id: "port-block-1", + name: "Port Block 1", + x: 100, + y: 100, + width: 200, + height: 400, + is: "port-based-block", + selected: false, + anchors: [ + { id: "port-block-1/input-1", blockId: "port-block-1", type: EAnchorType.IN }, + { id: "port-block-1/input-2", blockId: "port-block-1", type: EAnchorType.IN }, + { id: "port-block-1/output-1", blockId: "port-block-1", type: EAnchorType.OUT }, + ], + }, + { + id: "port-block-2", + name: "Port Block 2", + x: 400, + y: 100, + width: 200, + height: 400, + is: "port-based-block", + selected: false, + anchors: [ + { id: "port-block-2/input-1", blockId: "port-block-2", type: EAnchorType.IN }, + { id: "port-block-2/output-1", blockId: "port-block-2", type: EAnchorType.OUT }, + { id: "port-block-2/output-2", blockId: "port-block-2", type: EAnchorType.OUT }, + ], + }, + { + id: "port-block-3", + name: "Port Block 3", + x: 700, + y: 100, + width: 200, + height: 400, + is: "port-based-block", + selected: false, + anchors: [ + { id: "port-block-3/input-1", blockId: "port-block-3", type: EAnchorType.IN }, + { id: "port-block-3/input-2", blockId: "port-block-3", type: EAnchorType.IN }, + { id: "port-block-3/output-1", blockId: "port-block-3", type: EAnchorType.OUT }, + ], + }, + { + id: "conditional-block-1", + name: "Conditional Block 1", + x: 100, + y: 600, + width: 200, + height: 400, + is: "conditional-port-block", + selected: false, + anchors: [ + { id: "conditional-block-1/input-1", blockId: "conditional-block-1", type: EAnchorType.IN }, + { id: "conditional-block-1/output-1", blockId: "conditional-block-1", type: EAnchorType.OUT }, + ], + }, + { + id: "conditional-block-2", + name: "Conditional Block 2", + x: 400, + y: 600, + width: 200, + height: 400, + is: "conditional-port-block", + selected: false, + anchors: [ + { id: "conditional-block-2/input-1", blockId: "conditional-block-2", type: EAnchorType.IN }, + { id: "conditional-block-2/output-1", blockId: "conditional-block-2", type: EAnchorType.OUT }, + ], + }, + ]; +}; + +const GraphApp = () => { + const { graph, setEntities, start, addLayer, zoomTo } = useGraph({ + settings: { + canDrag: ECanDrag.ALL, + canCreateNewConnections: true, + useBlocksAnchors: true, + blockComponents: { + "port-based-block": PortBasedBlock, + "conditional-port-block": ConditionalPortBlock, + }, + }, + }); + + useEffect(() => { + setEntities({ + blocks: generatePortBlocks(), + }); + start(); + zoomTo("center", { padding: 300 }); + }, [graph]); + + const layerRef = useRef(null); + + useLayoutEffect(() => { + // Create icon for creating connections + const createIcon = { + path: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z", + fill: "#FFD700", + width: 24, + height: 24, + viewWidth: 24, + viewHeight: 24, + }; + + // Icon for connection point + const pointIcon = { + path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z", + fill: "#4CAF50", + stroke: "#FFFFFF", + width: 24, + height: 24, + viewWidth: 24, + viewHeight: 24, + }; + + // Function for drawing connection line + const drawLine = (start, end) => { + const path = new Path2D(); + path.moveTo(start.x, start.y); + path.lineTo(end.x, end.y); + return { + path, + style: { + color: "#4CAF50", + dash: [], + }, + }; + }; + + layerRef.current = addLayer(PortConnectionLayer, { + createIcon, + point: pointIcon, + drawLine, + searchRadius: 30, + }); + + return () => { + layerRef.current?.detachLayer(); + }; + }, []); + + const renderBlock = (graphInstance: Graph, block: TBlock) => { + return ; + }; + + return ( + + + + ); +}; + +const meta: Meta = { + title: "Examples/PortConnectionLayer", + component: GraphApp, + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; + +export const Default: StoryFn = () => ; diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index 84980fc..0c15397 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -244,3 +244,6 @@ export function computeCssVariable(name: string) { // Re-export scheduler utilities export { schedule, debounce, throttle } from "../utils/schedule"; export { isTrackpadWheelEvent } from "./isTrackpadDetector"; + +// Re-export vector utilities +export { vectorDistance, vectorDistanceSquared } from "./vector"; diff --git a/src/utils/functions/vector.test.ts b/src/utils/functions/vector.test.ts new file mode 100644 index 0000000..d2edca2 --- /dev/null +++ b/src/utils/functions/vector.test.ts @@ -0,0 +1,28 @@ +import { vectorDistance, vectorDistanceSquared } from "./vector"; + +describe("Vector utilities", () => { + describe("vectorDistance", () => { + it("should calculate distance between two points", () => { + expect(vectorDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5); + expect(vectorDistance({ x: 1, y: 1 }, { x: 4, y: 5 })).toBe(5); + expect(vectorDistance({ x: 0, y: 0 }, { x: 0, y: 0 })).toBe(0); + }); + + it("should handle negative coordinates", () => { + expect(vectorDistance({ x: -3, y: -4 }, { x: 0, y: 0 })).toBe(5); + expect(vectorDistance({ x: 0, y: 0 }, { x: -3, y: -4 })).toBe(5); + }); + }); + + describe("vectorDistanceSquared", () => { + it("should calculate squared distance between two points", () => { + expect(vectorDistanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(25); + expect(vectorDistanceSquared({ x: 1, y: 1 }, { x: 4, y: 5 })).toBe(25); + expect(vectorDistanceSquared({ x: 0, y: 0 }, { x: 0, y: 0 })).toBe(0); + }); + + it("should handle negative coordinates", () => { + expect(vectorDistanceSquared({ x: -3, y: -4 }, { x: 0, y: 0 })).toBe(25); + }); + }); +}); diff --git a/src/utils/functions/vector.ts b/src/utils/functions/vector.ts new file mode 100644 index 0000000..eaa728f --- /dev/null +++ b/src/utils/functions/vector.ts @@ -0,0 +1,46 @@ +import { TPoint } from "../types/shapes"; + +/** + * Calculate Euclidean distance between two points + * + * @param p1 First point + * @param p2 Second point + * @returns Distance between the points + * + * @example + * ```typescript + * const distance = vectorDistance({ x: 0, y: 0 }, { x: 3, y: 4 }); + * console.log(distance); // 5 + * ``` + */ +export function vectorDistance(p1: TPoint, p2: TPoint): number { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return Math.sqrt(dx * dx + dy * dy); +} + +/** + * Calculate squared Euclidean distance between two points + * Faster than vectorDistance as it avoids the square root operation + * Useful for comparisons where the actual distance value is not needed + * + * @param p1 First point + * @param p2 Second point + * @returns Squared distance between the points + * + * @example + * ```typescript + * const distSq = vectorDistanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 }); + * console.log(distSq); // 25 + * + * // Useful for distance comparisons without sqrt + * if (vectorDistanceSquared(p1, p2) < threshold * threshold) { + * // Point is within threshold + * } + * ``` + */ +export function vectorDistanceSquared(p1: TPoint, p2: TPoint): number { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return dx * dx + dy * dy; +}