/* eslint-disable @typescript-eslint/no-explicit-any */ import { boxToRect, clampPosition, getBoundsOfBoxes, getOverlappingArea, isNumeric, rectToBox, nodeToRect, pointToRendererPoint, getViewportForBounds, } from './general'; import { type Connection, type Transform, type XYPosition, type Rect, type NodeOrigin, type NodeBase, type EdgeBase, type FitViewParamsBase, type FitViewOptionsBase, NodeDragItem, CoordinateExtent, OnError, } from '../types'; import { errorMessages } from '../constants'; export const isEdgeBase = ( element: NodeType | Connection | EdgeType ): element is EdgeType => 'id' in element && 'source' in element && 'target' in element; export const isNodeBase = ( element: NodeType | Connection | EdgeType ): element is NodeType => 'id' in element && !('source' in element) && !('target' in element); export const getOutgoersBase = ( 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)); }; export const getIncomersBase = ( 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 | undefined, nodeOrigin: NodeOrigin = [0, 0] ): XYPosition & { positionAbsolute: XYPosition } => { if (!node) { return { x: 0, y: 0, positionAbsolute: { x: 0, y: 0, }, }; } const offsetX = (node.width ?? 0) * nodeOrigin[0]; const offsetY = (node.height ?? 0) * nodeOrigin[1]; const position: XYPosition = { x: node.position.x - offsetX, y: node.position.y - offsetY, }; return { ...position, positionAbsolute: node.positionAbsolute ? { x: node.positionAbsolute.x - offsetX, y: node.positionAbsolute.y - offsetY, } : position, }; }; export const getNodesBounds = (nodes: NodeBase[], nodeOrigin: NodeOrigin = [0, 0]): Rect => { if (nodes.length === 0) { return { x: 0, y: 0, width: 0, height: 0 }; } const box = nodes.reduce( (currBox, node) => { const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; return getBoundsOfBoxes( currBox, rectToBox({ x, y, width: node.width || 0, height: node.height || 0, }) ); }, { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity } ); return boxToRect(box); }; export const getNodesInside = ( nodes: NodeType[], 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, nodeOrigin: NodeOrigin = [0, 0] ): NodeType[] => { const paneRect = { ...pointToRendererPoint(rect, [tx, ty, tScale]), width: rect.width / tScale, height: rect.height / tScale, }; const visibleNodes = nodes.reduce((res, node) => { const { width, height, selectable = true, hidden = false } = node; if ((excludeNonSelectableNodes && !selectable) || hidden) { return res; } const overlappingArea = getOverlappingArea(paneRect, nodeToRect(node, nodeOrigin)); const notInitialized = width === undefined || height === undefined || width === null || height === null; const partiallyVisible = partially && overlappingArea > 0; const area = (width || 0) * (height || 0); const isVisible = notInitialized || partiallyVisible || overlappingArea >= area; if (isVisible || node.dragging) { res.push(node); } return res; }, []); return visibleNodes; }; export const getConnectedEdgesBase = ( 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)); }; export function fitView, Options extends FitViewOptionsBase>( { nodes, width, height, panZoom, minZoom, maxZoom, nodeOrigin = [0, 0] }: Params, options?: Options ) { const filteredNodes = nodes.filter((n) => { const isVisible = n.width && n.height && (options?.includeHiddenNodes || !n.hidden); if (options?.nodes?.length) { return isVisible && options?.nodes.some((optionNode) => optionNode.id === n.id); } return isVisible; }); if (filteredNodes.length > 0) { const bounds = getNodesBounds(filteredNodes, nodeOrigin); const viewport = getViewportForBounds( bounds, width, height, options?.minZoom ?? minZoom, options?.maxZoom ?? maxZoom, options?.padding ?? 0.1 ); panZoom.setViewport(viewport, { duration: options?.duration }); return true; } return false; } function clampNodeExtent(node: NodeDragItem | NodeBase, extent?: CoordinateExtent | 'parent') { if (!extent || extent === 'parent') { return extent; } return [extent[0], [extent[1][0] - (node.width || 0), extent[1][1] - (node.height || 0)]]; } export function calcNextPosition( node: NodeDragItem | NodeType, nextPosition: XYPosition, nodes: NodeType[], nodeExtent?: CoordinateExtent, nodeOrigin: NodeOrigin = [0, 0], onError?: OnError ): { position: XYPosition; positionAbsolute: XYPosition } { const clampedNodeExtent = clampNodeExtent(node, node.extent || nodeExtent); let currentExtent = clampedNodeExtent; let parentNode: NodeType | null = null; let parentPos = { x: 0, y: 0 }; if (node.parentNode) { parentNode = nodes.find((n) => n.id === node.parentNode) || null; parentPos = parentNode ? getNodePositionWithOrigin(parentNode, parentNode.origin || nodeOrigin).positionAbsolute : parentPos; } if (node.extent === 'parent' && !node.expandParent) { if (node.parentNode && node.width && node.height) { const currNodeOrigin = node.origin || nodeOrigin; currentExtent = parentNode && isNumeric(parentNode.width) && isNumeric(parentNode.height) ? [ [parentPos.x + node.width * currNodeOrigin[0], parentPos.y + node.height * currNodeOrigin[1]], [ parentPos.x + parentNode.width - node.width + node.width * currNodeOrigin[0], parentPos.y + parentNode.height - node.height + node.height * currNodeOrigin[1], ], ] : currentExtent; } else { onError?.('005', errorMessages['error005']()); currentExtent = clampedNodeExtent; } } else if (node.extent && node.parentNode && node.extent !== 'parent') { currentExtent = [ [node.extent[0][0] + parentPos.x, node.extent[0][1] + parentPos.y], [node.extent[1][0] + parentPos.x, node.extent[1][1] + parentPos.y], ]; } const positionAbsolute = currentExtent && currentExtent !== 'parent' ? clampPosition(nextPosition, currentExtent as CoordinateExtent) : nextPosition; return { position: { x: positionAbsolute.x - parentPos.x, y: positionAbsolute.y - parentPos.y, }, positionAbsolute, }; } // helper function to get arrays of nodes and edges that can be deleted // you can pass in a list of nodes and edges that should be deleted // and the function only returns elements that are deletable and also handles connected nodes and child nodes export function getElementsToRemove({ nodesToRemove, edgesToRemove, nodes, edges, }: { nodesToRemove: Partial[]; edgesToRemove: Partial[]; nodes: NodeType[]; edges: EdgeType[]; }): { matchingNodes: NodeType[]; matchingEdges: EdgeType[]; } { const nodeIds = nodesToRemove.map((node) => node.id); const edgeIds = edgesToRemove.map((edge) => edge.id); const matchingNodes = nodes.reduce((res, node) => { const parentHit = !nodeIds.includes(node.id) && node.parentNode && res.find((n) => n.id === node.parentNode); const deletable = typeof node.deletable === 'boolean' ? node.deletable : true; if (deletable && (nodeIds.includes(node.id) || parentHit)) { res.push(node); } return res; }, []); const deletableEdges = edges.filter((e) => (typeof e.deletable === 'boolean' ? e.deletable : true)); const initialHitEdges = deletableEdges.filter((e) => edgeIds.includes(e.id)); const connectedEdges = getConnectedEdgesBase(matchingNodes, deletableEdges); const matchingEdges = connectedEdges.reduce((res, edge) => { if (!res.find((e) => e.id === edge.id)) { res.push(edge); } return res; }, initialHitEdges); return { matchingEdges, matchingNodes, }; }