Further adaptations to transformations to align almost identical with how renderings used to be

Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
Gustaf Räntilä
2025-09-06 23:00:26 +02:00
parent b9a87f4593
commit 004557156b
8 changed files with 217 additions and 116 deletions
@@ -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<EntityEdge>;
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(() => {
@@ -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<unknown>) => 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,
],
);
@@ -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;
@@ -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<string>([...a, ...b]));
function concatRelations(
a: readonly string[] | undefined,
b: readonly string[] | undefined,
): string[] {
return Array.from(new Set<string>([...(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<string, EntityEdge>();
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());
};
@@ -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<string, EntityEdge[]>();
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<string>();
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;
});
};
@@ -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<string, EntityEdge[]>();
@@ -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;
});
};
@@ -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<string, EntityEdge[]>();
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<string>(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),
);
}
};
@@ -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<string, number>;
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;
}