Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@overture-stack/lectern-dictionary": "workspace:*",
"@overture-stack/lectern-validation": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"d3-dag": "^1.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-loading-skeleton": "^3.5.0",
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/theme/emotion/schemaNodeStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isHighlighte
justify-content: space-between;
transition: background-color 0.2s;
position: relative;
background-color: ${isHighlighted ? theme.colors.secondary_1 : 'transparent'};
border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : 'transparent'};
${isForeignKey ? 'cursor: pointer;' : ''}

Expand All @@ -43,8 +44,8 @@ export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isHighlighte
}

&:nth-child(even) {
background-color: ${theme.colors.accent_1};
border-block: 1.5px solid ${theme.colors.accent_2};
background-color: ${isHighlighted ? theme.colors.secondary_1 : theme.colors.accent_1};
border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : theme.colors.accent_2};
Comment on lines +47 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

}

&:nth-child(even):hover {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,7 @@ import ReactFlow, {
} from 'reactflow';
import 'reactflow/dist/style.css';
import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker';
import {
getEdgesFromMap,
getEdgesWithHighlight,
getNodesForDictionary,
type RelationshipEdgeData,
type SchemaNodeLayout,
} from './diagramUtils';
import { getEdgesFromMap, getEdgesWithHighlight, getLayoutedDiagram, type RelationshipEdgeData } from './diagramUtils';
import { useActiveRelationship } from './ActiveRelationshipContext';
import { SchemaNode } from './SchemaNode';

Expand All @@ -51,7 +45,6 @@ const nodeTypes: NodeTypes = {

type EntityRelationshipDiagramProps = {
dictionary: Dictionary;
layout?: Partial<SchemaNodeLayout>;
};

const edgeHoverStyles = (theme: Theme) => css`
Expand Down Expand Up @@ -85,17 +78,14 @@ const edgeHoverStyles = (theme: Theme) => css`
/**
* Entity Relationship Diagram visualizing schemas and their foreign key relationships.
* Must be rendered inside an `ActiveRelationshipProvider`.
*
* @param {Dictionary} dictionary — The Lectern dictionary whose schemas and relationships to visualize
* @param {Partial<SchemaNodeLayout>} layout — Optional overrides for the grid layout of schema nodes.
* maxColumns controls the number of nodes per row before wrapping (default 4),
* columnWidth sets horizontal spacing in pixels between column left edges (default 500),
* and rowHeight sets vertical spacing in pixels between row top edges (default 500)
* Uses d3-dag's Sugiyama algorithm to compute a hierarchical layout from FK relationships.
*/
export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityRelationshipDiagramProps) {
const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout));
export function EntityRelationshipDiagramContent({ dictionary }: EntityRelationshipDiagramProps) {
const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship();
const [edges, , onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap));
const allEdges = getEdgesFromMap(relationshipMap);
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedDiagram(dictionary, allEdges);
const [nodes, , onNodesChange] = useNodesState(layoutedNodes);
const [edges, , onEdgesChange] = useEdgesState(layoutedEdges);
const theme = useThemeContext();

const highlightedEdges = useMemo(
Expand Down Expand Up @@ -130,10 +120,10 @@ export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityR
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 20, maxZoom: 1.5, minZoom: 0.5 }}
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
style={{ width: '100%', height: '100%' }}
defaultViewport={{ x: 0, y: 0, zoom: 1.0 }}
minZoom={0.1}
minZoom={0.05}
maxZoom={3}
>
<Controls />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary';
import { type Edge, type Node, MarkerType } from 'reactflow';
import { graphStratify, sugiyama, type GraphNode } from 'd3-dag';
import { ONE_CARDINALITY_MARKER_ID, ONE_CARDINALITY_MARKER_ACTIVE_ID } from '../../theme/icons/OneCardinalityMarker';

