From cfbbbc4a9f947b1c2d7fcda9cba8103fd3e7331d Mon Sep 17 00:00:00 2001 From: draedful Date: Thu, 22 Jan 2026 22:48:40 +0300 Subject: [PATCH 1/8] experiment(): Add snapping for create connetions --- .../port_magnetic_snapping_95725e35.plan.md | 692 ++++++++++++++++++ .../canvas/GraphComponent/index.tsx | 12 + src/components/canvas/anchors/index.ts | 4 +- .../layers/connectionLayer/ConnectionLayer.ts | 265 ++++++- src/index.ts | 3 + src/store/connection/ConnectionList.ts | 14 +- src/store/connection/port/Port.ts | 39 +- .../magneticPorts/magneticPorts.stories.tsx | 348 +++++++++ 8 files changed, 1367 insertions(+), 10 deletions(-) create mode 100644 .cursor/plans/port_magnetic_snapping_95725e35.plan.md create mode 100644 src/stories/examples/magneticPorts/magneticPorts.stories.tsx diff --git a/.cursor/plans/port_magnetic_snapping_95725e35.plan.md b/.cursor/plans/port_magnetic_snapping_95725e35.plan.md new file mode 100644 index 00000000..2f3b3e34 --- /dev/null +++ b/.cursor/plans/port_magnetic_snapping_95725e35.plan.md @@ -0,0 +1,692 @@ +--- +name: Port Magnetic Snapping +overview: "Добавление функциональности \"магнитов\" для портов при создании связей: порты смогут автоматически примагничивать конечную точку связи в заданном радиусе с настраиваемыми условиями примагничивания." +todos: + - id: make-port-generic + content: Сделать TPort generic типом с полем meta и добавить updatePortWithMeta в PortState + status: completed + - id: add-update-port-method + content: Добавить метод updatePort в GraphComponent + status: completed + - id: add-rbush-to-connectionlayer + content: Добавить RBush, outdated флаг и подписку на $ports в ConnectionLayer + status: completed + - id: implement-lazy-rebuild + content: Реализовать lazy rebuild RBush и createMagneticPortBox в ConnectionLayer + status: completed + - id: implement-find-nearest + content: Реализовать метод findNearestMagneticPort в ConnectionLayer + status: completed + - id: connection-layer-integration + content: Интегрировать поиск магнитных портов в onMoveNewConnection + status: completed + - id: export-types + content: Экспортировать новые типы и утилиты в index.ts + status: completed + - id: test-implementation + content: Создать story для демонстрации работы магнитов + status: completed +--- + +# Реализация магнитов для портов + +## Краткое резюме + +Добавляется функциональность "магнитов" для портов - автоматическое примагничивание конечной точки связи к ближайшему порту при создании соединений. + +**Что получит пользователь:** +- Метод `updatePort(id, x?, y?, meta?)` в любом компоненте (Block, custom components) +- Возможность задать область магнита (width/height) через meta +- Кастомные условия примагничивания через функцию в meta +- Автоматический поиск ближайшего порта при создании связей +- Полный контроль над настройками через простой API + +## Архитектурный принцип: Разделение ответственности + +**Порты** (PortState, TPort): +- Хранят только позицию (x, y) и произвольные мета-данные (meta: T) +- НЕ знают про магниты - это универсальное хранилище +- Meta может использоваться для любых целей разными слоями + +**ConnectionLayer**: +- Решает КАК интерпретировать meta для своих целей +- Если видит meta с полями `magnetWidthArea/magnetHeightArea` - использует для примагничивания +- Строит и управляет RBush для spatial indexing +- Другие слои могут интерпретировать meta по-своему + +## Пользовательский API + +Пользователи смогут настраивать магниты через метод `updatePort` в любом компоненте, наследующем `GraphComponent` (включая `Block`): + +```typescript +// В кастомном блоке +class MyBlock extends Block { + protected override willMount(): void { + super.willMount(); + + // Настроить магнит для анкора + const portId = createAnchorPortId(this.state.id, "input-1"); + this.updatePort(portId, undefined, undefined, { + magnetWidthArea: 50, + magnetHeightArea: 50, + magnetCondition: (ctx) => ctx.sourcePort.meta?.type === "data" + }); + + // Настроить магнит и позицию для output порта блока + const outputPortId = createBlockPointPortId(this.state.id, false); + this.updatePort(outputPortId, customX, customY, { + magnetWidthArea: 40, + magnetHeightArea: 40 + }); + } +} +``` + +**Сигнатура метода:** + +```typescript +updatePort(id: TPortId, x?: number, y?: number, meta?: T): void +``` + +## Архитектурная диаграмма + +```mermaid +graph TB + User[User Custom Block] + GraphComponent[GraphComponent] + UpdatePort["updatePort method"] + PortState[PortState - хранит position + meta] + TPort["TPort<T> - универсальное хранилище"] + PortsStore[PortsStore - управление портами] + PortsSignal["$ports Signal"] + ConnectionLayer[ConnectionLayer - интерпретирует meta] + RBush[RBush Spatial Index] + + User -->|"extends"| GraphComponent + User -->|"calls"| UpdatePort + GraphComponent -->|"contains"| UpdatePort + UpdatePort -->|"updates"| PortState + PortState -->|"stores"| TPort + PortState -->|"managed by"| PortsStore + PortsStore -->|"emits"| PortsSignal + ConnectionLayer -->|"subscribes to"| PortsSignal + PortsSignal -->|"change marks outdated"| RBush + ConnectionLayer -->|"owns and rebuilds"| RBush + ConnectionLayer -->|"interprets meta for magnets"| PortState +``` + +## 1. Сделать TPort generic типом и добавить updatePort + +**Файл:** [`src/store/connection/port/Port.ts`](src/store/connection/port/Port.ts) + +### Изменения в TPort + +Сделать TPort generic с полем meta: + +```typescript +export type TPort = { + id: TPortId; + x: number; + y: number; + component?: Component; + lookup?: boolean; + meta?: T; // Произвольная мета-информация (порт не знает что в ней) +}; +``` + +### Стандартная структура meta для магнитов + +Определить интерфейс для мета-информации магнитов (используется ConnectionLayer): + +```typescript +export type TPortMagnetCondition = (context: { + sourcePort: PortState; + targetPort: PortState; + sourceComponent?: Component; + targetComponent?: Component; + cursorPosition: TPoint; + distance: number; +}) => boolean; + +/** + * Опциональная структура meta для магнитных портов + * ConnectionLayer интерпретирует эту структуру для примагничивания + */ +export interface IPortMagnetMeta { + magnetWidthArea?: number; // Ширина области магнита (по умолчанию 40) + magnetHeightArea?: number; // Высота области магнита (по умолчанию 40) + magnetCondition?: TPortMagnetCondition; // Условие примагничивания +} +``` + +### Обновление PortState + +Сделать PortState также generic и добавить метод обновления с мета-информацией: + +```typescript +export class PortState { + public $state = signal>(undefined); + + // ... остальные поля + + constructor(port: TPort) { + this.$state.value = { ...port }; + if (port.component) { + this.owner = port.component; + } + } + + // Геттер для мета-информации + public get meta(): T | undefined { + return this.$state.value.meta; + } + + // Обновить позицию и/или мета-информацию порта + public updatePortWithMeta(x?: number, y?: number, meta?: T): void { + const updates: Partial> = {}; + + if (x !== undefined) updates.x = x; + if (y !== undefined) updates.y = y; + if (meta !== undefined) updates.meta = meta; + + if (Object.keys(updates).length > 0) { + this.updatePort(updates); + } + } +} +``` + +## 2. Добавить updatePort в GraphComponent + +**Файл:** [`src/components/canvas/GraphComponent/index.tsx`](src/components/canvas/GraphComponent/index.tsx) + +Добавить публичный метод для обновления портов: + +```typescript +export class GraphComponent { + // ... существующие методы + + /** + * Update port position and metadata + * @param id Port identifier + * @param x New X coordinate (optional) + * @param y New Y coordinate (optional) + * @param meta Port metadata (optional) + */ + public updatePort( + id: TPortId, + x?: number, + y?: number, + meta?: T + ): void { + const port = this.getPort(id); + port.updatePortWithMeta(x, y, meta); + } +} +``` + +## 3. Добавить RBush в ConnectionLayer + +**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) + +### Добавить поля в ConnectionLayer + +```typescript +import RBush from "rbush"; + +type MagneticPortBox = { + minX: number; + minY: number; + maxX: number; + maxY: number; + port: PortState; +}; + +export class ConnectionLayer extends Layer { + // ... существующие поля + + private magneticPortsTree: RBush | null = null; + private isMagneticTreeOutdated = true; + private portsUnsubscribe?: () => void; +} +``` + +### Подписка на изменения портов + +В методе `afterInit()`: + +```typescript +protected afterInit(): void { + // ... существующий код + + // Подписаться на изменения портов для обновления RBush + const portsStore = this.context.graph.rootStore.connectionsList.portsStore; + this.portsUnsubscribe = portsStore.$ports.subscribe(() => { + this.isMagneticTreeOutdated = true; + }); + + super.afterInit(); +} +``` + +### Очистка при unmount + +```typescript +public override unmount(): void { + if (this.portsUnsubscribe) { + this.portsUnsubscribe(); + this.portsUnsubscribe = undefined; + } + this.magneticPortsTree = null; + super.unmount(); +} +``` + +## 4. Реализовать lazy rebuild RBush + +**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) + +### Константы по умолчанию + +```typescript +const DEFAULT_MAGNET_WIDTH = 40; +const DEFAULT_MAGNET_HEIGHT = 40; +``` + +### Метод создания bounding box + +```typescript +private createMagneticPortBox(port: PortState): MagneticPortBox | null { + const meta = port.meta as IPortMagnetMeta | undefined; + + // Проверить, является ли порт магнитным + if (!meta?.magnetWidthArea && !meta?.magnetHeightArea) { + return null; // Порт не магнитный + } + + const widthArea = meta.magnetWidthArea ?? DEFAULT_MAGNET_WIDTH; + const heightArea = meta.magnetHeightArea ?? DEFAULT_MAGNET_HEIGHT; + + return { + minX: port.x - widthArea / 2, + minY: port.y - heightArea / 2, + maxX: port.x + widthArea / 2, + maxY: port.y + heightArea / 2, + port: port + }; +} +``` + +### Метод пересоздания RBush + +```typescript +private rebuildMagneticTree(): void { + if (!this.isMagneticTreeOutdated) { + return; + } + + const magneticBoxes: MagneticPortBox[] = []; + const portsStore = this.context.graph.rootStore.connectionsList.portsStore; + + for (const port of portsStore.$ports.value) { + const box = this.createMagneticPortBox(port); + if (box) { + magneticBoxes.push(box); + } + } + + this.magneticPortsTree = new RBush(9); + if (magneticBoxes.length > 0) { + this.magneticPortsTree.load(magneticBoxes); + } + + this.isMagneticTreeOutdated = false; +} +``` + +## 5. Реализовать поиск ближайшего магнитного порта + +**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) + +```typescript +private findNearestMagneticPort( + point: TPoint, + sourcePort?: PortState, + sourceComponent?: Component +): { port: PortState; snapPoint: TPoint } | null { + // Пересоздать RBush если outdated + this.rebuildMagneticTree(); + + if (!this.magneticPortsTree) { + return null; + } + + // Поиск портов в области курсора + const searchRadius = 100; // можно настроить + const candidates = this.magneticPortsTree.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; + + // Пропустить source порт + if (sourcePort && port.id === sourcePort.id) { + continue; + } + + // Вычислить векторное расстояние + const dx = port.x - point.x; + const dy = port.y - point.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Проверить, находится ли точка в магнитной области + const meta = port.meta as IPortMagnetMeta | undefined; + const widthArea = meta?.magnetWidthArea ?? DEFAULT_MAGNET_WIDTH; + const heightArea = meta?.magnetHeightArea ?? DEFAULT_MAGNET_HEIGHT; + + if (Math.abs(dx) > widthArea / 2 || Math.abs(dy) > heightArea / 2) { + continue; // Вне магнитной области + } + + // Проверить кастомное условие, если есть + if (meta?.magnetCondition && sourcePort) { + const canSnap = meta.magnetCondition({ + sourcePort: sourcePort, + targetPort: port, + sourceComponent, + targetComponent: port.component, + cursorPosition: point, + distance, + }); + + if (!canSnap) { + continue; + } + } + + // Обновить ближайший порт + if (distance < nearestDistance) { + nearestDistance = distance; + nearestPort = port; + } + } + + if (!nearestPort) { + return null; + } + + return { + port: nearestPort, + snapPoint: { x: nearestPort.x, y: nearestPort.y }, + }; +} +``` + +## 6. Интегрировать в onMoveNewConnection + +**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) + +### Вспомогательные методы + +```typescript +private getSourcePort(component: BlockState | AnchorState): PortState | undefined { + const portsStore = this.context.graph.rootStore.connectionsList.portsStore; + + if (component instanceof AnchorState) { + return portsStore.getPort(createAnchorPortId(component.blockId, component.id)); + } + + // Для блока берём output port + return portsStore.getPort(createBlockPointPortId(component.id, false)); +} + +private getComponentByPort(port: PortState): Block | Anchor | undefined { + return port.component?.getViewComponent() as Block | Anchor | undefined; +} +``` + +### Изменения в onMoveNewConnection + +Текущий код (строка 328-338): + +```328:338:src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +private onMoveNewConnection(event: MouseEvent, point: Point) { + if (!this.startState || !this.sourceComponent) { + return; + } + console.log(this.sourceComponent, "onMoveNewConnection", point); + + const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); + + // Use world coordinates from point instead of screen coordinates + this.endState = new Point(point.x, point.y); + this.performRender(); +``` + +**Новая логика:** + +```typescript +private onMoveNewConnection(event: MouseEvent, point: Point) { + if (!this.startState || !this.sourceComponent) { + return; + } + + // Получаем source port + const sourcePort = this.getSourcePort(this.sourceComponent); + + // Сначала пытаемся примагнититься + const magnetResult = this.findNearestMagneticPort( + point, + sourcePort, + this.sourceComponent + ); + + let actualEndPoint = point; + let newTargetComponent = null; + + if (magnetResult) { + // Примагничиваемся к порту + actualEndPoint = magnetResult.snapPoint; + newTargetComponent = this.getComponentByPort(magnetResult.port); + } else { + // Используем существующую логику + newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); + } + + this.endState = new Point(actualEndPoint.x, actualEndPoint.y); + this.performRender(); + + // ... остальная логика обработки target (существующий код) +} +``` + +## 7. Экспорт типов и API + +**Файл:** [`src/index.ts`](src/index.ts) + +Экспортировать новые типы и утилиты для использования в приложениях: + +```typescript +// Экспорт типов портов +export type { TPort, TPortMagnetCondition, IPortMagnetMeta } from "./store/connection/port/Port"; + +// Экспорт утилит для создания port ID (если ещё не экспортированы) +export { createAnchorPortId, createBlockPointPortId } from "./store/connection/port/utils"; +``` + +**Примечание:** Метод `updatePort` уже доступен через `GraphComponent`, который экспортируется и от которого наследуется `Block`. + +## 8. Примеры использования в Story + +**Новый файл:** `src/stories/examples/magneticPorts/magneticPorts.stories.tsx` + +### Story 1: Базовое использование + +```typescript +import { Block, IPortMagnetMeta, createAnchorPortId } from "@gravity-ui/graph"; + +class MagneticBlock extends Block { + protected override willMount(): void { + super.willMount(); + + // Настроить магниты для всех анкоров + this.state.anchors?.forEach(anchor => { + const portId = createAnchorPortId(this.state.id, anchor.id); + this.updatePort(portId, undefined, undefined, { + magnetWidthArea: 50, + magnetHeightArea: 50 + } as IPortMagnetMeta); + }); + } +} +``` + +### Story 2: Динамические магниты + +```typescript +class DynamicMagneticBlock extends Block { + protected override willMount(): void { + super.willMount(); + this.setupMagnets(); + } + + private setupMagnets(): void { + const inputPortId = createBlockPointPortId(this.state.id, true); + const outputPortId = createBlockPointPortId(this.state.id, false); + + // Магниты зависят от размера блока + this.updatePort(inputPortId, undefined, undefined, { + magnetWidthArea: this.state.width * 0.5, + magnetHeightArea: 60 + } as IPortMagnetMeta); + + this.updatePort(outputPortId, undefined, undefined, { + magnetWidthArea: this.state.width * 0.5, + magnetHeightArea: 60 + } as IPortMagnetMeta); + } + + protected override stateChanged(nextState: TBlock): void { + super.stateChanged(nextState); + if (this.state.width !== nextState.width) { + this.setupMagnets(); + } + } +} +``` + +### Story 3: Условное примагничивание + +```typescript +class ConditionalMagneticBlock extends Block { + protected override willMount(): void { + super.willMount(); + + this.state.anchors?.forEach(anchor => { + const portId = createAnchorPortId(this.state.id, anchor.id); + + this.updatePort(portId, undefined, undefined, { + magnetWidthArea: 50, + magnetHeightArea: 50, + magnetCondition: (ctx) => { + // Примагничиваться только к портам с совместимым типом + const sourceMeta = ctx.sourcePort.meta as { dataType?: string }; + const targetMeta = ctx.targetPort.meta as { dataType?: string }; + + return sourceMeta?.dataType === targetMeta?.dataType; + } + } as IPortMagnetMeta); + }); + } +} +``` + +## Преимущества решения + +1. **Чистая архитектура - разделение ответственности**: + - Порты - универсальное хранилище (позиция + meta) + - ConnectionLayer - интерпретирует meta для своих нужд + - Другие слои могут использовать meta по-своему + +2. **Простота и удобство API**: + - Единый метод `updatePort(id, x?, y?, meta?)` для всех настроек + - Не требует изменений в TBlock или TAnchor + - Понятный и предсказуемый интерфейс + +3. **Полный контроль**: + - Пользователь сам решает, когда и как настраивать порты + - Возможность динамически менять магниты + - Можно настроить позицию и meta в одном вызове + +4. **Производительность**: + - RBush обеспечивает O(log n) поиск + - Lazy rebuild - пересоздание только при необходимости + - Подписка на `$ports` автоматически отслеживает изменения + +5. **Гибкость**: + - Generic TPort для любой мета-информации + - Кастомные условия через `magnetCondition` + - Асимметричные области магнитов + +6. **Расширяемость**: + - Meta может использоваться для других целей + - Легко добавить другие интерпретации meta в других слоях + +7. **Обратная совместимость**: + - TPort с дефолтным `unknown` + - Все поля опциональные + - Метод updatePort опционален + +## Ключевые технические детали + +### Формулы bounding box (симметричные) + +```typescript +minX = port.x - magnetWidthArea / 2 +minY = port.y - magnetHeightArea / 2 +maxX = port.x + magnetWidthArea / 2 +maxY = port.y + magnetHeightArea / 2 +``` + +### Алгоритм поиска + +1. Проверить `isMagneticTreeOutdated`, если true - пересоздать RBush +2. Выполнить поиск в RBush с квадратом вокруг курсора +3. Отфильтровать кандидатов: + - Исключить source порт + - Проверить, что точка внутри магнитной области + - Проверить кастомное условие (если есть) +4. Вернуть порт с минимальным векторным расстоянием + +### Когда RBush помечается outdated + +- При любом изменении `$ports` signal (через subscription) +- Автоматически отслеживает создание, удаление и обновление портов + +## Обратная совместимость + +### Существующий код продолжит работать + +1. **TPort становится TPort** - дефолтный generic параметр +2. **PortState становится PortState** - аналогично +3. **Новый метод updatePort** - опциональный +4. **RBush в ConnectionLayer** - работает прозрачно +5. **Изменения в ConnectionLayer** - сначала магниты, потом старая логика + +### Поведение по умолчанию + +- Если порт не имеет meta с магнитной информацией - не участвует в магнитном поиске +- Если ни один порт не найден - работает `getElementOverPoint` +- Существующие блоки продолжают работать как раньше \ No newline at end of file diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 007e5487..9ef058a7 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -117,6 +117,18 @@ export class GraphComponent< return this.ports.get(id); } + /** + * Update port position and metadata + * @param id Port identifier + * @param x New X coordinate (optional) + * @param y New Y coordinate (optional) + * @param meta Port metadata (optional) + */ + public updatePort(id: TPortId, x?: number, y?: number, meta?: T): void { + const port = this.getPort(id); + port.updatePortWithMeta(x, y, meta); + } + 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 f88d9d2b..f0171a6a 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -83,7 +83,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 }); }); @@ -159,7 +159,7 @@ export class Anchor extends GraphComponen } protected unmount() { - this.props.port.removeObserver(this); + this.props.port.setOwner(this.connectedState.block.getViewComponent()); super.unmount(); } diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 318d604f..019924b2 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -1,8 +1,12 @@ +import RBush from "rbush"; + import { GraphMouseEvent, extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { ESelectionStrategy } from "../../../../services/selection/types"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; +import { PortState } from "../../../../store/connection/port/Port"; +import { createAnchorPortId, createBlockPointPortId } from "../../../../store/connection/port/utils"; import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; @@ -11,6 +15,49 @@ import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; import { GraphComponent } from "../../GraphComponent"; +/** + * Default search radius for port snapping in pixels + * Ports within this radius will be considered for snapping + */ +const SNAP_SEARCH_RADIUS = 20; + +/** + * Snap condition function type + * Used by ConnectionLayer to determine if a port can snap to another port + * Note: sourceComponent and targetComponent can be accessed via sourcePort.component and targetPort.component + */ +export type TPortSnapCondition = (context: { + sourcePort: PortState; + targetPort: PortState; + cursorPosition: TPoint; + distance: number; +}) => boolean; + +/** + * Optional metadata structure for port snapping + * ConnectionLayer interprets this structure for port snapping behavior + * + * @example + * ```typescript + * const snapMeta: IPortSnapMeta = { + * snappable: true, + * snapCondition: (ctx) => { + * // Access components via ports + * const sourceComponent = ctx.sourcePort.component; + * const targetComponent = ctx.targetPort.component; + * // Custom validation logic + * return true; + * } + * }; + * ``` + */ +export interface IPortSnapMeta { + /** Enable snapping for this port. If false or undefined, port will not participate in snapping */ + snappable?: boolean; + /** Custom condition for snapping - access components via sourcePort.component and targetPort.component */ + snapCondition?: TPortSnapCondition; +} + type TIcon = { path: string; fill?: string; @@ -28,6 +75,14 @@ type LineStyle = { type DrawLineFunction = (start: TPoint, end: TPoint) => { path: Path2D; style: LineStyle }; +type SnappingPortBox = { + minX: number; + minY: number; + maxX: number; + maxY: number; + port: PortState; +}; + type ConnectionLayerProps = LayerProps & { createIcon?: TIcon; point?: TIcon; @@ -123,6 +178,11 @@ export class ConnectionLayer extends Layer< protected enabled: boolean; private declare eventAborter: AbortController; + // Port snapping support + private snappingPortsTree: RBush | null = null; + private isSnappingTreeOutdated = true; + private portsUnsubscribe?: () => void; + constructor(props: ConnectionLayerProps) { super({ canvas: { @@ -160,6 +220,16 @@ export class ConnectionLayer extends Layer< // Register event listeners with the graphOn wrapper method for automatic cleanup when unmounted this.onGraphEvent("mousedown", this.handleMouseDown); + // Subscribe to ports changes to mark snapping tree as outdated + // We'll mark the tree as outdated when ports change by polling + // Note: Direct subscription to internal signal requires access to connectionsList.ports + const checkPortsChanged = () => { + this.isSnappingTreeOutdated = true; + }; + + // Subscribe through the Layer's onSignal helper which handles cleanup + this.portsUnsubscribe = this.onSignal(this.context.graph.rootStore.connectionsList.ports.$ports, checkPortsChanged); + // Call parent afterInit to ensure proper initialization super.afterInit(); } @@ -341,10 +411,26 @@ export class ConnectionLayer extends Layer< return; } - const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); + // Get source port + const sourcePort = this.getSourcePort(this.sourceComponent); + + // Try to snap to nearby port first + const snapResult = this.findNearestSnappingPort(point, sourcePort); + + let actualEndPoint = point; + let newTargetComponent: Block | Anchor; + + if (snapResult) { + // Snap to port + actualEndPoint = new Point(snapResult.snapPoint.x, snapResult.snapPoint.y); + newTargetComponent = this.getComponentByPort(snapResult.port); + } else { + // Use existing logic - find element over point + newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); + } // Use world coordinates from point instead of screen coordinates - this.endState = new Point(point.x, point.y); + this.endState = new Point(actualEndPoint.x, actualEndPoint.y); this.performRender(); if (!newTargetComponent || !newTargetComponent.connectedState) { @@ -443,4 +529,179 @@ export class ConnectionLayer extends Layer< () => {} ); } + + /** + * Get the source port from a component (block or anchor) + * @param component Block or Anchor component + * @returns Port state or undefined + */ + private getSourcePort(component: BlockState | AnchorState): PortState | undefined { + const connectionsList = this.context.graph.rootStore.connectionsList; + + if (component instanceof AnchorState) { + return connectionsList.getPort(createAnchorPortId(component.blockId, component.id)); + } + + // For block, use output port + return connectionsList.getPort(createBlockPointPortId(component.id, false)); + } + + /** + * Get the component (Block or Anchor) that owns a port + * @param port Port state + * @returns Block or Anchor component + */ + private getComponentByPort(port: PortState): Block | Anchor | undefined { + const component = port.component; + if (!component) { + return undefined; + } + + // Check if component is Block or Anchor by checking instance + if (component instanceof Block || component instanceof Anchor) { + return component; + } + + return undefined; + } + + /** + * Create a snapping port bounding box for RBush spatial indexing + * @param port Port to create bounding box for + * @param searchRadius Search radius for snapping area + * @returns SnappingPortBox or null if port doesn't have snapping enabled + */ + private createSnappingPortBox(port: PortState, searchRadius: number): SnappingPortBox | null { + const meta = port.meta as IPortSnapMeta | undefined; + + // Check if port has snapping enabled + if (!meta?.snappable) { + return null; // Port doesn't participate in snapping + } + + return { + minX: port.x - searchRadius, + minY: port.y - searchRadius, + maxX: port.x + searchRadius, + maxY: port.y + searchRadius, + port: port, + }; + } + + /** + * Find the nearest snapping port to a given point + * @param point Point to search from + * @param sourcePort Source port to exclude from search + * @returns Nearest snapping port and snap point, or null if none found + */ + private findNearestSnappingPort( + point: TPoint, + sourcePort?: PortState + ): { port: PortState; snapPoint: TPoint } | null { + // Rebuild RBush if outdated + this.rebuildSnappingTree(); + + if (!this.snappingPortsTree) { + return null; + } + + // Search for ports in the area around cursor + const candidates = this.snappingPortsTree.search({ + minX: point.x - SNAP_SEARCH_RADIUS, + minY: point.y - SNAP_SEARCH_RADIUS, + maxX: point.x + SNAP_SEARCH_RADIUS, + maxY: point.y + SNAP_SEARCH_RADIUS, + }); + + if (candidates.length === 0) { + return null; + } + + // Find the nearest port by vector distance + 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 dx = port.x - point.x; + const dy = port.y - point.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Check custom condition if provided + const meta = port.meta as IPortSnapMeta | 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 (lazy rebuild) + */ + private rebuildSnappingTree(): void { + if (!this.isSnappingTreeOutdated) { + return; + } + + const snappingBoxes: SnappingPortBox[] = []; + const connectionsList = this.context.graph.rootStore.connectionsList; + + // Get all ports from connectionsList + const allPorts = connectionsList.getAllPorts(); + for (const port of allPorts) { + const box = this.createSnappingPortBox(port, SNAP_SEARCH_RADIUS); + if (box) { + snappingBoxes.push(box); + } + } + + this.snappingPortsTree = new RBush(9); + if (snappingBoxes.length > 0) { + this.snappingPortsTree.load(snappingBoxes); + } + + this.isSnappingTreeOutdated = false; + } + + public override unmount(): void { + // Cleanup ports subscription + if (this.portsUnsubscribe) { + this.portsUnsubscribe(); + this.portsUnsubscribe = undefined; + } + // Clear snapping tree + this.snappingPortsTree = null; + super.unmount(); + } } diff --git a/src/index.ts b/src/index.ts index 73628fa5..43e6c30b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,9 @@ 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 type { IPortSnapMeta, TPortSnapCondition } from "./components/canvas/layers/connectionLayer/ConnectionLayer"; export { ECanChangeBlockGeometry, ECanDrag } from "./store/settings"; export { type TMeasureTextOptions, type TWrapText } from "./utils/functions/text"; export { ESchedulerPriority } from "./lib/Scheduler"; diff --git a/src/store/connection/ConnectionList.ts b/src/store/connection/ConnectionList.ts index 12b1cb5e..2cba2048 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 f8e480f3..5731f889 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,10 +178,28 @@ export class PortState { * Update port state with partial data * @param port Partial port data to merge with current state */ - public updatePort(port: Partial): void { + public updatePort(port: Partial>): void { this.$state.value = { ...this.$state.value, ...port }; } + /** + * Update port position and/or metadata + * @param x New X coordinate (optional) + * @param y New Y coordinate (optional) + * @param meta New metadata (optional) + */ + public updatePortWithMeta(x?: number, y?: number, meta?: T): void { + const updates: Partial> = {}; + + if (x !== undefined) updates.x = x; + if (y !== undefined) updates.y = y; + if (meta !== undefined) updates.meta = meta; + + if (Object.keys(updates).length > 0) { + this.updatePort(updates); + } + } + /** * Check if this port can be safely deleted * @returns true if port has no owner and no observers diff --git a/src/stories/examples/magneticPorts/magneticPorts.stories.tsx b/src/stories/examples/magneticPorts/magneticPorts.stories.tsx new file mode 100644 index 00000000..4bf51b9c --- /dev/null +++ b/src/stories/examples/magneticPorts/magneticPorts.stories.tsx @@ -0,0 +1,348 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; + +import { Flex, Text, ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryFn } from "@storybook/react-webpack5"; + +import { Anchor, CanvasBlock, EAnchorType, Graph } from "../../../"; +import { Block, TBlock } from "../../../components/canvas/blocks/Block"; +import { ConnectionLayer, IPortSnapMeta } from "../../../components/canvas/layers/connectionLayer/ConnectionLayer"; +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: { component?: unknown }, targetPort: { component?: unknown }): boolean { + const sourceComponent = sourcePort.component; + const targetComponent = targetPort.component; + + if (!sourceComponent || !targetComponent) { + return false; + } + + const isSourceBlock = sourceComponent instanceof Block; + const isTargetBlock = targetComponent instanceof Block; + const isSourceAnchor = sourceComponent instanceof Anchor; + const isTargetAnchor = targetComponent instanceof Anchor; + + if (isSourceBlock && isTargetBlock) { + return sourceComponent.connectedState.id === targetComponent.connectedState.id; + } else if (isSourceAnchor && isTargetAnchor) { + return sourceComponent.connectedState.blockId === targetComponent.connectedState.blockId; + } else if (isSourceBlock && isTargetAnchor) { + return sourceComponent.connectedState.id === targetComponent.connectedState.blockId; + } else if (isSourceAnchor && isTargetBlock) { + return sourceComponent.connectedState.blockId === targetComponent.connectedState.id; + } + + return false; +} + +/** + * Helper function to check if connection is valid (IN to OUT or OUT to IN) + */ +function isValidConnection(sourcePort: { component?: unknown }, targetPort: { component?: unknown }): boolean { + const sourceComponent = sourcePort.component; + const targetComponent = targetPort.component; + + 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 === EAnchorType.IN && targetType === EAnchorType.OUT) || + (sourceType === EAnchorType.OUT && targetType === EAnchorType.IN) + ); + } + + return true; +} + +/** + * Custom block with snapping ports + * Demonstrates how to configure port snapping for anchors + * Rules: + * - Can only connect IN to OUT (not IN to IN or OUT to OUT) + * - Cannot connect input and output of the same block + */ +class MagneticBlock extends CanvasBlock { + protected override willMount(): void { + super.willMount(); + + // Configure snapping for all anchors + this.state.anchors?.forEach((anchor) => { + const portId = createAnchorPortId(this.state.id, anchor.id); + + const snapMeta: IPortSnapMeta = { + snappable: true, + snapCondition: (ctx) => { + // Cannot connect to the same block + if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Can only connect IN to OUT + return isValidConnection(ctx.sourcePort, ctx.targetPort); + }, + }; + + this.updatePort(portId, undefined, undefined, snapMeta); + }); + } +} + +/** + * Custom block with conditional snapping + * Only snaps to ports with matching data types + * Also applies the same rules as MagneticBlock: + * - Can only connect IN to OUT + * - Cannot connect input and output of the same block + */ +class ConditionalMagneticBlock extends CanvasBlock { + protected override willMount(): void { + super.willMount(); + + this.state.anchors?.forEach((anchor) => { + const portId = createAnchorPortId(this.state.id, anchor.id); + + const snapMeta: IPortSnapMeta = { + snappable: true, + snapCondition: (ctx) => { + // Cannot connect to the same block + if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Can only connect IN to OUT + if (!isValidConnection(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Only snap if both ports have matching data types + const sourceMeta = ctx.sourcePort.meta as { dataType?: string } | undefined; + const targetMeta = ctx.targetPort.meta as { dataType?: string } | undefined; + + if (!sourceMeta?.dataType || !targetMeta?.dataType) { + return true; // Allow snapping if no data type specified + } + + return sourceMeta.dataType === targetMeta.dataType; + }, + }; + + this.updatePort(portId, undefined, undefined, { + ...snapMeta, + dataType: anchor.type === EAnchorType.IN ? "input-data" : "output-data", + }); + }); + } +} + +const generateMagneticBlocks = (): TBlock[] => { + return [ + { + id: "block-1", + name: "Magnetic Block 1", + x: 100, + y: 100, + width: 200, + height: 400, + is: "magnetic-block", + selected: false, + anchors: [ + { id: "input-1", blockId: "block-1", type: EAnchorType.IN }, + { id: "input-2", blockId: "block-1", type: EAnchorType.IN }, + { id: "output-1", blockId: "block-1", type: EAnchorType.OUT }, + ], + }, + { + id: "block-2", + name: "Magnetic Block 2", + x: 400, + y: 100, + width: 200, + height: 400, + is: "magnetic-block", + selected: false, + anchors: [ + { id: "input-1", blockId: "block-2", type: EAnchorType.IN }, + { id: "output-1", blockId: "block-2", type: EAnchorType.OUT }, + { id: "output-2", blockId: "block-2", type: EAnchorType.OUT }, + ], + }, + { + id: "block-3", + name: "Magnetic Block 3", + x: 700, + y: 100, + width: 200, + height: 400, + is: "magnetic-block", + selected: false, + anchors: [ + { id: "input-1", blockId: "block-3", type: EAnchorType.IN }, + { id: "input-2", blockId: "block-3", type: EAnchorType.IN }, + { id: "output-1", blockId: "block-3", type: EAnchorType.OUT }, + ], + }, + { + id: "block-4", + name: "Conditional Block 1", + x: 100, + y: 600, + width: 200, + height: 400, + is: "conditional-magnetic-block", + selected: false, + anchors: [ + { id: "input-1", blockId: "block-4", type: EAnchorType.IN }, + { id: "output-1", blockId: "block-4", type: EAnchorType.OUT }, + ], + }, + { + id: "block-5", + name: "Conditional Block 2", + x: 400, + y: 600, + width: 200, + height: 400, + is: "conditional-magnetic-block", + selected: false, + anchors: [ + { id: "input-1", blockId: "block-5", type: EAnchorType.IN }, + { id: "output-1", blockId: "block-5", type: EAnchorType.OUT }, + ], + }, + ]; +}; + +const GraphApp = () => { + const { graph, setEntities, start, addLayer, zoomTo } = useGraph({ + settings: { + canCreateNewConnections: true, + useBlocksAnchors: true, + blockComponents: { + "magnetic-block": MagneticBlock, + "conditional-magnetic-block": ConditionalMagneticBlock, + }, + }, + }); + + useEffect(() => { + setEntities({ + blocks: generateMagneticBlocks(), + }); + start(); + zoomTo("center", { padding: 300 }); + }, [graph]); + + const connectionLayerRef = 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", // Star icon + 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: "#4285F4", + 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: "#4285F4", + dash: [], + }, + }; + }; + + connectionLayerRef.current = addLayer(ConnectionLayer, { + createIcon, + point: pointIcon, + drawLine, + }); + + return () => { + connectionLayerRef.current?.detachLayer(); + }; + }, []); + + const renderBlock = (graphInstance: Graph, block: TBlock) => { + return ; + }; + + return ( + + + + Port Snapping Demo + + This demo shows how ports can automatically snap to nearby ports when creating connections. + + + Try dragging from an anchor on one block to create a connection. Notice how the connection endpoint snaps to + nearby ports when you get close to them. + + + + Connection Rules: + + • Can only connect IN ports to OUT ports (not IN to IN or OUT to OUT) + • Cannot connect input and output ports of the same block + + Snapping Blocks (top row): All anchors have snapping enabled with connection validation + rules. + + + Conditional Blocks (bottom row): Apply the same connection rules plus additional data + type matching validation. + + + +
+ +
+
+
+ ); +}; + +const meta: Meta = { + title: "Examples/Port Snapping", + component: GraphApp, + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; + +export const Default: StoryFn = () => ; From 4674bdf5cf2e9b26b9311b0887592bd9bfce9097 Mon Sep 17 00:00:00 2001 From: draedful Date: Sun, 25 Jan 2026 17:24:01 +0300 Subject: [PATCH 2/8] feat: introduce PortConnectionLayer --- .../canvas/GraphComponent/index.tsx | 8 + src/components/canvas/anchors/index.ts | 6 +- .../layers/connectionLayer/ConnectionLayer.ts | 36 +- .../PortConnectionLayer.md | 267 +++++++ .../PortConnectionLayer.ts | 683 ++++++++++++++++++ .../layers/portConnectionLayer/index.ts | 2 + src/index.ts | 1 + src/services/drag/DragService.ts | 10 +- src/services/drag/types.ts | 5 + src/store/connection/port/PortList.ts | 69 ++ .../portConnectionLayer.stories.tsx | 356 +++++++++ src/utils/functions/index.ts | 3 + src/utils/functions/vector.test.ts | 28 + src/utils/functions/vector.ts | 46 ++ 14 files changed, 1504 insertions(+), 16 deletions(-) create mode 100644 src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.md create mode 100644 src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts create mode 100644 src/components/canvas/layers/portConnectionLayer/index.ts create mode 100644 src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx create mode 100644 src/utils/functions/vector.test.ts create mode 100644 src/utils/functions/vector.ts diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 9ef058a7..e122ea2a 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -117,6 +117,14 @@ 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 diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index f0171a6a..de52b623 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -102,6 +102,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 +163,7 @@ export class Anchor extends GraphComponen } protected unmount() { - this.props.port.setOwner(this.connectedState.block.getViewComponent()); + 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 019924b2..2d950b5a 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -7,7 +7,7 @@ import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; import { PortState } from "../../../../store/connection/port/Port"; import { createAnchorPortId, createBlockPointPortId } from "../../../../store/connection/port/utils"; -import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; +import { isBlock, isShiftKeyEvent, vectorDistance } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; import { Point, TPoint } from "../../../../utils/types/shapes"; @@ -230,6 +230,11 @@ export class ConnectionLayer extends Layer< // Subscribe through the Layer's onSignal helper which handles cleanup 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; + }); + // Call parent afterInit to ensure proper initialization super.afterInit(); } @@ -630,9 +635,7 @@ export class ConnectionLayer extends Layer< } // Calculate vector distance - const dx = port.x - point.x; - const dy = port.y - point.y; - const distance = Math.sqrt(dx * dx + dy * dy); + const distance = vectorDistance(point, port); // Check custom condition if provided const meta = port.meta as IPortSnapMeta | undefined; @@ -667,7 +670,8 @@ export class ConnectionLayer extends Layer< } /** - * Rebuild the RBush spatial index for snapping ports (lazy rebuild) + * Rebuild the RBush spatial index for snapping ports + * Optimization: Only includes ports from components visible in viewport + padding */ private rebuildSnappingTree(): void { if (!this.isSnappingTreeOutdated) { @@ -675,14 +679,22 @@ export class ConnectionLayer extends Layer< } const snappingBoxes: SnappingPortBox[] = []; - const connectionsList = this.context.graph.rootStore.connectionsList; - // Get all ports from connectionsList - const allPorts = connectionsList.getAllPorts(); - for (const port of allPorts) { - const box = this.createSnappingPortBox(port, SNAP_SEARCH_RADIUS); - if (box) { - snappingBoxes.push(box); + // 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 box = this.createSnappingPortBox(port, SNAP_SEARCH_RADIUS); + if (box) { + snappingBoxes.push(box); + } } } diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.md b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.md new file mode 100644 index 00000000..49a06f36 --- /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 00000000..ed952384 --- /dev/null +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -0,0 +1,683 @@ +import RBush from "rbush"; + +import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; +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; + +/** + * Snap condition function type + * Used by PortConnectionLayer to determine if a port can snap to another port + */ +export type TPortSnapCondition = (context: { + sourcePort: PortState; + targetPort: PortState; + cursorPosition: TPoint; + distance: number; +}) => 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; + 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; +}; + +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 - новый слой для создания связей, работающий только с портами + * + * Основные отличия от ConnectionLayer: + * - Работает только с портами, не зависит от компонентов Block/Anchor + * - Использует findPortAtPoint для определения портов под курсором + * - Метаданные хранятся под уникальным ключом PortConnectionLayer.PortMetaKey + * - Более эффективный поиск через пространственный индекс + * - События расширены параметрами sourcePort и targetPort + * + * @example + * ```typescript + * // Настройка порта для снаппинга + * port.updatePort({ + * meta: { + * [PortConnectionLayer.PortMetaKey]: { + * snappable: true, + * snapCondition: (ctx) => { + * // Кастомная логика валидации + * return true; + * } + * } + * } + * }); + * ``` + */ +export class PortConnectionLayer extends Layer< + PortConnectionLayerProps, + LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } +> { + /** + * Уникальный ключ для метаданных портов + * Использование символа предотвращает конфликты с другими слоями + */ + 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 => { + const initEvent = extractNativeGraphMouseEvent(nativeEvent); + if (!initEvent || !this.root?.ownerDocument) { + return; + } + + if (!this.enabled) { + return; + } + + nativeEvent.preventDefault(); + nativeEvent.stopPropagation(); + + const initialComponent = nativeEvent.detail.target as GraphComponent; + // DragService will provide world coordinates in callbacks + this.context.graph.dragService.startDrag( + { + onStart: (_event, coords) => { + const point = new Point(coords[0], coords[1]); + // Find port at cursor position + 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; + } + + const scale = this.context.camera.getCameraScale(); + this.context.ctx.lineWidth = Math.round(2 / scale); + + if (this.props.drawLine) { + const { path, style } = this.props.drawLine(this.startState, this.endState); + + 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.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.context.graph.executеDefaultEventAction( + "port-connection-create-start", + { + blockId: params.blockId, + anchorId: params.anchorId, + sourcePort: port, + }, + () => { + this.sourcePort = port; + this.startState = new Point(port.x, port.y); + + // Set selection on owner component + if (port.owner instanceof Block) { + this.context.graph.api.selectBlocks([params.blockId], true); + } else if (port.owner instanceof Anchor) { + this.context.graph.api.setAnchorSelection(params.blockId, params.anchorId, 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) { + // Deselect old target + if (this.targetPort?.owner) { + const oldParams = this.getEventParams(this.targetPort); + if (this.targetPort.owner instanceof Block) { + this.context.graph.api.selectBlocks([oldParams.blockId], false); + } else if (this.targetPort.owner instanceof Anchor) { + this.context.graph.api.setAnchorSelection(oldParams.blockId, oldParams.anchorId, 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, + }, + () => { + // Select new target + if (newTargetPort.owner instanceof Block) { + this.context.graph.api.selectBlocks([targetParams.blockId], true); + } else if (newTargetPort.owner instanceof Anchor) { + this.context.graph.api.setAnchorSelection(targetParams.blockId, targetParams.anchorId, true); + } + } + ); + } + } + } + + private onEndNewConnection(point: Point): void { + if (!this.sourcePort || !this.startState || !this.endState) { + return; + } + + // Use the target port that was found during move (snapping) + // instead of searching again at the drop point + const targetPort = this.targetPort; + + 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; + } + + const targetParams = this.getEventParams(targetPort); + + // Create connection + this.context.graph.executеDefaultEventAction( + "port-connection-created", + { + sourceBlockId: sourceParams.blockId, + sourceAnchorId: sourceParams.anchorId, + targetBlockId: targetParams.blockId, + targetAnchorId: targetParams.anchorId, + sourcePort: this.sourcePort, + targetPort: targetPort, + }, + () => { + this.context.graph.rootStore.connectionsList.addConnection({ + sourceBlockId: sourceParams.blockId, + sourceAnchorId: sourceParams.anchorId, + targetBlockId: targetParams.blockId, + targetAnchorId: targetParams.anchorId, + }); + } + ); + + // Deselect both ports + if (this.sourcePort.owner instanceof Block) { + this.context.graph.api.selectBlocks([sourceParams.blockId], false); + } else if (this.sourcePort.owner instanceof Anchor) { + this.context.graph.api.setAnchorSelection(sourceParams.blockId, sourceParams.anchorId, false); + } + + if (targetPort.owner instanceof Block) { + this.context.graph.api.selectBlocks([targetParams.blockId], false); + } else if (targetPort.owner instanceof Anchor) { + this.context.graph.api.setAnchorSelection(targetParams.blockId, targetParams.anchorId, false); + } + + // 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; + } + + /** + * 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 00000000..7840a47e --- /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 43e6c30b..c3679dd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export * from "./components/canvas/groups"; export * from "./components/canvas/layers/newBlockLayer/NewBlockLayer"; export * from "./components/canvas/layers/connectionLayer/ConnectionLayer"; +export * from "./components/canvas/layers/portConnectionLayer/PortConnectionLayer"; export * from "./lib/Component"; export * from "./services/selection/index.public"; diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts index 6b910065..0882d264 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 556073b9..4f6d44f1 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/port/PortList.ts b/src/store/connection/port/PortList.ts index 9aacf820..2f00d85c 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 00000000..898bd6f7 --- /dev/null +++ b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx @@ -0,0 +1,356 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; + +import { Flex, Text, ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryFn } from "@storybook/react-webpack5"; + +import { Anchor, CanvasBlock, EAnchorType, Graph } from "../../../"; +import { Block, 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 { + const sourceComponent = sourcePort.owner; + const targetComponent = targetPort.owner; + + if (!sourceComponent || !targetComponent) { + return false; + } + + const isSourceBlock = sourceComponent instanceof Block; + const isTargetBlock = targetComponent instanceof Block; + const isSourceAnchor = sourceComponent instanceof Anchor; + const isTargetAnchor = targetComponent instanceof Anchor; + + if (isSourceBlock && isTargetBlock) { + return sourceComponent.connectedState.id === targetComponent.connectedState.id; + } else if (isSourceAnchor && isTargetAnchor) { + return sourceComponent.connectedState.blockId === targetComponent.connectedState.blockId; + } else if (isSourceBlock && isTargetAnchor) { + return sourceComponent.connectedState.id === targetComponent.connectedState.blockId; + } else if (isSourceAnchor && isTargetBlock) { + return sourceComponent.connectedState.blockId === targetComponent.connectedState.id; + } + + return false; +} + +/** + * Helper function to check if connection is valid (IN to OUT or OUT to IN) + */ +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 === EAnchorType.IN && targetType === EAnchorType.OUT) || + (sourceType === EAnchorType.OUT && targetType === EAnchorType.IN) + ); + } + + return true; +} + +/** + * Custom block with port-based snapping using PortConnectionLayer + * Demonstrates the new port-centric approach + * Rules: + * - Can only connect IN to OUT (not IN to IN or OUT to OUT) + * - Cannot connect input and output 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 only connect IN to OUT + return isValidConnection(ctx.sourcePort, ctx.targetPort); + }, + }; + + // Use new API with PortMetaKey + this.updatePort(portId, undefined, undefined, { + [PortConnectionLayer.PortMetaKey]: snapMeta, + }); + }); + } +} + +/** + * Custom block with conditional snapping and data types + * Demonstrates advanced port validation with metadata + */ +class ConditionalPortBlock 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"; + + const snapMeta: IPortConnectionMeta = { + snappable: true, + snapCondition: (ctx) => { + // Cannot connect to the same block + if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { + return false; + } + + // Can only connect IN to OUT + 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, undefined, undefined, { + [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: "input-1", blockId: "port-block-1", type: EAnchorType.IN }, + { id: "input-2", blockId: "port-block-1", type: EAnchorType.IN }, + { id: "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: "input-1", blockId: "port-block-2", type: EAnchorType.IN }, + { id: "output-1", blockId: "port-block-2", type: EAnchorType.OUT }, + { id: "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: "input-1", blockId: "port-block-3", type: EAnchorType.IN }, + { id: "input-2", blockId: "port-block-3", type: EAnchorType.IN }, + { id: "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: "input-1", blockId: "conditional-block-1", type: EAnchorType.IN }, + { id: "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: "input-1", blockId: "conditional-block-2", type: EAnchorType.IN }, + { id: "output-1", blockId: "conditional-block-2", type: EAnchorType.OUT }, + ], + }, + ]; +}; + +const GraphApp = () => { + const { graph, setEntities, start, addLayer, zoomTo } = useGraph({ + settings: { + 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 ( + + + + PortConnectionLayer Demo + Демонстрация нового слоя PortConnectionLayer, работающего только с портами. + + Тяните от якоря одного блока к другому. Связь автоматически привязывается к ближайшим портам при + приближении. + + + + Правила подключения: + + • Можно соединять только IN порты с OUT портами + • Нельзя соединять входные и выходные порты одного блока + + Port Blocks (верхний ряд): Используют базовую валидацию подключений через порты. + + + Conditional Blocks (нижний ряд): Дополнительно проверяют совместимость типов данных. + + + Новые возможности: События теперь содержат прямые ссылки на sourcePort и targetPort, что + дает прямой доступ к метаданным портов и их владельцам. + + + +
+ +
+
+
+ ); +}; + +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 84980fc6..0c153976 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 00000000..d2edca27 --- /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 00000000..eaa728fb --- /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; +} From b95ffabb513b71de5860f36dbe9964a5edbca0d1 Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 3 Feb 2026 17:36:20 +0300 Subject: [PATCH 3/8] ... --- .../port_magnetic_snapping_95725e35.plan.md | 692 ------------------ .../layers/connectionLayer/ConnectionLayer.ts | 279 +------ .../PortConnectionLayer.ts | 80 +- .../magneticPorts/magneticPorts.stories.tsx | 348 --------- .../portConnectionLayer.stories.tsx | 82 +-- 5 files changed, 93 insertions(+), 1388 deletions(-) delete mode 100644 .cursor/plans/port_magnetic_snapping_95725e35.plan.md delete mode 100644 src/stories/examples/magneticPorts/magneticPorts.stories.tsx diff --git a/.cursor/plans/port_magnetic_snapping_95725e35.plan.md b/.cursor/plans/port_magnetic_snapping_95725e35.plan.md deleted file mode 100644 index 2f3b3e34..00000000 --- a/.cursor/plans/port_magnetic_snapping_95725e35.plan.md +++ /dev/null @@ -1,692 +0,0 @@ ---- -name: Port Magnetic Snapping -overview: "Добавление функциональности \"магнитов\" для портов при создании связей: порты смогут автоматически примагничивать конечную точку связи в заданном радиусе с настраиваемыми условиями примагничивания." -todos: - - id: make-port-generic - content: Сделать TPort generic типом с полем meta и добавить updatePortWithMeta в PortState - status: completed - - id: add-update-port-method - content: Добавить метод updatePort в GraphComponent - status: completed - - id: add-rbush-to-connectionlayer - content: Добавить RBush, outdated флаг и подписку на $ports в ConnectionLayer - status: completed - - id: implement-lazy-rebuild - content: Реализовать lazy rebuild RBush и createMagneticPortBox в ConnectionLayer - status: completed - - id: implement-find-nearest - content: Реализовать метод findNearestMagneticPort в ConnectionLayer - status: completed - - id: connection-layer-integration - content: Интегрировать поиск магнитных портов в onMoveNewConnection - status: completed - - id: export-types - content: Экспортировать новые типы и утилиты в index.ts - status: completed - - id: test-implementation - content: Создать story для демонстрации работы магнитов - status: completed ---- - -# Реализация магнитов для портов - -## Краткое резюме - -Добавляется функциональность "магнитов" для портов - автоматическое примагничивание конечной точки связи к ближайшему порту при создании соединений. - -**Что получит пользователь:** -- Метод `updatePort(id, x?, y?, meta?)` в любом компоненте (Block, custom components) -- Возможность задать область магнита (width/height) через meta -- Кастомные условия примагничивания через функцию в meta -- Автоматический поиск ближайшего порта при создании связей -- Полный контроль над настройками через простой API - -## Архитектурный принцип: Разделение ответственности - -**Порты** (PortState, TPort): -- Хранят только позицию (x, y) и произвольные мета-данные (meta: T) -- НЕ знают про магниты - это универсальное хранилище -- Meta может использоваться для любых целей разными слоями - -**ConnectionLayer**: -- Решает КАК интерпретировать meta для своих целей -- Если видит meta с полями `magnetWidthArea/magnetHeightArea` - использует для примагничивания -- Строит и управляет RBush для spatial indexing -- Другие слои могут интерпретировать meta по-своему - -## Пользовательский API - -Пользователи смогут настраивать магниты через метод `updatePort` в любом компоненте, наследующем `GraphComponent` (включая `Block`): - -```typescript -// В кастомном блоке -class MyBlock extends Block { - protected override willMount(): void { - super.willMount(); - - // Настроить магнит для анкора - const portId = createAnchorPortId(this.state.id, "input-1"); - this.updatePort(portId, undefined, undefined, { - magnetWidthArea: 50, - magnetHeightArea: 50, - magnetCondition: (ctx) => ctx.sourcePort.meta?.type === "data" - }); - - // Настроить магнит и позицию для output порта блока - const outputPortId = createBlockPointPortId(this.state.id, false); - this.updatePort(outputPortId, customX, customY, { - magnetWidthArea: 40, - magnetHeightArea: 40 - }); - } -} -``` - -**Сигнатура метода:** - -```typescript -updatePort(id: TPortId, x?: number, y?: number, meta?: T): void -``` - -## Архитектурная диаграмма - -```mermaid -graph TB - User[User Custom Block] - GraphComponent[GraphComponent] - UpdatePort["updatePort method"] - PortState[PortState - хранит position + meta] - TPort["TPort<T> - универсальное хранилище"] - PortsStore[PortsStore - управление портами] - PortsSignal["$ports Signal"] - ConnectionLayer[ConnectionLayer - интерпретирует meta] - RBush[RBush Spatial Index] - - User -->|"extends"| GraphComponent - User -->|"calls"| UpdatePort - GraphComponent -->|"contains"| UpdatePort - UpdatePort -->|"updates"| PortState - PortState -->|"stores"| TPort - PortState -->|"managed by"| PortsStore - PortsStore -->|"emits"| PortsSignal - ConnectionLayer -->|"subscribes to"| PortsSignal - PortsSignal -->|"change marks outdated"| RBush - ConnectionLayer -->|"owns and rebuilds"| RBush - ConnectionLayer -->|"interprets meta for magnets"| PortState -``` - -## 1. Сделать TPort generic типом и добавить updatePort - -**Файл:** [`src/store/connection/port/Port.ts`](src/store/connection/port/Port.ts) - -### Изменения в TPort - -Сделать TPort generic с полем meta: - -```typescript -export type TPort = { - id: TPortId; - x: number; - y: number; - component?: Component; - lookup?: boolean; - meta?: T; // Произвольная мета-информация (порт не знает что в ней) -}; -``` - -### Стандартная структура meta для магнитов - -Определить интерфейс для мета-информации магнитов (используется ConnectionLayer): - -```typescript -export type TPortMagnetCondition = (context: { - sourcePort: PortState; - targetPort: PortState; - sourceComponent?: Component; - targetComponent?: Component; - cursorPosition: TPoint; - distance: number; -}) => boolean; - -/** - * Опциональная структура meta для магнитных портов - * ConnectionLayer интерпретирует эту структуру для примагничивания - */ -export interface IPortMagnetMeta { - magnetWidthArea?: number; // Ширина области магнита (по умолчанию 40) - magnetHeightArea?: number; // Высота области магнита (по умолчанию 40) - magnetCondition?: TPortMagnetCondition; // Условие примагничивания -} -``` - -### Обновление PortState - -Сделать PortState также generic и добавить метод обновления с мета-информацией: - -```typescript -export class PortState { - public $state = signal>(undefined); - - // ... остальные поля - - constructor(port: TPort) { - this.$state.value = { ...port }; - if (port.component) { - this.owner = port.component; - } - } - - // Геттер для мета-информации - public get meta(): T | undefined { - return this.$state.value.meta; - } - - // Обновить позицию и/или мета-информацию порта - public updatePortWithMeta(x?: number, y?: number, meta?: T): void { - const updates: Partial> = {}; - - if (x !== undefined) updates.x = x; - if (y !== undefined) updates.y = y; - if (meta !== undefined) updates.meta = meta; - - if (Object.keys(updates).length > 0) { - this.updatePort(updates); - } - } -} -``` - -## 2. Добавить updatePort в GraphComponent - -**Файл:** [`src/components/canvas/GraphComponent/index.tsx`](src/components/canvas/GraphComponent/index.tsx) - -Добавить публичный метод для обновления портов: - -```typescript -export class GraphComponent { - // ... существующие методы - - /** - * Update port position and metadata - * @param id Port identifier - * @param x New X coordinate (optional) - * @param y New Y coordinate (optional) - * @param meta Port metadata (optional) - */ - public updatePort( - id: TPortId, - x?: number, - y?: number, - meta?: T - ): void { - const port = this.getPort(id); - port.updatePortWithMeta(x, y, meta); - } -} -``` - -## 3. Добавить RBush в ConnectionLayer - -**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) - -### Добавить поля в ConnectionLayer - -```typescript -import RBush from "rbush"; - -type MagneticPortBox = { - minX: number; - minY: number; - maxX: number; - maxY: number; - port: PortState; -}; - -export class ConnectionLayer extends Layer { - // ... существующие поля - - private magneticPortsTree: RBush | null = null; - private isMagneticTreeOutdated = true; - private portsUnsubscribe?: () => void; -} -``` - -### Подписка на изменения портов - -В методе `afterInit()`: - -```typescript -protected afterInit(): void { - // ... существующий код - - // Подписаться на изменения портов для обновления RBush - const portsStore = this.context.graph.rootStore.connectionsList.portsStore; - this.portsUnsubscribe = portsStore.$ports.subscribe(() => { - this.isMagneticTreeOutdated = true; - }); - - super.afterInit(); -} -``` - -### Очистка при unmount - -```typescript -public override unmount(): void { - if (this.portsUnsubscribe) { - this.portsUnsubscribe(); - this.portsUnsubscribe = undefined; - } - this.magneticPortsTree = null; - super.unmount(); -} -``` - -## 4. Реализовать lazy rebuild RBush - -**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) - -### Константы по умолчанию - -```typescript -const DEFAULT_MAGNET_WIDTH = 40; -const DEFAULT_MAGNET_HEIGHT = 40; -``` - -### Метод создания bounding box - -```typescript -private createMagneticPortBox(port: PortState): MagneticPortBox | null { - const meta = port.meta as IPortMagnetMeta | undefined; - - // Проверить, является ли порт магнитным - if (!meta?.magnetWidthArea && !meta?.magnetHeightArea) { - return null; // Порт не магнитный - } - - const widthArea = meta.magnetWidthArea ?? DEFAULT_MAGNET_WIDTH; - const heightArea = meta.magnetHeightArea ?? DEFAULT_MAGNET_HEIGHT; - - return { - minX: port.x - widthArea / 2, - minY: port.y - heightArea / 2, - maxX: port.x + widthArea / 2, - maxY: port.y + heightArea / 2, - port: port - }; -} -``` - -### Метод пересоздания RBush - -```typescript -private rebuildMagneticTree(): void { - if (!this.isMagneticTreeOutdated) { - return; - } - - const magneticBoxes: MagneticPortBox[] = []; - const portsStore = this.context.graph.rootStore.connectionsList.portsStore; - - for (const port of portsStore.$ports.value) { - const box = this.createMagneticPortBox(port); - if (box) { - magneticBoxes.push(box); - } - } - - this.magneticPortsTree = new RBush(9); - if (magneticBoxes.length > 0) { - this.magneticPortsTree.load(magneticBoxes); - } - - this.isMagneticTreeOutdated = false; -} -``` - -## 5. Реализовать поиск ближайшего магнитного порта - -**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) - -```typescript -private findNearestMagneticPort( - point: TPoint, - sourcePort?: PortState, - sourceComponent?: Component -): { port: PortState; snapPoint: TPoint } | null { - // Пересоздать RBush если outdated - this.rebuildMagneticTree(); - - if (!this.magneticPortsTree) { - return null; - } - - // Поиск портов в области курсора - const searchRadius = 100; // можно настроить - const candidates = this.magneticPortsTree.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; - - // Пропустить source порт - if (sourcePort && port.id === sourcePort.id) { - continue; - } - - // Вычислить векторное расстояние - const dx = port.x - point.x; - const dy = port.y - point.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Проверить, находится ли точка в магнитной области - const meta = port.meta as IPortMagnetMeta | undefined; - const widthArea = meta?.magnetWidthArea ?? DEFAULT_MAGNET_WIDTH; - const heightArea = meta?.magnetHeightArea ?? DEFAULT_MAGNET_HEIGHT; - - if (Math.abs(dx) > widthArea / 2 || Math.abs(dy) > heightArea / 2) { - continue; // Вне магнитной области - } - - // Проверить кастомное условие, если есть - if (meta?.magnetCondition && sourcePort) { - const canSnap = meta.magnetCondition({ - sourcePort: sourcePort, - targetPort: port, - sourceComponent, - targetComponent: port.component, - cursorPosition: point, - distance, - }); - - if (!canSnap) { - continue; - } - } - - // Обновить ближайший порт - if (distance < nearestDistance) { - nearestDistance = distance; - nearestPort = port; - } - } - - if (!nearestPort) { - return null; - } - - return { - port: nearestPort, - snapPoint: { x: nearestPort.x, y: nearestPort.y }, - }; -} -``` - -## 6. Интегрировать в onMoveNewConnection - -**Файл:** [`src/components/canvas/layers/connectionLayer/ConnectionLayer.ts`](src/components/canvas/layers/connectionLayer/ConnectionLayer.ts) - -### Вспомогательные методы - -```typescript -private getSourcePort(component: BlockState | AnchorState): PortState | undefined { - const portsStore = this.context.graph.rootStore.connectionsList.portsStore; - - if (component instanceof AnchorState) { - return portsStore.getPort(createAnchorPortId(component.blockId, component.id)); - } - - // Для блока берём output port - return portsStore.getPort(createBlockPointPortId(component.id, false)); -} - -private getComponentByPort(port: PortState): Block | Anchor | undefined { - return port.component?.getViewComponent() as Block | Anchor | undefined; -} -``` - -### Изменения в onMoveNewConnection - -Текущий код (строка 328-338): - -```328:338:src/components/canvas/layers/connectionLayer/ConnectionLayer.ts -private onMoveNewConnection(event: MouseEvent, point: Point) { - if (!this.startState || !this.sourceComponent) { - return; - } - console.log(this.sourceComponent, "onMoveNewConnection", point); - - const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); - - // Use world coordinates from point instead of screen coordinates - this.endState = new Point(point.x, point.y); - this.performRender(); -``` - -**Новая логика:** - -```typescript -private onMoveNewConnection(event: MouseEvent, point: Point) { - if (!this.startState || !this.sourceComponent) { - return; - } - - // Получаем source port - const sourcePort = this.getSourcePort(this.sourceComponent); - - // Сначала пытаемся примагнититься - const magnetResult = this.findNearestMagneticPort( - point, - sourcePort, - this.sourceComponent - ); - - let actualEndPoint = point; - let newTargetComponent = null; - - if (magnetResult) { - // Примагничиваемся к порту - actualEndPoint = magnetResult.snapPoint; - newTargetComponent = this.getComponentByPort(magnetResult.port); - } else { - // Используем существующую логику - newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); - } - - this.endState = new Point(actualEndPoint.x, actualEndPoint.y); - this.performRender(); - - // ... остальная логика обработки target (существующий код) -} -``` - -## 7. Экспорт типов и API - -**Файл:** [`src/index.ts`](src/index.ts) - -Экспортировать новые типы и утилиты для использования в приложениях: - -```typescript -// Экспорт типов портов -export type { TPort, TPortMagnetCondition, IPortMagnetMeta } from "./store/connection/port/Port"; - -// Экспорт утилит для создания port ID (если ещё не экспортированы) -export { createAnchorPortId, createBlockPointPortId } from "./store/connection/port/utils"; -``` - -**Примечание:** Метод `updatePort` уже доступен через `GraphComponent`, который экспортируется и от которого наследуется `Block`. - -## 8. Примеры использования в Story - -**Новый файл:** `src/stories/examples/magneticPorts/magneticPorts.stories.tsx` - -### Story 1: Базовое использование - -```typescript -import { Block, IPortMagnetMeta, createAnchorPortId } from "@gravity-ui/graph"; - -class MagneticBlock extends Block { - protected override willMount(): void { - super.willMount(); - - // Настроить магниты для всех анкоров - this.state.anchors?.forEach(anchor => { - const portId = createAnchorPortId(this.state.id, anchor.id); - this.updatePort(portId, undefined, undefined, { - magnetWidthArea: 50, - magnetHeightArea: 50 - } as IPortMagnetMeta); - }); - } -} -``` - -### Story 2: Динамические магниты - -```typescript -class DynamicMagneticBlock extends Block { - protected override willMount(): void { - super.willMount(); - this.setupMagnets(); - } - - private setupMagnets(): void { - const inputPortId = createBlockPointPortId(this.state.id, true); - const outputPortId = createBlockPointPortId(this.state.id, false); - - // Магниты зависят от размера блока - this.updatePort(inputPortId, undefined, undefined, { - magnetWidthArea: this.state.width * 0.5, - magnetHeightArea: 60 - } as IPortMagnetMeta); - - this.updatePort(outputPortId, undefined, undefined, { - magnetWidthArea: this.state.width * 0.5, - magnetHeightArea: 60 - } as IPortMagnetMeta); - } - - protected override stateChanged(nextState: TBlock): void { - super.stateChanged(nextState); - if (this.state.width !== nextState.width) { - this.setupMagnets(); - } - } -} -``` - -### Story 3: Условное примагничивание - -```typescript -class ConditionalMagneticBlock extends Block { - protected override willMount(): void { - super.willMount(); - - this.state.anchors?.forEach(anchor => { - const portId = createAnchorPortId(this.state.id, anchor.id); - - this.updatePort(portId, undefined, undefined, { - magnetWidthArea: 50, - magnetHeightArea: 50, - magnetCondition: (ctx) => { - // Примагничиваться только к портам с совместимым типом - const sourceMeta = ctx.sourcePort.meta as { dataType?: string }; - const targetMeta = ctx.targetPort.meta as { dataType?: string }; - - return sourceMeta?.dataType === targetMeta?.dataType; - } - } as IPortMagnetMeta); - }); - } -} -``` - -## Преимущества решения - -1. **Чистая архитектура - разделение ответственности**: - - Порты - универсальное хранилище (позиция + meta) - - ConnectionLayer - интерпретирует meta для своих нужд - - Другие слои могут использовать meta по-своему - -2. **Простота и удобство API**: - - Единый метод `updatePort(id, x?, y?, meta?)` для всех настроек - - Не требует изменений в TBlock или TAnchor - - Понятный и предсказуемый интерфейс - -3. **Полный контроль**: - - Пользователь сам решает, когда и как настраивать порты - - Возможность динамически менять магниты - - Можно настроить позицию и meta в одном вызове - -4. **Производительность**: - - RBush обеспечивает O(log n) поиск - - Lazy rebuild - пересоздание только при необходимости - - Подписка на `$ports` автоматически отслеживает изменения - -5. **Гибкость**: - - Generic TPort для любой мета-информации - - Кастомные условия через `magnetCondition` - - Асимметричные области магнитов - -6. **Расширяемость**: - - Meta может использоваться для других целей - - Легко добавить другие интерпретации meta в других слоях - -7. **Обратная совместимость**: - - TPort с дефолтным `unknown` - - Все поля опциональные - - Метод updatePort опционален - -## Ключевые технические детали - -### Формулы bounding box (симметричные) - -```typescript -minX = port.x - magnetWidthArea / 2 -minY = port.y - magnetHeightArea / 2 -maxX = port.x + magnetWidthArea / 2 -maxY = port.y + magnetHeightArea / 2 -``` - -### Алгоритм поиска - -1. Проверить `isMagneticTreeOutdated`, если true - пересоздать RBush -2. Выполнить поиск в RBush с квадратом вокруг курсора -3. Отфильтровать кандидатов: - - Исключить source порт - - Проверить, что точка внутри магнитной области - - Проверить кастомное условие (если есть) -4. Вернуть порт с минимальным векторным расстоянием - -### Когда RBush помечается outdated - -- При любом изменении `$ports` signal (через subscription) -- Автоматически отслеживает создание, удаление и обновление портов - -## Обратная совместимость - -### Существующий код продолжит работать - -1. **TPort становится TPort** - дефолтный generic параметр -2. **PortState становится PortState** - аналогично -3. **Новый метод updatePort** - опциональный -4. **RBush в ConnectionLayer** - работает прозрачно -5. **Изменения в ConnectionLayer** - сначала магниты, потом старая логика - -### Поведение по умолчанию - -- Если порт не имеет meta с магнитной информацией - не участвует в магнитном поиске -- Если ни один порт не найден - работает `getElementOverPoint` -- Существующие блоки продолжают работать как раньше \ No newline at end of file diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 2d950b5a..318d604f 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -1,13 +1,9 @@ -import RBush from "rbush"; - import { GraphMouseEvent, extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { ESelectionStrategy } from "../../../../services/selection/types"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; -import { PortState } from "../../../../store/connection/port/Port"; -import { createAnchorPortId, createBlockPointPortId } from "../../../../store/connection/port/utils"; -import { isBlock, isShiftKeyEvent, vectorDistance } from "../../../../utils/functions"; +import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; import { Point, TPoint } from "../../../../utils/types/shapes"; @@ -15,49 +11,6 @@ import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; import { GraphComponent } from "../../GraphComponent"; -/** - * Default search radius for port snapping in pixels - * Ports within this radius will be considered for snapping - */ -const SNAP_SEARCH_RADIUS = 20; - -/** - * Snap condition function type - * Used by ConnectionLayer to determine if a port can snap to another port - * Note: sourceComponent and targetComponent can be accessed via sourcePort.component and targetPort.component - */ -export type TPortSnapCondition = (context: { - sourcePort: PortState; - targetPort: PortState; - cursorPosition: TPoint; - distance: number; -}) => boolean; - -/** - * Optional metadata structure for port snapping - * ConnectionLayer interprets this structure for port snapping behavior - * - * @example - * ```typescript - * const snapMeta: IPortSnapMeta = { - * snappable: true, - * snapCondition: (ctx) => { - * // Access components via ports - * const sourceComponent = ctx.sourcePort.component; - * const targetComponent = ctx.targetPort.component; - * // Custom validation logic - * return true; - * } - * }; - * ``` - */ -export interface IPortSnapMeta { - /** Enable snapping for this port. If false or undefined, port will not participate in snapping */ - snappable?: boolean; - /** Custom condition for snapping - access components via sourcePort.component and targetPort.component */ - snapCondition?: TPortSnapCondition; -} - type TIcon = { path: string; fill?: string; @@ -75,14 +28,6 @@ type LineStyle = { type DrawLineFunction = (start: TPoint, end: TPoint) => { path: Path2D; style: LineStyle }; -type SnappingPortBox = { - minX: number; - minY: number; - maxX: number; - maxY: number; - port: PortState; -}; - type ConnectionLayerProps = LayerProps & { createIcon?: TIcon; point?: TIcon; @@ -178,11 +123,6 @@ export class ConnectionLayer extends Layer< protected enabled: boolean; private declare eventAborter: AbortController; - // Port snapping support - private snappingPortsTree: RBush | null = null; - private isSnappingTreeOutdated = true; - private portsUnsubscribe?: () => void; - constructor(props: ConnectionLayerProps) { super({ canvas: { @@ -220,21 +160,6 @@ export class ConnectionLayer extends Layer< // Register event listeners with the graphOn wrapper method for automatic cleanup when unmounted this.onGraphEvent("mousedown", this.handleMouseDown); - // Subscribe to ports changes to mark snapping tree as outdated - // We'll mark the tree as outdated when ports change by polling - // Note: Direct subscription to internal signal requires access to connectionsList.ports - const checkPortsChanged = () => { - this.isSnappingTreeOutdated = true; - }; - - // Subscribe through the Layer's onSignal helper which handles cleanup - 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; - }); - // Call parent afterInit to ensure proper initialization super.afterInit(); } @@ -416,26 +341,10 @@ export class ConnectionLayer extends Layer< return; } - // Get source port - const sourcePort = this.getSourcePort(this.sourceComponent); - - // Try to snap to nearby port first - const snapResult = this.findNearestSnappingPort(point, sourcePort); - - let actualEndPoint = point; - let newTargetComponent: Block | Anchor; - - if (snapResult) { - // Snap to port - actualEndPoint = new Point(snapResult.snapPoint.x, snapResult.snapPoint.y); - newTargetComponent = this.getComponentByPort(snapResult.port); - } else { - // Use existing logic - find element over point - newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); - } + const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); // Use world coordinates from point instead of screen coordinates - this.endState = new Point(actualEndPoint.x, actualEndPoint.y); + this.endState = new Point(point.x, point.y); this.performRender(); if (!newTargetComponent || !newTargetComponent.connectedState) { @@ -534,186 +443,4 @@ export class ConnectionLayer extends Layer< () => {} ); } - - /** - * Get the source port from a component (block or anchor) - * @param component Block or Anchor component - * @returns Port state or undefined - */ - private getSourcePort(component: BlockState | AnchorState): PortState | undefined { - const connectionsList = this.context.graph.rootStore.connectionsList; - - if (component instanceof AnchorState) { - return connectionsList.getPort(createAnchorPortId(component.blockId, component.id)); - } - - // For block, use output port - return connectionsList.getPort(createBlockPointPortId(component.id, false)); - } - - /** - * Get the component (Block or Anchor) that owns a port - * @param port Port state - * @returns Block or Anchor component - */ - private getComponentByPort(port: PortState): Block | Anchor | undefined { - const component = port.component; - if (!component) { - return undefined; - } - - // Check if component is Block or Anchor by checking instance - if (component instanceof Block || component instanceof Anchor) { - return component; - } - - return undefined; - } - - /** - * Create a snapping port bounding box for RBush spatial indexing - * @param port Port to create bounding box for - * @param searchRadius Search radius for snapping area - * @returns SnappingPortBox or null if port doesn't have snapping enabled - */ - private createSnappingPortBox(port: PortState, searchRadius: number): SnappingPortBox | null { - const meta = port.meta as IPortSnapMeta | undefined; - - // Check if port has snapping enabled - if (!meta?.snappable) { - return null; // Port doesn't participate in snapping - } - - return { - minX: port.x - searchRadius, - minY: port.y - searchRadius, - maxX: port.x + searchRadius, - maxY: port.y + searchRadius, - port: port, - }; - } - - /** - * Find the nearest snapping port to a given point - * @param point Point to search from - * @param sourcePort Source port to exclude from search - * @returns Nearest snapping port and snap point, or null if none found - */ - private findNearestSnappingPort( - point: TPoint, - sourcePort?: PortState - ): { port: PortState; snapPoint: TPoint } | null { - // Rebuild RBush if outdated - this.rebuildSnappingTree(); - - if (!this.snappingPortsTree) { - return null; - } - - // Search for ports in the area around cursor - const candidates = this.snappingPortsTree.search({ - minX: point.x - SNAP_SEARCH_RADIUS, - minY: point.y - SNAP_SEARCH_RADIUS, - maxX: point.x + SNAP_SEARCH_RADIUS, - maxY: point.y + SNAP_SEARCH_RADIUS, - }); - - if (candidates.length === 0) { - return null; - } - - // Find the nearest port by vector distance - 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 as IPortSnapMeta | 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[] = []; - - // 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 box = this.createSnappingPortBox(port, SNAP_SEARCH_RADIUS); - if (box) { - snappingBoxes.push(box); - } - } - } - - this.snappingPortsTree = new RBush(9); - if (snappingBoxes.length > 0) { - this.snappingPortsTree.load(snappingBoxes); - } - - this.isSnappingTreeOutdated = false; - } - - public override unmount(): void { - // Cleanup ports subscription - if (this.portsUnsubscribe) { - this.portsUnsubscribe(); - this.portsUnsubscribe = undefined; - } - // Clear snapping tree - this.snappingPortsTree = null; - super.unmount(); - } } diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts index ed952384..1778f381 100644 --- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -2,6 +2,7 @@ import RBush from "rbush"; import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; +import { EAnchorType } from "../../../../store/anchor/Anchor"; import { TBlockId } from "../../../../store/block/Block"; import { PortState } from "../../../../store/connection/port/Port"; import { vectorDistance } from "../../../../utils/functions"; @@ -236,32 +237,29 @@ export class PortConnectionLayer extends Layer< }; protected handleMouseDown = (nativeEvent: GraphMouseEvent): void => { + if (!this.enabled) { + return; + } const initEvent = extractNativeGraphMouseEvent(nativeEvent); - if (!initEvent || !this.root?.ownerDocument) { + const initialComponent = nativeEvent.detail.target as GraphComponent; + if (!initEvent || !this.root?.ownerDocument || !initialComponent) { return; } - - if (!this.enabled) { + if (!(initialComponent instanceof GraphComponent) || initialComponent.getPorts().length === 0) { return; } - - nativeEvent.preventDefault(); - nativeEvent.stopPropagation(); - - const initialComponent = nativeEvent.detail.target as GraphComponent; // DragService will provide world coordinates in callbacks this.context.graph.dragService.startDrag( { onStart: (_event, coords) => { const point = new Point(coords[0], coords[1]); - // Find port at cursor position 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); } @@ -473,25 +471,40 @@ export class PortConnectionLayer extends Layer< return; } - const targetParams = this.getEventParams(targetPort); + // 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: sourceParams.blockId, - sourceAnchorId: sourceParams.anchorId, - targetBlockId: targetParams.blockId, - targetAnchorId: targetParams.anchorId, - sourcePort: this.sourcePort, - targetPort: targetPort, + sourceBlockId: actualSourceParams.blockId, + sourceAnchorId: actualSourceParams.anchorId, + targetBlockId: actualTargetParams.blockId, + targetAnchorId: actualTargetParams.anchorId, + sourcePort: actualSourcePort, + targetPort: actualTargetPort, }, () => { this.context.graph.rootStore.connectionsList.addConnection({ - sourceBlockId: sourceParams.blockId, - sourceAnchorId: sourceParams.anchorId, - targetBlockId: targetParams.blockId, - targetAnchorId: targetParams.anchorId, + sourceBlockId: actualSourceParams.blockId, + sourceAnchorId: actualSourceParams.anchorId, + targetBlockId: actualTargetParams.blockId, + targetAnchorId: actualTargetParams.anchorId, }); } ); @@ -503,6 +516,7 @@ export class PortConnectionLayer extends Layer< this.context.graph.api.setAnchorSelection(sourceParams.blockId, sourceParams.anchorId, false); } + const targetParams = this.getEventParams(targetPort); if (targetPort.owner instanceof Block) { this.context.graph.api.selectBlocks([targetParams.blockId], false); } else if (targetPort.owner instanceof Anchor) { @@ -642,6 +656,30 @@ export class PortConnectionLayer extends Layer< 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 diff --git a/src/stories/examples/magneticPorts/magneticPorts.stories.tsx b/src/stories/examples/magneticPorts/magneticPorts.stories.tsx deleted file mode 100644 index 4bf51b9c..00000000 --- a/src/stories/examples/magneticPorts/magneticPorts.stories.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef } from "react"; - -import { Flex, Text, ThemeProvider } from "@gravity-ui/uikit"; -import type { Meta, StoryFn } from "@storybook/react-webpack5"; - -import { Anchor, CanvasBlock, EAnchorType, Graph } from "../../../"; -import { Block, TBlock } from "../../../components/canvas/blocks/Block"; -import { ConnectionLayer, IPortSnapMeta } from "../../../components/canvas/layers/connectionLayer/ConnectionLayer"; -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: { component?: unknown }, targetPort: { component?: unknown }): boolean { - const sourceComponent = sourcePort.component; - const targetComponent = targetPort.component; - - if (!sourceComponent || !targetComponent) { - return false; - } - - const isSourceBlock = sourceComponent instanceof Block; - const isTargetBlock = targetComponent instanceof Block; - const isSourceAnchor = sourceComponent instanceof Anchor; - const isTargetAnchor = targetComponent instanceof Anchor; - - if (isSourceBlock && isTargetBlock) { - return sourceComponent.connectedState.id === targetComponent.connectedState.id; - } else if (isSourceAnchor && isTargetAnchor) { - return sourceComponent.connectedState.blockId === targetComponent.connectedState.blockId; - } else if (isSourceBlock && isTargetAnchor) { - return sourceComponent.connectedState.id === targetComponent.connectedState.blockId; - } else if (isSourceAnchor && isTargetBlock) { - return sourceComponent.connectedState.blockId === targetComponent.connectedState.id; - } - - return false; -} - -/** - * Helper function to check if connection is valid (IN to OUT or OUT to IN) - */ -function isValidConnection(sourcePort: { component?: unknown }, targetPort: { component?: unknown }): boolean { - const sourceComponent = sourcePort.component; - const targetComponent = targetPort.component; - - 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 === EAnchorType.IN && targetType === EAnchorType.OUT) || - (sourceType === EAnchorType.OUT && targetType === EAnchorType.IN) - ); - } - - return true; -} - -/** - * Custom block with snapping ports - * Demonstrates how to configure port snapping for anchors - * Rules: - * - Can only connect IN to OUT (not IN to IN or OUT to OUT) - * - Cannot connect input and output of the same block - */ -class MagneticBlock extends CanvasBlock { - protected override willMount(): void { - super.willMount(); - - // Configure snapping for all anchors - this.state.anchors?.forEach((anchor) => { - const portId = createAnchorPortId(this.state.id, anchor.id); - - const snapMeta: IPortSnapMeta = { - snappable: true, - snapCondition: (ctx) => { - // Cannot connect to the same block - if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { - return false; - } - - // Can only connect IN to OUT - return isValidConnection(ctx.sourcePort, ctx.targetPort); - }, - }; - - this.updatePort(portId, undefined, undefined, snapMeta); - }); - } -} - -/** - * Custom block with conditional snapping - * Only snaps to ports with matching data types - * Also applies the same rules as MagneticBlock: - * - Can only connect IN to OUT - * - Cannot connect input and output of the same block - */ -class ConditionalMagneticBlock extends CanvasBlock { - protected override willMount(): void { - super.willMount(); - - this.state.anchors?.forEach((anchor) => { - const portId = createAnchorPortId(this.state.id, anchor.id); - - const snapMeta: IPortSnapMeta = { - snappable: true, - snapCondition: (ctx) => { - // Cannot connect to the same block - if (isSameBlock(ctx.sourcePort, ctx.targetPort)) { - return false; - } - - // Can only connect IN to OUT - if (!isValidConnection(ctx.sourcePort, ctx.targetPort)) { - return false; - } - - // Only snap if both ports have matching data types - const sourceMeta = ctx.sourcePort.meta as { dataType?: string } | undefined; - const targetMeta = ctx.targetPort.meta as { dataType?: string } | undefined; - - if (!sourceMeta?.dataType || !targetMeta?.dataType) { - return true; // Allow snapping if no data type specified - } - - return sourceMeta.dataType === targetMeta.dataType; - }, - }; - - this.updatePort(portId, undefined, undefined, { - ...snapMeta, - dataType: anchor.type === EAnchorType.IN ? "input-data" : "output-data", - }); - }); - } -} - -const generateMagneticBlocks = (): TBlock[] => { - return [ - { - id: "block-1", - name: "Magnetic Block 1", - x: 100, - y: 100, - width: 200, - height: 400, - is: "magnetic-block", - selected: false, - anchors: [ - { id: "input-1", blockId: "block-1", type: EAnchorType.IN }, - { id: "input-2", blockId: "block-1", type: EAnchorType.IN }, - { id: "output-1", blockId: "block-1", type: EAnchorType.OUT }, - ], - }, - { - id: "block-2", - name: "Magnetic Block 2", - x: 400, - y: 100, - width: 200, - height: 400, - is: "magnetic-block", - selected: false, - anchors: [ - { id: "input-1", blockId: "block-2", type: EAnchorType.IN }, - { id: "output-1", blockId: "block-2", type: EAnchorType.OUT }, - { id: "output-2", blockId: "block-2", type: EAnchorType.OUT }, - ], - }, - { - id: "block-3", - name: "Magnetic Block 3", - x: 700, - y: 100, - width: 200, - height: 400, - is: "magnetic-block", - selected: false, - anchors: [ - { id: "input-1", blockId: "block-3", type: EAnchorType.IN }, - { id: "input-2", blockId: "block-3", type: EAnchorType.IN }, - { id: "output-1", blockId: "block-3", type: EAnchorType.OUT }, - ], - }, - { - id: "block-4", - name: "Conditional Block 1", - x: 100, - y: 600, - width: 200, - height: 400, - is: "conditional-magnetic-block", - selected: false, - anchors: [ - { id: "input-1", blockId: "block-4", type: EAnchorType.IN }, - { id: "output-1", blockId: "block-4", type: EAnchorType.OUT }, - ], - }, - { - id: "block-5", - name: "Conditional Block 2", - x: 400, - y: 600, - width: 200, - height: 400, - is: "conditional-magnetic-block", - selected: false, - anchors: [ - { id: "input-1", blockId: "block-5", type: EAnchorType.IN }, - { id: "output-1", blockId: "block-5", type: EAnchorType.OUT }, - ], - }, - ]; -}; - -const GraphApp = () => { - const { graph, setEntities, start, addLayer, zoomTo } = useGraph({ - settings: { - canCreateNewConnections: true, - useBlocksAnchors: true, - blockComponents: { - "magnetic-block": MagneticBlock, - "conditional-magnetic-block": ConditionalMagneticBlock, - }, - }, - }); - - useEffect(() => { - setEntities({ - blocks: generateMagneticBlocks(), - }); - start(); - zoomTo("center", { padding: 300 }); - }, [graph]); - - const connectionLayerRef = 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", // Star icon - 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: "#4285F4", - 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: "#4285F4", - dash: [], - }, - }; - }; - - connectionLayerRef.current = addLayer(ConnectionLayer, { - createIcon, - point: pointIcon, - drawLine, - }); - - return () => { - connectionLayerRef.current?.detachLayer(); - }; - }, []); - - const renderBlock = (graphInstance: Graph, block: TBlock) => { - return ; - }; - - return ( - - - - Port Snapping Demo - - This demo shows how ports can automatically snap to nearby ports when creating connections. - - - Try dragging from an anchor on one block to create a connection. Notice how the connection endpoint snaps to - nearby ports when you get close to them. - - - - Connection Rules: - - • Can only connect IN ports to OUT ports (not IN to IN or OUT to OUT) - • Cannot connect input and output ports of the same block - - Snapping Blocks (top row): All anchors have snapping enabled with connection validation - rules. - - - Conditional Blocks (bottom row): Apply the same connection rules plus additional data - type matching validation. - - - -
- -
-
-
- ); -}; - -const meta: Meta = { - title: "Examples/Port Snapping", - component: GraphApp, - parameters: { - layout: "fullscreen", - }, -}; - -export default meta; - -export const Default: StoryFn = () => ; diff --git a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx index 898bd6f7..0fa009aa 100644 --- a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx +++ b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useLayoutEffect, useRef } from "react"; import { Flex, Text, ThemeProvider } from "@gravity-ui/uikit"; import type { Meta, StoryFn } from "@storybook/react-webpack5"; -import { Anchor, CanvasBlock, EAnchorType, Graph } from "../../../"; -import { Block, TBlock } from "../../../components/canvas/blocks/Block"; +import { Anchor, CanvasBlock, EAnchorType, ECanDrag, Graph } from "../../../"; +import { TBlock } from "../../../components/canvas/blocks/Block"; import { IPortConnectionMeta, PortConnectionLayer, @@ -19,33 +19,11 @@ 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 { - const sourceComponent = sourcePort.owner; - const targetComponent = targetPort.owner; - - if (!sourceComponent || !targetComponent) { - return false; - } - - const isSourceBlock = sourceComponent instanceof Block; - const isTargetBlock = targetComponent instanceof Block; - const isSourceAnchor = sourceComponent instanceof Anchor; - const isTargetAnchor = targetComponent instanceof Anchor; - - if (isSourceBlock && isTargetBlock) { - return sourceComponent.connectedState.id === targetComponent.connectedState.id; - } else if (isSourceAnchor && isTargetAnchor) { - return sourceComponent.connectedState.blockId === targetComponent.connectedState.blockId; - } else if (isSourceBlock && isTargetAnchor) { - return sourceComponent.connectedState.id === targetComponent.connectedState.blockId; - } else if (isSourceAnchor && isTargetBlock) { - return sourceComponent.connectedState.blockId === targetComponent.connectedState.id; - } - - return false; + return sourcePort.owner === targetPort.owner; } /** - * Helper function to check if connection is valid (IN to OUT or OUT to IN) + * Helper function to check if connection is valid (IN↔OUT bidirectional) */ function isValidConnection(sourcePort: { owner?: unknown }, targetPort: { owner?: unknown }): boolean { const sourceComponent = sourcePort.owner; @@ -61,11 +39,7 @@ function isValidConnection(sourcePort: { owner?: unknown }, targetPort: { owner? if (isSourceAnchor && isTargetAnchor) { const sourceType = sourceComponent.connectedState.state.type; const targetType = targetComponent.connectedState.state.type; - - return ( - (sourceType === EAnchorType.IN && targetType === EAnchorType.OUT) || - (sourceType === EAnchorType.OUT && targetType === EAnchorType.IN) - ); + return sourceType !== targetType; } return true; @@ -75,8 +49,9 @@ function isValidConnection(sourcePort: { owner?: unknown }, targetPort: { owner? * Custom block with port-based snapping using PortConnectionLayer * Demonstrates the new port-centric approach * Rules: - * - Can only connect IN to OUT (not IN to IN or OUT to OUT) - * - Cannot connect input and output of the same block + * - 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 { @@ -94,7 +69,7 @@ class PortBasedBlock extends CanvasBlock { return false; } - // Can only connect IN to OUT + // Can connect IN↔OUT (bidirectional) return isValidConnection(ctx.sourcePort, ctx.targetPort); }, }; @@ -110,6 +85,7 @@ class PortBasedBlock extends CanvasBlock { /** * 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 { @@ -117,7 +93,8 @@ class ConditionalPortBlock extends CanvasBlock { this.state.anchors?.forEach((anchor) => { const portId = createAnchorPortId(this.state.id, anchor.id); - const dataType = anchor.type === EAnchorType.IN ? "number" : "string"; + // Both IN and OUT have the same data type so they can connect + const dataType = "number"; const snapMeta: IPortConnectionMeta = { snappable: true, @@ -127,7 +104,7 @@ class ConditionalPortBlock extends CanvasBlock { return false; } - // Can only connect IN to OUT + // Can connect IN↔OUT (bidirectional) if (!isValidConnection(ctx.sourcePort, ctx.targetPort)) { return false; } @@ -167,9 +144,9 @@ const generatePortBlocks = (): TBlock[] => { is: "port-based-block", selected: false, anchors: [ - { id: "input-1", blockId: "port-block-1", type: EAnchorType.IN }, - { id: "input-2", blockId: "port-block-1", type: EAnchorType.IN }, - { id: "output-1", blockId: "port-block-1", type: EAnchorType.OUT }, + { 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 }, ], }, { @@ -182,9 +159,9 @@ const generatePortBlocks = (): TBlock[] => { is: "port-based-block", selected: false, anchors: [ - { id: "input-1", blockId: "port-block-2", type: EAnchorType.IN }, - { id: "output-1", blockId: "port-block-2", type: EAnchorType.OUT }, - { id: "output-2", blockId: "port-block-2", type: EAnchorType.OUT }, + { 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 }, ], }, { @@ -197,9 +174,9 @@ const generatePortBlocks = (): TBlock[] => { is: "port-based-block", selected: false, anchors: [ - { id: "input-1", blockId: "port-block-3", type: EAnchorType.IN }, - { id: "input-2", blockId: "port-block-3", type: EAnchorType.IN }, - { id: "output-1", blockId: "port-block-3", type: EAnchorType.OUT }, + { 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 }, ], }, { @@ -212,8 +189,8 @@ const generatePortBlocks = (): TBlock[] => { is: "conditional-port-block", selected: false, anchors: [ - { id: "input-1", blockId: "conditional-block-1", type: EAnchorType.IN }, - { id: "output-1", blockId: "conditional-block-1", type: EAnchorType.OUT }, + { 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 }, ], }, { @@ -226,8 +203,8 @@ const generatePortBlocks = (): TBlock[] => { is: "conditional-port-block", selected: false, anchors: [ - { id: "input-1", blockId: "conditional-block-2", type: EAnchorType.IN }, - { id: "output-1", blockId: "conditional-block-2", type: EAnchorType.OUT }, + { 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 }, ], }, ]; @@ -236,6 +213,7 @@ const generatePortBlocks = (): TBlock[] => { const GraphApp = () => { const { graph, setEntities, start, addLayer, zoomTo } = useGraph({ settings: { + canDrag: ECanDrag.ALL, canCreateNewConnections: true, useBlocksAnchors: true, blockComponents: { @@ -321,13 +299,15 @@ const GraphApp = () => { Правила подключения: - • Можно соединять только IN порты с OUT портами + • Можно соединять IN с OUT и OUT с IN (двунаправленные связи) + • Нельзя соединять одинаковые типы (IN→IN или OUT→OUT) • Нельзя соединять входные и выходные порты одного блока Port Blocks (верхний ряд): Используют базовую валидацию подключений через порты. - Conditional Blocks (нижний ряд): Дополнительно проверяют совместимость типов данных. + Conditional Blocks (нижний ряд): Дополнительно проверяют совместимость типов данных (все + используют тип "number"). Новые возможности: События теперь содержат прямые ссылки на sourcePort и targetPort, что From 2c1f0c1873bfa71de497cb19796f5796b27116e4 Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 3 Feb 2026 17:59:34 +0300 Subject: [PATCH 4/8] ... --- src/components/canvas/anchors/index.ts | 4 + .../PortConnectionLayer.ts | 87 +++++++++---------- src/index.ts | 3 +- .../portConnectionLayer.stories.tsx | 14 +-- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index de52b623..5ef6fbba 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; diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts index 1778f381..f8ed3134 100644 --- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -12,6 +12,7 @@ import { Point, TPoint } from "../../../../utils/types/shapes"; import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; import { GraphComponent } from "../../GraphComponent"; +import { ESelectionStrategy } from "../../../../services/selection"; /** * Default search radius for port detection and snapping in pixels @@ -135,24 +136,24 @@ declare module "../../../../graphEvents" { } /** - * PortConnectionLayer - новый слой для создания связей, работающий только с портами + * PortConnectionLayer - new layer for creating connections, working only with ports * - * Основные отличия от ConnectionLayer: - * - Работает только с портами, не зависит от компонентов Block/Anchor - * - Использует findPortAtPoint для определения портов под курсором - * - Метаданные хранятся под уникальным ключом PortConnectionLayer.PortMetaKey - * - Более эффективный поиск через пространственный индекс - * - События расширены параметрами sourcePort и targetPort + * 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; * } * } @@ -165,8 +166,8 @@ export class PortConnectionLayer extends Layer< 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"); @@ -358,12 +359,7 @@ export class PortConnectionLayer extends Layer< this.sourcePort = port; this.startState = new Point(port.x, port.y); - // Set selection on owner component - if (port.owner instanceof Block) { - this.context.graph.api.selectBlocks([params.blockId], true); - } else if (port.owner instanceof Anchor) { - this.context.graph.api.setAnchorSelection(params.blockId, params.anchorId, true); - } + this.context.graph.rootStore.selectionService.selectRelatedElements([port.owner as GraphComponent], ESelectionStrategy.REPLACE); } ); @@ -398,15 +394,7 @@ export class PortConnectionLayer extends Layer< // Handle target port change if (newTargetPort !== this.targetPort) { - // Deselect old target - if (this.targetPort?.owner) { - const oldParams = this.getEventParams(this.targetPort); - if (this.targetPort.owner instanceof Block) { - this.context.graph.api.selectBlocks([oldParams.blockId], false); - } else if (this.targetPort.owner instanceof Anchor) { - this.context.graph.api.setAnchorSelection(oldParams.blockId, oldParams.anchorId, false); - } - } + this.selectPort(this.targetPort, false); this.targetPort = newTargetPort; @@ -425,26 +413,44 @@ export class PortConnectionLayer extends Layer< targetPort: newTargetPort, }, () => { - // Select new target - if (newTargetPort.owner instanceof Block) { - this.context.graph.api.selectBlocks([targetParams.blockId], true); - } else if (newTargetPort.owner instanceof Anchor) { - this.context.graph.api.setAnchorSelection(targetParams.blockId, targetParams.anchorId, true); - } + this.selectPort(newTargetPort, true); } ); } } } + protected selectPort(port: PortState, select: boolean): void { + 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; } // Use the target port that was found during move (snapping) - // instead of searching again at the drop point - const targetPort = this.targetPort; + // If not found through snapping, try to find it at the drop point + let targetPort = this.targetPort; + + // Fallback: if no targetPort from snapping, try to find at drop point + if (!targetPort) { + 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; @@ -509,19 +515,10 @@ export class PortConnectionLayer extends Layer< } ); - // Deselect both ports - if (this.sourcePort.owner instanceof Block) { - this.context.graph.api.selectBlocks([sourceParams.blockId], false); - } else if (this.sourcePort.owner instanceof Anchor) { - this.context.graph.api.setAnchorSelection(sourceParams.blockId, sourceParams.anchorId, false); - } + this.selectPort(this.sourcePort, false); + this.selectPort(targetPort, false); const targetParams = this.getEventParams(targetPort); - if (targetPort.owner instanceof Block) { - this.context.graph.api.selectBlocks([targetParams.blockId], false); - } else if (targetPort.owner instanceof Anchor) { - this.context.graph.api.setAnchorSelection(targetParams.blockId, targetParams.anchorId, false); - } // Drop event this.context.graph.executеDefaultEventAction( diff --git a/src/index.ts b/src/index.ts index c3679dd6..885d8a7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,6 @@ export type { ConnectionState, TConnection, TConnectionId } from "./store/connec 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 type { IPortSnapMeta, TPortSnapCondition } from "./components/canvas/layers/connectionLayer/ConnectionLayer"; export { ECanChangeBlockGeometry, ECanDrag } from "./store/settings"; export { type TMeasureTextOptions, type TWrapText } from "./utils/functions/text"; export { ESchedulerPriority } from "./lib/Scheduler"; @@ -27,6 +26,8 @@ export { ESelectionStrategy } from "./services/selection/types"; export * from "./utils/shapes"; export { applyAlpha, clearColorCache } from "./utils/functions/color"; +export * from "./components/canvas/layers/portConnectionLayer"; + export * from "./components/canvas/groups"; export * from "./components/canvas/layers/newBlockLayer/NewBlockLayer"; diff --git a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx index 0fa009aa..ca8e1c13 100644 --- a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx +++ b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx @@ -75,9 +75,9 @@ class PortBasedBlock extends CanvasBlock { }; // Use new API with PortMetaKey - this.updatePort(portId, undefined, undefined, { - [PortConnectionLayer.PortMetaKey]: snapMeta, - }); + // this.updatePort(portId, undefined, undefined, { + // [PortConnectionLayer.PortMetaKey]: snapMeta, + // }); }); } } @@ -124,10 +124,10 @@ class ConditionalPortBlock extends CanvasBlock { }; // Store both snap metadata and data type - this.updatePort(portId, undefined, undefined, { - [PortConnectionLayer.PortMetaKey]: snapMeta, - dataType: dataType, - }); + // this.updatePort(portId, undefined, undefined, { + // [PortConnectionLayer.PortMetaKey]: snapMeta, + // dataType: dataType, + // }); }); } } From 0dcc56fe3197ac972e9e9b1fa0063ffc2f7cf668 Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 3 Feb 2026 18:04:58 +0300 Subject: [PATCH 5/8] ... --- .../PortConnectionLayer.ts | 24 ++++---- .../portConnectionLayer.stories.tsx | 57 +++++-------------- 2 files changed, 27 insertions(+), 54 deletions(-) diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts index f8ed3134..6add47f6 100644 --- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -2,6 +2,7 @@ import RBush from "rbush"; import { GraphMouseEvent, extractNativeGraphMouseEvent } 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"; @@ -12,7 +13,6 @@ import { Point, TPoint } from "../../../../utils/types/shapes"; import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; import { GraphComponent } from "../../GraphComponent"; -import { ESelectionStrategy } from "../../../../services/selection"; /** * Default search radius for port detection and snapping in pixels @@ -359,7 +359,7 @@ export class PortConnectionLayer extends Layer< this.sourcePort = port; this.startState = new Point(port.x, port.y); - this.context.graph.rootStore.selectionService.selectRelatedElements([port.owner as GraphComponent], ESelectionStrategy.REPLACE); + this.selectPort(port, true); } ); @@ -422,12 +422,12 @@ export class PortConnectionLayer extends Layer< protected selectPort(port: PortState, select: boolean): void { const component = port.owner; - if(component instanceof GraphComponent) { + if (component instanceof GraphComponent) { const bucket = this.context.graph.rootStore.selectionService.getBucketByElement(component); - if(!bucket) { + if (!bucket) { return; } - if(select) { + if (select) { bucket.select([component.getEntityId()], ESelectionStrategy.REPLACE); } else { bucket.deselect([component.getEntityId()]); @@ -440,12 +440,16 @@ export class PortConnectionLayer extends Layer< return; } - // Use the target port that was found during move (snapping) - // If not found through snapping, try to find it at the drop point - let targetPort = this.targetPort; + // 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; - // Fallback: if no targetPort from snapping, try to find at drop point - if (!targetPort) { + // 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; diff --git a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx index ca8e1c13..9d1a3e73 100644 --- a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx +++ b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useLayoutEffect, useRef } from "react"; -import { Flex, Text, ThemeProvider } from "@gravity-ui/uikit"; +import { ThemeProvider } from "@gravity-ui/uikit"; import type { Meta, StoryFn } from "@storybook/react-webpack5"; import { Anchor, CanvasBlock, EAnchorType, ECanDrag, Graph } from "../../../"; @@ -69,15 +69,15 @@ class PortBasedBlock extends CanvasBlock { return false; } - // Can connect IN↔OUT (bidirectional) + // Can connect IN ↔ OUT (bidirectional) return isValidConnection(ctx.sourcePort, ctx.targetPort); }, }; - // Use new API with PortMetaKey - // this.updatePort(portId, undefined, undefined, { - // [PortConnectionLayer.PortMetaKey]: snapMeta, - // }); + // Configure port for snapping + this.updatePort(portId, undefined, undefined, { + [PortConnectionLayer.PortMetaKey]: snapMeta, + }); }); } } @@ -104,7 +104,7 @@ class ConditionalPortBlock extends CanvasBlock { return false; } - // Can connect IN↔OUT (bidirectional) + // Can connect IN ↔ OUT (bidirectional) if (!isValidConnection(ctx.sourcePort, ctx.targetPort)) { return false; } @@ -124,10 +124,10 @@ class ConditionalPortBlock extends CanvasBlock { }; // Store both snap metadata and data type - // this.updatePort(portId, undefined, undefined, { - // [PortConnectionLayer.PortMetaKey]: snapMeta, - // dataType: dataType, - // }); + this.updatePort(portId, undefined, undefined, { + [PortConnectionLayer.PortMetaKey]: snapMeta, + dataType: dataType, + }); }); } } @@ -273,7 +273,7 @@ const GraphApp = () => { createIcon, point: pointIcon, drawLine, - searchRadius: 30, // Увеличенный радиус поиска портов + searchRadius: 30, }); return () => { @@ -287,38 +287,7 @@ const GraphApp = () => { return ( - - - PortConnectionLayer Demo - Демонстрация нового слоя PortConnectionLayer, работающего только с портами. - - Тяните от якоря одного блока к другому. Связь автоматически привязывается к ближайшим портам при - приближении. - - - - Правила подключения: - - • Можно соединять IN с OUT и OUT с IN (двунаправленные связи) - • Нельзя соединять одинаковые типы (IN→IN или OUT→OUT) - • Нельзя соединять входные и выходные порты одного блока - - Port Blocks (верхний ряд): Используют базовую валидацию подключений через порты. - - - Conditional Blocks (нижний ряд): Дополнительно проверяют совместимость типов данных (все - используют тип "number"). - - - Новые возможности: События теперь содержат прямые ссылки на sourcePort и targetPort, что - дает прямой доступ к метаданным портов и их владельцам. - - - -
- -
-
+
); }; From 069e6b280115505e0ef6ba30d3b53a3bedec1837 Mon Sep 17 00:00:00 2001 From: draedful Date: Wed, 4 Feb 2026 10:24:29 +0300 Subject: [PATCH 6/8] ... --- .../PortConnectionLayer.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts index 6add47f6..a3e21d2b 100644 --- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -1,6 +1,6 @@ import RBush from "rbush"; -import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { GraphMouseEvent, extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { ESelectionStrategy } from "../../../../services/selection"; import { EAnchorType } from "../../../../store/anchor/Anchor"; @@ -53,6 +53,7 @@ type TIcon = { type LineStyle = { color: string; + width?: number; dash: number[]; }; @@ -71,6 +72,8 @@ type PortConnectionLayerProps = LayerProps & { point?: TIcon; drawLine?: DrawLineFunction; searchRadius?: number; + lineWidth?: number; + lineDash?: number[]; }; declare module "../../../../graphEvents" { @@ -249,6 +252,9 @@ export class PortConnectionLayer extends Layer< 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( { @@ -318,17 +324,16 @@ export class PortConnectionLayer extends Layer< return; } - const scale = this.context.camera.getCameraScale(); - this.context.ctx.lineWidth = Math.round(2 / scale); - 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); @@ -348,6 +353,9 @@ export class PortConnectionLayer extends Layer< 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", { @@ -356,9 +364,6 @@ export class PortConnectionLayer extends Layer< sourcePort: port, }, () => { - this.sourcePort = port; - this.startState = new Point(port.x, port.y); - this.selectPort(port, true); } ); @@ -421,6 +426,7 @@ export class PortConnectionLayer extends Layer< } 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); From e7a0f55828252f79d749e841769f010b5b235674 Mon Sep 17 00:00:00 2001 From: draedful Date: Wed, 4 Feb 2026 16:00:46 +0300 Subject: [PATCH 7/8] ... --- .../canvas/GraphComponent/index.tsx | 10 +++---- .../layers/connectionLayer/ConnectionLayer.ts | 24 ++++++++-------- src/store/connection/port/Port.ts | 28 ++++++------------- .../portConnectionLayer.stories.tsx | 14 ++++++---- 4 files changed, 33 insertions(+), 43 deletions(-) diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index e122ea2a..c33921bc 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"; @@ -128,13 +128,11 @@ export class GraphComponent< /** * Update port position and metadata * @param id Port identifier - * @param x New X coordinate (optional) - * @param y New Y coordinate (optional) - * @param meta Port metadata (optional) + * @param portChanges port changes {x?, y?, meta?} */ - public updatePort(id: TPortId, x?: number, y?: number, meta?: T): void { + public updatePort(id: TPortId, portChanges: Partial>): void { const port = this.getPort(id); - port.updatePortWithMeta(x, y, meta); + port.updatePort(portChanges); } protected setAffectsUsableRect(affectsUsableRect: boolean) { diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 318d604f..de2a1aab 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -111,6 +111,8 @@ declare module "../../../../graphEvents" { * The layer renders on a separate canvas with a higher z-index and handles * all mouse interactions for connection creation. */ + +type TConnectableComponent = Block | Anchor; export class ConnectionLayer extends Layer< ConnectionLayerProps, LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } @@ -176,13 +178,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 +202,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/store/connection/port/Port.ts b/src/store/connection/port/Port.ts index 5731f889..2b11b49b 100644 --- a/src/store/connection/port/Port.ts +++ b/src/store/connection/port/Port.ts @@ -1,4 +1,5 @@ import { computed, signal } from "@preact/signals-core"; +import merge from "lodash/merge"; import { Component } from "../../../lib"; import { TPoint } from "../../../utils/types/shapes"; @@ -179,25 +180,14 @@ export class PortState { * @param port Partial port data to merge with current state */ public updatePort(port: Partial>): void { - this.$state.value = { ...this.$state.value, ...port }; - } - - /** - * Update port position and/or metadata - * @param x New X coordinate (optional) - * @param y New Y coordinate (optional) - * @param meta New metadata (optional) - */ - public updatePortWithMeta(x?: number, y?: number, meta?: T): void { - const updates: Partial> = {}; - - if (x !== undefined) updates.x = x; - if (y !== undefined) updates.y = y; - if (meta !== undefined) updates.meta = meta; - - if (Object.keys(updates).length > 0) { - this.updatePort(updates); - } + this.$state.value = { + ...this.$state.value, + ...port, + meta: { + ...this.$state.value.meta, + ...port.meta, + }, + }; } /** diff --git a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx index 9d1a3e73..754e4dfe 100644 --- a/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx +++ b/src/stories/examples/portConnectionLayer/portConnectionLayer.stories.tsx @@ -75,8 +75,10 @@ class PortBasedBlock extends CanvasBlock { }; // Configure port for snapping - this.updatePort(portId, undefined, undefined, { - [PortConnectionLayer.PortMetaKey]: snapMeta, + this.updatePort(portId, { + meta: { + [PortConnectionLayer.PortMetaKey]: snapMeta, + }, }); }); } @@ -124,9 +126,11 @@ class ConditionalPortBlock extends CanvasBlock { }; // Store both snap metadata and data type - this.updatePort(portId, undefined, undefined, { - [PortConnectionLayer.PortMetaKey]: snapMeta, - dataType: dataType, + this.updatePort(portId, { + meta: { + [PortConnectionLayer.PortMetaKey]: snapMeta, + dataType: dataType, + }, }); }); } From d58ac7d25e726ef0bc01adc790ef953e7a440433 Mon Sep 17 00:00:00 2001 From: draedful Date: Wed, 4 Feb 2026 16:07:52 +0300 Subject: [PATCH 8/8] ... --- .../canvas/layers/connectionLayer/ConnectionLayer.ts | 1 - .../layers/portConnectionLayer/PortConnectionLayer.ts | 9 +++------ src/index.ts | 4 +--- src/store/connection/port/Port.ts | 1 - 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index de2a1aab..f56df6a7 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -112,7 +112,6 @@ declare module "../../../../graphEvents" { * all mouse interactions for connection creation. */ -type TConnectableComponent = Block | Anchor; export class ConnectionLayer extends Layer< ConnectionLayerProps, LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } diff --git a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts index a3e21d2b..8d944616 100644 --- a/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts +++ b/src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts @@ -19,16 +19,13 @@ import { GraphComponent } from "../../GraphComponent"; */ const PORT_SEARCH_RADIUS = 20; -/** - * Snap condition function type - * Used by PortConnectionLayer to determine if a port can snap to another port - */ -export type TPortSnapCondition = (context: { +export type TPortSnapConditionContext = { sourcePort: PortState; targetPort: PortState; cursorPosition: TPoint; distance: number; -}) => boolean; +}; +export type TPortSnapCondition = (context: TPortSnapConditionContext) => boolean; /** * Port metadata structure for PortConnectionLayer diff --git a/src/index.ts b/src/index.ts index 885d8a7e..d94979f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,13 +26,11 @@ export { ESelectionStrategy } from "./services/selection/types"; export * from "./utils/shapes"; export { applyAlpha, clearColorCache } from "./utils/functions/color"; -export * from "./components/canvas/layers/portConnectionLayer"; - 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 "./components/canvas/layers/portConnectionLayer/PortConnectionLayer"; export * from "./lib/Component"; export * from "./services/selection/index.public"; diff --git a/src/store/connection/port/Port.ts b/src/store/connection/port/Port.ts index 2b11b49b..cfc426b4 100644 --- a/src/store/connection/port/Port.ts +++ b/src/store/connection/port/Port.ts @@ -1,5 +1,4 @@ import { computed, signal } from "@preact/signals-core"; -import merge from "lodash/merge"; import { Component } from "../../../lib"; import { TPoint } from "../../../utils/types/shapes";