From ba944f8b8df73d0a5fd10370629e3371d057755e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 7 Jun 2023 17:45:45 +0200 Subject: [PATCH 1/3] Add a possibility to specify animation duration --- .husky/prepare-commit-msg | 4 - README.md | 2 - package.json | 6 - src/components/graphs/GraphComponent.tsx | 8 +- src/constants/animations.ts | 8 ++ src/examples/Development.tsx | 135 ++--------------------- src/hooks/observers.ts | 47 ++++---- src/models/graphs/DirectedGraph.ts | 40 ++++--- src/models/graphs/Graph.ts | 87 ++++++++------- src/models/graphs/UndirectedGraph.ts | 52 ++++----- src/types/animations.ts | 8 ++ src/types/data/index.ts | 6 +- src/types/graphs/shared.ts | 26 +++-- src/utils/animations.ts | 14 +-- yarn.lock | 2 +- 15 files changed, 174 insertions(+), 271 deletions(-) delete mode 100755 .husky/prepare-commit-msg create mode 100644 src/constants/animations.ts create mode 100644 src/types/animations.ts diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg deleted file mode 100755 index cb932df0..00000000 --- a/.husky/prepare-commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -exec < /dev/tty && npx cz --hook || true diff --git a/README.md b/README.md index 39891f23..9c54f3f6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1 @@ # React Native Smart Graph - -[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) diff --git a/package.json b/package.json index 9e7663f4..9e5713a3 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,6 @@ "name": "Mateusz Łopaciński", "email": "lop.mateusz.2001@gmail.com" }, - "config": { - "commitizen": { - "path": "./node_modules/cz-conventional-changelog" - } - }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", @@ -34,7 +29,6 @@ "babel-jest": "^29.5.0", "babel-plugin-module-resolver": "^5.0.0", "babel-plugin-react-require": "^4.0.0", - "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", "eslint": "^8.41.0", "eslint-import-resolver-typescript": "^3.5.5", diff --git a/src/components/graphs/GraphComponent.tsx b/src/components/graphs/GraphComponent.tsx index 1bc42103..9366785d 100644 --- a/src/components/graphs/GraphComponent.tsx +++ b/src/components/graphs/GraphComponent.tsx @@ -70,7 +70,8 @@ export default function GraphComponent< graphEventsContext }: GraphComponentProps & GraphComponentPrivateProps) { // GRAPH OBSERVER - const [{ vertices, orderedEdges }] = useGraphObserver(graph); + const [{ vertices, orderedEdges, animationSettings }] = + useGraphObserver(graph); // HELPER REFS const isFirstRenderRef = useRef(true); @@ -293,10 +294,11 @@ export default function GraphComponent< () => { animateVerticesToFinalPositions( animatedVerticesPositions, - memoGraphLayout.verticesPositions + memoGraphLayout.verticesPositions, + animationSettings ); }, - [animatedVerticesPositions, memoGraphLayout] + [animatedVerticesPositions, memoGraphLayout, animationSettings] ); const handleVertexRender = useCallback( diff --git a/src/constants/animations.ts b/src/constants/animations.ts new file mode 100644 index 00000000..a043035e --- /dev/null +++ b/src/constants/animations.ts @@ -0,0 +1,8 @@ +import { AnimationSettingWithDefaults } from '@/types/animations'; + +import EASING from './easings'; + +export const DEFAULT_ANIMATION_SETTINGS: AnimationSettingWithDefaults = { + duration: 500, + easing: EASING.bounce +}; diff --git a/src/examples/Development.tsx b/src/examples/Development.tsx index 22a713da..40902876 100644 --- a/src/examples/Development.tsx +++ b/src/examples/Development.tsx @@ -3,139 +3,24 @@ import { SafeAreaView, StyleSheet, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import DefaultEdgeLabelRenderer from '@/components/graphs/labels/renderers/DefaultEdgeLabelRenderer'; -import UndirectedGraphComponent from '@/components/graphs/UndirectedGraphComponent'; -import { UndirectedGraph } from '@/models/graphs'; +import { DirectedGraph } from '@/models/graphs'; import PannableScalableView from '@/views/PannableScalableView'; -const ADDED_COMPONENTS = [ - { key: 'B', data: 'B' }, - { - key: 'AB', - from: 'A', - to: 'B', - data: 'AB' - }, - { key: 'C', data: 'C' }, - { - key: 'AC', - from: 'A', - to: 'C', - data: 'AC' - }, - { key: 'D', data: 'D' }, - { key: 'E', data: 'E' }, - { - key: 'CD', - from: 'C', - to: 'D', - data: 'CD' - }, - { - key: 'CE', - from: 'C', - to: 'E', - data: 'CE' - }, - { key: 'F', data: 'F' }, - { key: 'G', data: 'G' }, - { key: 'H', data: 'H' }, - { key: 'I', data: 'I' }, - { key: 'J', data: 'J' }, - { key: 'K', data: 'K' }, - { key: 'L', data: 'L' }, - { - key: 'EK', - from: 'E', - to: 'K', - data: 'EK' - }, - { - key: 'EL', - from: 'E', - to: 'L', - data: 'EL' - }, - { - key: 'CF', - from: 'C', - to: 'F', - data: 'CF' - }, - { key: 'M', data: 'M' }, - { key: 'N', data: 'N' }, - { key: 'O', data: 'O' }, - { - key: 'CG', - from: 'C', - to: 'G', - data: 'CG' - }, - { - key: 'CH', - from: 'C', - to: 'H', - data: 'CH' - }, - { - key: 'CI', - from: 'C', - to: 'I', - data: 'CI' - }, - { - key: 'CJ', - from: 'C', - to: 'J', - data: 'CJ' - } -]; - -let idx = 0; -let mode = 0; +import { DirectedGraphComponent } from '..'; export default function App() { - const graph = new UndirectedGraph({ - vertices: [{ key: 'A', data: 'A' }] + const graph = new DirectedGraph({ + vertices: [{ key: 'A', value: 'A' }] }); useEffect(() => { - const interval = setInterval(() => { - if (idx < 0 || idx >= ADDED_COMPONENTS.length) { - mode = mode === 0 ? 1 : 0; - idx = Math.max(0, Math.min(ADDED_COMPONENTS.length - 1, idx)); - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const component = ADDED_COMPONENTS[idx]!; + setTimeout(() => { + graph.insertVertex({ key: 'B', value: 'B' }, { duration: 100 }); + }, 200); - try { - if (mode === 0) { - if (component.from && component.to) { - graph.insertEdge( - component.key, - component.data, - component.from, - component.to - ); - } else { - graph.insertVertex(component.key, component.data); - } - idx++; - } else { - if (component.from && component.to) { - graph.removeEdge(component.key); - } else { - graph.removeVertex(component.key); - } - idx--; - } - } catch (e) { - clearInterval(interval); - console.error(e); - return; - } - }, 500); + // const interval = setInterval(() => {}, 500); - return () => clearInterval(interval); + // return () => clearInterval(interval); }, []); return ( @@ -143,7 +28,7 @@ export default function App() { - = { + vertices: Array>; + edges: Array>; + orderedEdges: Array<{ + edge: Edge; + order: number; + edgesCount: number; + }>; + animationSettings: AnimationSettingWithDefaults; +}; + export const useGraphObserver = ( graph: Graph, active = true -): [ - { - vertices: Array>; - edges: Array>; - orderedEdges: Array<{ - edge: Edge; - order: number; - edgesCount: number; - }>; - }, - (value: boolean) => void -] => { - const [state, setState] = useState({ +): [State, (value: boolean) => void] => { + const [state, setState] = useState>({ vertices: graph.vertices, edges: graph.edges, - orderedEdges: graph.orderedEdges + orderedEdges: graph.orderedEdges, + animationSettings: DEFAULT_ANIMATION_SETTINGS }); - const updateState = () => { - setState({ - vertices: graph.vertices, - edges: graph.edges, - orderedEdges: graph.orderedEdges - }); - }; const isObservingRef = useRef(false); const isFirstRenderRef = useRef(true); const observerRef = useRef({ - graphChanged: updateState + graphChanged(animationSettings) { + setState({ + vertices: graph.vertices, + edges: graph.edges, + orderedEdges: graph.orderedEdges, + animationSettings + }); + } }); if (isFirstRenderRef.current) { diff --git a/src/models/graphs/DirectedGraph.ts b/src/models/graphs/DirectedGraph.ts index 5cebd24f..0a515023 100644 --- a/src/models/graphs/DirectedGraph.ts +++ b/src/models/graphs/DirectedGraph.ts @@ -1,5 +1,6 @@ import DirectedEdge from '@/models/edges/DirectedEdge'; import DirectedGraphVertex from '@/models/vertices/DirectedGraphVertex'; +import { AnimationSettings } from '@/types/animations'; import { DirectedEdgeData, VertexData } from '@/types/data'; import Graph from './Graph'; @@ -24,22 +25,18 @@ export default class DirectedGraph extends Graph< } override insertVertex( - key: string, - value: V, - notifyObservers?: boolean + { key, value }: VertexData, + animationSettings?: AnimationSettings | null ): DirectedGraphVertex { return this.insertVertexObject( new DirectedGraphVertex(key, value), - notifyObservers + animationSettings ); } override insertEdge( - key: string, - value: E, - sourceKey: string, - targetKey: string, - notifyObservers?: boolean + { key, value, from: sourceKey, to: targetKey }: DirectedEdgeData, + animationSettings?: AnimationSettings | null ): DirectedEdge { this.checkSelfLoop(sourceKey, targetKey); const source = this.getVertex(sourceKey); @@ -55,12 +52,15 @@ export default class DirectedGraph extends Graph< const edge = new DirectedEdge(key, value, source, target); source.addOutEdge(edge); target.addInEdge(edge); - this.insertEdgeObject(edge, notifyObservers); + this.insertEdgeObject(edge, animationSettings); return edge; } - override removeEdge(key: string, notifyObservers?: boolean): E { + override removeEdge( + key: string, + animationSettings?: AnimationSettings | null + ): E { const edge = this.getEdge(key); if (!edge) { @@ -69,7 +69,7 @@ export default class DirectedGraph extends Graph< edge.source.removeOutEdge(key); edge.target.removeInEdge(key); - this.removeEdgeObject(edge, notifyObservers); + this.removeEdgeObject(edge, animationSettings); return edge.value; } @@ -97,16 +97,14 @@ export default class DirectedGraph extends Graph< vertices?: Array>; edges?: Array>; }, - notifyObservers = true + animationSettings?: AnimationSettings | null ): void { // Insert edges and vertices to the graph model - vertices?.forEach(({ key, data }) => this.insertVertex(key, data, false)); - edges?.forEach(({ key, data, from, to }) => - this.insertEdge(key, data, from, to, false) - ); + vertices?.forEach(data => this.insertVertex(data, null)); + edges?.forEach(data => this.insertEdge(data, null)); // Notify observers after all changes to the graph model are made - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } } @@ -115,11 +113,11 @@ export default class DirectedGraph extends Graph< vertices?: Array>; edges?: Array>; }, - notifyObservers = true + animationSettings?: AnimationSettings | null ): void { this.clear(); setTimeout(() => { - this.insertBatch(batchData, notifyObservers); + this.insertBatch(batchData, animationSettings); }, 0); } } diff --git a/src/models/graphs/Graph.ts b/src/models/graphs/Graph.ts index a220217b..bbc36424 100644 --- a/src/models/graphs/Graph.ts +++ b/src/models/graphs/Graph.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { DEFAULT_ANIMATION_SETTINGS } from '@/constants/animations'; +import { AnimationSettings } from '@/types/animations'; import { DirectedEdgeData, UndirectedEdgeData, VertexData } from '@/types/data'; import { Edge, @@ -24,7 +26,7 @@ export default abstract class Graph< Record> > = {}; - private readonly observers: Array = []; + private readonly observers: Set = new Set(); get vertices(): Array { return Object.values(this.vertices$); @@ -75,17 +77,20 @@ export default abstract class Graph< abstract isDirected(): boolean; - abstract insertVertex(key: string, value: V, notifyObservers?: boolean): GV; + abstract insertVertex( + data: VertexData, + animationSettings?: AnimationSettings | null + ): GV; abstract insertEdge( - key: string, - value: E, - sourceKey: string, - targetKey: string, - notifyObservers?: boolean + data: ED, + animationSettings?: AnimationSettings | null ): GE; - abstract removeEdge(key: string, notifyObservers?: boolean): E; + abstract removeEdge( + key: string, + animationSettings?: AnimationSettings | null + ): E; abstract orderEdgesBetweenVertices( edges: Array @@ -96,7 +101,7 @@ export default abstract class Graph< vertices?: Array>; edges?: Array; }, - notifyObservers?: boolean + animationSettings?: AnimationSettings | null ): void; abstract replaceBatch( @@ -104,7 +109,7 @@ export default abstract class Graph< vertices?: Array>; edges?: Array; }, - notifyObservers?: boolean + animationSettings?: AnimationSettings | null ): void; removeBatch( @@ -112,38 +117,35 @@ export default abstract class Graph< vertices?: Array; edges?: Array; }, - notifyObservers = true + animationSettings?: AnimationSettings | null ): void { // Remove edges and vertices from graph - data.edges?.forEach(key => this.removeEdge(key, false)); - data.vertices?.forEach(key => this.removeVertex(key, false)); + data.edges?.forEach(key => this.removeEdge(key, null)); + data.vertices?.forEach(key => this.removeVertex(key, null)); // Notify observers after all changes to the graph model are made - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } } - clear(notifyObservers = true): void { + clear(animationSettings?: AnimationSettings | null): void { // Clear the whole graph (this.vertices$ as Mutable) = {}; (this.edges$ as Mutable) = {}; (this.edgesBetweenVertices$ as Mutable) = {}; // Notify observers after all changes to the graph model are made - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } } addObserver(observer: GraphObserver): void { - this.observers.push(observer); + this.observers.add(observer); } removeObserver(observer: GraphObserver): void { - const index = this.observers.indexOf(observer); - if (index > -1) { - this.observers.splice(index, 1); - } + this.observers.delete(observer); } hasVertex(key: string): boolean { @@ -167,7 +169,7 @@ export default abstract class Graph< return res ? [...res] : []; // Create a copy of the array } - removeVertex(key: string, notifyObservers = true): V { + removeVertex(key: string, animationSettings?: AnimationSettings | null): V { if (!this.vertices$[key]) { throw new Error(`Vertex with key ${key} does not exist.`); } @@ -178,26 +180,32 @@ export default abstract class Graph< }); delete this.vertices$[key]; // Notify change if notifyObservers is set to true - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } return vertex.value; } - protected insertVertexObject(vertex: GV, notifyObservers = true): GV { + protected insertVertexObject( + vertex: GV, + animationSettings?: AnimationSettings | null + ): GV { if (this.vertices$[vertex.key]) { throw new Error(`Vertex with key ${vertex.key} already exists.`); } this.vertices$[vertex.key] = vertex; // Notify change if notifyObservers is set to true - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } return vertex; } - protected insertEdgeObject(edge: GE, notifyObservers = true): GE { + protected insertEdgeObject( + edge: GE, + animationSettings?: AnimationSettings | null + ): GE { if (this.edges$[edge.key]) { throw new Error(`Edge with key ${edge.key} already exists.`); } @@ -220,13 +228,16 @@ export default abstract class Graph< // Add edge to edges this.edges$[edge.key] = edge; // Notify change if notifyObservers is set to true - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } return edge; } - protected removeEdgeObject(edge: GE, notifyObservers = true): void { + protected removeEdgeObject( + edge: GE, + animationSettings?: AnimationSettings | null + ): void { // Remove edge from edges between vertices const [vertex1, vertex2] = edge.vertices; this.edgesBetweenVertices$[vertex1.key]![vertex2.key]?.splice( @@ -246,14 +257,16 @@ export default abstract class Graph< // Remove the edge from edges delete this.edges$[edge.key]; // Notify change if notifyObservers is set to true - if (notifyObservers) { - this.notifyChange(); + if (animationSettings !== null) { + this.notifyChange(animationSettings); } } - protected notifyChange(): void { + protected notifyChange(animationSettings?: AnimationSettings): void { + const settings = { ...DEFAULT_ANIMATION_SETTINGS, ...animationSettings }; + this.observers.forEach(observer => { - observer.graphChanged(); + observer.graphChanged(settings); }); } diff --git a/src/models/graphs/UndirectedGraph.ts b/src/models/graphs/UndirectedGraph.ts index f4fc413b..08ba63d4 100644 --- a/src/models/graphs/UndirectedGraph.ts +++ b/src/models/graphs/UndirectedGraph.ts @@ -1,5 +1,6 @@ import UndirectedEdge from '@/models/edges/UndirectedEdge'; import UndirectedGraphVertex from '@/models/vertices/UndirectedGraphVertex'; +import { AnimationSettings } from '@/types/animations'; import { UndirectedEdgeData, VertexData } from '@/types/data'; import Graph from './Graph'; @@ -24,23 +25,23 @@ export default class UndirectedGraph extends Graph< } override insertVertex( - key: string, - value: V, - notifyObservers = true + { key, value }: VertexData, + animationSettings?: AnimationSettings | null ): UndirectedGraphVertex { return this.insertVertexObject( new UndirectedGraphVertex(key, value), - notifyObservers + animationSettings ); } override insertEdge( - key: string, - value: E, - vertex1key: string, - vertex2key: string, - notifyObservers = true + { key, value, vertices: [vertex1key, vertex2key] }: UndirectedEdgeData, + animationSettings?: AnimationSettings | null ): UndirectedEdge { + if (!vertex1key || !vertex2key) { + throw new Error(`Edge ${key} must have two vertices`); + } + this.checkSelfLoop(vertex1key, vertex2key); const vertex1 = this.getVertex(vertex1key); const vertex2 = this.getVertex(vertex2key); @@ -58,12 +59,15 @@ export default class UndirectedGraph extends Graph< if (vertex1key !== vertex2key) { vertex2.addEdge(edge); } - this.insertEdgeObject(edge, notifyObservers); + this.insertEdgeObject(edge, animationSettings); return edge; } - override removeEdge(key: string, notifyObservers = true): E { + override removeEdge( + key: string, + animationSettings?: AnimationSettings | null + ): E { const edge = this.getEdge(key); if (!edge) { @@ -74,7 +78,7 @@ export default class UndirectedGraph extends Graph< if (!edge.isLoop) { edge.vertices[1].removeEdge(key); } - this.removeEdgeObject(edge, notifyObservers); + this.removeEdgeObject(edge, animationSettings); return edge.value; } @@ -96,13 +100,13 @@ export default class UndirectedGraph extends Graph< vertices?: Array>; edges?: Array>; }, - notifyObservers = true + animationSettings?: AnimationSettings | null ): void { // Insert edges and vertices to the graph model - vertices?.forEach(({ key, data }) => this.insertVertex(key, data, false)); - edges?.forEach(edge => this.insertEdgeFromData(edge, false)); + vertices?.forEach(data => this.insertVertex(data, null)); + edges?.forEach(data => this.insertEdge(data, null)); // Notify observers after all changes to the graph model are made - if (notifyObservers) { + if (animationSettings !== null) { this.notifyChange(); } } @@ -112,23 +116,11 @@ export default class UndirectedGraph extends Graph< vertices?: Array>; edges?: Array>; }, - notifyObservers = true + animationSettings?: AnimationSettings | null ): void { this.clear(); setTimeout(() => { - this.insertBatch(batchData, notifyObservers); + this.insertBatch(batchData, animationSettings); }, 0); } - - private insertEdgeFromData( - { key, data, vertices: [v1, v2] }: UndirectedEdgeData, - notifyObservers = true - ): void { - if (!v1 || !v2) { - throw new Error( - `Edge ${key} vertices are invalid. Edge must have 2 vertices` - ); - } - this.insertEdge(key, data, v1, v2, notifyObservers); - } } diff --git a/src/types/animations.ts b/src/types/animations.ts new file mode 100644 index 00000000..20cddff3 --- /dev/null +++ b/src/types/animations.ts @@ -0,0 +1,8 @@ +import { EasingFunctionFactory } from 'react-native-reanimated'; + +export type AnimationSettings = { + duration?: number; + easing?: EasingFunctionFactory; +}; + +export type AnimationSettingWithDefaults = Required; diff --git a/src/types/data/index.ts b/src/types/data/index.ts index 9a457236..1f360aea 100644 --- a/src/types/data/index.ts +++ b/src/types/data/index.ts @@ -1,17 +1,17 @@ export type VertexData = { key: string; - data: V; + value: V; }; export type DirectedEdgeData = { key: string; from: string; to: string; - data: E; + value: E; }; export type UndirectedEdgeData = { key: string; vertices: string[]; - data: E; + value: E; }; diff --git a/src/types/graphs/shared.ts b/src/types/graphs/shared.ts index 9b485671..69dbc227 100644 --- a/src/types/graphs/shared.ts +++ b/src/types/graphs/shared.ts @@ -1,5 +1,11 @@ // TODO - add documentation to all interfaces +import { + AnimationSettings, + AnimationSettingWithDefaults +} from '@/types/animations'; +import { DirectedEdgeData, UndirectedEdgeData, VertexData } from '@/types/data'; + export interface Vertex { get key(): string; get value(): V; @@ -18,7 +24,7 @@ export interface Edge { export type GraphConnections = Record>; export type GraphObserver = { - graphChanged(): void; + graphChanged(animationSettings: AnimationSettingWithDefaults): void; }; export interface Graph { @@ -38,15 +44,15 @@ export interface Graph { getEdgesBetween(vertex1key: string, vertex2key: string): Array>; getVertex(key: string): Vertex | null; getEdge(key: string): Edge | null; - insertVertex(key: string, value: V, notifyObservers?: boolean): Vertex; + insertVertex( + data: VertexData, + animationSettings?: AnimationSettings | null + ): Vertex; insertEdge( - key: string, - value: E, - vertex1key: string, - vertex2key: string, - notifyObservers?: boolean + data: DirectedEdgeData | UndirectedEdgeData, + animationSettings?: AnimationSettings | null ): Edge; - removeVertex(key: string, notifyObservers?: boolean): V; - removeEdge(key: string, notifyObservers?: boolean): E; - clear(notifyObservers?: boolean): void; + removeVertex(key: string, animationSettings?: AnimationSettings | null): V; + removeEdge(key: string, animationSettings?: AnimationSettings | null): E; + clear(animationSettings?: AnimationSettings | null): void; } diff --git a/src/utils/animations.ts b/src/utils/animations.ts index afe1b135..6d7e3215 100644 --- a/src/utils/animations.ts +++ b/src/utils/animations.ts @@ -1,25 +1,25 @@ import { Vector } from '@shopify/react-native-skia'; import { withTiming } from 'react-native-reanimated'; -import EASING from '@/constants/easings'; +import { AnimationSettings } from '@/types/animations'; import { AnimatedVectorCoordinates } from '@/types/layout'; export const animateVerticesToFinalPositions = ( animatedPositions: Record, - finalPositions: Record + finalPositions: Record, + { duration, easing }: AnimationSettings ) => { 'worklet'; - // TODO - improve this animation (add settings) Object.entries(finalPositions).forEach(([key, finalPosition]) => { const animatedPosition = animatedPositions[key]; if (animatedPosition) { animatedPosition.x.value = withTiming(finalPosition.x, { - duration: 300, - easing: EASING.bounce + duration, + easing }); animatedPosition.y.value = withTiming(finalPosition.y, { - duration: 300, - easing: EASING.bounce + duration, + easing }); } }); diff --git a/yarn.lock b/yarn.lock index 9e9cbe42..cacc203c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4164,7 +4164,7 @@ commander@~2.13.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" integrity sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA== -commitizen@^4.0.3, commitizen@^4.3.0: +commitizen@^4.0.3: version "4.3.0" resolved "https://registry.yarnpkg.com/commitizen/-/commitizen-4.3.0.tgz#0d056c542a2d2b1f9b9aba981aa32575b2849924" integrity sha512-H0iNtClNEhT0fotHvGV3E9tDejDeS04sN1veIebsKYGMuGscFaswRoYJKmT3eW85eIJAs0F28bG2+a/9wCOfPw== From bf8270ec9c62691ab8ac31106c259c9ee0628bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 7 Jun 2023 18:16:48 +0200 Subject: [PATCH 2/3] Create animation example --- src/constants/animations.ts | 3 +- src/examples/Development.tsx | 60 +++++++++++++++++++++++++++++++----- src/types/animations.ts | 1 + src/utils/animations.ts | 31 +++++++++++++------ 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/constants/animations.ts b/src/constants/animations.ts index a043035e..ef16e15d 100644 --- a/src/constants/animations.ts +++ b/src/constants/animations.ts @@ -4,5 +4,6 @@ import EASING from './easings'; export const DEFAULT_ANIMATION_SETTINGS: AnimationSettingWithDefaults = { duration: 500, - easing: EASING.bounce + easing: EASING.bounce, + onComplete: () => undefined }; diff --git a/src/examples/Development.tsx b/src/examples/Development.tsx index 40902876..db3dfe2f 100644 --- a/src/examples/Development.tsx +++ b/src/examples/Development.tsx @@ -14,13 +14,57 @@ export default function App() { }); useEffect(() => { - setTimeout(() => { - graph.insertVertex({ key: 'B', value: 'B' }, { duration: 100 }); - }, 200); - - // const interval = setInterval(() => {}, 500); - - // return () => clearInterval(interval); + graph.insertVertex( + { key: 'B', value: 'B' }, + { + duration: 200, + onComplete: () => { + graph.insertEdge({ key: 'AB', value: 'AB', from: 'A', to: 'B' }); + graph.insertVertex( + { key: 'C', value: 'C' }, + { + duration: 400, + onComplete: () => { + graph.insertEdge({ + key: 'BC', + value: 'BC', + from: 'B', + to: 'C' + }); + graph.insertVertex( + { key: 'D', value: 'D' }, + { + duration: 600, + onComplete: () => { + graph.insertEdge({ + key: 'CD', + value: 'CD', + from: 'C', + to: 'D' + }); + graph.insertVertex( + { key: 'E', value: 'E' }, + { + duration: 800, + onComplete: () => { + graph.insertEdge({ + key: 'DE', + value: 'DE', + from: 'D', + to: 'E' + }); + } + } + ); + } + } + ); + } + } + ); + } + } + ); }, []); return ( @@ -32,7 +76,7 @@ export default function App() { graph={graph} settings={{ placement: { - strategy: 'orbits', + strategy: 'circle', minVertexSpacing: 100 }, components: { diff --git a/src/types/animations.ts b/src/types/animations.ts index 20cddff3..7ab3b299 100644 --- a/src/types/animations.ts +++ b/src/types/animations.ts @@ -3,6 +3,7 @@ import { EasingFunctionFactory } from 'react-native-reanimated'; export type AnimationSettings = { duration?: number; easing?: EasingFunctionFactory; + onComplete?: () => void; }; export type AnimationSettingWithDefaults = Required; diff --git a/src/utils/animations.ts b/src/utils/animations.ts index 6d7e3215..39ea5aa0 100644 --- a/src/utils/animations.ts +++ b/src/utils/animations.ts @@ -1,26 +1,37 @@ import { Vector } from '@shopify/react-native-skia'; -import { withTiming } from 'react-native-reanimated'; +import { runOnJS, withTiming } from 'react-native-reanimated'; import { AnimationSettings } from '@/types/animations'; import { AnimatedVectorCoordinates } from '@/types/layout'; -export const animateVerticesToFinalPositions = ( +export function animateVerticesToFinalPositions( animatedPositions: Record, finalPositions: Record, - { duration, easing }: AnimationSettings -) => { + { duration, easing, onComplete }: AnimationSettings +) { 'worklet'; - Object.entries(finalPositions).forEach(([key, finalPosition]) => { + const finalPositionsEntries = Object.entries(finalPositions); + + finalPositionsEntries.forEach(([key, finalPosition], idx) => { const animatedPosition = animatedPositions[key]; if (animatedPosition) { animatedPosition.x.value = withTiming(finalPosition.x, { duration, easing }); - animatedPosition.y.value = withTiming(finalPosition.y, { - duration, - easing - }); + animatedPosition.y.value = withTiming( + finalPosition.y, + { + duration, + easing + }, + // Call onComplete only once, when the last vertex animation is complete + onComplete && idx === finalPositionsEntries.length - 1 + ? () => { + runOnJS(onComplete)(); + } + : undefined + ); } }); -}; +} From 1e9d235172aa62cf10a38ab6e665b8055cf9fe66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 8 Jun 2023 00:16:55 +0200 Subject: [PATCH 3/3] Start working on graph timeline animations --- src/animations/GraphTimeline.ts | 222 +++++++++++++++++++++++++++ src/data/index.ts | 3 + src/data/{ => lists}/LinkedList.ts | 14 +- src/data/queues/MinPriorityQueue.ts | 7 + src/data/queues/PriorityQueue.ts | 117 ++++++++++++++ src/data/{ => queues}/Queue.ts | 20 +-- src/examples/Development.tsx | 131 ++++++++++------ src/models/graphs/DirectedGraph.ts | 4 +- src/models/graphs/UndirectedGraph.ts | 4 +- src/types/animations.ts | 14 ++ src/types/data/index.ts | 2 + src/utils/algorithms/graphs.ts | 2 +- src/utils/models.ts | 21 +++ 13 files changed, 489 insertions(+), 72 deletions(-) create mode 100644 src/animations/GraphTimeline.ts create mode 100644 src/data/index.ts rename src/data/{ => lists}/LinkedList.ts (75%) create mode 100644 src/data/queues/MinPriorityQueue.ts create mode 100644 src/data/queues/PriorityQueue.ts rename src/data/{ => queues}/Queue.ts (63%) create mode 100644 src/utils/models.ts diff --git a/src/animations/GraphTimeline.ts b/src/animations/GraphTimeline.ts new file mode 100644 index 00000000..8e507d08 --- /dev/null +++ b/src/animations/GraphTimeline.ts @@ -0,0 +1,222 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import EASING from '@/constants/easings'; +import { MinPriorityQueue } from '@/data'; +import { + AnimationSettings, + EdgeDataType, + TimelineAnimationSettings +} from '@/types/animations'; +import { VertexData } from '@/types/data'; +import { isEdgeData, isVertexData } from '@/utils/models'; + +import { DirectedGraph, UndirectedGraph } from '..'; + +// TODO - implement config support +type SequenceAnimationConfig = { + delay?: number; + paused?: boolean; + repeat?: number; + repeatDelay?: number; + yoyo?: boolean; + onComplete?: () => void; + onRepeat?: () => void; + onReverseComplete?: () => void; + onStart: () => void; + onUpdate?: () => void; +}; + +type AnimationStep = { + fn: () => void; + delay: number; +}; + +export default class GraphTimeline< + V, + E, + G extends DirectedGraph | UndirectedGraph +> { + private readonly steps: MinPriorityQueue = + new MinPriorityQueue(); + private maxStartDelay = 0; + private maxEndDelay = 0; + + constructor( + private readonly graph: G, + private readonly config?: SequenceAnimationConfig + ) {} + + insertVertex( + data: VertexData, + duration: number, + settings?: TimelineAnimationSettings + ) { + return this.addStep( + this.graph.insertVertex.bind(this.graph), + data, + duration, + this.maxEndDelay, + settings + ); + } + + removeVertex( + key: string, + duration: number, + settings?: TimelineAnimationSettings + ) { + return this.addStep( + this.graph.removeVertex.bind(this.graph), + key, + duration, + this.maxEndDelay, + settings + ); + } + + insertEdge( + data: EdgeDataType, + duration: number, + settings?: TimelineAnimationSettings + ) { + return this.addStep( + this.graph.insertEdge.bind(this.graph), + data, + duration, + this.maxEndDelay, + settings + ); + } + + removeEdge( + key: string, + duration: number, + settings?: TimelineAnimationSettings + ) { + return this.addStep( + this.graph.removeEdge.bind(this.graph), + key, + duration, + this.maxEndDelay, + settings + ); + } + + insertSimultaneous( + data: Array | EdgeDataType>, + duration: number, + settings?: TimelineAnimationSettings + ) { + const edges = data.filter(isEdgeData); + const vertices = data.filter(isVertexData); + this.addStep( + this.graph.insertBatch.bind(this.graph), + { edges, vertices }, + duration, + this.maxEndDelay, + settings + ); + } + + insertSequential( + data: + | Array | EdgeDataType> + | Array | EdgeDataType>>, + duration: number, + settings?: TimelineAnimationSettings + ) { + const minDelay = this.maxEndDelay; + data.forEach((d, i) => { + if (Array.isArray(d)) { + this.addStep( + this.graph.insertBatch.bind(this.graph), + { + edges: d.filter(isEdgeData), + vertices: d.filter(isVertexData) + }, + duration, + minDelay + (i * duration) / data.length, + settings + ); + return; + } + if (isEdgeData(d)) { + this.addStep( + this.graph.insertEdge.bind(this.graph), + d, + duration, + minDelay + (i * duration) / data.length, + settings + ); + } else { + this.addStep( + this.graph.insertVertex.bind(this.graph), + d, + duration, + minDelay + (i * duration) / data.length, + settings + ); + } + }); + return this; + } + + removeSimultaneous( + keys: Array, + duration: number, + settings?: TimelineAnimationSettings + ) { + // TODO - implement + return this; + } + + removeSequential( + keys: Array, + duration: number, + settings?: TimelineAnimationSettings + ) { + // TODO - implement + return this; + } + + async play() { + let currentDelay = 0; + while (!this.steps.isEmpty()) { + const { fn, delay } = this.steps.dequeue()!; + // eslint-disable-next-line no-await-in-loop + await this.sleep(delay - currentDelay); + currentDelay = delay; + fn(); + } + } + + private sleep(ms: number) { + // eslint-disable-next-line no-promise-executor-return + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private addStep( + fn: (data: D, settings?: AnimationSettings) => void, + data: D, + duration: number, + delay: number, + settings?: TimelineAnimationSettings + ) { + this.maxStartDelay = Math.max(this.maxStartDelay, delay); + this.maxEndDelay = Math.max(this.maxEndDelay, delay + duration); + this.steps.enqueue( + { + fn: () => { + fn(data, { + duration, + easing: settings?.easing || EASING.bounce, + onComplete: settings?.onComplete + }); + }, + delay + }, + delay + ); + return this; + } +} diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 00000000..83cb92da --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,3 @@ +export { default as LinkedList } from './lists/LinkedList'; +export { default as MinPriorityQueue } from './queues/MinPriorityQueue'; +export { default as Queue } from './queues/Queue'; diff --git a/src/data/LinkedList.ts b/src/data/lists/LinkedList.ts similarity index 75% rename from src/data/LinkedList.ts rename to src/data/lists/LinkedList.ts index 95d49d04..40439152 100644 --- a/src/data/LinkedList.ts +++ b/src/data/lists/LinkedList.ts @@ -1,19 +1,19 @@ class Node { - public next: Node | null = null; + next: Node | null = null; constructor(public value: T) {} } export default class LinkedList { - public head: Node | null = null; - public tail: Node | null = null; + head: Node | null = null; + tail: Node | null = null; private length$ = 0; - public get length(): number { + get length(): number { return this.length$; } - public append(value: T): void { + append(value: T): void { const node = new Node(value); if (!this.tail) { @@ -27,7 +27,7 @@ export default class LinkedList { this.length$++; } - public prepend(value: T): void { + prepend(value: T): void { const node = new Node(value); if (!this.head) { @@ -41,7 +41,7 @@ export default class LinkedList { this.length$++; } - public popLeft(): T | null { + popLeft(): T | null { if (!this.head) { return null; } diff --git a/src/data/queues/MinPriorityQueue.ts b/src/data/queues/MinPriorityQueue.ts new file mode 100644 index 00000000..a3d8e9e2 --- /dev/null +++ b/src/data/queues/MinPriorityQueue.ts @@ -0,0 +1,7 @@ +import PriorityQueue from './PriorityQueue'; + +export default class MinPriorityQueue extends PriorityQueue { + constructor() { + super((a, b) => a - b); + } +} diff --git a/src/data/queues/PriorityQueue.ts b/src/data/queues/PriorityQueue.ts new file mode 100644 index 00000000..2ab35b9c --- /dev/null +++ b/src/data/queues/PriorityQueue.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +export default class PriorityQueue { + private readonly heap: Array<{ data: T; priority: number }> = []; + + constructor(private readonly comparator: (a: number, b: number) => number) {} + + get length(): number { + return this.heap.length; + } + + isEmpty(): boolean { + return this.length === 0; + } + + getItems(): Array { + const pqCopy = new PriorityQueue(this.comparator); + pqCopy.heap.push(...this.heap); + + const items: Array = []; + while (pqCopy.length > 0) { + items.push(pqCopy.dequeue() as T); + } + + return items; + } + + enqueue(data: T, priority: number) { + this.heap.push({ data, priority }); + this.siftUp(this.heap.length - 1); + } + + enqueueMany(items: Array<{ item: T; priority: number }>) { + items.forEach(({ item, priority }) => this.enqueue(item, priority)); + } + + dequeue(): T | undefined { + if (this.heap.length === 0) return undefined; + + const top = this.heap[0]!.data; + const bottom = this.heap.pop()!; + + if (this.heap.length > 0) { + this.heap[0] = bottom; + this.siftDown(0); + } + + return top; + } + + private siftUp(idx: number) { + while (idx > 0) { + const parentIdx = this.parentIndex(idx); + + if ( + this.comparator( + this.heap[idx]!.priority, + this.heap[parentIdx]!.priority + ) >= 0 + ) { + break; + } + + this.swap(idx, parentIdx); + idx = parentIdx; + } + } + + private siftDown(idx: number) { + // eslint-disable-next-line no-constant-condition + while (true) { + const leftIdx = this.leftChildIndex(idx); + const rightIdx = this.rightChildIndex(idx); + let smallestIdx = idx; + + if (leftIdx < this.heap.length) { + if ( + this.comparator( + this.heap[leftIdx]!.priority, + this.heap[smallestIdx]!.priority + ) < 0 + ) { + smallestIdx = leftIdx; + } + if ( + rightIdx < this.heap.length && + this.comparator( + this.heap[rightIdx]!.priority, + this.heap[smallestIdx]!.priority + ) < 0 + ) { + smallestIdx = rightIdx; + } + } + + if (smallestIdx === idx) break; + + this.swap(idx, smallestIdx); + idx = smallestIdx; + } + } + + private parentIndex(index: number): number { + return Math.floor((index - 1) / 2); + } + + private leftChildIndex(index: number): number { + return index * 2 + 1; + } + + private rightChildIndex(index: number): number { + return index * 2 + 2; + } + + private swap(a: number, b: number) { + [this.heap[a], this.heap[b]] = [this.heap[b]!, this.heap[a]!]; + } +} diff --git a/src/data/Queue.ts b/src/data/queues/Queue.ts similarity index 63% rename from src/data/Queue.ts rename to src/data/queues/Queue.ts index 14671aa8..92b5e93c 100644 --- a/src/data/Queue.ts +++ b/src/data/queues/Queue.ts @@ -1,33 +1,33 @@ -import LinkedList from './LinkedList'; +import { LinkedList } from '@/data'; export default class Queue { protected linkedList: LinkedList = new LinkedList(); - public isEmpty(): boolean { + get length(): number { + return this.linkedList.length; + } + + isEmpty(): boolean { return !this.linkedList.head; } - public enqueue(value: T): void { + enqueue(value: T): void { this.linkedList.append(value); } - public enqueueMany(values: Array): void { + enqueueMany(values: Array): void { values.forEach(value => this.enqueue(value)); } - public dequeue(): T | null { + dequeue(): T | null { return this.linkedList.popLeft(); } - public peek(): T | null { + peek(): T | null { if (!this.linkedList.head) { return null; } return this.linkedList.head.value; } - - public get length(): number { - return this.linkedList.length; - } } diff --git a/src/examples/Development.tsx b/src/examples/Development.tsx index db3dfe2f..fcb83c58 100644 --- a/src/examples/Development.tsx +++ b/src/examples/Development.tsx @@ -1,7 +1,9 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { useEffect } from 'react'; import { SafeAreaView, StyleSheet, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import SequenceAnimation from '@/animations/GraphTimeline'; import DefaultEdgeLabelRenderer from '@/components/graphs/labels/renderers/DefaultEdgeLabelRenderer'; import { DirectedGraph } from '@/models/graphs'; import PannableScalableView from '@/views/PannableScalableView'; @@ -14,57 +16,86 @@ export default function App() { }); useEffect(() => { - graph.insertVertex( - { key: 'B', value: 'B' }, - { - duration: 200, - onComplete: () => { - graph.insertEdge({ key: 'AB', value: 'AB', from: 'A', to: 'B' }); - graph.insertVertex( - { key: 'C', value: 'C' }, - { - duration: 400, - onComplete: () => { - graph.insertEdge({ - key: 'BC', - value: 'BC', - from: 'B', - to: 'C' - }); - graph.insertVertex( - { key: 'D', value: 'D' }, - { - duration: 600, - onComplete: () => { - graph.insertEdge({ - key: 'CD', - value: 'CD', - from: 'C', - to: 'D' - }); - graph.insertVertex( - { key: 'E', value: 'E' }, - { - duration: 800, - onComplete: () => { - graph.insertEdge({ - key: 'DE', - value: 'DE', - from: 'D', - to: 'E' - }); - } - } - ); + const vertices1 = [...'ABCDEFG']; + const vertices2 = [...'HIJKLMNOPQRS']; + + const animation = new SequenceAnimation(graph) + .insertSequential( + vertices1.slice(1).map(key => [ + { key, value: [] }, + { + key: `A${key}0`, + from: 'A', + to: key, + value: [] + } + ]), + 1000 + ) + .insertSequential( + vertices1 + .slice(1) + .map(key => + new Array(6).fill(0).map((_, i) => [ + { + ...(i % 2 + ? { key: `A${key}${i + 1}`, from: 'A', to: key } + : { + key: `${key}A${i + 1}`, + from: key, + to: 'A' + }), + value: [] + } + ]) + ) + .flat(), + 1000 + ) + .insertSequential( + vertices2.map((key, i) => [ + { key, value: [] }, + { + key: `${vertices1[Math.floor((i + 2) / 2)]!}${key}0`, + from: vertices1[Math.floor((i + 2) / 2)]!, + to: key, + value: [] + } + ]), + 1000 + ) + .insertSequential( + vertices2 + .map((key, i) => + new Array(3).fill(0).map((_, j) => [ + { + ...(j % 2 + ? { + key: `${vertices1[Math.floor((i + 2) / 2)]!}${key}${ + j + 1 + }`, + from: vertices1[Math.floor((i + 2) / 2)]!, + to: key } - } - ); + : { + key: `${key}${vertices1[Math.floor((i + 2) / 2)]!}${ + j + 1 + }`, + from: key, + to: vertices1[Math.floor((i + 2) / 2)]! + }), + value: [] } - } - ); - } - } - ); + ]) + ) + .flat(), + 1000 + ); + + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + animation.play(); + }, 1000); }, []); return ( @@ -76,7 +107,7 @@ export default function App() { graph={graph} settings={{ placement: { - strategy: 'circle', + strategy: 'orbits', minVertexSpacing: 100 }, components: { diff --git a/src/models/graphs/DirectedGraph.ts b/src/models/graphs/DirectedGraph.ts index 0a515023..0fd11303 100644 --- a/src/models/graphs/DirectedGraph.ts +++ b/src/models/graphs/DirectedGraph.ts @@ -12,12 +12,12 @@ export default class DirectedGraph extends Graph< DirectedEdge, DirectedEdgeData > { - constructor(data: { + constructor(data?: { vertices: Array>; edges?: Array>; }) { super(); - this.insertBatch(data); + if (data) this.insertBatch(data); } override isDirected(): this is DirectedGraph { diff --git a/src/models/graphs/UndirectedGraph.ts b/src/models/graphs/UndirectedGraph.ts index 08ba63d4..1569f540 100644 --- a/src/models/graphs/UndirectedGraph.ts +++ b/src/models/graphs/UndirectedGraph.ts @@ -12,12 +12,12 @@ export default class UndirectedGraph extends Graph< UndirectedEdge, UndirectedEdgeData > { - constructor(data: { + constructor(data?: { vertices: Array>; edges?: Array>; }) { super(); - this.insertBatch(data); + if (data) this.insertBatch(data); } override isDirected() { diff --git a/src/types/animations.ts b/src/types/animations.ts index 7ab3b299..240447ba 100644 --- a/src/types/animations.ts +++ b/src/types/animations.ts @@ -1,5 +1,8 @@ import { EasingFunctionFactory } from 'react-native-reanimated'; +import { DirectedGraph, UndirectedGraph } from '..'; +import { DirectedEdgeData, UndirectedEdgeData } from './data'; + export type AnimationSettings = { duration?: number; easing?: EasingFunctionFactory; @@ -7,3 +10,14 @@ export type AnimationSettings = { }; export type AnimationSettingWithDefaults = Required; + +export type TimelineAnimationSettings = { + easing: EasingFunctionFactory; + onComplete?: () => void; +}; + +export type EdgeDataType = G extends DirectedGraph + ? DirectedEdgeData + : G extends UndirectedGraph + ? UndirectedEdgeData + : never; diff --git a/src/types/data/index.ts b/src/types/data/index.ts index 1f360aea..83f524bc 100644 --- a/src/types/data/index.ts +++ b/src/types/data/index.ts @@ -15,3 +15,5 @@ export type UndirectedEdgeData = { vertices: string[]; value: E; }; + +export type EdgeData = DirectedEdgeData | UndirectedEdgeData; diff --git a/src/utils/algorithms/graphs.ts b/src/utils/algorithms/graphs.ts index 110508f9..b3c5f8fa 100644 --- a/src/utils/algorithms/graphs.ts +++ b/src/utils/algorithms/graphs.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import Queue from '@/data/Queue'; +import { Queue } from '@/data'; import { Vertex } from '@/types/graphs'; export const bfs = ( diff --git a/src/utils/models.ts b/src/utils/models.ts new file mode 100644 index 00000000..3fda8ecf --- /dev/null +++ b/src/utils/models.ts @@ -0,0 +1,21 @@ +import { + DirectedEdgeData, + EdgeData, + UndirectedEdgeData, + VertexData +} from '@/types/data'; + +export const isVertexData = (data: any): data is VertexData => + Object.hasOwn(data, 'key') && + Object.hasOwn(data, 'value') && + !isEdgeData(data); + +export const isDirectedEdgeData = (data: any): data is DirectedEdgeData => + Object.hasOwn(data, 'from'); + +export const isUndirectedEdgeData = ( + data: any +): data is UndirectedEdgeData => Object.hasOwn(data, 'vertices'); + +export const isEdgeData = (data: any): data is EdgeData => + isDirectedEdgeData(data) || isUndirectedEdgeData(data);