diff --git a/dashboard/components/explorer/DependencyGraph.tsx b/dashboard/components/explorer/DependencyGraph.tsx index ccf97c6de..a9f421ed2 100644 --- a/dashboard/components/explorer/DependencyGraph.tsx +++ b/dashboard/components/explorer/DependencyGraph.tsx @@ -1,24 +1,16 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import React, { useState, memo, useEffect } from 'react'; +import React, { useState, memo } from 'react'; import CytoscapeComponent from 'react-cytoscapejs'; -import Cytoscape, { EventObject } from 'cytoscape'; +import Cytoscape from 'cytoscape'; import popper from 'cytoscape-popper'; - -import nodeHtmlLabel, { - CytoscapeNodeHtmlParams - // @ts-ignore -} from 'cytoscape-node-html-label'; - +// @ts-ignore +import nodeHtmlLabel from 'cytoscape-node-html-label'; // @ts-ignore import COSEBilkent from 'cytoscape-cose-bilkent'; - import EmptyState from '@components/empty-state/EmptyState'; - import Tooltip from '@components/tooltip/Tooltip'; import WarningIcon from '@components/icons/WarningIcon'; import { ReactFlowData } from './hooks/useDependencyGraph'; import { - edgeAnimationConfig, edgeStyleConfig, graphLayoutConfig, leafStyleConfig, @@ -28,6 +20,7 @@ import { nodeStyeConfig, zoomLevelBreakpoint } from './config'; +import { animateEdges } from './animateEdge'; export type DependencyGraphProps = { data: ReactFlowData; @@ -40,17 +33,6 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { const dataIsEmpty: boolean = data.nodes.length === 0; - // Type technically is Cytoscape.EdgeCollection but that throws an unexpected error - const loopAnimation = (eles: any) => { - const ani = eles.animation(edgeAnimationConfig[0], edgeAnimationConfig[1]); - - ani - .reverse() - .play() - .promise('complete') - .then(() => loopAnimation(eles)); - }; - const cyActionHandlers = (cy: Cytoscape.Core) => { // make sure we did not init already, otherwise this will be bound more than once if (!initDone) { @@ -79,8 +61,6 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { cy.nodes().leaves().addClass('leaf'); // same for root notes cy.nodes().roots().addClass('root'); - // Animate edges - cy.edges().forEach(loopAnimation); // Add hover tooltip on edges cy.edges().bind('mouseover', event => { @@ -107,50 +87,37 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { }); // Hide labels when being zoomed out - cy.on('zoom', event => { + cy.on('zoom', () => { const opacity = cy.zoom() <= zoomLevelBreakpoint ? 0 : 1; Array.from( - document.querySelectorAll('.dependency-graph-node-label'), + document.querySelectorAll( + '.dependency-graph-node-label' + ), e => { - // @ts-ignore - e.style.opacity = opacity; + e.style.opacity = opacity.toString(); return e; } ); }); // Make sure to tell we inited successfully and prevent another init setInitDone(true); + } else { + // Because the animation requires the DOM, we need to wait until the DOM is ready + animateEdges( + { + direction: 'alternate', + mode: 'speed', + modeValue: 0.2, + randomOffset: true + }, + cy + ); } }; return (
- cyActionHandlers(cy)} - /> {dataIsEmpty ? ( <>
diff --git a/dashboard/components/explorer/animateEdge.ts b/dashboard/components/explorer/animateEdge.ts new file mode 100644 index 000000000..b08352eb0 --- /dev/null +++ b/dashboard/components/explorer/animateEdge.ts @@ -0,0 +1,161 @@ +import { Core, EdgeSingular, NodeSingular } from 'cytoscape'; +import { IPoint } from 'cytoscape-layers'; +import { edgeAnimationConfig, edgeStyleConfig } from './config'; + +// maybe this can be needed in the future +export function dashAnimation( + edge: EdgeSingular, + duration: number, + offset: number +) { + return edge + .animation({ + style: { + 'line-dash-offset': offset, + 'line-dash-pattern': edgeAnimationConfig.style['line-dash-pattern'] + }, + duration, + position: edge.sourceEndpoint(), + renderedPosition: edge.sourceEndpoint(), + easing: edgeAnimationConfig.easing as 'linear' + }) + .play() + .promise('complete'); +} + +type Options = { + direction: 'alternate' | 'forward' | 'backward'; + mode: 'speed' | 'duration'; + modeValue: number; + randomOffset: boolean; +}; + +function getOrSet( + elem: NodeSingular | EdgeSingular, + key: string, + value: () => T +): T { + const v = elem.scratch(key); + if (v != null) { + return v; + } + const vSet = value(); + elem.scratch(key, vSet); + return vSet; +} + +function dist(start: IPoint, end: IPoint) { + return Math.sqrt((start.x - end.x) ** 2 + (start.y - end.y) ** 2); +} + +export const animateEdges = async (options: Options, cy: Core) => { + let cyLayers; + + function computeFactor( + elapsed: number, + offset: number, + start: IPoint, + end: IPoint + ) { + const distance = dist(start, end); + let duration = options.modeValue; + + if (options.mode !== 'duration' && options.modeValue !== 0) { + duration = distance / options.modeValue; + } + + if ( + !Number.isFinite(duration) || + Number.isNaN(duration) || + duration === 0 + ) { + return 0; + } + + let f = elapsed / duration; + + if (options.direction === 'alternate') { + f = f / 2 + offset; + const v = 2 * (f - Math.floor(f) - 0.5); + return Math.abs(v); + } + + f += offset; + const v = f - Math.floor(f); + return options.direction === 'forward' ? v : 1 - v; + } + + // let animationId: number | null = null; + + if ( + typeof window !== 'undefined' && + window.document.URL.includes('explorer') + ) { + // Modified but mainly taken from: https://github.com/sgratzl/cytoscape.js-layers/blob/main/samples/animatedEdges.ts + const cytoLayersModule = await import('cytoscape-layers'); + cyLayers = cytoLayersModule.layers(cy); + const animationLayer = cyLayers.nodeLayer.insertBefore('canvas'); + + let start: number | null = null; + + let elapsed = 0; + const update = (time: number) => { + if (start == null) { + start = time; + } + elapsed = time - start; + animationLayer.update(); + requestAnimationFrame(update); + }; + cyLayers.renderPerEdge( + animationLayer, + ( + ctx: CanvasRenderingContext2D, + edge: EdgeSingular, + path: Path2D, + startPoint: IPoint, + end: IPoint + ) => { + const offset = options.randomOffset + ? getOrSet(edge, '_animOffset', () => Math.random()) + : 0; + const g = ctx.createLinearGradient( + startPoint.x, + startPoint.y, + end.x, + end.y + ); + + const v = computeFactor(elapsed, offset, startPoint, end); + + const colorStop1 = edgeStyleConfig['line-gradient-stop-colors'] + ? edgeStyleConfig['line-gradient-stop-colors'][0] + : '#008484'; + const colorStop2 = edgeStyleConfig['line-gradient-stop-colors'] + ? edgeStyleConfig['line-gradient-stop-colors'][1] + : '#33CCCC'; + + if (typeof colorStop1 === 'string' && typeof colorStop2 === 'string') { + g.addColorStop(Math.max(v - 0.1, 0), colorStop1); + g.addColorStop(v, 'white'); + g.addColorStop(Math.min(v + 0.1, 1), colorStop2); + } + ctx.strokeStyle = g; + ctx.lineWidth = 3; + ctx.stroke(path); + }, + { + checkBounds: true, + checkBoundsPointCount: 5 + } + ); + return requestAnimationFrame(update); + } + return { + stop: (animationId: number) => { + if (animationId !== null) { + cancelAnimationFrame(animationId); + } + } + }; +}; diff --git a/dashboard/components/explorer/config.ts b/dashboard/components/explorer/config.ts index df3dd13ec..d69508082 100644 --- a/dashboard/components/explorer/config.ts +++ b/dashboard/components/explorer/config.ts @@ -94,19 +94,14 @@ export const leafStyleConfig = { opacity: 1 } as Cytoscape.Css.Node; -export const edgeAnimationConfig = [ - { - zoom: { level: 1 }, - easing: 'linear', - style: { - 'line-dash-offset': 24, - 'line-dash-pattern': [4, 4] - } +export const edgeAnimationConfig = { + easing: 'linear', + style: { + 'line-dash-offset': 24, + 'line-dash-pattern': [4, 4] }, - { - duration: 4000 - } -]; + duration: 4000 +}; export const nodeHTMLLabelConfig = { query: 'node', // cytoscape query selector diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 5feb2e605..b208207e2 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -16,6 +16,7 @@ "classnames": "^2.3.2", "cytoscape": "^3.26.0", "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-layers": "^2.4.2", "cytoscape-node-html-label": "^1.2.2", "cytoscape-popper": "^2.0.0", "html-to-image": "^1.11.11", @@ -9229,8 +9230,7 @@ "node_modules/@types/cytoscape": { "version": "3.19.11", "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.19.11.tgz", - "integrity": "sha512-ny4i4BOoZxdc9DrSa9RrasXHPRFgt0PeINgj/CegzKu7CJO+UQP0KnjebYJ+KoLymyUbCX86vmqz5B3LK10w5Q==", - "devOptional": true + "integrity": "sha512-ny4i4BOoZxdc9DrSa9RrasXHPRFgt0PeINgj/CegzKu7CJO+UQP0KnjebYJ+KoLymyUbCX86vmqz5B3LK10w5Q==" }, "node_modules/@types/cytoscape-popper": { "version": "2.0.2", @@ -12592,6 +12592,17 @@ "cytoscape": "^3.2.0" } }, + "node_modules/cytoscape-layers": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/cytoscape-layers/-/cytoscape-layers-2.4.2.tgz", + "integrity": "sha512-laJelF434HqG92yIIqSIO7hLII+P9AxSlZJGrc+5LnGI0k2p/JIRi2nhSkjcinWnsInELhXe4WGOHzUgbtCl7w==", + "dependencies": { + "@types/cytoscape": "^3.19.10" + }, + "peerDependencies": { + "cytoscape": "^3.23.0" + } + }, "node_modules/cytoscape-node-html-label": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cytoscape-node-html-label/-/cytoscape-node-html-label-1.2.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 92f3cb6bb..7a8e56c8d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -23,6 +23,7 @@ "classnames": "^2.3.2", "cytoscape": "^3.26.0", "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-layers": "^2.4.2", "cytoscape-node-html-label": "^1.2.2", "cytoscape-popper": "^2.0.0", "html-to-image": "^1.11.11",