diff --git a/docs/examples/animate-node-position/App.vue b/docs/examples/animate-node-position/App.vue new file mode 100644 index 000000000..25ff995f4 --- /dev/null +++ b/docs/examples/animate-node-position/App.vue @@ -0,0 +1 @@ +<script setup></script> diff --git a/docs/examples/animate-node-position/useAnimateNode.js b/docs/examples/animate-node-position/useAnimateNode.js new file mode 100644 index 000000000..cddd1f90c --- /dev/null +++ b/docs/examples/animate-node-position/useAnimateNode.js @@ -0,0 +1,62 @@ +/** + * @typedef {object} Node + * @property {{ x: number, y: number }} position - The current position of the node. + */ + +/** + * @typedef {object} XYPosition + * @property {number} x - The target x-coordinate. + * @property {number} y - The target y-coordinate. + */ + +/** + * @typedef {object} AnimateOptions + * @property {number} [duration=300] - Total time for the transition in milliseconds. + * @property {(t: number) => number} [easing] - Easing function that maps a normalized time (0 to 1) + * to another value (0 to 1). Defaults to linear if not provided. + * @property {() => void} [onFinished] - Callback fired when the transition completes. + * @property {(position: XYPosition) => void} [onAnimate] - Callback fired on each animation frame. + */ + +/** + * Creates a function that animates a node from its current position to a given target position. + * + * @returns {(node: Node, position: XYPosition, options?: AnimateOptions) => void} + * A function that, when invoked, starts the animation. + */ +export function useAnimateNode() { + return (node, position, options = {}) => { + const startX = node.position.x + const startY = node.position.y + const endX = position.x + const endY = position.y + + const duration = typeof options.duration === 'number' ? options.duration : 300 + const easing = typeof options.easing === 'function' ? options.easing : (t) => t + const startTime = performance.now() + + /** + * Runs one animation frame, updating the node's position based on the elapsed time. + */ + function animate() { + const currentTime = performance.now() + const elapsed = currentTime - startTime + const t = Math.min(elapsed / duration, 1) + const easedT = easing(t) + + options.onAnimate({ + x: startX + (endX - startX) * easedT, + y: startY + (endY - startY) * easedT, + }) + + if (t < 1) { + requestAnimationFrame(animate) + } else if (typeof options.onFinished === 'function') { + options.onFinished() + } + } + + // Start the animation + requestAnimationFrame(animate) + } +} diff --git a/docs/examples/index.ts b/docs/examples/index.ts index 9504f6874..e1790e569 100644 --- a/docs/examples/index.ts +++ b/docs/examples/index.ts @@ -18,7 +18,17 @@ import { IntersectionApp, IntersectionCSS } from './intersection' import { SnapToHandleApp, SnappableConnectionLine } from './connection-radius' import { NodeResizerApp, ResizableNode } from './node-resizer' import { ToolbarApp, ToolbarNode } from './node-toolbar' -import { LayoutApp, LayoutEdge, LayoutElements, LayoutIcon, LayoutNode, useLayout, useRunProcess, useShuffle } from './layout' +import { + LayoutApp, + LayoutEdge, + LayoutElements, + LayoutIcon, + LayoutNode, + useAnimateNode, + useLayout, + useRunProcess, + useShuffle, +} from './layout' import { SimpleLayoutApp, SimpleLayoutElements, SimpleLayoutIcon, useSimpleLayout } from './layout-simple' import { LoopbackApp, LoopbackCSS, LoopbackEdge } from './loopback' import { MathApp, MathCSS, MathElements, MathIcon, MathOperatorNode, MathResultNode, MathValueNode } from './math' @@ -137,6 +147,7 @@ export const exampleImports = { 'useRunProcess.js': useRunProcess, 'useShuffle.js': useShuffle, 'useLayout.js': useLayout, + 'useAnimateNode.js': useAnimateNode, 'Icon.vue': LayoutIcon, 'additionalImports': { '@dagrejs/dagre': 'https://cdn.jsdelivr.net/npm/@dagrejs/dagre@1.1.2/+esm', diff --git a/docs/examples/layout/App.vue b/docs/examples/layout/App.vue index 766f77f2c..d2fa08fde 100644 --- a/docs/examples/layout/App.vue +++ b/docs/examples/layout/App.vue @@ -1,6 +1,6 @@ <script setup> import { nextTick, ref } from 'vue' -import { Panel, VueFlow, useVueFlow } from '@vue-flow/core' +import { Panel, VueFlow } from '@vue-flow/core' import { Background } from '@vue-flow/background' import Icon from './Icon.vue' import ProcessNode from './ProcessNode.vue' @@ -17,14 +17,14 @@ const edges = ref(initialEdges) const cancelOnError = ref(true) +const animatePositionChange = ref(false) + const shuffle = useShuffle() const { graph, layout, previousDirection } = useLayout() const { run, stop, reset, isRunning } = useRunProcess({ graph, cancelOnError }) -const { fitView } = useVueFlow() - async function shuffleGraph() { await stop() @@ -42,19 +42,15 @@ async function layoutGraph(direction) { reset(nodes.value) - nodes.value = layout(nodes.value, edges.value, direction) - - nextTick(() => { - fitView() - }) + await layout(nodes.value, edges.value, direction, animatePositionChange.value) } </script> <template> <div class="layout-flow"> <VueFlow - :nodes="nodes" - :edges="edges" + v-model:nodes="nodes" + v-model:edges="edges" :default-edge-options="{ type: 'animation', animated: true }" @nodes-initialized="layoutGraph('LR')" > @@ -105,6 +101,11 @@ async function layoutGraph(direction) { <label>Cancel on error</label> <input v-model="cancelOnError" type="checkbox" /> </div> + + <div class="checkbox-panel"> + <label>Animate position change</label> + <input v-model="animatePositionChange" type="checkbox" /> + </div> </Panel> </VueFlow> </div> diff --git a/docs/examples/layout/index.ts b/docs/examples/layout/index.ts index 75b3a14c5..6c2c992d3 100644 --- a/docs/examples/layout/index.ts +++ b/docs/examples/layout/index.ts @@ -6,3 +6,4 @@ export { default as useRunProcess } from './useRunProcess.js?raw' export { default as useShuffle } from './useShuffle.js?raw' export { default as useLayout } from './useLayout.js?raw' export { default as LayoutIcon } from './Icon.vue?raw' +export { default as useAnimateNode } from './useAnimateNode.js?raw' diff --git a/docs/examples/layout/useAnimateNode.js b/docs/examples/layout/useAnimateNode.js new file mode 100644 index 000000000..cddd1f90c --- /dev/null +++ b/docs/examples/layout/useAnimateNode.js @@ -0,0 +1,62 @@ +/** + * @typedef {object} Node + * @property {{ x: number, y: number }} position - The current position of the node. + */ + +/** + * @typedef {object} XYPosition + * @property {number} x - The target x-coordinate. + * @property {number} y - The target y-coordinate. + */ + +/** + * @typedef {object} AnimateOptions + * @property {number} [duration=300] - Total time for the transition in milliseconds. + * @property {(t: number) => number} [easing] - Easing function that maps a normalized time (0 to 1) + * to another value (0 to 1). Defaults to linear if not provided. + * @property {() => void} [onFinished] - Callback fired when the transition completes. + * @property {(position: XYPosition) => void} [onAnimate] - Callback fired on each animation frame. + */ + +/** + * Creates a function that animates a node from its current position to a given target position. + * + * @returns {(node: Node, position: XYPosition, options?: AnimateOptions) => void} + * A function that, when invoked, starts the animation. + */ +export function useAnimateNode() { + return (node, position, options = {}) => { + const startX = node.position.x + const startY = node.position.y + const endX = position.x + const endY = position.y + + const duration = typeof options.duration === 'number' ? options.duration : 300 + const easing = typeof options.easing === 'function' ? options.easing : (t) => t + const startTime = performance.now() + + /** + * Runs one animation frame, updating the node's position based on the elapsed time. + */ + function animate() { + const currentTime = performance.now() + const elapsed = currentTime - startTime + const t = Math.min(elapsed / duration, 1) + const easedT = easing(t) + + options.onAnimate({ + x: startX + (endX - startX) * easedT, + y: startY + (endY - startY) * easedT, + }) + + if (t < 1) { + requestAnimationFrame(animate) + } else if (typeof options.onFinished === 'function') { + options.onFinished() + } + } + + // Start the animation + requestAnimationFrame(animate) + } +} diff --git a/docs/examples/layout/useLayout.js b/docs/examples/layout/useLayout.js index 652aced6d..ef4b5ee6e 100644 --- a/docs/examples/layout/useLayout.js +++ b/docs/examples/layout/useLayout.js @@ -1,53 +1,98 @@ import dagre from '@dagrejs/dagre' -import { Position, useVueFlow } from '@vue-flow/core' -import { ref } from 'vue' +import { Position, getRectOfNodes, useVueFlow } from '@vue-flow/core' +import { nextTick, ref } from 'vue' +import { useAnimateNode } from './useAnimateNode' /** * Composable to run the layout algorithm on the graph. * It uses the `dagre` library to calculate the layout of the nodes and edges. */ export function useLayout() { - const { findNode } = useVueFlow() + const { findNode, updateNode, fitView, fitBounds } = useVueFlow() + + const animateNode = useAnimateNode() const graph = ref(new dagre.graphlib.Graph()) const previousDirection = ref('LR') - function layout(nodes, edges, direction) { - // we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there - const dagreGraph = new dagre.graphlib.Graph() + async function layout(nodes, edges, direction, animate = false) { + return new Promise((resolve) => { + // we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there + const dagreGraph = new dagre.graphlib.Graph() + + graph.value = dagreGraph + + dagreGraph.setDefaultEdgeLabel(() => ({})) + + const isHorizontal = direction === 'LR' + dagreGraph.setGraph({ rankdir: direction }) + + previousDirection.value = direction + + for (const node of nodes) { + // if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type) + const graphNode = findNode(node.id) - graph.value = dagreGraph + dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 }) + } + + for (const edge of edges) { + dagreGraph.setEdge(edge.source, edge.target) + } - dagreGraph.setDefaultEdgeLabel(() => ({})) + dagre.layout(dagreGraph) - const isHorizontal = direction === 'LR' - dagreGraph.setGraph({ rankdir: direction }) + if (animate) { + const nodesWithPosition = [...nodes].map((node) => { + const nodeWithPosition = dagreGraph.node(node.id) + const position = { x: nodeWithPosition.x, y: nodeWithPosition.y } - previousDirection.value = direction + return { + ...node, + position, + computedPosition: position, + } + }) + + const nodesRect = getRectOfNodes(nodesWithPosition) + + fitBounds(nodesRect, { duration: 1000 }) + } - for (const node of nodes) { - // if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type) - const graphNode = findNode(node.id) + // set nodes with updated positions + nodes.forEach((node) => { + const nodeWithPosition = dagreGraph.node(node.id) + const position = { x: nodeWithPosition.x, y: nodeWithPosition.y } - dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 }) - } + updateNode(node.id, { + targetPosition: isHorizontal ? Position.Left : Position.Top, + sourcePosition: isHorizontal ? Position.Right : Position.Bottom, + }) - for (const edge of edges) { - dagreGraph.setEdge(edge.source, edge.target) - } + if (animate) { + animateNode(node, position, { + duration: 1000, + onAnimate: (animatedPosition) => { + updateNode(node.id, { position: animatedPosition }) + }, + onFinished: () => { + updateNode(node.id, { position }) - dagre.layout(dagreGraph) + resolve(true) + }, + }) + } else { + updateNode(node.id, { position }) + } + }) - // set nodes with updated positions - return nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id) + if (!animate) { + nextTick(() => { + fitView() + }) - return { - ...node, - targetPosition: isHorizontal ? Position.Left : Position.Top, - sourcePosition: isHorizontal ? Position.Right : Position.Bottom, - position: { x: nodeWithPosition.x, y: nodeWithPosition.y }, + resolve(true) } }) }