const DEFAULT_MARKER_CONFIG = {
Expand All @@ -30,13 +31,15 @@ const DEFAULT_MARKER_CONFIG = {
color: '#374151',
};

const NODE_WIDTH = 350;
const HEADER_HEIGHT = 60;
const FIELD_ROW_HEIGHT = 45;
const GAP_X = 100;
const GAP_Y = 100;

export type SchemaFlowNode = Node<Schema, 'schema'>;

export type SchemaNodeLayout = {
maxColumns: number;
columnWidth: number;
rowHeight: number;
};
type StratifyDatum = { id: string; parentIds: string[] };

function buildSchemaNode(schema: Schema): Omit<SchemaFlowNode, 'position'> {
return {
Expand Down Expand Up @@ -68,31 +71,76 @@ export type RelationshipMap = {
fieldKeyToFkIndices: Map<string, number[]>;
};

function estimateNodeHeight(schema: Schema): number {
return HEADER_HEIGHT + schema.fields.length * FIELD_ROW_HEIGHT;
}

/**
* Converts a dictionary's schemas into positioned ReactFlow nodes arranged in a grid layout.
*
* @param {Dictionary} dictionary — The Lectern dictionary containing schemas to visualize
* @param {Partial<SchemaNodeLayout>} layout — Optional overrides for grid layout configuration
* @returns {Node[]} Array of positioned ReactFlow nodes
* Computes node positions using d3-dag's Sugiyama layout algorithm.
*/
export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial<SchemaNodeLayout>): Node[] {
const maxColumns = layout?.maxColumns ?? 4;
const columnWidth = layout?.columnWidth ?? 500;
const rowHeight = layout?.rowHeight ?? 500;
export function getLayoutedElements(nodes: SchemaFlowNode[], edges: Edge[]): Node[] {
if (nodes.length === 0) {
return [];
}

return dictionary.schemas.map((schema, index) => {
const partialNode = buildSchemaNode(schema);
const parentMap = new Map<string, Set<string>>();
for (const edge of edges) {
let parentSet = parentMap.get(edge.target);

const row = Math.floor(index / maxColumns);
const col = index % maxColumns;
if (!parentSet) {
parentSet = new Set();
parentMap.set(edge.target, parentSet);
}

const position: Node['position'] = {
x: col * columnWidth,
y: row * rowHeight,
};
parentSet.add(edge.source);
}

const stratifyData: StratifyDatum[] = nodes.map((node) => ({
id: node.id,
parentIds: Array.from(parentMap.get(node.id) ?? []),
}));

const dag = graphStratify()(stratifyData);
const schemaByName = new Map<string, Schema>();

return { ...partialNode, position };
for (const node of nodes) {
schemaByName.set(node.id, node.data);
}

const layout = sugiyama().nodeSize((dagNode: GraphNode<StratifyDatum, undefined>): [number, number] => {
const schema = schemaByName.get(dagNode.data.id);
const height = schema ? estimateNodeHeight(schema) : HEADER_HEIGHT;
return [NODE_WIDTH + GAP_X, height + GAP_Y];
});

layout(dag);

const positionMap = new Map<string, { x: number; y: number }>();
for (const dagNode of dag.nodes()) {
positionMap.set(dagNode.data.id, { x: dagNode.x, y: dagNode.y });
}

return nodes.map((node) => ({
...node,
position: positionMap.get(node.id) ?? { x: 0, y: 0 },
}));
}

/**
* Builds unpositioned nodes from the dictionary and computes layout using
* d3-dag's Sugiyama algorithm.
*
* @param {Dictionary} dictionary — The Lectern dictionary containing schemas to visualize
* @param {Edge[]} edges — ReactFlow edges representing foreign key relationships
* @returns {{ nodes: Node[]; edges: Edge[] }} Positioned nodes and the original edges
*/
export function getLayoutedDiagram(dictionary: Dictionary, edges: Edge[]): { nodes: Node[]; edges: Edge[] } {
const unpositionedNodes: SchemaFlowNode[] = dictionary.schemas.map((schema) => ({
...buildSchemaNode(schema),
position: { x: 0, y: 0 },
}));
const layoutedNodes = getLayoutedElements(unpositionedNodes, edges);
return { nodes: layoutedNodes, edges };
}

/**
Expand Down
Loading