@redocly/theme
Version:
Shared UI components lib
237 lines (202 loc) • 7.25 kB
text/typescript
import { useCallback, useEffect, useMemo } from 'react';
import {
addEdge,
type Node,
type Edge,
type Connection,
useNodesState,
useEdgesState,
Position,
OnNodesChange,
OnEdgesChange,
} from '@xyflow/react';
import { type CatalogEntityNodeData } from '@redocly/theme/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsNode';
import { BffCatalogEntity, BffCatalogRelatedEntity } from '../../types';
import {
GraphCustomEdgeType,
GraphCustomNodeType,
GraphHandleType,
reverseRelationMap,
} from '../../constants/catalog';
export type UseGraphProps = {
entity: BffCatalogEntity;
relations: BffCatalogRelatedEntity[];
};
export type UseGraphReturn = {
nodes: Node<CatalogEntityNodeData>[];
edges: Edge[];
onNodesChange: OnNodesChange<Node<CatalogEntityNodeData>>;
onEdgesChange: OnEdgesChange<Edge>;
onConnect: (params: Connection) => void;
};
type EntityGraphData = {
id: string;
title: string;
entityType: string;
relationLabel: string;
key: string;
};
// Note: This isn't final implementation, leaved comments for future reference.
export function useGraph({ entity, relations }: UseGraphProps): UseGraphReturn {
const rootNodeId = entity.id;
// Compute final label for a relation considering its role
const getRelationLabel = useCallback((relation: BffCatalogRelatedEntity): string => {
const relationType = relation.relationType;
if (!relationType) {
return 'related';
}
return relation.relationRole === 'source' ? reverseRelationMap[relationType] : relationType;
}, []);
const processedRelations = useMemo(() => {
// Exclude self-relations and deduplicate by id
const seenIds = new Set<string>();
const filtered = (relations ?? []).filter((r) => r.id !== rootNodeId && r.key !== entity.key);
const unique = [] as Array<{
id: string;
title: string;
entityType: string;
relationLabel: string;
key: string;
}>;
for (const r of filtered) {
if (seenIds.has(r.id)) continue;
seenIds.add(r.id);
unique.push({
id: r.id,
title: r.title,
entityType: r.type, // Group by entity type, not relation type
relationLabel: getRelationLabel(r),
key: r.key,
});
}
return unique;
}, [relations, getRelationLabel, rootNodeId, entity.key]);
// Entity data type for layout
const computedNodes = useMemo<Node<CatalogEntityNodeData>[]>(() => {
if (!processedRelations.length) {
return [
{
id: rootNodeId,
type: GraphCustomNodeType.CatalogEntity,
position: { x: 0, y: 0 },
data: {
label: entity.title,
entityType: entity.type,
isRoot: true,
entityKey: entity.key,
},
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
},
];
}
// Group entities by their entity type
const entityTypeGroups = new Map<string, EntityGraphData[]>();
for (const rel of processedRelations) {
const entityData: EntityGraphData = {
id: rel.id,
title: rel.title,
entityType: rel.entityType,
relationLabel: rel.relationLabel,
key: rel.key,
};
const current = entityTypeGroups.get(rel.entityType);
if (current) {
current.push(entityData);
} else {
entityTypeGroups.set(rel.entityType, [entityData]);
}
}
// Sort entity types for consistent ordering
const entityTypes = Array.from(entityTypeGroups.keys()).sort();
// Layout constants
const rootY = 0;
const verticalGap = 80; // Gap between entities of same type (vertical)
const horizontalGap = 250; // Gap between different entity types (horizontal)
const topMargin = 240; // Distance from root to first row of entities
// Special handling for single entity type group - root on left, entities on right
const isSingleGroup = entityTypes.length === 1;
let rootX = 0;
let startX = 0;
if (isSingleGroup) {
// Position root on the left, entities on the right
rootX = -horizontalGap / 2;
startX = horizontalGap / 2;
} else {
// Calculate starting X position to center all groups (original behavior)
const totalWidth = (entityTypes.length - 1) * horizontalGap;
startX = -totalWidth / 2;
}
const nodes: Node<CatalogEntityNodeData>[] = [
// Root entity
{
id: rootNodeId,
type: GraphCustomNodeType.CatalogEntity,
position: { x: rootX, y: rootY },
data: { label: entity.title, entityType: entity.type, isRoot: true, entityKey: entity.key },
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
},
];
// Position entities by type groups
for (let typeIndex = 0; typeIndex < entityTypes.length; typeIndex++) {
const entityType = entityTypes[typeIndex];
const entitiesOfType = entityTypeGroups.get(entityType) ?? [];
// Calculate X position for this entity type group
const groupX = startX + typeIndex * horizontalGap;
// Calculate starting Y position to center entities vertically within the group
const groupHeight = (entitiesOfType.length - 1) * verticalGap;
const groupStartY = rootY + topMargin - groupHeight / 2;
// Position each entity within the group
for (let entityIndex = 0; entityIndex < entitiesOfType.length; entityIndex++) {
const entityData = entitiesOfType[entityIndex];
const entityY = groupStartY + entityIndex * verticalGap;
nodes.push({
id: entityData.id,
type: GraphCustomNodeType.CatalogEntity,
position: { x: groupX, y: entityY },
data: {
label: entityData.title,
entityType: entityData.entityType,
isRoot: false,
entityKey: entityData.key,
},
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
});
}
}
return nodes;
}, [rootNodeId, entity.title, entity.type, entity.key, processedRelations]);
const computedEdges = useMemo<Edge[]>(() => {
return processedRelations.map((relation) => ({
id: `e-${rootNodeId}-${relation.id}`,
source: rootNodeId,
target: relation.id,
sourceHandle: GraphHandleType.Source, // Use the bottom handle of the center node
targetHandle: GraphHandleType.Target, // Use the target handle (top) of related nodes
type: GraphCustomEdgeType.CatalogEdge,
label: relation.relationLabel,
}));
}, [rootNodeId, processedRelations]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<CatalogEntityNodeData>>(computedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>(computedEdges);
useEffect(() => {
setNodes(computedNodes);
}, [computedNodes, setNodes]);
useEffect(() => {
setEdges(computedEdges);
}, [computedEdges, setEdges]);
const onConnect = useCallback(
(params: Connection) => setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)),
[setEdges],
);
return {
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
};
}