From 8f55c21cd7ae423b73aec5ac325d70de689a62fd Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 2 Nov 2022 19:38:02 +0100 Subject: [PATCH] feat(useReactFlow): add intersection helpers --- examples/vite-app/src/App/index.tsx | 6 + .../src/examples/Intersection/index.tsx | 104 ++++++++++++++++++ .../src/examples/Intersection/style.css | 4 + packages/core/src/hooks/useReactFlow.ts | 63 +++++++++++ packages/core/src/types/instance.ts | 18 ++- packages/core/src/utils/graph.ts | 12 +- packages/core/src/utils/index.ts | 18 ++- 7 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 examples/vite-app/src/examples/Intersection/index.tsx create mode 100644 examples/vite-app/src/examples/Intersection/style.css diff --git a/examples/vite-app/src/App/index.tsx b/examples/vite-app/src/App/index.tsx index 31d41fd6..f0e32fb8 100644 --- a/examples/vite-app/src/App/index.tsx +++ b/examples/vite-app/src/App/index.tsx @@ -16,6 +16,7 @@ import Empty from '../examples/Empty'; import FloatingEdges from '../examples/FloatingEdges'; import Hidden from '../examples/Hidden'; import Interaction from '../examples/Interaction'; +import Intersection from '../examples/Intersection'; import Layouting from '../examples/Layouting'; import MultiFlows from '../examples/MultiFlows'; import NestedNodes from '../examples/NestedNodes'; @@ -132,6 +133,11 @@ const routes: IRoute[] = [ path: '/interaction', component: Interaction, }, + { + name: 'Intersection', + path: '/intersection', + component: Intersection, + }, { name: 'Interactive Minimap', path: '/interactive-minimap', diff --git a/examples/vite-app/src/examples/Intersection/index.tsx b/examples/vite-app/src/examples/Intersection/index.tsx new file mode 100644 index 00000000..1f82b308 --- /dev/null +++ b/examples/vite-app/src/examples/Intersection/index.tsx @@ -0,0 +1,104 @@ +import { useCallback, MouseEvent } from 'react'; +import ReactFlow, { + MiniMap, + Background, + Controls, + ReactFlowProvider, + Node, + Edge, + useReactFlow, + useNodesState, +} from 'reactflow'; + +import './style.css'; + +const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node); +const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); + +const initialNodes: Node[] = [ + { + id: '1', + type: 'input', + data: { label: 'Node 1' }, + position: { x: 0, y: 0 }, + className: 'light', + style: { + width: 200, + height: 100, + }, + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 0, y: 150 }, + className: 'light', + }, + { + id: '3', + data: { label: 'Node 3' }, + position: { x: 250, y: 0 }, + className: 'light', + }, + { + id: '4', + data: { label: 'Node' }, + position: { x: 350, y: 150 }, + style: { + width: 50, + height: 50, + }, + className: 'light', + }, +]; + +const initialEdges: Edge[] = []; + +const defaultEdgeOptions = { zIndex: 0 }; + +const BasicFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const { getIntersectingNodes, isNodeIntersecting } = useReactFlow(); + + const onNodeDrag = useCallback((_: MouseEvent, node: Node) => { + const intersections = getIntersectingNodes(node).map((n) => n.id); + const isIntersecting = isNodeIntersecting(node, { x: 0, y: 0, width: 100, height: 100 }); + + console.log(isIntersecting); + + setNodes((ns) => + ns.map((n) => ({ + ...n, + className: intersections.includes(n.id) ? 'highlight' : '', + })) + ); + }, []); + + return ( + + + + + + ); +}; + +export default function App() { + return ( + + + + ); +} diff --git a/examples/vite-app/src/examples/Intersection/style.css b/examples/vite-app/src/examples/Intersection/style.css new file mode 100644 index 00000000..16b4cc4c --- /dev/null +++ b/examples/vite-app/src/examples/Intersection/style.css @@ -0,0 +1,4 @@ +.react-flow__node.highlight { + background-color: #ff5050; + color: white; +} diff --git a/packages/core/src/hooks/useReactFlow.ts b/packages/core/src/hooks/useReactFlow.ts index 77415b9b..0801a772 100644 --- a/packages/core/src/hooks/useReactFlow.ts +++ b/packages/core/src/hooks/useReactFlow.ts @@ -13,8 +13,10 @@ import type { EdgeRemoveChange, NodeChange, Node, + Rect, } from '../types'; import { getConnectedEdges } from '../utils/graph'; +import { getOverlappingArea, isRectObject, nodeToRect } from '../utils'; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export default function useReactFlow(): ReactFlowInstance { @@ -191,6 +193,63 @@ export default function useReactFlow(): ReactFlo } }, []); + const getNodeRect = useCallback( + ( + nodeOrRect: (Partial> & { id: Node['id'] }) | Rect + ): [Rect | null, Node | null | undefined, boolean] => { + const isRect = isRectObject(nodeOrRect); + const node = isRect ? null : store.getState().nodeInternals.get(nodeOrRect.id); + + if (!isRect && !node) { + [null, null, isRect]; + } + + const nodeRect = isRect ? nodeOrRect : nodeToRect(node!); + + return [nodeRect, node, isRect]; + }, + [] + ); + + const getIntersectingNodes = useCallback>( + (nodeOrRect, partially = true, nodes) => { + const [nodeRect, node, isRect] = getNodeRect(nodeOrRect); + + if (!nodeRect) { + return []; + } + + return (nodes || Array.from(store.getState().nodeInternals.values())).filter((n) => { + if (!isRect && (n.id === node!.id || !n.positionAbsolute)) { + return false; + } + + const currNodeRect = nodeToRect(n); + const overlappingArea = getOverlappingArea(currNodeRect, nodeRect); + const partiallyVisible = partially && overlappingArea > 0; + + return partiallyVisible || overlappingArea >= nodeOrRect.width! * nodeOrRect.height!; + }); + }, + [] + ); + + const isNodeIntersecting = useCallback>( + (nodeOrRect, area, partially = true) => { + const [nodeRect] = getNodeRect(nodeOrRect); + + if (!nodeRect) { + return false; + } + + const overlappingArea = getOverlappingArea(nodeRect, area); + const partiallyVisible = partially && overlappingArea > 0; + + return partiallyVisible || overlappingArea >= nodeOrRect.width! * nodeOrRect.height!; + }, + [] + ); + return useMemo(() => { return { ...viewportHelper, @@ -204,6 +263,8 @@ export default function useReactFlow(): ReactFlo addEdges, toObject, deleteElements, + getIntersectingNodes, + isNodeIntersecting, }; }, [ viewportHelper, @@ -217,5 +278,7 @@ export default function useReactFlow(): ReactFlo addEdges, toObject, deleteElements, + getIntersectingNodes, + isNodeIntersecting, ]); } diff --git a/packages/core/src/types/instance.ts b/packages/core/src/types/instance.ts index 0c7200d2..7ff85606 100644 --- a/packages/core/src/types/instance.ts +++ b/packages/core/src/types/instance.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-namespace */ -import { ViewportHelperFunctions, Viewport, Node, Edge } from '.'; +import { ViewportHelperFunctions, Viewport, Node, Edge, Rect } from '.'; export type ReactFlowJsonObject = { nodes: Node[]; @@ -9,8 +9,8 @@ export type ReactFlowJsonObject = { }; export type DeleteElementsOptions = { - nodes?: (Partial & { id: Node['id'] })[], - edges?: (Partial & { id: Edge['id'] })[] + nodes?: (Partial & { id: Node['id'] })[]; + edges?: (Partial & { id: Edge['id'] })[]; }; export namespace Instance { export type GetNodes = () => Node[]; @@ -27,6 +27,16 @@ export namespace Instance { export type AddEdges = (payload: Edge[] | Edge) => void; export type ToObject = () => ReactFlowJsonObject; export type DeleteElements = ({ nodes, edges }: DeleteElementsOptions) => void; + export type GetIntersectingNodes = ( + node: (Partial> & { id: Node['id'] }) | Rect, + partially?: boolean, + nodes?: Node[] + ) => Node[]; + export type IsNodeIntersecting = ( + node: (Partial> & { id: Node['id'] }) | Rect, + area: Rect, + partially?: boolean + ) => boolean; } export type ReactFlowInstance = { @@ -40,5 +50,7 @@ export type ReactFlowInstance = { getEdge: Instance.GetEdge; toObject: Instance.ToObject; deleteElements: Instance.DeleteElements; + getIntersectingNodes: Instance.GetIntersectingNodes; + isNodeIntersecting: Instance.IsNodeIntersecting; viewportInitialized: boolean; } & Omit; diff --git a/packages/core/src/utils/graph.ts b/packages/core/src/utils/graph.ts index 123fd13e..78569276 100644 --- a/packages/core/src/utils/graph.ts +++ b/packages/core/src/utils/graph.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Selection as D3Selection } from 'd3'; -import { boxToRect, clamp, devWarn, getBoundsOfBoxes, rectToBox } from '../utils'; +import { boxToRect, clamp, devWarn, getBoundsOfBoxes, getOverlappingArea, rectToBox } from '../utils'; import type { Node, Edge, Connection, EdgeMarkerType, Transform, XYPosition, Rect, NodeInternals } from '../types'; export const isEdge = (element: Node | Connection | Edge): element is Edge => @@ -161,12 +161,12 @@ export const getNodesInside = ( // set excludeNonSelectableNodes if you want to pay attention to the nodes "selectable" attribute excludeNonSelectableNodes = false ): Node[] => { - const rBox = rectToBox({ + const paneRect = { x: (rect.x - tx) / tScale, y: (rect.y - ty) / tScale, width: rect.width / tScale, height: rect.height / tScale, - }); + }; const visibleNodes: Node[] = []; @@ -177,10 +177,8 @@ export const getNodesInside = ( return false; } - const nBox = rectToBox({ ...positionAbsolute, width: width || 0, height: height || 0 }); - const xOverlap = Math.max(0, Math.min(rBox.x2, nBox.x2) - Math.max(rBox.x, nBox.x)); - const yOverlap = Math.max(0, Math.min(rBox.y2, nBox.y2) - Math.max(rBox.y, nBox.y)); - const overlappingArea = Math.ceil(xOverlap * yOverlap); + const nodeRect = { ...positionAbsolute, width: width || 0, height: height || 0 }; + const overlappingArea = getOverlappingArea(paneRect, nodeRect); const notInitialized = typeof width === 'undefined' || typeof height === 'undefined' || width === null || height === null; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index c3afe773..02cff5a0 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,4 +1,4 @@ -import type { Dimensions, XYPosition, CoordinateExtent, Box, Rect } from '../types'; +import type { Dimensions, Node, XYPosition, CoordinateExtent, Box, Rect } from '../types'; export const getDimensions = (node: HTMLDivElement): Dimensions => ({ width: node.offsetWidth, @@ -36,9 +36,25 @@ export const boxToRect = ({ x, y, x2, y2 }: Box): Rect => ({ height: y2 - y, }); +export const nodeToRect = (node: Node): Rect => ({ + ...(node.positionAbsolute || { x: 0, y: 0 }), + width: node.width || 0, + height: node.height || 0, +}); + export const getBoundsOfRects = (rect1: Rect, rect2: Rect): Rect => boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2))); +export const getOverlappingArea = (rectA: Rect, rectB: Rect): number => { + const xOverlap = Math.max(0, Math.min(rectA.x + rectA.width, rectB.x + rectB.width) - Math.max(rectA.x, rectB.x)); + const yOverlap = Math.max(0, Math.min(rectA.y + rectA.height, rectB.y + rectB.height) - Math.max(rectA.y, rectB.y)); + + return Math.ceil(xOverlap * yOverlap); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isRectObject = (obj: any): obj is Rect => !!obj.width && !!obj.height && !!obj.x && !!obj.y; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export const isNumeric = (n: any): n is number => !isNaN(n) && isFinite(n);