/* eslint-disable @typescript-eslint/no-explicit-any */ import { boxToRect, clampPosition, getBoundsOfBoxes, getOverlappingArea, nodeToRect, pointToRendererPoint, getViewportForBounds, isCoordinateExtent, getNodeDimensions, nodeToBox, } from './general'; import { type Transform, type XYPosition, type Rect, type NodeOrigin, type NodeBase, type EdgeBase, type FitViewParamsBase, type FitViewOptionsBase, CoordinateExtent, OnError, OnBeforeDeleteBase, NodeLookup, InternalNodeBase, NodeDragItem, } from '../types'; import { errorMessages } from '../constants'; /** * Test whether an object is usable as an Edge * @public * @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Edge if it returns true * @param element - The element to test * @returns A boolean indicating whether the element is an Edge */ export const isEdgeBase = (element: any): element is EdgeType => 'id' in element && 'source' in element && 'target' in element; /** * Test whether an object is usable as a Node * @public * @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Node if it returns true * @param element - The element to test * @returns A boolean indicating whether the element is an Node */ export const isNodeBase = (element: any): element is NodeType => 'id' in element && 'position' in element && !('source' in element) && !('target' in element); export const isInternalNodeBase = ( element: any ): element is NodeType => 'id' in element && 'internals' in element && !('source' in element) && !('target' in element); /** * This util is used to tell you what nodes, if any, are connected to the given node * as the _target_ of an edge. * @public * @param node - The node to get the connected nodes from. * @param nodes - The array of all nodes. * @param edges - The array of all edges. * @returns An array of nodes that are connected over edges where the source is the given node. * * @example * ```ts *import { getOutgoers } from '@xyflow/react'; * *const nodes = []; *const edges = []; * *const outgoers = getOutgoers( * { id: '1', position: { x: 0, y: 0 }, data: { label: 'node' } }, * nodes, * edges, *); *``` */ export const getOutgoers = ( node: NodeType | { id: string }, nodes: NodeType[], edges: EdgeType[] ): NodeType[] => { if (!node.id) { return []; } const outgoerIds = new Set(); edges.forEach((edge) => { if (edge.source === node.id) { outgoerIds.add(edge.target); } }); return nodes.filter((n) => outgoerIds.has(n.id)); }; /** * This util is used to tell you what nodes, if any, are connected to the given node * as the _source_ of an edge. * @public * @param node - The node to get the connected nodes from. * @param nodes - The array of all nodes. * @param edges - The array of all edges. * @returns An array of nodes that are connected over edges where the target is the given node. * * @example * ```ts *import { getIncomers } from '@xyflow/react'; * *const nodes = []; *const edges = []; * *const incomers = getIncomers( * { id: '1', position: { x: 0, y: 0 }, data: { label: 'node' } }, * nodes, * edges, *); *``` */ export const getIncomers = ( node: NodeType | { id: string }, nodes: NodeType[], edges: EdgeType[] ): NodeType[] => { if (!node.id) { return []; } const incomersIds = new Set(); edges.forEach((edge) => { if (edge.target === node.id) { incomersIds.add(edge.source); } }); return nodes.filter((n) => incomersIds.has(n.id)); }; export const getNodePositionWithOrigin = (node: NodeBase, nodeOrigin: NodeOrigin = [0, 0]): XYPosition => { const { width, height } = getNodeDimensions(node); const origin = node.origin ?? nodeOrigin; const offsetX = width * origin[0]; const offsetY = height * origin[1]; return { x: node.position.x - offsetX, y: node.position.y - offsetY, }; }; export type GetNodesBoundsParams = { /** * Origin of the nodes: `[0, 0]` for top-left, `[0.5, 0.5]` for center. * @default [0, 0] */ nodeOrigin?: NodeOrigin; nodeLookup?: NodeLookup>; }; /** * Returns the bounding box that contains all the given nodes in an array. This can * be useful when combined with [`getViewportForBounds`](/api-reference/utils/get-viewport-for-bounds) * to calculate the correct transform to fit the given nodes in a viewport. * @public * @remarks Useful when combined with {@link getViewportForBounds} to calculate the correct transform to fit the given nodes in a viewport. * @param nodes - Nodes to calculate the bounds for. * @returns Bounding box enclosing all nodes. * * @remarks This function was previously called `getRectOfNodes` * * @example * ```js *import { getNodesBounds } from '@xyflow/react'; * *const nodes = [ * { * id: 'a', * position: { x: 0, y: 0 }, * data: { label: 'a' }, * width: 50, * height: 25, * }, * { * id: 'b', * position: { x: 100, y: 100 }, * data: { label: 'b' }, * width: 50, * height: 25, * }, *]; * *const bounds = getNodesBounds(nodes); *``` */ export const getNodesBounds = ( nodes: (NodeType | InternalNodeBase | string)[], params: GetNodesBoundsParams = { nodeOrigin: [0, 0] } ): Rect => { if (process.env.NODE_ENV === 'development' && !params.nodeLookup) { console.warn( 'Please use `getNodesBounds` from `useReactFlow`/`useSvelteFlow` hook to ensure correct values for sub flows. If not possible, you have to provide a nodeLookup to support sub flows.' ); } if (nodes.length === 0) { return { x: 0, y: 0, width: 0, height: 0 }; } const box = nodes.reduce( (currBox, nodeOrId) => { const isId = typeof nodeOrId === 'string'; let currentNode = !params.nodeLookup && !isId ? nodeOrId : undefined; if (params.nodeLookup) { currentNode = isId ? params.nodeLookup.get(nodeOrId) : !isInternalNodeBase(nodeOrId) ? params.nodeLookup.get(nodeOrId.id) : nodeOrId; } const nodeBox = currentNode ? nodeToBox(currentNode, params.nodeOrigin) : { x: 0, y: 0, x2: 0, y2: 0 }; return getBoundsOfBoxes(currBox, nodeBox); }, { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity } ); return boxToRect(box); }; export type GetInternalNodesBoundsParams = { useRelativePosition?: boolean; filter?: (node: NodeType) => boolean; }; /** * Determines a bounding box that contains all given nodes in an array * @internal */ export const getInternalNodesBounds = ( nodeLookup: Map, params: GetInternalNodesBoundsParams = {} ): Rect => { if (nodeLookup.size === 0) { return { x: 0, y: 0, width: 0, height: 0 }; } let box = { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity }; nodeLookup.forEach((node) => { if (params.filter === undefined || params.filter(node)) { const nodeBox = nodeToBox(node as InternalNodeBase); box = getBoundsOfBoxes(box, nodeBox); } }); return boxToRect(box); }; export const getNodesInside = ( nodes: Map>, rect: Rect, [tx, ty, tScale]: Transform = [0, 0, 1], partially = false, // set excludeNonSelectableNodes if you want to pay attention to the nodes "selectable" attribute excludeNonSelectableNodes = false ): InternalNodeBase[] => { const paneRect = { ...pointToRendererPoint(rect, [tx, ty, tScale]), width: rect.width / tScale, height: rect.height / tScale, }; const visibleNodes: InternalNodeBase[] = []; for (const node of nodes.values()) { const { measured, selectable = true, hidden = false } = node; if ((excludeNonSelectableNodes && !selectable) || hidden) { continue; } const width = measured.width ?? node.width ?? node.initialWidth ?? null; const height = measured.height ?? node.height ?? node.initialHeight ?? null; const overlappingArea = getOverlappingArea(paneRect, nodeToRect(node)); const area = (width ?? 0) * (height ?? 0); const partiallyVisible = partially && overlappingArea > 0; const forceInitialRender = !node.internals.handleBounds; const isVisible = forceInitialRender || partiallyVisible || overlappingArea >= area; if (isVisible || node.dragging) { visibleNodes.push(node); } } return visibleNodes; }; /** * This utility filters an array of edges, keeping only those where either the source or target * node is present in the given array of nodes. * @public * @param nodes - Nodes you want to get the connected edges for. * @param edges - All edges. * @returns Array of edges that connect any of the given nodes with each other. * * @example * ```js *import { getConnectedEdges } from '@xyflow/react'; * *const nodes = [ * { id: 'a', position: { x: 0, y: 0 } }, * { id: 'b', position: { x: 100, y: 0 } }, *]; * *const edges = [ * { id: 'a->c', source: 'a', target: 'c' }, * { id: 'c->d', source: 'c', target: 'd' }, *]; * *const connectedEdges = getConnectedEdges(nodes, edges); * // => [{ id: 'a->c', source: 'a', target: 'c' }] *``` */ export const getConnectedEdges = ( nodes: NodeType[], edges: EdgeType[] ): EdgeType[] => { const nodeIds = new Set(); nodes.forEach((node) => { nodeIds.add(node.id); }); return edges.filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target)); }; function getFitViewNodes< Params extends NodeLookup>, Options extends FitViewOptionsBase >(nodeLookup: Params, options?: Options) { const fitViewNodes: NodeLookup = new Map(); const optionNodeIds = options?.nodes ? new Set(options.nodes.map((node) => node.id)) : null; nodeLookup.forEach((n) => { const isVisible = n.measured.width && n.measured.height && (options?.includeHiddenNodes || !n.hidden); if (isVisible && (!optionNodeIds || optionNodeIds.has(n.id))) { fitViewNodes.set(n.id, n); } }); return fitViewNodes; } export async function fitViewport< Params extends FitViewParamsBase, Options extends FitViewOptionsBase >( { nodes, width, height, panZoom, minZoom, maxZoom }: Params, options?: Omit ): Promise { if (nodes.size === 0) { return Promise.resolve(true); } const nodesToFit = getFitViewNodes(nodes, options); const bounds = getInternalNodesBounds(nodesToFit); const viewport = getViewportForBounds( bounds, width, height, options?.minZoom ?? minZoom, options?.maxZoom ?? maxZoom, options?.padding ?? 0.1 ); await panZoom.setViewport(viewport, { duration: options?.duration }); return Promise.resolve(true); } /** * This function calculates the next position of a node, taking into account the node's extent, parent node, and origin. * * @internal * @returns position, positionAbsolute */ export function calculateNodePosition({ nodeId, nextPosition, nodeLookup, nodeOrigin = [0, 0], nodeExtent, onError, }: { nodeId: string; nextPosition: XYPosition; nodeLookup: NodeLookup>; nodeOrigin?: NodeOrigin; nodeExtent?: CoordinateExtent; onError?: OnError; }): { position: XYPosition; positionAbsolute: XYPosition } { const node = nodeLookup.get(nodeId)!; const parentNode = node.parentId ? nodeLookup.get(node.parentId) : undefined; const { x: parentX, y: parentY } = parentNode ? parentNode.internals.positionAbsolute : { x: 0, y: 0 }; const origin = node.origin ?? nodeOrigin; let extent = nodeExtent; if (node.extent === 'parent' && !node.expandParent) { if (!parentNode) { onError?.('005', errorMessages['error005']()); } else { const parentWidth = parentNode.measured.width; const parentHeight = parentNode.measured.height; if (parentWidth && parentHeight) { extent = [ [parentX, parentY], [parentX + parentWidth, parentY + parentHeight], ]; } } } else if (parentNode && isCoordinateExtent(node.extent)) { extent = [ [node.extent[0][0] + parentX, node.extent[0][1] + parentY], [node.extent[1][0] + parentX, node.extent[1][1] + parentY], ]; } const positionAbsolute = isCoordinateExtent(extent) ? clampPosition(nextPosition, extent, node.measured) : nextPosition; if (node.measured.width === undefined || node.measured.height === undefined) { onError?.('015', errorMessages['error015']()); } return { position: { x: positionAbsolute.x - parentX + (node.measured.width ?? 0) * origin[0], y: positionAbsolute.y - parentY + (node.measured.height ?? 0) * origin[1], }, positionAbsolute, }; } /** * Pass in nodes & edges to delete, get arrays of nodes and edges that actually can be deleted * @internal * @param param.nodesToRemove - The nodes to remove * @param param.edgesToRemove - The edges to remove * @param param.nodes - All nodes * @param param.edges - All edges * @param param.onBeforeDelete - Callback to check which nodes and edges can be deleted * @returns nodes: nodes that can be deleted, edges: edges that can be deleted */ export async function getElementsToRemove({ nodesToRemove = [], edgesToRemove = [], nodes, edges, onBeforeDelete, }: { nodesToRemove: Partial[]; edgesToRemove: Partial[]; nodes: NodeType[]; edges: EdgeType[]; onBeforeDelete?: OnBeforeDeleteBase; }): Promise<{ nodes: NodeType[]; edges: EdgeType[]; }> { const nodeIds = new Set(nodesToRemove.map((node) => node.id)); const matchingNodes: NodeType[] = []; for (const node of nodes) { if (node.deletable === false) { continue; } const isIncluded = nodeIds.has(node.id); const parentHit = !isIncluded && node.parentId && matchingNodes.find((n) => n.id === node.parentId); if (isIncluded || parentHit) { matchingNodes.push(node); } } const edgeIds = new Set(edgesToRemove.map((edge) => edge.id)); const deletableEdges = edges.filter((edge) => edge.deletable !== false); const connectedEdges = getConnectedEdges(matchingNodes, deletableEdges); const matchingEdges: EdgeType[] = connectedEdges; for (const edge of deletableEdges) { const isIncluded = edgeIds.has(edge.id); if (isIncluded && !matchingEdges.find((e) => e.id === edge.id)) { matchingEdges.push(edge); } } if (!onBeforeDelete) { return { edges: matchingEdges, nodes: matchingNodes, }; } const onBeforeDeleteResult = await onBeforeDelete({ nodes: matchingNodes, edges: matchingEdges, }); if (typeof onBeforeDeleteResult === 'boolean') { return onBeforeDeleteResult ? { edges: matchingEdges, nodes: matchingNodes } : { edges: [], nodes: [] }; } return onBeforeDeleteResult; }