diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx index f77ca4b37d..47ce365cb6 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx @@ -33,6 +33,7 @@ import { DefaultRenderNode } from './DefaultRenderNode'; import { RelationPairs } from '../../lib/types'; import { Direction, EntityEdge, EntityNode } from '../../lib/types'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; +import { GraphTransformationDebugger } from '../../lib/graph-transformations'; /** @public */ export type EntityRelationsGraphClassKey = 'progress' | 'container' | 'graph'; @@ -91,6 +92,7 @@ export type EntityRelationsGraphProps = { renderLabel?: DependencyGraphTypes.RenderLabelFunction; curve?: 'curveStepBefore' | 'curveMonotoneX'; showArrowHeads?: boolean; + onPostTransformation?: GraphTransformationDebugger; }; /** @@ -116,6 +118,7 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { renderLabel, curve, showArrowHeads, + onPostTransformation, } = props; const theme = useTheme(); @@ -139,6 +142,7 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { entityFilter, onNodeClick, relationPairs, + onPostTransformation, }); useEffect(() => { diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts index d65100567d..2aa301fef9 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { MouseEvent, useMemo, useState } from 'react'; +import { MouseEvent, useState } from 'react'; import useDebounce from 'react-use/esm/useDebounce'; import { RelationPairs } from '../../lib/types'; import { EntityEdge, EntityNode } from '../../lib/types'; @@ -22,8 +22,12 @@ import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { useRelations } from '../../hooks/useRelations'; import { buildGraph } from '../../lib/graph'; import { - builtInTransformers, - TransformerContext, + BuiltInTransformations, + builtInTransformations, + cloneTransformationContext, + GraphTransformationDebugger, + GraphTransformer, + TransformationContext, } from '../../lib/graph-transformations'; /** @@ -39,6 +43,7 @@ export function useEntityRelationNodesAndEdges({ entityFilter, onNodeClick, relationPairs: incomingRelationPairs, + onPostTransformation, }: { rootEntityRefs: string[]; maxDepth?: number; @@ -49,6 +54,7 @@ export function useEntityRelationNodesAndEdges({ entityFilter?: (entity: Entity) => boolean; onNodeClick?: (value: EntityNode, event: MouseEvent) => void; relationPairs?: RelationPairs; + onPostTransformation?: GraphTransformationDebugger; }): { loading: boolean; nodes?: EntityNode[]; @@ -74,11 +80,6 @@ export function useEntityRelationNodesAndEdges({ relationPairs: incomingRelationPairs, }); - const forwardRelations = useMemo( - () => relationPairs.map(pair => pair[0]), - [relationPairs], - ); - useDebounce( () => { if (!entities || Object.keys(entities).length === 0) { @@ -118,32 +119,62 @@ export function useEntityRelationNodesAndEdges({ unidirectional, }); - const transformerContext: TransformerContext = { + const transformationContext: TransformationContext = { nodeDistances: new Map(), edges, + nodes, rootEntityRefs, unidirectional, maxDepth, - forwardRelations, }; - builtInTransformers['reduce-edges'](transformerContext); - builtInTransformers['set-distances'](transformerContext); + + const debugTransformation = ( + transformation: BuiltInTransformations | GraphTransformer | undefined, + ) => { + onPostTransformation?.( + typeof transformation === 'function' + ? transformation.name + : transformation, + transformationContext, + cloneTransformationContext(transformationContext), + ); + }; + + debugTransformation(undefined); + + const runTransformation = ( + transformation: BuiltInTransformations | GraphTransformer, + ) => { + if (typeof transformation === 'function') { + transformation(transformationContext); + } else { + builtInTransformations[transformation](transformationContext); + } + + debugTransformation(transformation); + }; + + runTransformation('reduce-edges'); + runTransformation('set-distances'); if (unidirectional) { - builtInTransformers['order-forward'](transformerContext); - builtInTransformers['strip-distant-edges'](transformerContext); + runTransformation('strip-distant-edges'); } if (mergeRelations || unidirectional) { // Merge relations even if only unidirectional, the next transformer // 'remove-backward-edges' needs to know about all relations before it // strips away the backward ones - builtInTransformers['merge-relations'](transformerContext); + runTransformation('merge-relations'); } if (unidirectional && !mergeRelations) { - builtInTransformers['remove-backward-edges'](transformerContext); + runTransformation('order-forward'); + runTransformation('remove-backward-edges'); } - setNodesAndEdges({ nodes, edges: transformerContext.edges }); + setNodesAndEdges({ + nodes: transformationContext.nodes, + edges: transformationContext.edges, + }); }, 100, [ @@ -156,7 +187,6 @@ export function useEntityRelationNodesAndEdges({ mergeRelations, onNodeClick, relationPairs, - forwardRelations, ], ); diff --git a/plugins/catalog-graph/src/lib/graph-transformations/index.ts b/plugins/catalog-graph/src/lib/graph-transformations/index.ts index 948596ab3d..3ed4cdba21 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/index.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/index.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -export type { GraphTransformer, TransformerContext } from './types'; +export type { + GraphTransformer, + TransformationContext, + GraphTransformationDebugger, +} from './types'; +export { cloneTransformationContext } from './types'; import { reduceEdges } from './reduce-edges'; import { setDistances } from './set-distance'; @@ -23,7 +28,7 @@ import { stripDistantEdges } from './strip-distant-edges'; import { mergeRelations } from './merge-relations'; import { removeBackwardEdges } from './remove-backward-edges'; -export const builtInTransformers = { +export const builtInTransformations = { 'reduce-edges': reduceEdges, 'set-distances': setDistances, 'order-forward': orderForward, @@ -31,3 +36,5 @@ export const builtInTransformers = { 'merge-relations': mergeRelations, 'remove-backward-edges': removeBackwardEdges, }; + +export type BuiltInTransformations = keyof typeof builtInTransformations; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts index aed9d0dd85..88d4bbcd10 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts @@ -17,14 +17,17 @@ import { EntityEdge } from '../types'; import { GraphTransformer } from './types'; -function concatRelations(a: readonly string[], b: readonly string[]): string[] { - return Array.from(new Set([...a, ...b])); +function concatRelations( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): string[] { + return Array.from(new Set([...(a ?? []), ...(b ?? [])])); } /** Merge relations in multiple edges into one edge */ export const mergeRelations: GraphTransformer = options => { - // The key is on the form `from-to` const mergedEdges = new Map(); + options.edges.forEach(edge => { const keyForward = `${edge.from}-${edge.to}`; const keyBackward = `${edge.to}-${edge.from}`; @@ -39,12 +42,13 @@ export const mergeRelations: GraphTransformer = options => { ); } else if (edgeBackward) { edgeBackward.relations = concatRelations( - edge.relations, edgeBackward.relations, + edge.relations, ); } else { mergedEdges.set(keyForward, edge); } }); + options.edges = Array.from(mergedEdges.values()); }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts index d832a1411d..964c55e352 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts @@ -14,20 +14,71 @@ * limitations under the License. */ +import { EntityEdge } from '../types'; import { GraphTransformer } from './types'; /** Orders the edges direction so that the graph goes strictly forward */ -export const orderForward: GraphTransformer = ({ nodeDistances, edges }) => { +export const orderForward: GraphTransformer = ({ + rootEntityRefs, + nodes, + edges, +}) => { + const entityEdges = new Map(); edges.forEach(edge => { - const fromDistance = nodeDistances.get(edge.from) ?? 0; - const toDistance = nodeDistances.get(edge.to) ?? 0; - - if (toDistance < fromDistance) { - // Reverse order - const { from, to } = edge; - edge.from = to; - edge.to = from; - edge.relations.reverse(); + let fromEdges = entityEdges.get(edge.from); + if (!fromEdges) { + fromEdges = []; + entityEdges.set(edge.from, fromEdges); } + fromEdges.push(edge); + + let toEdges = entityEdges.get(edge.to); + if (!toEdges) { + toEdges = []; + entityEdges.set(edge.to, toEdges); + } + toEdges.push(edge); + }); + + const visitedNodes = new Set(); + + for (const rootEntityRef of rootEntityRefs) { + let currentNodes: string[] = [rootEntityRef].filter( + node => !visitedNodes.has(node), + ); + + while (currentNodes.length > 0) { + for (const currentNode of currentNodes) { + visitedNodes.add(currentNode); + } + + const nextNodes: string[] = []; + + currentNodes.forEach(node => { + entityEdges.get(node)?.forEach(edge => { + if (edge.to === node && !visitedNodes.has(edge.from)) { + // Reverse direction + const { from, to } = edge; + edge.from = to; + edge.to = from; + edge.relations.reverse(); + } + + nextNodes.push(edge.from, edge.to); + }); + }); + + currentNodes = Array.from(new Set(nextNodes)).filter( + node => !visitedNodes.has(node), + ); + } + } + + const nodeOrder = Array.from(visitedNodes); + + nodes.sort((a, b) => { + const aOrder = nodeOrder.findIndex(node => node === a.id); + const bOrder = nodeOrder.findIndex(node => node === b.id); + return aOrder - bOrder; }); }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts index 1be4c0db01..2c6f634ba9 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts @@ -22,7 +22,7 @@ import { GraphTransformer } from './types'; * merged the relations */ export const removeBackwardEdges: GraphTransformer = ctx => { - const { forwardRelations, edges } = ctx; + const { edges } = ctx; const edgesMapFrom = new Map(); @@ -32,22 +32,9 @@ export const removeBackwardEdges: GraphTransformer = ctx => { edgesMapFrom.set(edge.from, theseEdges); }); - ctx.edges = edges.filter(edge => { - if (edge.relations.length === 2) { - // If there are two relations, only keep the forward one - if (!forwardRelations.includes(edge.relations[1])) { - edge.relations.pop(); - } - if (!forwardRelations.includes(edge.relations[0])) { - edge.relations.pop(); - } - return edge.relations.length > 0; - } else if (edge.relations.length === 1) { - // If there is only one relation and it's backwards, remove it - if (!forwardRelations.includes(edge.relations[0])) { - return false; - } + edges.forEach(edge => { + if (edge.relations.length > 1) { + edge.relations = edge.relations.slice(0, 1); } - return true; }); }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts index 965f01fc14..5986512305 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts @@ -17,12 +17,6 @@ import { EntityEdge } from '../types'; import { GraphTransformer } from './types'; -function minDistance(a: number | undefined, b: number | undefined) { - if (typeof a === 'undefined') return b; - if (typeof b === 'undefined') return a; - return Math.min(a, b); -} - /** Set the distance of each edge to the distance from a root entity */ export const setDistances: GraphTransformer = ({ nodeDistances, @@ -32,67 +26,45 @@ export const setDistances: GraphTransformer = ({ const edgeMap = new Map(); edges.forEach(edge => { const fromEdges = edgeMap.get(edge.from) ?? []; - edgeMap.set(edge.from, [...fromEdges, edge]); + fromEdges.push(edge); + edgeMap.set(edge.from, fromEdges); const toEdges = edgeMap.get(edge.to) ?? []; - edgeMap.set(edge.to, [...toEdges, edge]); + toEdges.push(edge); + edgeMap.set(edge.to, toEdges); }); - // Sets the distance on as many edges as possible. - // Returns true if all edges have a distance - const setEdgeDistances = () => { - return ( - edges.filter(edge => { - if ( - rootEntityRefs.includes(edge.from) || - rootEntityRefs.includes(edge.to) - ) { - edge.distance = 1; - return false; - } - - const distance = minDistance( - edgeMap - .get(edge.from)! - .reduce( - (prev, cur) => minDistance(prev, cur.distance), - undefined as number | undefined, - ), - edgeMap - .get(edge.to)! - .reduce( - (prev, cur) => minDistance(prev, cur.distance), - undefined as number | undefined, - ), - ); - - if (typeof distance !== 'undefined') { - edge.distance = minDistance(edge.distance, distance + 1); - } - return typeof edge.distance === 'undefined'; - }).length === 0 - ); - }; - - let distanceComplete = false; - while (!distanceComplete) { - distanceComplete = setEdgeDistances(); - } - rootEntityRefs.forEach(rootEntityRef => { nodeDistances.set(rootEntityRef, 0); }); - edges.forEach(edge => { - const curFrom = nodeDistances.get(edge.from); - const curTo = nodeDistances.get(edge.to); + const visitedNodes = new Set(rootEntityRefs); + let currentNodes = rootEntityRefs; + let distance = 0; - const fromDistance = minDistance(curFrom, edge.distance); - const toDistance = minDistance(curTo, edge.distance); - if (typeof fromDistance !== 'undefined') { - nodeDistances.set(edge.from, fromDistance); + while (currentNodes.length > 0) { + const thisDistance = ++distance; + + const nextNodes: string[] = []; + currentNodes.forEach(node => { + edgeMap.get(node)?.forEach(edge => { + edge.distance ??= thisDistance; + + const fromDistance = nodeDistances.get(edge.from) ?? thisDistance; + nodeDistances.set(edge.from, fromDistance); + nextNodes.push(edge.from); + + const toDistance = nodeDistances.get(edge.to) ?? thisDistance; + nodeDistances.set(edge.to, toDistance); + nextNodes.push(edge.to); + }); + }); + + for (const currentNode of currentNodes) { + visitedNodes.add(currentNode); } - if (typeof toDistance !== 'undefined') { - nodeDistances.set(edge.to, toDistance); - } - }); + + currentNodes = Array.from(new Set(nextNodes)).filter( + node => !visitedNodes.has(node), + ); + } }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/types.ts b/plugins/catalog-graph/src/lib/graph-transformations/types.ts index 6d8fc92392..9e498ba027 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/types.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/types.ts @@ -14,23 +14,69 @@ * limitations under the License. */ -import { EntityEdge } from '../types'; +import { EntityEdge, EntityNode } from '../types'; -export interface TransformerContext { +/** + * Contextual information for a graph transformation. + * + * @public + */ +export interface TransformationContext { /** * The distance from an entity node to a root entity * - * NOTE: This is not set until the setDistances transformation is applied + * NOTE: This is empty until the 'set-distances' transformation is applied */ nodeDistances: Map; edges: EntityEdge[]; + nodes: EntityNode[]; // Options: rootEntityRefs: string[]; unidirectional: boolean; maxDepth: number; - forwardRelations: string[]; } -export type GraphTransformer = (options: TransformerContext) => void; +/** + * A function that transforms a graph. The function modifies `nodes` and `edges` + * in place. + * + * @public + */ +export type GraphTransformer = (context: TransformationContext) => void; + +/** + * A function that debugs a graph transformation. + * It is given the three arguments: + * * The transformation that was just applied (or undefined before the first + * transformation). If the transformation is a function, its `.name` property + * will be used. + * * The current state (context) of the graph, which _can_ be mutated + * * A cloned copy of the context, useful for logging (as transformations are + * made in place, the original context is modified and the logs will be + * confusing) + * + * @public + */ +export type GraphTransformationDebugger = ( + transformation: string | undefined, + transformationContext: TransformationContext, + clonedContext: TransformationContext, +) => void; + +/** @internal */ +export function cloneTransformationContext( + transformationContext: TransformationContext, +): TransformationContext { + const clonesContext = JSON.parse( + JSON.stringify(transformationContext), + ) as TransformationContext; + clonesContext.edges = clonesContext.edges.sort((a, b) => { + return a.from.localeCompare(b.from) || a.to.localeCompare(b.to); + }); + + clonesContext.nodeDistances = new Map(transformationContext.nodeDistances); + + return clonesContext; +}